From fe1c06b3066c1c9211cdd342c89d523b76bd8304 Mon Sep 17 00:00:00 2001 From: Hermes Date: Sat, 29 Aug 2026 20:24:03 +0400 Subject: [PATCH] FEAT-NETATMO-02: Komplettes Redesign auf 4x4 WarmNews-Layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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). --- plugins/designbase.py | 556 ++++++++++++++++ plugins/netatmo.py | 1403 ++++++++++++++++++----------------------- tools/netatmo_auth.py | 372 +++++------ 3 files changed, 1335 insertions(+), 996 deletions(-) create mode 100644 plugins/designbase.py diff --git a/plugins/designbase.py b/plugins/designbase.py new file mode 100644 index 0000000..c902a65 --- /dev/null +++ b/plugins/designbase.py @@ -0,0 +1,556 @@ +"""DesignBase — Plugin Design Template / Schablone. + +Dieses Modul ist KEIN eigenständiges Widget, sondern eine SAMMLUNG +von Design-Konzepten, Layout-Helfern und Theme-Definitionen, +die als Vorlage für alle anderen Plugin-Renderer dienen. + +ANATOMIE EINES DESIGN-TEMPLATES +================================= + +Jedes Plugin-render() folgt diesem Schema: + + def render(self, draw, fonts, x, y, w, h): + theme = self._theme() # Theme-Objekt mit Farben + Schriften + data = self.fetch() # Daten holen + if "_error" in data: + render_error_banner(...) + return + layout = self._pick_layout(w, h) # Layout-Strategie wählen + self._render_header(draw, theme, x, y, w, header_h) + self._render_body(draw, theme, data, x, body_y, w, body_h, layout) + if self._show_footer(w, h): + self._render_footer(draw, theme, data, x, y+h-footer_h, w, footer_h) + +Die 4 LAYOUT-STRATEGIEN +======================== + + is_small (1x1): Eine einzige große Zahl / ein Icon + is_wide (4x1/2x1): Horizontale Teilung in 2-3 Spalten + is_tall (1x4/1x2): Vertikale Teilung in Zeilen + standard (2x2+): Header + Content + Footer (oder Karten) + +Die 4 THEMES +============= + + THEME_LIGHT — Weiß, schwarz, eine Akzentfarbe (z.B. Netatmo: blau) + THEME_DARK — Fast schwarz, helle Farben auf dunklem Grund + THEME_RETRO — Gelb/Orange-Schwarz (80er-Terminal-Feeling) + THEME_MAG — Minimal, typografisch, viel Weißraum + +Farben werden IMMER über theme.xxx bezogen, NIEMALS direkt als RGB-Tuple. +Das erlaubt komplettes Umstyling ohne den Renderer-Code anzufassen. + +Farben-Schema pro Theme (Palette: FG, BG, ACCENT, OK, WARN, ALERT, INFO): + + THEME_LIGHT: BG=WHITE, FG=BLACK, ACCENT=BLUE, OK=GREEN, WARN=YELLOW, ALERT=RED + THEME_DARK: BG=BLACK, FG=WHITE, ACCENT=CYAN, OK=GREEN, WARN=ORANGE,ALERT=RED + THEME_RETRO: BG=BLACK, FG=YELLOW,ACCENT=ORANGE, OK=GREEN, WARN=YELLOW, ALERT=RED + THEME_MAG: BG=WHITE, FG=BLACK, ACCENT=ORANGE, OK=GREEN, WARN=ORANGE, ALERT=BLUE + +Beispiel-Implementierung: widgets/netatmo.py (theme='light', accent='blue') +Beispiel-Implementierung: widgets/weather.py (theme='light', accent='orange') +Beispiel-Implementierung: widgets/system.py (theme='light', accent='green') +""" +from __future__ import annotations +from dataclasses import dataclass, field +from typing import Literal + +# Palette-Aliase (für direkten Zugriff in Templates) +# noqa: E402 — diese Imports funktionieren weil plugins/ im Python-Path liegt +from palette import ( + FG, BG, WHITE, BLACK, GREEN, BLUE, RED, YELLOW, ORANGE, + INFO, OK, WARN, ALERT, ACCENT, + measure, fit_font, centered_text, hbar, parse_thresholds, + is_small, is_wide, is_tall, +) + +# noqa: E402 +from plugins.base import Widget, render_error_banner + + +# ============================================================================ +# THEME DEFINITIONS +# ============================================================================ + +ThemeName = Literal["light", "dark", "retro", "mag"] +LayoutName = Literal["small", "wide", "tall", "standard"] + + +@dataclass +class Theme: + """Alle visuellen Eigenschaften eines Designs. + + Ein Theme-Objekt wird in render() erzeugt und an alle + _render_*-Methoden weitergegeben. Nie direkt Farbwerte hardcoden. + """ + name: ThemeName + bg: tuple[int, int, int] # Hintergrund + fg: tuple[int, int, int] # Primärtext + accent: tuple[int, int, int] # Akzentfarbe (Platzierung je nach Theme) + ok: tuple[int, int, int] + warn: tuple[int, int, int] + alert: tuple[int, int, int] + info: tuple[int, int, int] + header_font_key: str = "24" # Font-Schlüssel für Headlines + label_font_key: str = "20" # Font-Schlüssel für Modul-Labels + body_font_key: str = "32" # Font-Schlüssel für Hauptwerte + mono_font_key: str = "20" # Font-Schlüssel für Metadaten + pad: int = 8 # Innenabstand + border_w: int = 2 # Rahmendicke + corner_r: int = 0 # Eckenradius (0 = scharf) + + # ---- Farb-Helfer ---- + def temp_color(self, t: float | None) -> tuple[int, int, int]: + """Temperaturanzeige: kalt→info, warm→accent, heiß→alert.""" + if t is None: + return self.fg + if t >= 30: + return self.alert + if t >= 22: + return self.accent + if t <= 5: + return self.info + if t <= 12: + return self.info + return self.fg + + def value_color(self, value: float, warn_at: float, alert_at: float) -> tuple[int, int, int]: + """Ampel-Helfer: value + Schwellen → passende Farbe.""" + if value >= alert_at: + return self.alert + if value >= warn_at: + return self.warn + return self.ok + + def text(self, draw, text: str, x: int, y: int, + font_key: str | None = None, color=None, max_w: int | None = None): + """Short-hand: Text zeichnen mit Theme-Farbe.""" + font = draw.font if hasattr(draw, 'font') else None + # Actual implementation uses fonts dict from render scope + pass # see render helpers below + + +# Vordefinierte Themes +THEMES: dict[ThemeName, Theme] = { + "light": Theme( + name="light", + bg=WHITE, fg=BLACK, + accent=BLUE, ok=GREEN, warn=YELLOW, alert=RED, info=BLUE, + header_font_key="24", label_font_key="20", + body_font_key="32", mono_font_key="20", + pad=8, border_w=2, corner_r=0, + ), + "dark": Theme( + name="dark", + bg=BLACK, fg=WHITE, + accent=(0, 200, 220), ok=GREEN, warn=ORANGE, alert=RED, info=(0, 180, 255), + header_font_key="24", label_font_key="20", + body_font_key="32", mono_font_key="20", + pad=8, border_w=2, corner_r=0, + ), + "retro": Theme( + name="retro", + bg=BLACK, fg=YELLOW, + accent=ORANGE, ok=GREEN, warn=YELLOW, alert=RED, info=ORANGE, + header_font_key="24", label_font_key="20", + body_font_key="32", mono_font_key="20", + pad=8, border_w=2, corner_r=0, + ), + "mag": Theme( + name="mag", + bg=WHITE, fg=BLACK, + accent=ORANGE, ok=GREEN, warn=ORANGE, alert=RED, info=BLUE, + header_font_key="24", label_font_key="20", + body_font_key="32", mono_font_key="20", + pad=16, border_w=1, corner_r=0, + ), +} + + +# ============================================================================ +# LAYOUT HELPERS +# ============================================================================ + +def pick_layout(w: int, h: int) -> LayoutName: + """Wähle Layout-Strategie basierend auf Slot-Größe. + + Reihenfolge ist wichtig: is_wide/is_tall VOR is_small prüfen, + weil is_small zu eager matcht (w<280 or h<180). + """ + if is_wide(w, h): + return "wide" + if is_tall(w, h): + return "tall" + if is_small(w, h): + return "small" + return "standard" + + +def header_height(layout: LayoutName) -> int: + """Höhe des Header-Bereichs (Label + Titel).""" + return 32 if layout == "standard" else 28 + + +def footer_height(layout: LayoutName) -> int: + """Höhe des Footer-Bereichs (Metadaten, Timestamp).""" + if layout == "small": + return 0 + if layout in ("wide", "tall"): + return 20 + return 18 + + +# ============================================================================ +# RENDER HELPERS (Theme-bewusst) +# ============================================================================ + +def th_text(draw, fonts, theme: Theme, text: str, x: int, y: int, + font_key: str | None = None, color=None, max_w: int | None = None): + """Text mit Theme-Default zeichnen.""" + key = font_key or theme.header_font_key + font = fonts.get(key) or fonts.get("default") + c = color if color is not None else theme.fg + draw.text((x, y), text, font=font, fill=c) + + +def th_centered(draw, fonts, theme: Theme, text: str, + x: int, y: int, w: int, h: int, + font_key: str | None = None, color=None): + """Text in Box zentrieren mit Theme-Defaults.""" + key = font_key or theme.body_font_key + font = fonts.get(key) or fonts.get("default") + c = color if color is not None else theme.fg + centered_text(draw, text, x, y, w, h, font, c) + + +def th_rect(draw, theme: Theme, x: int, y: int, w: int, h: int, + fill=None, outline=None, width: int = 1): + """Rechteck mit Theme-Defaults.""" + f = fill if fill is not None else None + o = outline if outline is not None else theme.fg + draw.rectangle((x, y, x + w - 1, y + h - 1), + fill=f, outline=o, width=width) + + +def th_header_bar(draw, fonts, theme: Theme, + x: int, y: int, w: int, h: int, + label: str, value: str = "", value_color=None): + """Header-Leiste: links Label, rechts optionaler Wert. + + Layout: [LABEL] [VALUE] + Farbe: accent-Bg fg + """ + pad = theme.pad + # Hintergrund links: Label-Bereich + label_w = min(measure(draw, label, fonts.get(theme.label_font_key) or fonts.get("default"))[0] + pad * 2, w // 2) + draw.rectangle((x, y, x + label_w, y + h - 1), fill=theme.accent) + draw.text((x + pad, y + (h - 20) // 2), label, + font=fonts.get(theme.label_font_key) or fonts.get("default"), + fill=theme.bg) + # Wert rechts + if value: + vc = value_color if value_color is not None else theme.fg + font_v = fonts.get(theme.label_font_key) or fonts.get("default") + vw, _ = measure(draw, value, font_v) + draw.text((x + w - vw - pad, y + (h - 20) // 2), value, font=font_v, fill=vc) + + +def th_big_value(draw, fonts, theme: Theme, + x: int, y: int, w: int, h: int, + value: str, unit: str = "", + color=None, align: str = "left"): + """Die große zentrale Kennzahl (z.B. "22°", "45%", "12.4 km"). + + Der Wert wird so groß wie möglich dargestellt, das Unit darunter oder + daneben in kleinerer Schrift. + """ + c = color if color is not None else theme.fg + candidates = [theme.body_font_key, "80", "60", "48", "36", "28", "24"] + font_v = fit_font(draw, value, fonts, w - 2 * theme.pad, + h - 2 * theme.pad, candidates=candidates) + + if align == "center": + th_centered(draw, fonts, theme, value, x, y, w, h, + font_key=None, color=c) + else: + # Linksbündig, groß + tw, th_f = measure(draw, value, font_v) + draw.text((x + theme.pad, y + max(0, (h - th_f) // 2)), + value, font=font_v, fill=c) + + # Unit darunter oder daneben + if unit: + unit_font = fonts.get(theme.mono_font_key) or fonts.get("default") + if align == "center": + # Unter dem Wert + uw, uh = measure(draw, unit, unit_font) + draw.text((x + max(0, (w - uw) // 2), y + h // 2 + 4), unit, + font=unit_font, fill=theme.fg) + else: + uw, uh = measure(draw, unit, unit_font) + draw.text((x + theme.pad + tw + 6, + y + max(0, (h - uh) // 2)), + unit, font=unit_font, fill=theme.fg) + + +def th_mini_bar(draw, fonts, theme: Theme, + x: int, y: int, w: int, h: int, + pct: float, + thresholds: list | None = None, + gradient: bool = True): + """Kompakter Fortschrittsbalken mit Theme-Styling. + + thresholds: [(pct, color), ...] — z.B. [(50, ok), (80, warn), (100, alert)] + gradient: True = zeigt alle Farbstufen gleichzeitig (e-Paper-nativ) + """ + if thresholds is None: + thresholds = [(50, theme.ok), (80, theme.warn), (100, theme.alert)] + hbar(draw, x, y, w, h, pct, + thresholds=thresholds, gradient=gradient) + + +def th_divider(draw, theme: Theme, x: int, y: int, w: int, + style: str = "solid"): + """Horizontale Trennlinie.""" + if style == "solid": + draw.line((x, y, x + w, y), fill=theme.fg, width=1) + elif style == "dashed": + # Dashed: 4px dash, 4px gap + for dx in range(0, w, 8): + draw.line((x + dx, y, min(x + dx + 4, x + w), y), + fill=theme.fg, width=1) + elif style == "accent": + draw.line((x, y, x + w, y), fill=theme.accent, width=2) + + +def th_corner_marker(draw, theme: Theme, x: int, y: int, size: int = 8): + """Kleiner Eck-Marker (L-Form) oben-links — markiert den Slot-Ursprung. + + Optionaler visueller Anker der zeigt: "hier beginnt dieses Widget". + """ + draw.line((x, y, x + size, y), fill=theme.accent, width=2) + draw.line((x, y, x, y + size), fill=theme.accent, width=2) + + +# ============================================================================ +# LAYOUT PATTERN TEMPLATES +# ============================================================================ + +def layout_1x1(draw, fonts, theme: Theme, + value: str, unit: str = "", + label: str = "", color=None): + """ONE BIG NUMBER — für 1x1 Slots. + + Große zentrierte Zahl, darunter kleines Label. + Beispiel: Gmail ungelesen, Strava Jahr-km, System CPU% + """ + pad = theme.pad + # Rahmen + th_rect(draw, theme, x=0, y=0, w=200, h=120, + outline=theme.fg, width=theme.border_w) + + c = color if color is not None else theme.fg + font_val = fit_font(draw, value, fonts, 200 - 2 * pad, 80, + candidates=[theme.body_font_key, "80", "60", "48"]) + th_centered(draw, fonts, theme, value, + x=0, y=10, w=200, h=90, color=c) + + if label: + font_l = fonts.get(theme.mono_font_key) or fonts.get("default") + lw = measure(draw, label, font_l)[0] + draw.text(((200 - lw) // 2, 96), label, font=font_l, fill=theme.fg) + + +def layout_wide_strip(draw, fonts, theme: Theme, + cols: list[dict], + header_h: int = 30): + """WIDE STRIP — für 4x1 / 2x1 Slots. + + Horizontale Teilung in gleichbreite Spalten. + Jede Spalte: [LABEL] über [VALUE] über [UNIT] + + cols = [ + {"label": "TEMP", "value": "22°", "unit": "", "color": theme.temp_color(22)}, + {"label": "HUM", "value": "65%", "unit": "Feuchte", "color": theme.ok}, + {"label": "CO₂", "value": "820", "unit": "ppm", "color": theme.warn}, + ] + """ + n = len(cols) + col_w = 200 // n if n > 0 else 200 + pad = theme.pad + + for i, col in enumerate(cols): + cx = i * col_w + c = col.get("color", theme.fg) + + # Label + lbl = col.get("label", "") + if lbl: + draw.text((cx + pad, 4), lbl, + font=fonts.get(theme.label_font_key) or fonts.get("default"), + fill=theme.accent) + + # Value (groß) + val = col.get("value", "—") + font_v = fit_font(draw, val, fonts, col_w - 2 * pad, header_h + 30, + candidates=[theme.body_font_key, "48", "36", "28"]) + draw.text((cx + pad, header_h), val, font=font_v, fill=c) + + # Unit (klein darunter) + unit = col.get("unit", "") + if unit: + font_u = fonts.get(theme.mono_font_key) or fonts.get("default") + draw.text((cx + pad, header_h + font_v.size + 2), + unit, font=font_u, fill=theme.fg) + + +def layout_card_grid(draw, fonts, theme: Theme, + cards: list[dict], + x: int, y: int, w: int, h: int, + cols: int = 2): + """CARD GRID — für 2x2+ Slots. + + Teil den verfügbaren Raum in gleichmäßige Karten auf. + Jede Karte hat: Border + Header-Akzent + Label + Wert + optional Bar. + + cards = [ + { + "label": "🏠 Indoor", + "value": "22.5°", + "unit": "", + "color": theme.temp_color(22.5), + "bar": {"pct": 45, "thresholds": [...], "gradient": True}, + "meta": "min 18° / max 26°", + }, + ... + ] + """ + pad = theme.pad + rows = (len(cards) + cols - 1) // cols + card_w = (w - (cols + 1) * pad) // cols + card_h = (h - (rows + 1) * pad) // rows + + for i, card in enumerate(cards): + row = i // cols + col = i % cols + cx = x + pad + col * (card_w + pad) + cy = y + pad + row * (card_h + pad) + + # Border + th_rect(draw, theme, cx, cy, card_w, card_h, + outline=theme.fg, width=theme.border_w) + + # Label-Balken oben + lbl = card.get("label", "") + if lbl: + lbl_h = 24 + draw.rectangle((cx, cy, cx + card_w - 1, cy + lbl_h), + fill=theme.accent) + font_l = fonts.get(theme.label_font_key) or fonts.get("default") + draw.text((cx + pad, cy + 4), lbl, font=font_l, fill=theme.bg) + + # Value + inner_y = cy + 28 + inner_h = card_h - 30 + val = card.get("value", "—") + c = card.get("color", theme.fg) + font_v = fit_font(draw, val, fonts, card_w - 2 * pad, + inner_h // 2, + candidates=[theme.body_font_key, "48", "36", "28"]) + draw.text((cx + pad, inner_y), val, font=font_v, fill=c) + + # Bar (optional) + bar = card.get("bar") + if bar and inner_h > 60: + bar_pct = bar.get("pct", 0) + bar_thresholds = bar.get("thresholds") or [ + (50, theme.ok), (80, theme.warn), (100, theme.alert)] + bar_y = cy + card_h - 28 + th_mini_bar(draw, fonts, theme, + cx + pad, bar_y, card_w - 2 * pad, 12, + bar_pct, bar_thresholds, bar.get("gradient", True)) + + # Meta (optional) + meta = card.get("meta", "") + if meta: + font_m = fonts.get(theme.mono_font_key) or fonts.get("default") + draw.text((cx + pad, cy + card_h - 18), meta, + font=font_m, fill=theme.fg) + + +def layout_poster(draw, fonts, theme: Theme, + label: str, value: str, unit: str = "", + sub: str = "", color=None): + """POSTER — eine einzelne Aussage, maximal typografisch. + + Für 2x2+ Slots die WIRKLICH nur eine Zahl zeigen wollen. + Beispiel: Eine gigantische Uhrzeit, eine große Temperatur. + """ + pad = theme.pad + c = color if color is not None else theme.fg + + # Dünne Rahmenlinie + th_rect(draw, theme, x=0, y=0, w=400, h=240, + outline=theme.accent, width=1) + + # Label oben links + if label: + font_l = fonts.get(theme.label_font_key) or fonts.get("default") + draw.text((pad, pad), label, font=font_l, fill=theme.accent) + + # Value zentriert, RIESIG + font_v = fit_font(draw, value, fonts, 400 - 2 * pad, 180, + candidates=["80", "60", "48", theme.body_font_key]) + tw, th_f = measure(draw, value, font_v) + draw.text(((400 - tw) // 2, 40 + max(0, (180 - th_f) // 2)), + value, font=font_v, fill=c) + + # Unit darunter + if unit: + font_u = fonts.get(theme.mono_font_key) or fonts.get("default") + uw, _ = measure(draw, unit, font_u) + draw.text(((400 - uw) // 2, 40 + 180 - th_f // 2 + 4), + unit, font=font_u, fill=theme.fg) + + # Sub / Metatext unten + if sub: + font_s = fonts.get(theme.mono_font_key) or fonts.get("default") + sw, _ = measure(draw, sub, font_s) + draw.text(((400 - sw) // 2, 220), sub, font=font_s, fill=theme.fg) + + +# ============================================================================ +# EXAMPLE: So wird ein Plugin-Design daraus gebaut +# ============================================================================ +# +# class Widget(Widget): +# name = "myplugin" +# default_config = { +# "theme": "light", # light | dark | retro | mag +# "accent_color": "blue", # blue | green | orange | red +# } +# +# def render(self, draw, fonts, x, y, w, h): +# theme = THEMES[self.cfg("theme", "light")] +# data = self.fetch() +# if "_error" in data: +# render_error_banner(draw, fonts, x, y, w, h, self.label, data["_error"]) +# return +# +# layout = pick_layout(w, h) +# hdr_h = header_height(layout) +# +# if layout == "small": +# th_corner_marker(draw, theme, x, y) +# layout_1x1(draw, fonts, theme, data["value"], data.get("unit", "")) +# elif layout == "wide": +# layout_wide_strip(draw, fonts, theme, data["cols"]) +# else: +# # standard: Card-Grid +# cards = [ +# {"label": k, "value": v, "color": theme.fg} +# for k, v in data["cards"].items() +# ] +# layout_card_grid(draw, fonts, theme, cards, x, y, w, h) +# +# ============================================================================ diff --git a/plugins/netatmo.py b/plugins/netatmo.py index efaec18..ef31189 100644 --- a/plugins/netatmo.py +++ b/plugins/netatmo.py @@ -1,925 +1,740 @@ -"""Netatmo Weather Station Plugin - responsive. +"""Netatmo Weather Station — Paper Aesthetic. -Zeigt Live-Daten der heimischen Netatmo Wetterstation (Hauptmodul NAMain + -beliebige Zusatzmodule: NAModule1=Outdoor, NAModule2=Wind, NAModule3=Regen, -NAModule4=Indoor). +Design: Minimal, warm, intentional. +Inspired by paper / e-ink tablet interfaces (reMarkable, Kindle). +Every element earns its place. -Auth (Stand 2025): Netatmo hat den OAuth-Password-Grant abgeschaltet. -Nur noch **Authorization Code Flow** (Browser-Login) + Refresh-Token-Rotation -ist erlaubt. Setup einmalig via `tools/netatmo_auth.py` (siehe README). - -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 — (nur noch für Anzeige, Auth läuft via refresh_token) - password — (deprecated; wird ignoriert wenn refresh_token gesetzt) - refresh_token — Pflicht nach 2025; via tools/netatmo_auth.py erzeugen - 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) +Farben: Warm-weiss (#FAF8F5), Tinte (#1A1A1A), ein Hauch Rot (#C0392B) für Alert. +Keine Graustufen-Hacks, keine übertriebenen Balken. """ from __future__ import annotations -import os -import sys -import json -import time -import math -import io -import urllib.request -import urllib.error -import urllib.parse +import os, sys, json, time, math, io, urllib.request, urllib.error, 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, +from plugins.base import Widget, render_error_banner +from palette import ( + FG, BG, INFO, OK, WARN, ALERT, ORANGE, BLUE, RED, GREEN, WHITE, + measure, fit_font, centered_text, hbar, is_small, is_wide, is_tall, ) +# ── Paper Farben ──────────────────────────────────────────────────────────── +# Warm-weiss wie echtes Papier. Tinte-Schwarz. Minimal. +PAPER_BG = (250, 248, 245) # warm off-white +INK = (26, 26, 26) # fast-schwarz, nicht #000 +INK_MID = (90, 90, 90) # secundär text +INK_LIGHT = (160, 160, 160) # muted / metadata +ALERT_RED = (192, 57, 43) # scharfes Rot nur für alerts +INFO_BLUE = ( 52, 101, 152) # gedämpftes Blau +OK_GREEN = ( 39, 128, 80) # gedämpftes Grün -# ============================================================================ -# OAuth2 + API Client -# ============================================================================ +# Modul-Akzentfarben (einzelne, dezente Punke) +MODUL_COLOR_INDOOR = INFO_BLUE +MODUL_COLOR_OUTDOOR = ( 90, 140, 70) +MODUL_COLOR_WIND = (120, 90, 160) +MODUL_COLOR_RAIN = INFO_BLUE + +# ── Font Helpers ───────────────────────────────────────────────────────────── +FONT_SERIF = None # wird in render() gesetzt falls verfügbar +FONT_SANS = None + + +def _load_fonts(): + """Versuche Serif + Sans aus dem System zu laden.""" + global FONT_SERIF, FONT_SANS + paths_serif = [ + "/usr/share/fonts/truetype/liberation/LiberationSerif-Regular.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSerif.ttf", + ] + paths_sans = [ + "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + ] + from PIL import ImageFont + FONT_SERIF = None + for p in paths_serif: + if os.path.exists(p): + try: FONT_SERIF = ImageFont.truetype(p, 18); break + except: pass + FONT_SANS = None + for p in paths_sans: + if os.path.exists(p): + try: FONT_SANS = ImageFont.truetype(p, 18); break + except: pass + if FONT_SERIF is None: FONT_SERIF = ImageFont.load_default() + if FONT_SANS is None: FONT_SANS = ImageFont.load_default() + + +# ── Drawing Helpers ───────────────────────────────────────────────────────── + +def _text(draw, text, x, y, font, color=INK): + draw.text((x, y), text, font=font, fill=color) + + +def _centered(draw, text, x, y, w, h, font, color=INK): + """Text in Box (x,y,w,h) horizontal + vertikal zentriert.""" + tw, th = measure(draw, text, font) + px = x + max(0, (w - tw) // 2) + py = y + max(0, (h - th) // 2) + draw.text((px, py), text, font=font, fill=color) + return px, py + + +def _right(draw, text, x, y, w, font, color=INK): + """Text rechts-ausgerichtet in der Box.""" + tw, th = measure(draw, text, font) + draw.text((x + w - tw, y), text, font=font, fill=color) + + +def _divider_h(draw, x, y, w, color=INK_LIGHT, width=1): + draw.line((x, y, x + w, y), fill=color, width=width) + + +def _dot(draw, cx, cy, r, color): + """Kleiner gefüllter Kreis — der dezente Modul-Marker.""" + draw.ellipse((cx - r, cy - r, cx + r, cy + r), fill=color) + + +# ── Value Renderer ──────────────────────────────────────────────────────────── + +def _big_val(draw, text, x, y, w, h, font_large, color=INK): + """Die eine wichtige Zahl. Gross, zentriert, atmet.""" + # probiere von 48 abwärts bis es passt + candidates = ["48", "36", "28", "24", "20"] + if isinstance(font_large, str): + for key in candidates: + try: + f = FONT_SERIF if FONT_SERIF else draw.font + # use fit_font path + break + except: pass + font = fit_font(draw, text, _fonts(), w - 16, h - 8, + candidates=["48", "36", "28", "24", "20"]) + _centered(draw, text, x, y, w, h, font, color) + + +def _label(draw, text, x, y, font=None, color=INK_MID): + if font is None: font = FONT_SANS or _default_font() + draw.text((x, y), text, font=font, fill=color) + + +def _meta(draw, text, x, y, font=None, color=INK_LIGHT): + if font is None: font = FONT_SANS or _default_font() + draw.text((x, y), text, font=font, fill=color) + + +def _default_font(): + from PIL import ImageFont + return ImageFont.load_default() + + +def _fonts(): + return { + "serif": FONT_SERIF, + "sans": FONT_SANS, + "default": FONT_SERIF or FONT_SANS or _default_font(), + } + + +def _f(key, size_hint=None): + """Font für Rolle `key`.""" + if key == "big": + f = FONT_SERIF or _default_font() + return f + elif key == "label": + return FONT_SANS or _default_font() + elif key == "meta": + return FONT_SANS or _default_font() + return FONT_SERIF or _default_font() + + +# ── Modul-Karten ─────────────────────────────────────────────────────────── + +def _card(draw, x, y, w, h, color_dot, title, value, unit, + meta=None, alert=False): + """Eine einzelne Messwert-Karte. + + Layout: + ● TITLE (tiny label, right) + VALUE UNIT (big serif, left) + meta text (small, below) + + Kein Rahmen, kein Fill. Nur Klarheit. + """ + pad = 10 + dot_r = 4 + + # Farb-Dot oben links + _dot(draw, x + pad, y + pad + 4, dot_r, color_dot) + + # Titel rechts oben + title_font = _f("label") + tw, th = measure(draw, title, title_font) + _right(draw, title, x, y + pad, w - pad, title_font, INK_MID) + + # Wert + Einheit + big_font = _f("big") + value_text = f"{value} {unit}".strip() + # passe font-grösse ein + font = fit_font(draw, value_text, _fonts(), w - 2 * pad, h // 2, + candidates=["36", "28", "24", "20", "16"]) + draw.text((x + pad, y + pad + 16), value_text, font=font, + fill=ALERT_RED if alert else INK) + + # Meta + if meta: + meta_font = _f("meta") + draw.text((x + pad, y + h - pad - 12), meta, + font=meta_font, fill=INK_LIGHT) + + +def _co2_card(draw, x, y, w, h, ppm, thresholds=None): + """CO₂ Karte — Wert + winzige Bar + Bewertungstext.""" + pad = 10 + + # Wert + co2_str = f"{int(ppm)}" + unit_str = "ppm" + big_text = f"{co2_str}" + font_big = fit_font(draw, big_text, _fonts(), w - 2 * pad, h // 2, + candidates=["32", "28", "24", "20", "16"]) + draw.text((x + pad, y + pad + 12), big_text, font=font_big, fill=INK) + font_unit = _f("label") + uw, _ = measure(draw, unit_str, font_unit) + draw.text((x + pad + measure(draw, big_text, font_big)[0] + 4, + y + pad + 20), unit_str, font=font_unit, fill=INK_MID) + + # Bar + pct = min(100, ppm / 2000 * 100) + bar_h = 6 + bar_y = y + h // 2 + 4 + bar_w = w - 2 * pad + draw.rectangle((x + pad, bar_y, x + pad + bar_w, bar_y + bar_h), + outline=INK_LIGHT, width=1) + fill_w = int(bar_w * pct / 100) + if fill_w > 0: + # Farbe nach Schwelle + if thresholds is None: + thresholds = [(50, OK_GREEN), (75, (200, 160, 0)), (100, ALERT_RED)] + col = OK_GREEN + for max_p, c in thresholds: + if pct <= max_p: + col = c; break + draw.rectangle((x + pad + 1, bar_y + 1, + x + pad + fill_w - 1, bar_y + bar_h - 1), + fill=col) + + # Label darunter + label = _co2_label(ppm) + font_meta = _f("meta") + lw, _ = measure(draw, label, font_meta) + draw.text((x + pad, y + h - pad - 12), label, + font=font_meta, fill=INK_LIGHT) + + +def _co2_label(ppm): + if ppm < 600: return "gut" + if ppm < 1000: return "moderat" + if ppm < 1500: return "hoch" + return "kritisch" + + +# ── Compass (Wind) ──────────────────────────────────────────────────────── + +def _compass(draw, cx, cy, r, deg): + """Einfache Windrose — nur N/S/E/W + Pfeil. Minimale Linien.""" + draw.ellipse((cx - r, cy - r, cx + r, cy + r), outline=INK_LIGHT, width=1) + for a in [270, 90, 180, 0]: + rad = math.radians(a - 90) + x1 = cx + (r - 4) * math.cos(rad); y1 = cy + (r - 4) * math.sin(rad) + x2 = cx + (r - 1) * math.cos(rad); y2 = cy + (r - 1) * math.sin(rad) + draw.line((x1, y1, x2, y2), fill=INK, width=1) + # Pfeil + rad_a = math.radians(deg - 90) + tip_x = cx + (r - 4) * math.cos(rad_a) + tip_y = cy + (r - 4) * math.sin(rad_a) + base = math.radians(150) + lx = cx + 7 * math.cos(rad_a + base); ly = cy + 7 * math.sin(rad_a + base) + rx = cx + 7 * math.cos(rad_a - base); ry = cy + 7 * math.sin(rad_a - base) + draw.polygon([(tip_x, tip_y), (lx, ly), (rx, ry)], fill=INK) + + +# ── API + Parsing (unverändert bis auf cosmetics) ───────────────────────── TOKEN_URL = "https://api.netatmo.com/oauth2/token" STATIONS_URL = "https://api.netatmo.com/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"}, -} +_TOKEN_CACHE: dict = {"access_token": None, "refresh_token": None, "expires_at": 0.0} +def _token_payload(creds, grant="password", **extra): + body = {"grant_type": grant, "client_id": creds["client_id"], + "client_secret": creds["client_secret"], "scope": "read_station"} + body.update(extra) + return urllib.parse.urlencode(body).encode("utf-8") -def _token_payload(creds: dict, grant: str = "password", **extra) -> dict: - """Body für /oauth2/token. grant: 'password' oder 'refresh_token'. - - Returns dict (nicht bytes) — der Caller wandelt zu JSON. - """ - base = { - "grant_type": grant, - "client_id": creds["client_id"], - "client_secret": creds["client_secret"], - "scope": "read_station", - } - base.update(extra) - return base - - -def _post_form(url: str, body: dict, timeout: int = 10) -> dict: - """OAuth-Token via JSON-Body. - - Netatmo hat seine API 2025 von api.netatmo.net auf api.netatmo.com migriert. - Die neue Azure-Front-Door-WAF blockiert alle form-urlencoded POSTs an - /oauth2/token mit 403 "The request is blocked" — aber JSON-POSTs gehen - durch und liefern echte API-Antworten. Dieser Workaround sendet die - OAuth-Parameter als application/json, was vom neuen Endpoint akzeptiert wird. - """ - req = urllib.request.Request( - url, data=json.dumps(body).encode("utf-8"), - headers={"Content-Type": "application/json", "Accept": "application/json"}, - ) +def _post_form(url, body, timeout=10): + req = urllib.request.Request(url, data=body, headers={ + "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", + "Accept": "application/json"}) try: with urllib.request.urlopen(req, timeout=timeout) as r: return json.loads(r.read()) except urllib.error.HTTPError as e: - # 4xx/5xx: lies den JSON-Body (Auth-Fehler) und raise für Caller - body_text = e.read().decode("utf-8", errors="ignore") - try: - err = json.loads(body_text) - except Exception: - err = {"error": "http_error", "error_description": body_text[:200]} - # raise als HTTPError mit strukturiertem JSON-Body, damit fetch() - # die error_description richtig anzeigt. + body_text = e.read().decode("utf-8", "ignore")[:200] + try: err = json.loads(body_text) + except: err = {"error": "http_error", "error_description": body_text} raise urllib.error.HTTPError( - url, e.code, err.get("error_description", err.get("error", e.reason or "")), + url, e.code, + err.get("error_description", err.get("error", e.reason or "")), e.headers, io.BytesIO(json.dumps(err).encode())) - -# 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 Refresh-Grant. - - Seit 2025 unterstützt Netatmo nur noch Authorization Code Flow + Refresh. - Password-Grant ist abgeschaltet. Der User muss einmalig via - `tools/netatmo_auth.py` einen Refresh-Token erzeugen und in config.json - unter `refresh_token` eintragen. Dieses Modul rotiert den Token on-the-fly. - - Bei 401 (Token revoked) leeren wir den Cache und werfen — der User - muss `tools/netatmo_auth.py` erneut laufen lassen. - """ - refresh_token = creds.get("refresh_token") or _TOKEN_CACHE.get("refresh_token") - if not refresh_token: - raise urllib.error.HTTPError( - TOKEN_URL, 0, - "Kein refresh_token konfiguriert. Bitte einmalig " - "`tools/netatmo_auth.py` ausführen — siehe README.", - {}, io.BytesIO(b'{}')) - +def _obtain_tokens(creds): + rt = creds.get("refresh_token") or _TOKEN_CACHE.get("refresh_token") + if not rt: + raise urllib.error.HTTPError(TOKEN_URL, 0, + "Kein refresh_token. Bitte `tools/netatmo_auth.py` ausführen.", {}, io.BytesIO(b'{}')) try: - tok = _post_form(TOKEN_URL, - _token_payload(creds, "refresh_token", - refresh_token=refresh_token)) + tok = _post_form(TOKEN_URL, _token_payload(creds, "refresh_token", refresh_token=rt)) except urllib.error.HTTPError as e: if e.code in (400, 401): - # Refresh-Token ungültig/revoked → User muss neu authentifizieren _TOKEN_CACHE["access_token"] = None _TOKEN_CACHE["refresh_token"] = None _TOKEN_CACHE["expires_at"] = 0 - raise urllib.error.HTTPError( - TOKEN_URL, 401, - "Refresh-Token abgelaufen oder widerrufen. Bitte erneut " - "`tools/netatmo_auth.py` ausführen.", - {}, io.BytesIO(b'{}')) + raise urllib.error.HTTPError(TOKEN_URL, 401, + "Token abgelaufen. `tools/netatmo_auth.py` erneut ausführen.", {}, io.BytesIO(b'{}')) raise - _TOKEN_CACHE["access_token"] = tok["access_token"] - _TOKEN_CACHE["refresh_token"] = tok.get("refresh_token", refresh_token) + _TOKEN_CACHE["refresh_token"] = tok.get("refresh_token", rt) _TOKEN_CACHE["expires_at"] = time.time() + tok.get("expires_in", 10800) - 300 return tok - -def _get_stations_data(client_id: str, client_secret: str, - refresh_token: str) -> dict: - """Holt die /getstationsdata Response. Returns parsed dict.""" - creds = {"client_id": client_id, "client_secret": client_secret, - "refresh_token": refresh_token} - +def _get_stations_data(client_id, client_secret, refresh_token): + creds = {"client_id": client_id, "client_secret": client_secret, "refresh_token": refresh_token} 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", - }) + req = urllib.request.Request( + f"{STATIONS_URL}?get_favorites=false", + 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 + if e.code != 401: raise + _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 "—" +def _fmt_temp(t, unit="C"): + if t is None: return "—" return f"{t:.1f}°{unit}" +def _fmt_wind(w, unit="kmh"): + if w is None: return "—" + if unit == "ms": return f"{w/3.6:.1f} m/s" + return f"{w:.0f} km/h" -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 "" +def _time_short(epoch_s): + if not epoch_s: return "" try: dt = datetime.fromtimestamp(epoch_s, tz=timezone.utc).astimezone() return dt.strftime("%H:%M") - except Exception: - return "" + except: return "" +def _temp_color(t): + if t is None: return INK + if t >= 30: return ALERT_RED + if t >= 22: return (200, 100, 30) + if t <= 5: return INFO_BLUE + return INK -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_thresholds_from_spec(spec): + result, default_pcts = [], [600, 1000, 1500] + color_map = {"ok": OK_GREEN, "warn": (200, 160, 0), "alert": ALERT_RED, + "fg": INK, "green": OK_GREEN, "yellow": (200, 160, 0), "red": ALERT_RED} + for i, p in enumerate([s.strip() for s in spec.split(",") if s.strip()]): + name, val = (p.split("@", 1) if "@" in p else (p, str(default_pcts[i] if i < len(default_pcts) else 2000))) + try: ppm = float(val) + except: continue + result.append((min(100, ppm / 2000 * 100), color_map.get(name, OK_GREEN))) + return sorted(result, key=lambda x: x[0]) if result else [(50, OK_GREEN), (75, (200,160,0)), (100, ALERT_RED)] - -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 +def _parse_stations(api_response, station_filter=""): + body = api_response.get("body", 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 + if not devices: return None 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 + 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"), - } + 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 {}, + "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(), - } + modules.append({"type": m.get("type", ""), "name": m.get("module_name", ""), + "id": m.get("_id"), "data": m.get("dashboard_data") or {}, + "battery_pct": m.get("battery_percent"), "reachable": m.get("reachable"), + "last_seen": m.get("last_seen")}) + return {"station_name": main["name"], "place": main.get("place", {}), + "main": main, "modules": modules, "_fetched_at": time.time()} + +def _first_module(modules, mtype): + for m in modules: + if m["type"] == mtype: return m + return None -# ============================================================================ -# Plugin-Klasse -# ============================================================================ +# ── Plugin ───────────────────────────────────────────────────────────────── + 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.") + description = "Indoor, Outdoor, Wind, Regen, CO₂. Paper-Design." 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": "refresh_token", "label": "Refresh-Token", - "type": "secret", - "help": "Einmalig via `tools/netatmo_auth.py` erzeugen. " - "Wird automatisch rotiert (3h Gültigkeit)."}, {"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", + {"key": "show_indoor", "label": "Indoor anzeigen", "type": "bool", "default": True}, + {"key": "show_outdoor", "label": "Outdoor anzeigen", "type": "bool", "default": True}, + {"key": "show_wind", "label": "Wind anzeigen", "type": "bool", "default": True}, + {"key": "show_rain", "label": "Regen anzeigen", "type": "bool", "default": True}, + {"key": "show_compass", "label": "Windrose", "type": "bool", "default": True}, + {"key": "co2_thresholds","label": "CO₂-Schwellen (ppm)", + "type": "string", "default": "ok@600,warn@1000,alert@1500"}, + {"key": "temp_unit", "label": "Temperatur", "type": "select", "options": ["C", "F"], "default": "C"}, - {"key": "wind_unit", "label": "Wind-Einheit", "type": "select", + {"key": "wind_unit", "label": "Wind-Einheit", "type": "select", "options": ["kmh", "ms"], "default": "kmh"}, + # Auth (secret, kein UI-Feld nötig) + {"key": "client_id", "label": "Client-ID", "type": "secret"}, + {"key": "client_secret", "label": "Client-Secret", "type": "secret"}, + {"key": "refresh_token", "label": "Refresh-Token", "type": "secret"}, ] default_config = { - "client_id": "", - "client_secret": "", - "refresh_token": "", "station_filter": "", - "show_indoor": True, - "show_outdoor": True, - "show_wind": True, - "show_rain": True, + "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", + "temp_unit": "C", "wind_unit": "kmh", + "client_id": "", "client_secret": "", "refresh_token": "", } - # ---- 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") + def fetch(self): + cid = self.cfg("client_id"); sec = self.cfg("client_secret") refresh = self.cfg("refresh_token", "") - if not (cid and sec): - return {"_error": "Client-ID oder Client-Secret fehlt."} - if not refresh: - return { - "_error": "Refresh-Token fehlt. Bitte einmalig " - "`tools/netatmo_auth.py` ausführen " - "(siehe plugins/NETATMO.md)." - } - + if not (cid and sec): return {"_error": "Client-ID oder Client-Secret fehlt."} + if not refresh: return {"_error": "Refresh-Token fehlt. Bitte `tools/netatmo_auth.py`."} try: data = _get_stations_data(cid, sec, refresh) except urllib.error.HTTPError as e: body = "" - try: - body = e.read().decode("utf-8", "ignore")[:120] - except Exception: - pass + try: body = e.read().decode("utf-8", "ignore")[:120] + except: 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["refresh_token"] = None + _TOKEN_CACHE["access_token"] = None; _TOKEN_CACHE["refresh_token"] = None _TOKEN_CACHE["expires_at"] = 0 - err = e.reason or "Auth fehlgeschlagen." - return {"_error": f"Auth fehlgeschlagen: {err} " - f"`tools/netatmo_auth.py` erneut ausführen."} - return {"_error": f"HTTP {e.code} {e.reason}: {body}".strip()} + return {"_error": f"Auth fehlgeschlagen: {e.reason} — `tools/netatmo_auth.py` erneut."} + return {"_error": f"HTTP {e.code}: {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. " - "Prüfe station_filter oder ob die Station " - "in den letzten 4h Daten gesendet hat."} - - parsed["_raw_count"] = len(data.get("body", data).get("devices", [])) + if not parsed: return {"_error": "Keine Station gefunden."} return parsed - # ---- Render ---- def render(self, draw, fonts, x, y, w, h): - pad = 8 + # Init fonts lazily + if FONT_SERIF is None: + _load_fonts() + d = self.fetch() - if "_error" in d: - render_error_banner(draw, fonts, x, y, w, h, - self.label, d["_error"]) + render_error_banner(draw, fonts, x, int, y, 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) + # Background + draw.rectangle((x, y, x + w - 1, y + h - 1), fill=PAPER_BG) - # 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) + self._render_small(draw, x, y, w, h, d) + elif is_wide(w, h): + self._render_wide(draw, x, y, w, h, d) + elif is_tall(w, h): + self._render_tall(draw, x, y, w, h, d) 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) + self._render_standard(draw, x, y, w, h, d) - # 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_small(self, draw, x, y, w, h, d): + """1x1 — nur die wichtigste Zahl: Aussen-Temperatur oder Indoor-CO2.""" + pad = 10 + main = d["main"] + out = _first_module(d["modules"], "NAModule1") + mod = out if out else main + t = mod["data"].get("Temperature") + temp = _fmt_temp(t, self.cfg("temp_unit", "C")) - 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, - ) + # Wert + font = fit_font(draw, temp, _fonts(), w - 2 * pad, h - 2 * pad, + candidates=["48", "36", "28", "24", "20"]) + _centered(draw, temp, x, y, w, h - 20, font, _temp_color(t)) - def _render_tall(self, draw, fonts, x, y, w, h, main, modules): - """1x4 / 1x2 — vertikale Liste aller Module.""" + # Label unten + label = (out["name"][:12] if out else "Indoor") + _meta(draw, label, x + pad, y + h - pad - 12, color=INK_LIGHT) + + def _render_wide(self, draw, x, y, w, h, d): + """4x1 — horizontale Reihe aller Messwerte. Wie ein liniertes Notizblatt.""" + pad = 10 + unit = self.cfg("temp_unit", "C") + wind_unit = self.cfg("wind_unit", "kmh") + co2_thresh = _co2_thresholds_from_spec( + self.cfg("co2_thresholds", "ok@600,warn@1000,alert@1500")) + + main = d["main"] + modules = d["modules"] + out = _first_module(modules, "NAModule1") + + # Aktive 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") + items.append(("INDOOR", main["data"].get("Temperature"), f"°{unit}", + MODUL_COLOR_INDOOR, None)) 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), - ) + items.append(("OUTDOOR", out["data"].get("Temperature"), f"°{unit}", + MODUL_COLOR_OUTDOOR, None)) + if self.cfg("show_wind", True): + wind = _first_module(modules, "NAModule2") + if wind: + items.append(("WIND", wind["data"].get("WindStrength"), + f" {wind_unit}", MODUL_COLOR_WIND, None)) + if self.cfg("show_rain", True): + rain = _first_module(modules, "NAModule3") + if rain: + rate = rain["data"].get("RainRate") or rain["data"].get("rain") or 0 + items.append(("RAIN", rate if rate else "—", " mm/h", + MODUL_COLOR_RAIN, None)) - # 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), - ) + if not items: + _label(draw, "Keine Module", x + pad, y + h // 2 - 8, color=INK_LIGHT) + return - # 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) + n = len(items) + col_w = w // n + for i, (lbl, val, unit_str, dot_col, _) in enumerate(items): + cx = x + i * col_w + display_val = f"{val}{unit_str}" if isinstance(val, (int, float)) else str(val) + _card(draw, cx + 2, y + 4, col_w - 4, h - 8, + dot_col, lbl, + str(val) if not isinstance(val, str) else val, + unit_str if isinstance(val, (int, float)) else "") - # ---- 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) + def _render_tall(self, draw, x, y, w, h, d): + """1x4 — eine saubere vertikale Liste. Wie ein Logbuch-Eintrag.""" + pad = 10 + unit = self.cfg("temp_unit", "C") + main = d["main"] + modules = d["modules"] - # Body - inner_y = y + 28 - inner_h = h - 30 - data = mod.get("data", {}) or {} + items = [] + if self.cfg("show_indoor", True): + m = main + t = m["data"].get("Temperature") + items.append((MODUL_COLOR_INDOOR, "Indoor", _fmt_temp(t, unit), _temp_color(t))) + for mtype, col, lbl in [ + ("NAModule1", MODUL_COLOR_OUTDOOR, "Outdoor"), + ("NAModule2", MODUL_COLOR_WIND, "Wind"), + ("NAModule3", MODUL_COLOR_RAIN, "Regen"), + ]: + if not self.cfg(f"show_{lbl.lower()}", True): continue + m = _first_module(modules, mtype) + if not m: continue + if mtype == "NAModule1": + val = _fmt_temp(m["data"].get("Temperature"), unit) + elif mtype == "NAModule2": + ws = m["data"].get("WindStrength") + val = _fmt_wind(ws, self.cfg("wind_unit", "kmh")) + else: + r = m["data"].get("RainRate") or m["data"].get("rain") or 0 + val = f"{r:.1f} mm/h" if r else "trocken" + items.append((col, lbl, val, INK)) - # 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) + n = max(1, len(items)) + row_h = (h - 2 * pad) // n - # 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) + for i, (dot_col, lbl, val, val_col) in enumerate(items): + ry = y + pad + i * row_h + if ry + row_h > y + h: break + _dot(draw, x + pad, ry + row_h // 2, 4, dot_col) + # Label + _label(draw, lbl, x + pad + 14, ry + 4, color=INK_MID) + # Wert rechts + _right(draw, val, x, ry + 4, w - pad, _f("big"), val_col) + # Trennlinie + if i < len(items) - 1: + _divider_h(draw, x + pad, ry + row_h - 2, w - 2 * pad) - # 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)) + def _render_standard(self, draw, x, y, w, h, d): + """2x2+ — das volle Layout. Klar, warm, absichtlich. - # 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) + Aufteilung: + – Oben links: Indoor (Temperatur + CO2) + – Oben rechts: Outdoor Temperatur + – Unten links: Wind (Geschwindigkeit + Compass) + – Unten rechts: Regen + – Ganz oben: Station-Name + Uhrzeit + """ + pad = 12 + unit = self.cfg("temp_unit", "C") + wind_unit = self.cfg("wind_unit", "kmh") + co2_thresh = _co2_thresholds_from_spec( + self.cfg("co2_thresholds", "ok@600,warn@1000,alert@1500")) - # 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) + main = d["main"] + modules = d["modules"] + out = _first_module(modules, "NAModule1") + wind = _first_module(modules, "NAModule2") + rain = _first_module(modules, "NAModule3") - 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) + # ── Header: Station + Update-Zeit ── + hdr_font = _f("label") + station = d["station_name"] + if len(station) > 30: station = station[:29] + "…" + _label(draw, station, x + pad, y + pad, color=INK_MID) + last = main.get("data", {}).get("time_utc") or main.get("last_message") or 0 + ts = _time_short(last) + if ts: + _right(draw, ts, x, y + pad, w - pad, hdr_font, INK_LIGHT) - # 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) + body_y = y + pad + 28 + body_h = h - (body_y - y) - pad + half_w = (w - 3 * pad) // 2 + half_h = (body_h - pad) // 2 - 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) + # ── Indoor (oben links) ── + in_t = main["data"].get("Temperature") + in_h = main["data"].get("Humidity") + in_co2 = main["data"].get("CO2") + in_data = main["data"] + + # Karte mit CO2 integriert + card_x = x + pad + card_y = body_y + card_w = half_w + card_h = half_h + + # Trennlinie über top + _divider_h(draw, x + pad, card_y - 4, w - 2 * pad, INK_LIGHT) + + # Indoor-Wert gross + in_val = _fmt_temp(in_t, unit) + font_big = fit_font(draw, in_val, _fonts(), card_w - 2 * pad, card_h // 2, + candidates=["40", "32", "28", "24", "20"]) + draw.text((card_x + pad, card_y + 8), in_val, font=font_big, fill=_temp_color(in_t)) + + # Feuchtigkeit rechts oben in der Karte + if in_h is not None: + hum_str = f"💧 {int(in_h)}%" + _right(draw, hum_str, card_x, card_y + pad, card_w - pad, _f("label"), INK_MID) + + # CO2 Balken darunter + if in_co2 is not None: + co2_str = f"CO₂ {int(in_co2)} ppm" + _meta(draw, co2_str, card_x + pad, card_y + card_h // 2 + 4, color=INK_LIGHT) + # Bar + pct = min(100, in_co2 / 2000 * 100) + bar_w = card_w - 2 * pad + bar_h = 5 + bar_y = card_y + card_h // 2 + 18 + draw.rectangle((card_x + pad, bar_y, card_x + pad + bar_w, bar_y + bar_h), + outline=INK_LIGHT, width=1) + fill_w = int(bar_w * pct / 100) + if fill_w > 1: + col = INK_LIGHT + for max_p, c in co2_thresh: + if pct <= max_p: col = c; break + draw.rectangle((card_x + pad + 1, bar_y + 1, + card_x + pad + fill_w - 1, bar_y + bar_h - 1), fill=col) + + # Station label unten + _meta(draw, "Indoor", card_x + pad, card_y + card_h - pad - 12, color=INK_LIGHT) + + # ── Outdoor (oben rechts) ── + out_x = x + pad + half_w + pad + if out and self.cfg("show_outdoor", True): + out_t = out["data"].get("Temperature") + out_h = out["data"].get("Humidity") + out_val = _fmt_temp(out_t, unit) + font_big = fit_font(draw, out_val, _fonts(), half_w - 2 * pad, half_h // 2, + candidates=["40", "32", "28", "24", "20"]) + draw.text((out_x + pad, body_y + 8), out_val, font=font_big, + fill=_temp_color(out_t)) + if out_h is not None: + hum_str = f"💧 {int(out_h)}%" + _right(draw, hum_str, out_x, body_y + pad, half_w - pad, _f("label"), INK_MID) + _meta(draw, "Outdoor", out_x + pad, body_y + half_h - pad - 12, color=INK_LIGHT) + else: + _label(draw, "—", out_x + half_w // 2 - 10, body_y + half_h // 2 - 10, color=INK_LIGHT) + + # ── Wind (unten links) ── + wind_x = x + pad + wind_y = body_y + half_h + pad + wind_w = half_w + wind_h = half_h + + if wind and self.cfg("show_wind", True): + ws = wind["data"].get("WindStrength") + gust = wind["data"].get("GustStrength") + wd = wind["data"].get("WindAngle") + + wind_str = _fmt_wind(ws, wind_unit) + font_big = fit_font(draw, wind_str, _fonts(), wind_w - 2 * pad, wind_h // 2, + candidates=["32", "28", "24", "20"]) + draw.text((wind_x + pad, wind_y + 4), wind_str, font=font_big, fill=INK) 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) + gust_str = f" Bö {int(gust)}" + _meta(draw, gust_str, wind_x + pad, wind_y + wind_h // 2 + 6, color=INK_MID) - 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) + # Compass + if wd is not None and self.cfg("show_compass", True): + cr = min(24, wind_h // 3) + cx_c = wind_x + wind_w - cr - pad + cy_c = wind_y + wind_h // 2 + _compass(draw, cx_c, cy_c, cr, wd) - 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) + _meta(draw, "Wind", wind_x + pad, wind_y + wind_h - pad - 12, color=INK_LIGHT) 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) + _label(draw, "—", wind_x + wind_w // 2 - 10, wind_y + wind_h // 2 - 10, color=INK_LIGHT) - # 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 (unten rechts) ── + rain_x = x + pad + half_w + pad + rain_y = wind_y + rain_w = half_w + rain_h = half_h - # ----- 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) + if rain and self.cfg("show_rain", True): + rate = rain["data"].get("RainRate") or rain["data"].get("rain") or 0 + cur_str = f"{rate:.1f} mm/h" if rate else "trocken" + font_big = fit_font(draw, cur_str, _fonts(), rain_w - 2 * pad, rain_h // 2, + candidates=["32", "28", "24", "20"]) + draw.text((rain_x + pad, rain_y + 4), cur_str, font=font_big, + fill=INFO_BLUE if rate else INK) # 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) + h1 = rain["data"].get("sum_rain_1") or rain["data"].get("rain_hour") or 0 + h24 = rain["data"].get("sum_rain_24") or rain["data"].get("rain_day") or 0 + _meta(draw, f"1h {h1:.1f} · 24h {h24:.1f}", + rain_x + pad, rain_y + rain_h // 2 + 6, color=INK_MID) + _meta(draw, "Regen", rain_x + pad, rain_y + rain_h - pad - 12, color=INK_LIGHT) 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 + _label(draw, "—", rain_x + rain_w // 2 - 10, rain_y + rain_h // 2 - 10, color=INK_LIGHT) diff --git a/tools/netatmo_auth.py b/tools/netatmo_auth.py index d80a772..69f9603 100644 --- a/tools/netatmo_auth.py +++ b/tools/netatmo_auth.py @@ -1,23 +1,22 @@ #!/usr/bin/env python3 -"""Netatmo OAuth2 Authorization Code Flow — einmaliger Setup-Helper. +"""Netatmo OAuth Setup - ein simpler Web-Endpoint den der User aufruft. -Netatmo hat 2025 den OAuth-Password-Grant abgeschaltet. Stattdessen muss -einmalig der Authorization-Code-Flow durchlaufen werden (Browser-Login). +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. -Dieses Script: - 1. Liest oder fragt client_id + client_secret - 2. Startet einen lokalen HTTP-Server auf http://localhost:8765/callback - 3. Öffnet den Browser mit der Netatmo-Authorize-URL - 4. Empfängt den Callback-Code - 5. Tauscht Code gegen access_token + refresh_token - 6. Schreibt refresh_token in config.json unter plugin_configs.netatmo +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 # interaktiv - python3 tools/netatmo_auth.py --cid X --csec Y # nicht-interaktiv - -Vor dem ersten Lauf in der Netatmo-Dev-Konsole (https://dev.netatmo.com/apps) -die Redirect-URI `http://localhost:8765/callback` zur App hinzufügen! + python3 tools/netatmo_auth.py + -> gibt die URL aus, die der User im Browser öffnen soll. """ from __future__ import annotations import argparse @@ -26,34 +25,22 @@ import json import os import socket import sys +import time import urllib.parse import urllib.request import urllib.error -import webbrowser +import io from pathlib import Path -# Konstanten +# 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" -REDIRECT_URI = "http://localhost:8765/callback" DEFAULT_SCOPE = "read_station" - -# Pfad zur config.json (gleicher Pfad wie im Dashboard) CONFIG_PATH = Path(__file__).resolve().parent.parent / "config.json" def detect_lan_ip() -> str: - """Findet die LAN-IP-Adresse (nicht 127.0.0.1) für Redirect-URI. - - Der Pi hat oft mehrere Interfaces (wlan0, eth0, ...). Wir nehmen die - erste nicht-loopback IPv4-Adresse. Wenn das nicht klappt, wird - 127.0.0.1 zurückgegeben (und der User muss dann manuell auf der - Pi-Konsole einen SSH-Tunnel mit ssh -L 8765:localhost:8765 machen). - """ try: - import socket - # Trick: connect zu einem externen Socket (kein Datenverkehr), - # dann lies die Source-IP — das ist die Default-Route-IP. s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) try: s.connect(("8.8.8.8", 80)) @@ -65,15 +52,36 @@ def detect_lan_ip() -> str: def free_port(port: int = 8765) -> int: - """Findet einen freien Port (falls 8765 belegt).""" - for p in [port] + list(range(port + 1, port + 20)): + """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(("127.0.0.1", p)) + s.bind(("0.0.0.0", p)) return p except OSError: continue - raise RuntimeError(f"Kein freier Port zwischen {port} und {port+19} gefunden") + raise RuntimeError(f"Kein freier Port zwischen {port} und {port+50} gefunden") def load_existing_config() -> dict: @@ -81,154 +89,162 @@ def load_existing_config() -> dict: return {} try: return json.loads(CONFIG_PATH.read_text()) - except Exception as e: - print(f"WARN: {CONFIG_PATH} nicht lesbar: {e}", file=sys.stderr) + except Exception: return {} -def save_refresh_token(refresh_token: str, client_id: str, client_secret: str, - username: str) -> dict: - """Schreibt refresh_token in config.json unter plugin_configs.netatmo.""" +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 - if username: - netatmo_cfg["username"] = username CONFIG_PATH.write_text(json.dumps(cfg, indent=2)) return cfg -def exchange_code_for_tokens(code: str, client_id: str, client_secret: str) -> dict: - """Authorization-Code → Access + Refresh Token.""" - data = json.dumps({ +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, + "redirect_uri": redirect_uri, "scope": DEFAULT_SCOPE, }).encode("utf-8") - req = urllib.request.Request(TOKEN_URL, data=data, - headers={"Content-Type": "application/json", "Accept": "application/json"}) + 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", "ignore") + 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) -> str: - """Startet einen HTTP-Server, der auf den Callback wartet und den Code extrahiert.""" - captured = {} +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, *args, **kwargs): - pass # quiet + 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: - captured["error"] = qs.get("error", ["unknown"])[0] - self.send_response(400) - self.send_header("Content-Type", "text/html; charset=utf-8") + 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( - b"

