* 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).
293 lines
11 KiB
Python
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() |