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.
+
+
+
+---
+
+## 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