#!/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://: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"

Fehler bei Netatmo-Authentifizierung

" f"

Grund: {err}

" f"

Du kannst dieses Fenster schliessen und es nochmal " f"probieren.

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

State mismatch (CSRF-Schutz)

") 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 = ( "

OK!

" "

Refresh-Token wurde in config.json gespeichert.

" "

Das Netatmo-Plugin ist jetzt aktiv. Du kannst dieses " "Fenster schliessen.

" f"

" f"refresh_token: {refresh_token[:24]}...

" ).encode() status, hdrs, body = make_response(200, html) except SystemExit as e: saved["error"] = str(e) status, hdrs, body = make_response(500, ( f"

Token-Exchange fehlgeschlagen

" f"
{e}
").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://: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()