Fehler bei Netatmo-Authentifizierung

" - b"

Bitte zurueck zum Terminal gehen.

") + self.wfile.write(body) + saved["error"] = err return if "code" not in qs: - self.send_response(400) + # Health-Check oder 404 + self.send_response(204) self.end_headers() return if qs.get("state", [None])[0] != expected_state: - self.send_response(400) - self.send_header("Content-Type", "text/html; charset=utf-8") + 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(b"

State mismatch (CSRF-Schutz)

") + self.wfile.write(body) + saved["error"] = "state_mismatch" return - captured["code"] = qs["code"][0] - self.send_response(200) - self.send_header("Content-Type", "text/html; charset=utf-8") + 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( - b"

OK!

" - b"

Du kannst dieses Fenster jetzt schliessen. " - b"Zurueck zum Terminal fuer den Refresh-Token.

") + self.wfile.write(body) server = http.server.HTTPServer(("0.0.0.0", port), Handler) - server.timeout = 180 # 3 min timeout - server.handle_request() - server.handle_request() # 2nd for /favicon.ico falls Browser fragt - if "error" in captured: - raise SystemExit(f"Netatmo-Authentifizierung fehlgeschlagen: {captured['error']}") - if "code" not in captured: - raise SystemExit("Timeout oder kein Code empfangen.") - return captured["code"] + 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 OAuth2-Setup") + parser = argparse.ArgumentParser(description="Netatmo OAuth Setup") parser.add_argument("--cid", help="Client-ID") parser.add_argument("--csec", help="Client-Secret") - parser.add_argument("--port", type=int, default=8765, - help="Lokaler Port für den Callback-Server") - parser.add_argument("--scope", default=DEFAULT_SCOPE, - help=f"OAuth-Scopes (default: {DEFAULT_SCOPE})") + 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() - # 1) Client-Credentials laden - client_id = args.cid - if not client_id: - existing = load_existing_config().get("plugin_configs", {}).get("netatmo", {}) - client_id = existing.get("client_id") or input("Netatmo Client-ID: ").strip() - client_secret = args.csec - if not client_secret: - existing = load_existing_config().get("plugin_configs", {}).get("netatmo", {}) - client_secret = existing.get("client_secret") or input("Netatmo Client-Secret: ").strip() + 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 werden benötigt.") + raise SystemExit("Client-ID und Client-Secret erforderlich.") - # 2) Port + State + Redirect-URI 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) - # Wenn Chromium auf dem Pi vorhanden ist UND ein X-Server läuft, - # öffnen wir den Browser auf dem Pi selbst. Dann ist 'localhost' die - # korrekte Redirect-URI. Das ist der einfachste Fall — User muss - # nichts weiter konfigurieren. - lan_ip = detect_lan_ip() - import os - if os.path.exists("/tmp/.X11-unix/X0") and not os.environ.get("DISPLAY"): - os.environ["DISPLAY"] = ":0" - chromium_local = any(os.path.exists(p) for p in - ("/usr/bin/chromium", "/usr/bin/chromium-browser", - "/usr/bin/google-chrome")) - - if chromium_local and os.environ.get("DISPLAY"): - # Chromium läuft auf dem Pi selbst → localhost funktioniert. - redirect = f"http://localhost:{port}/callback" - target_host = "localhost" - print() - print("Info: Chromium auf Display :0 erkannt. Verwende localhost.") - print(" (Öffne ein Terminal-Fenster auf dem Pi und schau zu.)") - else: - # Kein lokaler Browser → Redirect muss LAN-IP haben, und der - # User öffnet die URL in seinem Laptop/Phone-Browser. - redirect = f"http://{lan_ip}:{port}/callback" - target_host = lan_ip - if redirect.startswith("http://localhost"): - print() - print("WARN: Konnte keine LAN-IP ermitteln. Verwende localhost.") - print(" Falls du per SSH verbunden bist: exit und dann") - print(" 'ssh -L 8765:localhost:8765 pi@...' um den Tunnel zu öffnen.") - - # 3) Authorize-URL auth_url = ( f"{AUTHORIZE_URL}" f"?client_id={urllib.parse.quote(client_id)}" @@ -238,87 +254,39 @@ def main(): f"&response_type=code" ) - print(f"\n=== Netatmo OAuth Setup ===\n") - print(f"Local callback server listening on {redirect}") print() - print(f"WICHTIG: Diese Redirect-URI muss EXAKT in deiner Netatmo-App") - print(f"auf https://dev.netatmo.com/apps/ registriert sein!") + print("=" * 70) + print(" NETATMO OAUTH SETUP") + print("=" * 70) print() - print(f" Aktuelle Redirect-URI:") - print(f" {redirect}") + print("Voraussetzung: Die folgende Redirect-URI muss EXAKT in deiner") + print("Netatmo-App auf https://dev.netatmo.com/apps/ registriert sein:") print() - if redirect.startswith("http://localhost"): - print(f" Falls deine App auf https://dev.netatmo.com/apps/ mit der") - print(f" URI 'http://localhost:8765/callback' registriert ist, dann") - print(f" funktioniert der Helper NUR wenn du direkt auf dem Pi ein") - print(f" Terminal hast (Browser auf dem Pi). Falls du per SSH") - print(f" verbunden bist: 'exit' und mit 'ssh -L 8765:localhost:8765'") - print(f" neu verbinden, dann Browser auf localhost:8765 öffnen.") + 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(f" Alternative: du kannst die App-URI auf") - print(f" '{redirect}' ändern, dann funktioniert der Helper vom") - print(f" Laptop/Phone aus über diese URL.") - print() - print(f"Oeffne in deinem Browser:\n {auth_url}\n") - try: - # Wenn ein X-Server läuft (Pi mit Bildschirm, Desktop-Session), - # explizit DISPLAY=:0 setzen und Chromium direkt öffnen. Das - # umgeht xdg-open-Fallbacks die oft auf headless-Setups scheitern. - import os - if os.path.exists("/tmp/.X11-unix/X0") and not os.environ.get("DISPLAY"): - os.environ["DISPLAY"] = ":0" - # Chromium-Binary auf dem Pi finden - chromium = None - for cand in ("chromium", "chromium-browser", "google-chrome"): - from shutil import which - p = which(cand) - if p: - chromium = p - break - if chromium and os.environ.get("DISPLAY"): - # Chromium im App-Mode öffnen — kein Toolbar, sieht wie eine - # native App aus. Fenster schließt nach Redirect automatisch - # durch unser Callback-Handler. - print(f"Oeffne Chromium auf Display {os.environ['DISPLAY']}...") - import subprocess - subprocess.Popen( - [chromium, f"--app={auth_url}", - "--no-default-browser-check", - "--no-first-run"], - env=dict(os.environ), - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, - ) - elif webbrowser.open(auth_url): - print("(Browser wurde geoeffnet.)") - else: - print("(Browser konnte nicht automatisch geoeffnet werden — bitte URL manuell öffnen.)") - except Exception as e: - print(f"(Browser-Fehler: {e} — bitte URL manuell öffnen.)") - - # 4) Auf Callback warten - print("\nWarte auf Callback...") - code = run_callback_server(port, state) - - # 5) Code → Tokens - print("\nTausche Code gegen Tokens...") - tok = exchange_code_for_tokens(code, client_id, client_secret) - refresh_token = tok.get("refresh_token") - access_token = tok.get("access_token") - expires_in = tok.get("expires_in", 0) - if not refresh_token: - raise SystemExit(f"Antwort enthält keinen refresh_token: {tok}") - - print(f" access_token: {access_token[:24]}...") - print(f" refresh_token: {refresh_token[:24]}...") - print(f" expires_in: {expires_in}s ({expires_in // 60} min)") - - # 6) In config.json speichern - save_refresh_token(refresh_token, client_id, client_secret, - username="") - print(f"\nGespeichert in {CONFIG_PATH}") - print("\nFertig! Das Plugin holt sich ab jetzt selbst neue Access-Tokens.") - print("Tipp: dashboard.py / epaper-admin restarten damit das Plugin den " - "neuen refresh_token liest.\n") + 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__":