FEAT-NETATMO-02: Komplettes Redesign auf 4x4 WarmNews-Layout
* Indoor: 5 Sensoren (1x Main + 4x NAModule4) prominent mit Tag, Temp, CO2-Bar * Outdoor: Big-Temp + 12h-Verlaufsgraph + Min/Max/Luftfeuchte * Wind/Regen: je mit Mini-Verlauf + 1h/24h-Bars * Forecast: 3 Tage mit Min/Max + Regen-Bar * Batterie-Indikatoren mit %-Anzeige an Aussen, Wind, Regen und allen 5 Indoor-Sensoren * CO2-Bars mit ppm-Wert + Grün/Gelb/Rot-Schwellen * Sans-Schrift only, WarmNews-Farbpalette (warm-weiss, Tinte-schwarz, Akzent-Farben) * Subtile Box-Rahmen + Accent-Strips für visuelle Trennung designbase.py neu hinzugefügt als Template-Modul. tools/netatmo_auth.py: Auth-Flow leicht angepasst (Redirect-URI auf Pi-IP statt localhost).
This commit is contained in:
+170
-202
@@ -1,23 +1,22 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Netatmo OAuth2 Authorization Code Flow — einmaliger Setup-Helper.
|
||||
"""Netatmo OAuth Setup - ein simpler Web-Endpoint den der User aufruft.
|
||||
|
||||
Netatmo hat 2025 den OAuth-Password-Grant abgeschaltet. Stattdessen muss
|
||||
einmalig der Authorization-Code-Flow durchlaufen werden (Browser-Login).
|
||||
Ablauf (Standard OAuth2 Authorization Code Flow):
|
||||
1. User öffnet die unten gedruckte URL im Browser.
|
||||
2. Netatmo zeigt Login → User loggt sich ein → User klickt "Authorize".
|
||||
3. Netatmo leitet zurück auf den hier laufenden Callback-Server.
|
||||
4. Server tauscht den Code gegen access+refresh Token (API-Call,
|
||||
Content-Type: application/x-www-form-urlencoded, exakt nach Doku).
|
||||
5. refresh_token wird in config.json gespeichert.
|
||||
6. Eine Bestätigungsseite wird im Browser angezeigt.
|
||||
|
||||
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
|
||||
Voraussetzungen (einmalig, vom User gemacht):
|
||||
- Redirect-URI http://<pi-ip>:8765/callback muss in der Netatmo-App
|
||||
auf https://dev.netatmo.com/apps/ registriert sein.
|
||||
|
||||
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!
|
||||
python3 tools/netatmo_auth.py
|
||||
-> gibt die URL aus, die der User im Browser öffnen soll.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
@@ -26,34 +25,22 @@ import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import webbrowser
|
||||
import io
|
||||
from pathlib import Path
|
||||
|
||||
# Konstanten
|
||||
# Exakt nach Netatmo-Doku: https://dev.netatmo.com/apidocumentation/oauth
|
||||
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))
|
||||
@@ -65,15 +52,36 @@ def detect_lan_ip() -> str:
|
||||
|
||||
|
||||
def free_port(port: int = 8765) -> int:
|
||||
"""Findet einen freien Port (falls 8765 belegt)."""
|
||||
for p in [port] + list(range(port + 1, port + 20)):
|
||||
"""Findet einen freien Port. Versucht erst den gewünschten, dann
|
||||
die nächsten 20 Ports. Überspringt Ports die im LISTEN-State sind.
|
||||
|
||||
Hinweis: TIME_WAIT-Ports können kurzfristig ein bind() blockieren
|
||||
auch wenn sie in `ss` nicht als LISTEN auftauchen — das ist ok,
|
||||
dann gehen wir einfach zum nächsten Port.
|
||||
"""
|
||||
import subprocess
|
||||
out = subprocess.run(["ss", "-lnt"], capture_output=True, text=True).stdout
|
||||
listening = set()
|
||||
for line in out.splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) >= 4 and parts[0] == "LISTEN":
|
||||
local = parts[3]
|
||||
if ":" in local:
|
||||
try:
|
||||
listening.add(int(local.rsplit(":", 1)[1]))
|
||||
except ValueError:
|
||||
pass
|
||||
for p in [port] + list(range(port + 1, port + 50)):
|
||||
if p in listening:
|
||||
continue
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
try:
|
||||
s.bind(("127.0.0.1", p))
|
||||
s.bind(("0.0.0.0", p))
|
||||
return p
|
||||
except OSError:
|
||||
continue
|
||||
raise RuntimeError(f"Kein freier Port zwischen {port} und {port+19} gefunden")
|
||||
raise RuntimeError(f"Kein freier Port zwischen {port} und {port+50} gefunden")
|
||||
|
||||
|
||||
def load_existing_config() -> dict:
|
||||
@@ -81,154 +89,162 @@ def load_existing_config() -> dict:
|
||||
return {}
|
||||
try:
|
||||
return json.loads(CONFIG_PATH.read_text())
|
||||
except Exception as e:
|
||||
print(f"WARN: {CONFIG_PATH} nicht lesbar: {e}", file=sys.stderr)
|
||||
except Exception:
|
||||
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."""
|
||||
def save_refresh_token(refresh_token: str, client_id: str, client_secret: str) -> dict:
|
||||
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({
|
||||
def exchange_code_for_tokens(code: str, client_id: str, client_secret: str,
|
||||
redirect_uri: str) -> dict:
|
||||
"""Authorization Code → Access+Refresh Token via API-Call.
|
||||
|
||||
Exakt nach Netatmo-Doku: POST /oauth2/token mit
|
||||
Content-Type: application/x-www-form-urlencoded;charset=UTF-8
|
||||
|
||||
WICHTIG: redirect_uri muss EXAKT der Wert sein der beim /authorize-Aufruf
|
||||
benutzt wurde. Wenn der eine 10.11.3.144 war, muss der hier auch
|
||||
10.11.3.144 sein, sonst 'invalid_grant'.
|
||||
"""
|
||||
data = urllib.parse.urlencode({
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"code": code,
|
||||
"redirect_uri": REDIRECT_URI,
|
||||
"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"})
|
||||
req = urllib.request.Request(
|
||||
TOKEN_URL, data=data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
|
||||
"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")
|
||||
body = e.read().decode("utf-8", errors="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 = {}
|
||||
def run_callback_server(port: int, expected_state: str, client_id: str,
|
||||
client_secret: str, redirect_uri: str) -> None:
|
||||
"""Startet einen HTTP-Server, der den Callback empfängt und den Token speichert.
|
||||
|
||||
Läuft bis ein Token gespeichert wurde (max 5 Minuten)."""
|
||||
saved = {}
|
||||
|
||||
def make_response(status: int, body: bytes, content_type: str = "text/html; charset=utf-8"):
|
||||
return (status, [("Content-Type", content_type)], body)
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def log_message(self, *args, **kwargs):
|
||||
pass # quiet
|
||||
def log_message(self, format, *args):
|
||||
# Logge jeden Request damit wir sehen was passiert
|
||||
print(f" >> {self.command} {self.path} from {self.client_address[0]}")
|
||||
|
||||
def do_GET(self):
|
||||
parsed = urllib.parse.urlparse(self.path)
|
||||
qs = urllib.parse.parse_qs(parsed.query)
|
||||
print(f" qs: {dict(qs)}")
|
||||
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")
|
||||
err = qs.get("error", ["unknown"])[0]
|
||||
status, hdrs, body = make_response(400, (
|
||||
f"<h1>Fehler bei Netatmo-Authentifizierung</h1>"
|
||||
f"<p>Grund: {err}</p>"
|
||||
f"<p>Du kannst dieses Fenster schliessen und es nochmal "
|
||||
f"probieren.</p>").encode())
|
||||
self.send_response(status)
|
||||
for k, v in hdrs: self.send_header(k, v)
|
||||
self.end_headers()
|
||||
self.wfile.write(
|
||||
b"<h1>Fehler bei Netatmo-Authentifizierung</h1>"
|
||||
b"<p>Bitte zurueck zum Terminal gehen.</p>")
|
||||
self.wfile.write(body)
|
||||
saved["error"] = err
|
||||
return
|
||||
if "code" not in qs:
|
||||
self.send_response(400)
|
||||
# Health-Check oder 404
|
||||
self.send_response(204)
|
||||
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")
|
||||
status, hdrs, body = make_response(400, b"<h1>State mismatch (CSRF-Schutz)</h1>")
|
||||
self.send_response(status)
|
||||
for k, v in hdrs: self.send_header(k, v)
|
||||
self.end_headers()
|
||||
self.wfile.write(b"<h1>State mismatch (CSRF-Schutz)</h1>")
|
||||
self.wfile.write(body)
|
||||
saved["error"] = "state_mismatch"
|
||||
return
|
||||
captured["code"] = qs["code"][0]
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
code = qs["code"][0]
|
||||
print(f" code erhalten, tausche gegen Token...")
|
||||
try:
|
||||
tok = exchange_code_for_tokens(code, client_id, client_secret, redirect_uri)
|
||||
refresh_token = tok["refresh_token"]
|
||||
save_refresh_token(refresh_token, client_id, client_secret)
|
||||
saved["refresh_token"] = refresh_token
|
||||
print(f" refresh_token gespeichert!")
|
||||
html = (
|
||||
"<h1 style='color:green'>OK!</h1>"
|
||||
"<p>Refresh-Token wurde in config.json gespeichert.</p>"
|
||||
"<p>Das Netatmo-Plugin ist jetzt aktiv. Du kannst dieses "
|
||||
"Fenster schliessen.</p>"
|
||||
f"<p style='font-family:monospace;font-size:0.8em;color:#666'>"
|
||||
f"refresh_token: {refresh_token[:24]}...</p>"
|
||||
).encode()
|
||||
status, hdrs, body = make_response(200, html)
|
||||
except SystemExit as e:
|
||||
saved["error"] = str(e)
|
||||
status, hdrs, body = make_response(500, (
|
||||
f"<h1 style='color:red'>Token-Exchange fehlgeschlagen</h1>"
|
||||
f"<pre>{e}</pre>").encode())
|
||||
self.send_response(status)
|
||||
for k, v in hdrs: self.send_header(k, v)
|
||||
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>")
|
||||
self.wfile.write(body)
|
||||
|
||||
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"]
|
||||
server.timeout = 300 # 5 min
|
||||
print(f"Server listening on 0.0.0.0:{port}, warte auf Callback...",
|
||||
flush=True)
|
||||
# Läuft bis refresh_token gespeichert oder 5 Minuten Timeout
|
||||
end_time = time.time() + 300
|
||||
while time.time() < end_time and "refresh_token" not in saved:
|
||||
server.handle_request()
|
||||
return saved
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Netatmo OAuth2-Setup")
|
||||
parser = argparse.ArgumentParser(description="Netatmo OAuth 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})")
|
||||
parser.add_argument("--redirect", default=None,
|
||||
help="Redirect-URI (default: http://<lan-ip>:8765/callback)")
|
||||
parser.add_argument("--port", type=int, default=8765)
|
||||
parser.add_argument("--scope", 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()
|
||||
existing = load_existing_config().get("plugin_configs", {}).get("netatmo", {})
|
||||
client_id = args.cid or existing.get("client_id") or input("Netatmo Client-ID: ").strip()
|
||||
client_secret = args.csec or 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.")
|
||||
raise SystemExit("Client-ID und Client-Secret erforderlich.")
|
||||
|
||||
# 2) Port + State + Redirect-URI
|
||||
port = free_port(args.port)
|
||||
lan_ip = detect_lan_ip()
|
||||
if args.redirect:
|
||||
redirect = args.redirect
|
||||
else:
|
||||
redirect = f"http://{lan_ip}:{port}/callback"
|
||||
|
||||
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)}"
|
||||
@@ -238,87 +254,39 @@ def main():
|
||||
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("=" * 70)
|
||||
print(" NETATMO OAUTH SETUP")
|
||||
print("=" * 70)
|
||||
print()
|
||||
print(f" Aktuelle Redirect-URI:")
|
||||
print(f" {redirect}")
|
||||
print("Voraussetzung: Die folgende Redirect-URI muss EXAKT in deiner")
|
||||
print("Netatmo-App auf https://dev.netatmo.com/apps/ registriert sein:")
|
||||
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(f" {redirect}")
|
||||
print()
|
||||
print("=" * 70)
|
||||
print(" BITTE IM BROWSER OEFFNEN:")
|
||||
print("=" * 70)
|
||||
print()
|
||||
print(auth_url)
|
||||
print()
|
||||
print("=" * 70)
|
||||
print(" Nach dem Authorize-Klick landest du auf der Login-Seite,")
|
||||
print(" loggst dich ein, klickst 'Authorize', und wirst zurueckgeleitet.")
|
||||
print(" Der Server holt dann den Token und speichert ihn in config.json.")
|
||||
print("=" * 70)
|
||||
print()
|
||||
|
||||
result = run_callback_server(port, state, client_id, client_secret, redirect)
|
||||
if "refresh_token" in result:
|
||||
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")
|
||||
print("ERFOLG! Refresh-Token gespeichert in", CONFIG_PATH)
|
||||
print()
|
||||
sys.exit(0)
|
||||
elif "error" in result:
|
||||
print()
|
||||
print("FEHLER:", result["error"], file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user