diff --git a/README.md b/README.md index 9c28e38..58c8488 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,7 @@ Default Login: `admin` / `admin` — in `.env` mit `EPAPER_ADMIN_PASSWORD` setze | `strava` | Strava API | `access_token`, `club_id` | | `gmail` | Gmail API | `credentials_json` (OAuth2) | | `minimax` | MiniMax AI | `api_key`, `model`, `prompt` | +| `netatmo` | Netatmo Weather API (OAuth2) | `client_id`, `client_secret`, `username`, `password` (Details: [NETATMO.md](plugins/NETATMO.md)) | | `hello` | statisch | `text`, `color`, `size` | ## Eigenes Plugin schreiben diff --git a/config.netatmo.example.json b/config.netatmo.example.json new file mode 100644 index 0000000..49b1c0e --- /dev/null +++ b/config.netatmo.example.json @@ -0,0 +1,30 @@ +{ + "_comment": "Beispiel-Konfiguration für das Netatmo-Plugin. Diese Datei kannst du 1:1 in deine config.json unter 'plugin_configs.netatmo' kopieren und deine Werte einsetzen. Secrets wie client_secret / username / password NIEMALS in git committen — am besten direkt in der Admin-UI (http://pi:8080/) eintragen.", + + "client_id": "REPLACE_WITH_YOUR_NETATMO_CLIENT_ID", + "client_secret": "REPLACE_WITH_YOUR_NETATMO_CLIENT_SECRET", + "username": "deine@email.de", + "password": "REPLACE_WITH_YOUR_NETATMO_PASSWORD", + + "_comment_station": "Optional: Name der Station wenn du mehrere hast. Leer = erste Station wird genommen.", + "station_filter": "", + + "_comment_modules": "Welche Module in der UI gezeigt werden. Bei einer typischen Wetterstation hast du NAMain (Indoor), NAModule1 (Outdoor), NAModule2 (Wind), NAModule3 (Regen). NAModule4 ist ein zusätzliches Indoor-Modul.", + "show_indoor": true, + "show_outdoor": true, + "show_wind": true, + "show_rain": true, + + "_comment_display": "Optionale Anzeige-Elemente", + "show_compass": true, + "show_secondary": true, + "_comment_secondary": "Zeigt Min/Max-Temperaturen + Timestamp der letzten Aktualisierung. Bei großen Slots (≥ 2x2) sichtbar.", + + "_comment_co2": "Schwellen für den CO₂-Balken in ppm (parts per million). Standardwerte orientieren sich an der Netatmo-App: grün ≤ 600, gelb ≤ 1000, rot > 1500.", + "co2_thresholds": "ok@600,warn@1000,alert@1500", + "bar_gradient": true, + + "_comment_units": "C oder F für Temperatur, kmh oder ms für Windgeschwindigkeit. Netatmo liefert die Daten in Celsius und km/h — Umrechnung passiert im Plugin.", + "temp_unit": "C", + "wind_unit": "kmh" +} \ No newline at end of file diff --git a/layout.py b/layout.py index 173a7aa..0c2a20c 100644 --- a/layout.py +++ b/layout.py @@ -164,6 +164,7 @@ def auto_size_for_plugin(plugin_name: str) -> tuple[int, int]: "clock": (2, 2), "clock_wordclock": (2, 2), "weather": (2, 2), + "netatmo": (4, 4), # zeigt 4 Sub-Cards; ab 2x2 sinnvoll, 4x4 ideal "system": (2, 2), "minimax": (2, 2), "spotify": (2, 1), diff --git a/plugins/NETATMO.md b/plugins/NETATMO.md new file mode 100644 index 0000000..962d273 --- /dev/null +++ b/plugins/NETATMO.md @@ -0,0 +1,201 @@ +# Netatmo Weather Station Plugin + +Zeigt die Live-Daten deiner heimischen [Netatmo Wetterstation](https://www.netatmo.com/de-de/weather/weatherstation) +auf dem 7.3″ ACeP Dashboard: Indoor-Temperatur, CO₂, Luftfeuchte, Luftdruck, +Outdoor-Werte, Wind (Stärke + Böen + Richtung mit Windrose) und Regenmengen. + +Funktioniert mit **jeder** Netatmo-Konfiguration (Hauptmodul + beliebige +Zusatzmodule). Module, die du nicht hast, werden automatisch weggelassen. + +![Netatmo 4×4](docs/netatmo_4x4.png) + +--- + +## 1. Netatmo-App registrieren + +Damit das Plugin Daten abrufen darf, brauchst du eine App-Registrierung +bei Netatmo. Das ist einmalig und kostenlos. + +1. Gehe zu und logge dich mit deinem + Netatmo-Account ein. +2. Klicke **Create an App**. +3. Fülle das Formular aus: + - **Name**: z. B. „epaper-dashboard" + - **Description**: z. B. „Wetterdaten auf meinem ePaper-Display" + - **Data Protection Officer**: nicht erforderlich für eine persönliche App +4. Bei den **Scopes** wähle mindestens **`read_station`** aus + (Standard-Scope der Weather API). Mehr brauchst du für dieses Plugin nicht. +5. Speichern. Du bekommst **Client-ID** und **Client-Secret** angezeigt — + die brauchst du gleich. + +> Hinweis: Netatmo verwendet **OAuth2 mit Password-Grant** für die +> Weather API (kein Browser-Redirect nötig). Dein Passwort wird nur +> lokal für den initialen Token verwendet — es wird **nie** auf der +> Festplatte persistiert, sondern liegt nur im RAM des laufenden +> Dashboard-Prozesses. + +--- + +## 2. Plugin konfigurieren + +### Option A — über die Admin-Weboberfläche (empfohlen) + +`http://:8080/` öffnen, in der Sidebar das Widget **Netatmo** +auswählen und die Werte eintragen: + +| Feld | Wert | +|---|---| +| Client-ID | von dev.netatmo.com/apps | +| Client-Secret | von dev.netatmo.com/apps | +| Username | deine Netatmo-Login-E-Mail | +| Password | dein Netatmo-Passwort | +| Station (Filter) | leer lassen, wenn du nur eine Station hast | + +Speichern. Klicke **Refresh Now** — wenn alles passt, sollten nach +~30s die ersten Daten erscheinen. + +### Option B — direkt in `config.json` + +Unter `plugin_configs.netatmo` eintragen (Beispiel siehe +[`config.netatmo.example.json`](config.netatmo.example.json)): + +```json +{ + "plugin_configs": { + "netatmo": { + "client_id": "abc123...", + "client_secret": "def456...", + "username": "du@example.com", + "password": "GEHEIM", + "station_filter": "", + "show_indoor": true, + "show_outdoor": true, + "show_wind": true, + "show_rain": true, + "show_compass": true, + "show_secondary": true, + "co2_thresholds": "ok@600,warn@1000,alert@1500", + "bar_gradient": true, + "temp_unit": "C", + "wind_unit": "kmh" + } + } +} +``` + +> ⚠️ Secrets in `config.json` sind auf dem Pi persistent (lokal in +> `/home//.hermes/projects/pi/config.json`). Wenn du das +> vermeiden willst, nimm die Admin-UI — die legt sie genauso ab, aber +> du siehst die Werte nie im Klartext-Editor. + +--- + +## 3. Layout zuweisen + +In der Admin-UI ein Netatmo-Widget auf das Grid ziehen. Das Plugin ist +**responsive**: dieselbe Konfiguration sieht auf jedem Slot gut aus. + +| Slot | Was du siehst | +|---|---| +| **1×1** | Große Außen-Temperatur, Mini-CO₂/Feuchte-Status | +| **2×1** | Outdoor-Temperatur + Wind-Geschwindigkeit kompakt | +| **4×1** | Indoor · Outdoor · Wind/Regen — drei Spalten | +| **1×4** | Vertikale Liste aller Module | +| **2×2** | Indoor-Card (mit CO₂-Bar) + Outdoor-Card + Wind/Regen-Bereich | +| **4×4** | Volle Ansicht mit allen Modulen, Min/Max, Windrose | + +Das Plugin priorisiert bei der Layout-Wahl **wide/tall** vor `small`, +damit 4×1- und 1×4-Slots nicht in die Mini-Ansicht fallen. + +--- + +## 4. Optionen im Detail + +| Option | Typ | Default | Bedeutung | +|---|---|---|---| +| `client_id` | secret | — | Netatmo App Client-ID | +| `client_secret` | secret | — | Netatmo App Client-Secret | +| `username` | secret | — | Netatmo Login (E-Mail) | +| `password` | secret | — | Netatmo Passwort (nur RAM) | +| `station_filter` | string | `""` | Substring-Filter auf Stations-/Modulname | +| `show_indoor` | bool | `true` | Hauptmodul + zusätzliche Indoor-Sensoren anzeigen | +| `show_outdoor` | bool | `true` | NAModule1 (Außen) anzeigen | +| `show_wind` | bool | `true` | NAModule2 (Wind) anzeigen | +| `show_rain` | bool | `true` | NAModule3 (Regen) anzeigen | +| `show_compass` | bool | `true` | Windrose in der Wind-Card zeichnen | +| `show_secondary` | bool | `true` | Min/Max + letzte Aktualisierung | +| `co2_thresholds` | string | `ok@600,warn@1000,alert@1500` | CO₂-Bar Schwellen (ppm) | +| `bar_gradient` | bool | `true` | Verlaufsmodus der Bar | +| `temp_unit` | select | `C` | `C` oder `F` | +| `wind_unit` | select | `kmh` | `kmh` oder `ms` | + +### CO₂-Schwellen anpassen + +`co2_thresholds` ist ein String im Format `farbe@ppm_in_ppm`. +Mehrere Stufen durch Komma trennen: + +- `ok@600,warn@1000,alert@1500` — Netatmo-Default-Empfehlungen +- `ok@800,warn@1200` — nur 2 Stufen (kompakt) +- `ok@1000,warn@1500,alert@2000` — strenger (z. B. für Schlafzimmer) + +### Station-Filter + +Wenn du mehrere Stationen hast (z. B. „Home" und „Office"), kannst du +über `station_filter` einen Substring matchen. Erst wird der +Stationsname geprüft, dann die Modulnamen. Leer = erste Station. + +--- + +## 5. Fehlerdiagnose + +### Auth-Fehler (HTTP 401) + +- Stimmen Client-ID/Secret mit dem Eintrag auf dev.netatmo.com/apps überein? +- Stimmt das Passwort (case-sensitive)? +- Wurde deine App bei Netatmo evtl. deaktiviert? + +→ Plugin setzt den Token-Cache automatisch zurück, beim nächsten Render +wird ein neuer Token geholt. + +### "Keine Station gefunden" + +- Hat deine Station in den letzten 4h Daten an Netatmo geschickt? + (Netatmo markiert sie sonst als offline.) +- Stimmt der `station_filter`? Wenn du dort z. B. „Home" eingibst, aber + die Station heißt „Home Office", passt es trotzdem (Substring-Match). + Leer = erste Station. + +### "API nicht erreichbar" + +- Pi hat Internet? (`ping api.netatmo.net`) +- DNS? (`nslookup api.netatmo.net`) + +### Werte fehlen / sind 0 + +- Manche Datenpunkte sind nur in der **Paid**-Subscription verfügbar + (z. B. Historische Daten > 1h). Aktuelle Werte sind immer frei. + +--- + +## 6. Technische Details + +| Aspekt | Wert | +|---|---| +| API-Version | Netatmo Connect OAuth2 | +| Endpoints | `POST /oauth2/token`, `GET /api/getstationsdata` | +| Token-Lebensdauer | 10800s (3h), Refresh on the fly | +| Cache | In-Memory, kein Disk-IO pro Render | +| Datenquelle | Netatmo Cloud (Update alle ~10 Min) | +| Modul-Typen | NAMain, NAModule1..4 | +| `reachable`-Feld | „true" wenn das Modul in den letzten 4h gesehen wurde | + +### Was wird pro Refresh gemacht? + +1. Prüfe Token-Cache (RAM). Wenn abgelaufen oder fehlend → hole neuen + Token via Password-Grant (initial) oder Refresh-Grant. +2. `GET /api/getstationsdata?get_favorites=false` mit Bearer-Token. +3. Parse Antwort, filtere nach `station_filter` und sichtbaren Modulen. +4. Rendere je nach Slot-Größe (`small` / `wide` / `tall` / `standard`). + +Bei 401 wird der Token-Cache geleert und **einmal** neu authentifiziert. +Wenn das auch fehlschlägt → roter Error-Banner mit der genauen HTTP-Meldung. \ No newline at end of file diff --git a/plugins/netatmo.py b/plugins/netatmo.py new file mode 100644 index 0000000..3cc2083 --- /dev/null +++ b/plugins/netatmo.py @@ -0,0 +1,883 @@ +"""Netatmo Weather Station Plugin - responsive. + +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). + +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 + 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) + show_wind — Wind-Modul anzeigen (bool, default True) + show_rain — Regen-Modul anzeigen (bool, default True) + show_compass — Windrose zeichnen (bool, default True) + co2_thresholds — CO2-Schwellen (Format "ok@600,warn@1000,alert@1500") + temp_unit — "C" oder "F" (Default "C", Netatmo liefert Celsius) + wind_unit — "kmh" oder "ms" (Default "kmh", Netatmo liefert km/h) +""" +from __future__ import annotations +import os +import sys +import json +import time +import math +import urllib.request +import urllib.error +import urllib.parse +from datetime import datetime, timezone + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +from plugins.base import Widget, fetch_url, render_error_banner # noqa: E402 +from palette import ( # noqa: E402 + FG, BG, INFO, OK, WARN, ALERT, ORANGE, BLUE, RED, GREEN, + measure, fit_font, centered_text, hbar, parse_thresholds, + is_small, is_wide, is_tall, +) + + +# ============================================================================ +# OAuth2 + API Client +# ============================================================================ +TOKEN_URL = "https://api.netatmo.net/oauth2/token" +STATIONS_URL = "https://api.netatmo.net/api/getstationsdata" + +# Module-Type → Friendly-Name +MODULE_TYPES = { + "NAMain": {"label": "Indoor", "icon": "🏠", "short": "in"}, + "NAModule1": {"label": "Outdoor", "icon": "🌳", "short": "out"}, + "NAModule2": {"label": "Wind", "icon": "💨", "short": "wind"}, + "NAModule3": {"label": "Regen", "icon": "🌧", "short": "rain"}, + "NAModule4": {"label": "Extra", "icon": "📍", "short": "extra"}, +} + + +def _token_payload(creds: dict, grant: str = "password", **extra) -> bytes: + """Body für /oauth2/token. grant: 'password' oder 'refresh_token'.""" + base = { + "grant_type": grant, + "client_id": creds["client_id"], + "client_secret": creds["client_secret"], + "scope": "read_station", + } + base.update(extra) + return urllib.parse.urlencode(base).encode("utf-8") + + +def _post_form(url: str, body: bytes, timeout: int = 10) -> dict: + req = urllib.request.Request( + url, data=body, + headers={"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8"}, + ) + with urllib.request.urlopen(req, timeout=timeout) as r: + return json.loads(r.read()) + + +# In-Memory Token-Cache: pro Prozess ein Access-Token. +# Kein Disk-IO bei jedem Render. Access-Token ist 3h gültig (10800s), +# wir holen einen frischen, wenn weniger als 5 Min Restlaufzeit. +_TOKEN_CACHE: dict = { + "access_token": None, + "refresh_token": None, + "expires_at": 0.0, +} + + +def _obtain_tokens(creds: dict) -> dict: + """Holt einen frischen Access-Token via Password- oder 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). + """ + 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 + + # 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["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: + """Holt die /getstationsdata Response. Returns parsed dict.""" + creds = {"client_id": client_id, "client_secret": client_secret, + "username": username, "password": password} + + now = time.time() + if _TOKEN_CACHE["expires_at"] <= now or not _TOKEN_CACHE["access_token"]: + _obtain_tokens(creds) + + def _do(): + url = f"{STATIONS_URL}?get_favorites=false" + req = urllib.request.Request(url, headers={ + "Authorization": f"Bearer {_TOKEN_CACHE['access_token']}", + "Accept": "application/json", + }) + with urllib.request.urlopen(req, timeout=10) as r: + return json.loads(r.read()) + + try: + return _do() + except urllib.error.HTTPError as e: + if e.code != 401: + raise + # 401 → Token abgelaufen / widerrufen, einmal neu holen + _TOKEN_CACHE["access_token"] = None + _TOKEN_CACHE["expires_at"] = 0 + _obtain_tokens(creds) + return _do() + + +# ============================================================================ +# Parsing Helpers +# ============================================================================ +def _fmt_temp(t: float | None, unit: str = "C") -> str: + if t is None: + return "—" + return f"{t:.1f}°{unit}" + + +def _fmt_pct(v: float | None) -> str: + if v is None: + return "—" + return f"{int(v)}%" + + +def _fmt_wind(w: float | None, unit: str = "kmh") -> str: + if w is None: + return "—" + if unit == "ms": + return f"{w / 3.6:.1f} m/s" + return f"{w:.1f} km/h" + + +def _fmt_rain(mm: float | None) -> str: + if mm is None: + return "—" + return f"{mm:.1f} mm" + + +def _time_short(epoch_s: int | None) -> str: + """Unix timestamp → 'HH:MM' oder '' wenn None.""" + if not epoch_s: + return "" + try: + dt = datetime.fromtimestamp(epoch_s, tz=timezone.utc).astimezone() + return dt.strftime("%H:%M") + except Exception: + return "" + + +def _temp_color(t: float | None) -> tuple: + if t is None: + return FG + if t >= 30: + return RED + if t >= 22: + return ORANGE + if t <= 0: + return BLUE + if t <= 8: + return INFO + return FG + + +def _co2_pct(co2: float | None) -> float: + """CO2 in ppm → 0..100 % der Skala bis 2000 ppm.""" + if co2 is None: + return 0 + return min(100.0, max(0.0, co2 / 2000.0 * 100.0)) + + +def _co2_color(co2: float | None) -> tuple: + if co2 is None: + return FG + if co2 >= 1500: + return ALERT + if co2 >= 1000: + return WARN + return OK + + +def _humidity_color(h: float | None) -> tuple: + if h is None: + return FG + if h < 30 or h > 65: + return WARN + return OK + + +def _parse_stations(api_response: dict, station_filter: str = "") -> dict | None: + """Extrahiert die passende Station und alle Module. + + Returns dict mit: + - station_name, place + - main: dict (NAMain dashboard_data + type) + - modules: list[dict] (alle Zusatzmodule mit type + dashboard_data) + Oder None wenn keine Station gefunden. + """ + body = api_response.get("body") if "body" in api_response else api_response + devices = body.get("devices") if isinstance(body, dict) else None + if not devices: + return None + + # Falls ein Filter gesetzt ist, matche auf station_name oder module_name + chosen = None + if station_filter: + sf = station_filter.strip().lower() + for dev in devices: + if sf in (dev.get("station_name") or "").lower(): + chosen = dev + break + for m in dev.get("modules", []): + if sf in (m.get("module_name") or "").lower(): + chosen = dev + break + if chosen: + break + if chosen is None: + chosen = devices[0] + + main = { + "type": "NAMain", + "name": chosen.get("station_name", "Station"), + "data": chosen.get("dashboard_data") or {}, + "place": chosen.get("place") or {}, + "wifi_status": chosen.get("wifi_status"), + "reachable": chosen.get("reachable"), + } + modules = [] + for m in chosen.get("modules", []): + mtype = m.get("type", "") + # Module ohne aktuelle Daten (rf_strength, battery_level, last_seen) + modules.append({ + "type": mtype, + "name": m.get("module_name") or MODULE_TYPES.get(mtype, {}).get("label", mtype), + "id": m.get("_id"), + "data": m.get("dashboard_data") or {}, + "battery_pct": m.get("battery_percent"), + "rf_status": m.get("rf_status"), + "reachable": m.get("reachable"), + "last_seen": m.get("last_seen"), + "last_message": m.get("last_message"), + }) + return { + "station_name": main["name"], + "place": main.get("place", {}), + "main": main, + "modules": modules, + "_fetched_at": time.time(), + } + + +# ============================================================================ +# Plugin-Klasse +# ============================================================================ +class Widget(Widget): + name = "netatmo" + label = "Netatmo Wetterstation" + description = ("Live-Daten der heimischen Netatmo Station: Indoor, Outdoor, " + "Wind, Regen, CO₂. Responsive 1×1..4×4.") + category = "weather" + + config_schema = [ + {"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": "station_filter", "label": "Station (leer = erste)", + "type": "string", "default": ""}, + {"key": "show_indoor", "label": "Indoor-Modul anzeigen", "type": "bool", + "default": True}, + {"key": "show_outdoor", "label": "Outdoor-Modul anzeigen", "type": "bool", + "default": True}, + {"key": "show_wind", "label": "Wind-Modul anzeigen", "type": "bool", + "default": True}, + {"key": "show_rain", "label": "Regen-Modul anzeigen", "type": "bool", + "default": True}, + {"key": "show_compass", "label": "Windrose", "type": "bool", + "default": True}, + {"key": "show_secondary", "label": "Min/Max + Letzte Aktualisierung", + "type": "bool", "default": True, + "help": "Zeigt Tages-Min/Max und Timestamp. Bei großen Slots."}, + {"key": "co2_thresholds", + "label": "CO₂-Schwellen (ppm)", + "type": "string", "default": "ok@600,warn@1000,alert@1500", + "help": "ppm-Schwellen für die CO₂-Bar (grün/gelb/rot)."}, + {"key": "bar_gradient", + "label": "CO₂-Bar Verlaufsmodus", "type": "bool", "default": True}, + {"key": "temp_unit", "label": "Temperatur-Einheit", "type": "select", + "options": ["C", "F"], "default": "C"}, + {"key": "wind_unit", "label": "Wind-Einheit", "type": "select", + "options": ["kmh", "ms"], "default": "kmh"}, + ] + default_config = { + "client_id": "", + "client_secret": "", + "username": "", + "password": "", + "station_filter": "", + "show_indoor": True, + "show_outdoor": True, + "show_wind": True, + "show_rain": True, + "show_compass": True, + "show_secondary": True, + "co2_thresholds": "ok@600,warn@1000,alert@1500", + "bar_gradient": True, + "temp_unit": "C", + "wind_unit": "kmh", + } + + # ---- Helper ---- + def _co2_thresholds(self) -> list: + # Wenn user ok@600 geschrieben hat, parst parse_thresholds das richtig. + # Aber parse_thresholds erwartet Prozent. Hier rechnen wir auf % + # um, damit wir die gleiche hbar-Routine nutzen können. + spec = self.cfg("co2_thresholds", "ok@600,warn@1000,alert@1500") + if isinstance(spec, str): + result = [] + default_pcts = [600, 1000, 1500] + for i, p in enumerate([s.strip() for s in spec.split(",") if s.strip()]): + if "@" in p: + name, val = p.split("@", 1) + try: + ppm = float(val) + except ValueError: + continue + else: + name = p + ppm = default_pcts[i] if i < len(default_pcts) else 2000 + try: + color = { + "ok": OK, "warn": WARN, "alert": ALERT, + "fg": FG, "green": GREEN, "yellow": WARN, + "red": RED, "orange": ORANGE, "blue": BLUE, + }[name] + except KeyError: + continue + # Convert ppm → % of 2000 scale + result.append((min(100.0, ppm / 2000.0 * 100.0), color)) + return sorted(result, key=lambda x: x[0]) if result else [ + (30.0, OK), (50.0, WARN), (75.0, ALERT), + ] + return [(30.0, OK), (50.0, WARN), (75.0, ALERT)] + + def _bar_args(self): + return (self._co2_thresholds(), + bool(self.cfg("bar_gradient", True))) + + # ---- Fetch ---- + 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."} + + try: + data = _get_stations_data(cid, sec, user, pw) + except urllib.error.HTTPError as e: + body = "" + try: + body = e.read().decode("utf-8", "ignore")[:120] + except Exception: + pass + if e.code in (401, 403): + # Auth-Fehler: Token-Cache zurücksetzen für nächsten Versuch + _TOKEN_CACHE["access_token"] = None + _TOKEN_CACHE["expires_at"] = 0 + return {"_error": f"Auth fehlgeschlagen (HTTP {e.code}). " + f"Credentials prüfen."} + 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."} + + parsed["_raw_count"] = len(data.get("body", data).get("devices", [])) + return parsed + + # ---- Render ---- + def render(self, draw, fonts, x, y, w, h): + pad = 8 + d = self.fetch() + + if "_error" in d: + render_error_banner(draw, fonts, x, y, w, h, + self.label, d["_error"]) + return + + # Header + header_font = fonts.get("24") or fonts.get("20") or fonts.get("default") + draw.text((x + pad, y + pad), "NETATMO", font=header_font, fill=INFO) + sub = d["station_name"] + if len(sub) > 24: + sub = sub[:23] + "…" + font_sub = fit_font(draw, sub, fonts, w - 2 * pad - 4, 18) + draw.text((x + w - measure(draw, sub, font_sub)[0] - pad, y + pad + 4), + sub, font=font_sub, fill=FG) + + # Module nach Typ filtern + main = d["main"] + modules = [m for m in d["modules"] + if self._module_visible(m["type"])] + + # Dispatches je Slot-Größe. Reihenfolge wichtig: + # wide/tall zuerst prüfen, weil `is_small(w,h)` sehr eager ist + # (alles mit w<280 oder h<180) und sonst 4x1/1x4 Slots überschattet. + if is_wide(w, h): + self._render_wide(draw, fonts, x, y, w, h, main, modules) + return + if is_tall(w, h): + self._render_tall(draw, fonts, x, y, w, h, main, modules) + return + if is_small(w, h): + self._render_small(draw, fonts, x, y, w, h, main, modules) + return + # Standard (2x2 oder größer quadratisch) + self._render_standard(draw, fonts, x, y, w, h, main, modules, d) + + def _module_visible(self, mtype: str) -> bool: + return { + "NAMain": self.cfg("show_indoor", True), + "NAModule1": self.cfg("show_outdoor", True), + "NAModule2": self.cfg("show_wind", True), + "NAModule3": self.cfg("show_rain", True), + "NAModule4": self.cfg("show_indoor", True), # Extra-Indoor + }.get(mtype, True) + + # ---- Layouts ---- + def _render_small(self, draw, fonts, x, y, w, h, main, modules): + """1x1 — Outdoor-Temp prominent + Mini-Status.""" + out = self._first_module(modules, "NAModule1") + if out: + t = out["data"].get("Temperature") + temp = _fmt_temp(t, self.cfg("temp_unit", "C")) + color = _temp_color(t) + font = fit_font(draw, temp, fonts, w - 2 * 8, int(h * 0.55)) + centered_text(draw, temp, x, y + 26, w, int(h * 0.55), font, color) + else: + in_t = main["data"].get("Temperature") + temp = _fmt_temp(in_t, self.cfg("temp_unit", "C")) + color = _temp_color(in_t) + font = fit_font(draw, temp, fonts, w - 2 * 8, int(h * 0.55)) + centered_text(draw, temp, x, y + 26, w, int(h * 0.55), font, color) + + # Mini-Status-Zeile unten: Hum + CO2 + sub_y = y + h - 22 + parts = [] + if out: + h_val = out["data"].get("Humidity") + if h_val is not None: + parts.append(f"💧{int(h_val)}%") + co2 = main["data"].get("CO2") + if co2 is not None: + parts.append(f"CO₂ {int(co2)}") + line = " ".join(parts) or "—" + font = fit_font(draw, line, fonts, w - 2 * 8, 18) + centered_text(draw, line, x, sub_y, w, 22, font, FG) + + def _render_wide(self, draw, fonts, x, y, w, h, main, modules): + """4x1 — Indoor | Outdoor | Wind/Regen kompakt.""" + col_w = w // 3 + # Col 1: Indoor Temp + Hum + CO2-bar + self._draw_module_cell( + draw, fonts, + x + 4, y + 28, col_w - 8, h - 32, + main, label="🏠 IN", show_co2_bar=True, + ) + # Col 2: Outdoor + out = self._first_module(modules, "NAModule1") + if out: + self._draw_module_cell( + draw, fonts, + x + col_w + 4, y + 28, col_w - 8, h - 32, + out, label="🌳 OUT", show_co2_bar=False, + ) + # Col 3: Wind + Rain stacked + cx3 = x + 2 * col_w + 4 + cw3 = col_w - 8 + row_h = (h - 32) // 2 + wind = self._first_module(modules, "NAModule2") + if wind: + self._draw_module_cell( + draw, fonts, cx3, y + 28, cw3, row_h, + wind, label="💨", show_co2_bar=False, compact=True, + ) + rain = self._first_module(modules, "NAModule3") + if rain: + self._draw_module_cell( + draw, fonts, cx3, y + 28 + row_h, cw3, row_h, + rain, label="🌧", show_co2_bar=False, compact=True, + ) + + def _render_tall(self, draw, fonts, x, y, w, h, main, modules): + """1x4 / 1x2 — vertikale Liste aller Module.""" + items = [] + if self.cfg("show_indoor", True): + items.append(("🏠", "IN", main)) + for m in modules: + label = MODULE_TYPES.get(m["type"], {}).get("short", m["type"][-2:]).upper() + items.append((MODULE_TYPES.get(m["type"], {}).get("icon", "•"), label, m)) + + row_h = max(40, (h - 32) // max(1, len(items))) + for i, (icon, label, mod) in enumerate(items): + ry = y + 30 + i * row_h + self._draw_module_cell( + draw, fonts, x + 4, ry, w - 8, row_h - 4, + mod, label=f"{icon} {label}", show_co2_bar=False, + compact=(row_h < 70), + ) + + def _render_standard(self, draw, fonts, x, y, w, h, main, modules, d): + """Standard 2x2+ Layout.""" + pad = 8 + # Top-Header-Zone (NETATMO + station name) wird in render() gemacht. + body_y = y + 36 + body_h = h - 36 + + # Indoor Card (links) + card_w = (w - 3 * pad) // 2 + card_h = (body_h - pad) // 2 + if self.cfg("show_indoor", True): + self._draw_card( + draw, fonts, x + pad, body_y, + card_w, card_h, + "🏠 Indoor", main, + show_co2=True, show_minmax=self.cfg("show_secondary", True), + ) + + # Outdoor Card (rechts) + out = self._first_module(modules, "NAModule1") + if out and self.cfg("show_outdoor", True): + self._draw_card( + draw, fonts, x + pad + card_w + pad, body_y, + card_w, card_h, + "🌳 Outdoor", out, + show_co2=False, show_minmax=self.cfg("show_secondary", True), + ) + + # Bottom: Wind-Modul + Rain-Modul (zusammen in einer Card) + bottom_y = body_y + card_h + pad + wind = self._first_module(modules, "NAModule2") + rain = self._first_module(modules, "NAModule3") + self._draw_wind_rain_card( + draw, fonts, x + pad, bottom_y, w - 2 * pad, card_h, + wind, rain, + show_compass=self.cfg("show_compass", True), + show_secondary=self.cfg("show_secondary", True), + ) + + # Footer: Letzte Aktualisierung + Modul-Status (nur wenn Platz) + if self.cfg("show_secondary", True) and h >= 280: + self._draw_footer(draw, fonts, x, y + h - 16, w, main, modules) + + # ---- Card-Drawing Helpers ---- + def _draw_card(self, draw, fonts, x, y, w, h, title, mod, + show_co2=True, show_minmax=False): + """Eine Modul-Card mit Header, Temperatur, Humidity, CO2-Bar.""" + pad = 6 + # Border + draw.rectangle((x, y, x + w - 1, y + h - 1), outline=FG, width=2) + # Title bar + title_font = fit_font(draw, title, fonts, w - 2 * pad, 22) + draw.rectangle((x, y, x + w - 1, y + 24), fill=FG) + draw.text((x + pad, y + 4), title, + font=title_font, fill=BG) + # Name des Moduls rechts (klein) + name = mod.get("name", "") + if name and name.lower() != title.split(" ", 1)[-1].lower(): + short = name[:18] + ("…" if len(name) > 18 else "") + font_n = fit_font(draw, short, fonts, w // 3, 16) + tw, _ = measure(draw, short, font_n) + draw.text((x + w - tw - pad - 2, y + 6), + short, font=font_n, fill=BG) + + # Body + inner_y = y + 28 + inner_h = h - 30 + data = mod.get("data", {}) or {} + + # Big Temp + t = data.get("Temperature") + temp_str = _fmt_temp(t, self.cfg("temp_unit", "C")) + temp_color = _temp_color(t) + font_t = fit_font(draw, temp_str, fonts, + w - 2 * pad - 4, int(inner_h * 0.6)) + draw.text((x + pad, inner_y + 4), temp_str, + font=font_t, fill=temp_color) + + # Min/Max als kleine Labels darunter + if show_minmax and inner_h > 80: + mn = data.get("min_temp") + mx = data.get("max_temp") + if mn is not None or mx is not None: + tmin = (data.get("date_min_temp") or 0) + tmax = (data.get("date_max_temp") or 0) + line = "" + if mn is not None: + line += f"↓{mn:.1f}° {_time_short(tmin) or '—'}" + if mx is not None: + line += f" ↑{mx:.1f}° {_time_short(tmax) or '—'}" + font_m = fit_font(draw, line, fonts, w - 2 * pad, 16) + draw.text((x + pad, inner_y + 4 + font_t.size + 4), + line, font=font_m, fill=FG) + + # Humidity badge rechts oben + h_val = data.get("Humidity") + if h_val is not None and w > 180: + h_str = f"💧 {int(h_val)}%" + font_h = fit_font(draw, h_str, fonts, w // 3, 22) + tw, th = measure(draw, h_str, font_h) + draw.text((x + w - tw - pad - 2, inner_y + 6), + h_str, font=font_h, fill=_humidity_color(h_val)) + + # CO2-Bar (nur für Indoor) + if show_co2: + co2 = data.get("CO2") + if co2 is not None: + # Label + label = f"CO₂ {int(co2)} ppm" + font_l = fit_font(draw, label, fonts, w - 2 * pad, 18) + draw.text((x + pad, y + h - 38), + label, font=font_l, fill=_co2_color(co2)) + # Bar + thresholds, gradient = self._bar_args() + hbar(draw, x + pad, y + h - 18, w - 2 * pad, 12, + _co2_pct(co2), thresholds=thresholds, gradient=gradient) + + # Pressure (nur wenn da, z.B. NAMain) + if w > 280 and "Pressure" in data: + p = data.get("Pressure") + if p is not None: + p_str = f"{p:.0f} mbar" + font_p = fit_font(draw, p_str, fonts, w // 4, 18) + tw, _ = measure(draw, p_str, font_p) + draw.text((x + w - tw - pad - 2, inner_y + 30), + p_str, font=font_p, fill=INFO) + + def _draw_wind_rain_card(self, draw, fonts, x, y, w, h, wind, rain, + show_compass=True, show_secondary=True): + """Untere Card: Wind-Modul links, Regen-Modul rechts.""" + pad = 6 + draw.rectangle((x, y, x + w - 1, y + h - 1), outline=FG, width=2) + + # 2 Spalten + col_w = (w - 3 * pad) // 2 + # ----- Wind ----- + wx = x + pad + wy = y + 4 + wh = h - 8 + if wind: + wd = wind["data"] + title_font = fit_font(draw, "💨 Wind", fonts, col_w - 2 * pad, 20) + draw.text((wx + pad, wy), "💨 Wind", font=title_font, fill=FG) + + speed = wd.get("WindStrength") + gust = wd.get("GustStrength") + direction = wd.get("WindAngle") + unit = self.cfg("wind_unit", "kmh") + s_str = _fmt_wind(speed, unit) + font_s = fit_font(draw, s_str, fonts, col_w - 2 * pad, int(wh * 0.55)) + draw.text((wx + pad, wy + 24), s_str, font=font_s, fill=ORANGE) + + if gust is not None: + g_str = f"♨ {_fmt_wind(gust, unit)}" + font_g = fit_font(draw, g_str, fonts, col_w - 2 * pad, 18) + draw.text((wx + pad, wy + 24 + font_s.size + 4), + g_str, font=font_g, fill=FG) + + if direction is not None and show_compass: + cr = min(28, col_w // 4, (wh - 24) // 3) + cx = wx + col_w - cr - pad - 4 + cy = wy + wh - cr - 4 + self._draw_compass(draw, cx, cy, cr, direction, fonts) + + if show_secondary and wh > 110: + # Min/Max + mn = wd.get("min_wind_str") + mx = wd.get("max_wind_str") + line = "" + if mn is not None: + line += f"↓{mn:.0f} " + if mx is not None: + line += f"↑{mx:.0f}" + if line: + font_l = fit_font(draw, line, fonts, col_w - 2 * pad - 30, 16) + draw.text((wx + pad, wy + wh - 20), + line, font=font_l, fill=FG) + else: + font_e = fit_font(draw, "kein Wind-Sensor", fonts, col_w - 2 * pad, 18) + draw.text((wx + pad, wy + 12), "kein Wind-Sensor", + font=font_e, fill=FG) + + # Trennlinie zwischen Wind und Rain + sep_x = x + col_w + 2 * pad + draw.line((sep_x, y + 6, sep_x, y + h - 6), fill=FG, width=1) + + # ----- Regen ----- + rx = x + col_w + 3 * pad + ry = y + 4 + if rain: + rd = rain["data"] + title_font = fit_font(draw, "🌧 Regen", fonts, col_w - 2 * pad, 20) + draw.text((rx + pad, ry), "🌧 Regen", font=title_font, fill=FG) + + # Aktuelle Regenrate + rate = rd.get("RainRate") or rd.get("rain_rate") or 0 + # Verschiedene Felder je nach Netatmo-Variante + cur = (rd.get("rain") if rate is None else rate) + cur_str = f"{cur:.1f} mm/h" if cur else "trocken" + font_r = fit_font(draw, cur_str, fonts, col_w - 2 * pad, int(wh * 0.4)) + draw.text((rx + pad, ry + 22), + cur_str, font=font_r, + fill=BLUE if (cur and cur > 0) else FG) + + # Summen + if show_secondary and wh > 110: + h1 = rd.get("sum_rain_1") or rd.get("rain_hour") or 0 + h24 = rd.get("sum_rain_24") or rd.get("rain_day") or 0 + font_s = fit_font(draw, "1h: 1.0 mm", fonts, col_w - 2 * pad, 18) + yl = ry + 22 + font_r.size + 6 + draw.text((rx + pad, yl), + f"1h: {_fmt_rain(h1)}", + font=font_s, fill=FG) + draw.text((rx + pad, yl + font_s.size + 2), + f"24h: {_fmt_rain(h24)}", + font=font_s, fill=FG) + else: + font_e = fit_font(draw, "kein Regen-Sensor", fonts, col_w - 2 * pad, 18) + draw.text((rx + pad, ry + 12), + "kein Regen-Sensor", font=font_e, fill=FG) + + def _draw_module_cell(self, draw, fonts, x, y, w, h, mod, label="", + show_co2_bar=False, compact=False): + """Kleine Modul-Zelle (z.B. für wide/tall Layout).""" + pad = 4 + data = mod.get("data", {}) or {} + t = data.get("Temperature") + temp = _fmt_temp(t, self.cfg("temp_unit", "C")) + color = _temp_color(t) + + # Top label + font_l = fit_font(draw, label, fonts, w - 2 * pad, 18) + draw.text((x + pad, y + 2), label, font=font_l, fill=INFO) + + if compact: + font_t = fit_font(draw, temp, fonts, w - 2 * pad, h - 20) + draw.text((x + pad, y + 22), temp, font=font_t, fill=color) + else: + font_t = fit_font(draw, temp, fonts, w - 2 * pad, int(h * 0.5)) + draw.text((x + pad, y + 22), temp, font=font_t, fill=color) + # Humidity + h_val = data.get("Humidity") + if h_val is not None: + h_str = f"💧 {int(h_val)}%" + font_h = fit_font(draw, h_str, fonts, w - 2 * pad, 18) + draw.text((x + pad, y + 22 + font_t.size + 6), + h_str, font=font_h, fill=_humidity_color(h_val)) + + # CO2-Bar wenn Indoor + if show_co2_bar: + co2 = data.get("CO2") + if co2 is not None and h > 60: + thresholds, gradient = self._bar_args() + bar_y = y + h - 14 + hbar(draw, x + pad, bar_y, w - 2 * pad, 8, + _co2_pct(co2), thresholds=thresholds, gradient=gradient) + # Label + lbl = f"CO₂ {int(co2)}" + font_c = fit_font(draw, lbl, fonts, w // 2, 14) + draw.text((x + pad, bar_y - 14), + lbl, font=font_c, fill=_co2_color(co2)) + + def _draw_compass(self, draw, cx, cy, r, deg, fonts): + """Windrose: Kreis + N/E/S/W + Pfeil auf deg (0=N).""" + draw.ellipse((cx - r, cy - r, cx + r, cy + r), outline=FG, width=1) + for a in range(0, 360, 90): + rad = math.radians(a - 90) + x1 = cx + (r - 5) * math.cos(rad) + y1 = cy + (r - 5) * math.sin(rad) + x2 = cx + r * math.cos(rad) + y2 = cy + r * math.sin(rad) + draw.line((x1, y1, x2, y2), fill=FG, width=1) + # N/E/S/W Labels + for a, t in [(270, "N"), (0, "E"), (90, "S"), (180, "W")]: + rad = math.radians(a - 90) + tx = cx + (r + 2) * math.cos(rad) - 4 + ty = cy + (r + 2) * math.sin(rad) - 5 + font_x = fonts.get("16") or fonts.get("default") + draw.text((tx, ty), t, font=font_x, fill=FG) + # Pfeil + rad_arrow = math.radians(deg - 90) + tip_x = cx + (r - 4) * math.cos(rad_arrow) + tip_y = cy + (r - 4) * math.sin(rad_arrow) + base = math.radians(150) + lx = cx + 7 * math.cos(rad_arrow + base) + ly = cy + 7 * math.sin(rad_arrow + base) + rx = cx + 7 * math.cos(rad_arrow - base) + ry = cy + 7 * math.sin(rad_arrow - base) + draw.polygon([(tip_x, tip_y), (lx, ly), (rx, ry)], fill=RED) + draw.ellipse((cx - 2, cy - 2, cx + 2, cy + 2), fill=FG) + + def _draw_footer(self, draw, fonts, x, y, w, main, modules): + """Footer: letzte Aktualisierung + Reichweite.""" + # 'last_message' der Hauptstation ist meist die letzte Datenaktualisierung + last_msg = main.get("data", {}).get("time_utc") or 0 + # Manche API-Versionen liefern 'last_message' als Unix-Sekunden + if not last_msg: + last_msg = main.get("last_message", 0) + ts = _time_short(last_msg) + reach = main.get("reachable", True) + text = f"last update: {ts or '—'}" + if not reach: + text += " ⚠ offline" + # Reichweite der Module + offline = sum(1 for m in modules if not m.get("reachable", True)) + if offline: + text += f" · {offline} offline" + + font_f = fit_font(draw, text, fonts, w - 16, 14) + draw.text((x + 8, y), text, font=font_f, fill=FG) + + # ---- Utility ---- + @staticmethod + def _first_module(modules: list, mtype: str) -> dict | None: + for m in modules: + if m["type"] == mtype: + return m + return None \ No newline at end of file