Files
epaper-dashboard/tools/netatmo_auth.py
T
Hermes fe1c06b306 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).
2026-08-29 20:24:03 +04:00

293 lines
11 KiB
Python

#!/usr/bin/env python3
"""Netatmo OAuth Setup - ein simpler Web-Endpoint den der User aufruft.
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.
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
-> gibt die URL aus, die der User im Browser öffnen soll.
"""
from __future__ import annotations
import argparse
import http.server
import json
import os
import socket
import sys
import time
import urllib.parse
import urllib.request
import urllib.error
import io
from pathlib import Path
# 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"
DEFAULT_SCOPE = "read_station"
CONFIG_PATH = Path(__file__).resolve().parent.parent / "config.json"
def detect_lan_ip() -> str:
try:
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. 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(("0.0.0.0", p))
return p
except OSError:
continue
raise RuntimeError(f"Kein freier Port zwischen {port} und {port+50} gefunden")
def load_existing_config() -> dict:
if not CONFIG_PATH.exists():
return {}
try:
return json.loads(CONFIG_PATH.read_text())
except Exception:
return {}
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
CONFIG_PATH.write_text(json.dumps(cfg, indent=2))
return cfg
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,
"scope": DEFAULT_SCOPE,
}).encode("utf-8")
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", errors="ignore")
raise SystemExit(f"Token-Exchange fehlgeschlagen: HTTP {e.code}\n{body}")
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, 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:
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(body)
saved["error"] = err
return
if "code" not in qs:
# Health-Check oder 404
self.send_response(204)
self.end_headers()
return
if qs.get("state", [None])[0] != expected_state:
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(body)
saved["error"] = "state_mismatch"
return
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(body)
server = http.server.HTTPServer(("0.0.0.0", port), Handler)
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 OAuth Setup")
parser.add_argument("--cid", help="Client-ID")
parser.add_argument("--csec", help="Client-Secret")
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()
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 erforderlich.")
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)
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()
print("=" * 70)
print(" NETATMO OAUTH SETUP")
print("=" * 70)
print()
print("Voraussetzung: Die folgende Redirect-URI muss EXAKT in deiner")
print("Netatmo-App auf https://dev.netatmo.com/apps/ registriert sein:")
print()
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("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__":
main()