Author SHA1 Message Date
Patrik 5de086e1b1 FEAT-DEEPSLEEP: Display-Refresh-Timer + CPU-Throttle + Admin Power-Tab
DeepSleep-Modus fuer das ePaper-Display:
- dashboard.py: _is_deepsleep(), _next_wake(), _seconds_until_wake(),
                _apply_cpu_throttle() (Pi auf 600MHz im Sleep)
- 60s sleep-chunks in main loop statt festes Intervall
- render_full() mit deepsleep_active + next_wake params
- ZZZ-Indikator + CPU-Throttle-Badge wenn schlafend
- load_config() auto-filled 'power' defaults aus POWER_DEFAULTS

Admin UI (templates/power.html + admin.py):
- /power (GET+POST): DeepSleep-Toggle, Wake-Times, CPU-Throttle-Settings
- /api/power (JSON): live status polling fuer Sidebar-Badge
- /api/wake (POST): manuelles Display-Aufwecken aus dem Admin
- templates/index.html: Energie-Sidebar-Link + Live-Status-Badge
  (Zz / wach / an / aus) via refreshPowerStatus()
2026-08-31 00:47:46 +02:00
Patrik 5a83826aa5 FEAT-NETATMO-06: V1 Status-Update Redesign + shared helpers foundation
Netatmo-Plugin komplett auf V1 Status-Update Layout umgebaut:
- 4-Sektionen: Aussen / Wind+Regen / Indoor / Vorhersage
- Echte 12h-History fuer MIN/MAX (vorher estimated +-2/+-4)
- Dynamische Forecast-Daten (datetime.now() + timedelta)
- DE strings durchgaengig (AUSSEN, FEUCHTE, DRUCK, Bo, HEUTE, Boerse, ...)
- 7-Farben ACeP palette (kein Greyscale, kein Anti-Alias)
- Indoor-Sensoren: rote Tag-Box + 22pt+ Temp + CO2-Bar + Batt aligned
- Chart mit 2-Tick Y-Grid (min/max), 12h Linie in Gruen
- Wetter-Icons (WMO codes) in Vorhersage, Regen-Drop-Icon pro Tag

Foundation / shared helpers:
- palette.py: BATT_*, DE_WEEKDAYS/MONTHS, WMO_ICONS, format_*,
              POWER_DEFAULTS, temp_color, co2_color, batt_color
- plugins/base.py: draw_battery, draw_compass, draw_trend,
                   draw_line_chart, draw_mini_bar, draw_weather_icon,
                   draw_deepsleep_indicator, draw_zzz_indicator,
                   _fit_size, _get_base_sans, de_* formatters
- designbase.py: THEMES dict (light/dark/retro/mag), Layout templates
                 (bereits importierbar)

Tools:
- tools/render_mockup.py: 3 design-Varianten Mockup-Generator
- tools/verify_netatmo.py: Self-verification mit dynamic mock data
2026-08-31 00:47:40 +02:00
Hermes c5a09833c9 FIX-NETATMO-05: ACeP Native-Farben + bessere Spalten-Verhaeltnisse
* Farben auf ACeP 7-Farben-Palette gemappt: BLACK, WHITE, GREEN, BLUE, RED, YELLOW, ORANGE
  (PIL.quantize dithert sonst alles in matschige Mischfarben)
* Background PAPER_BG = (255,255,255) echtes Weiss statt warm-creme
* Spalten-Verhaeltnis neu: Indoor 32% (mehr Platz fuer 5 Sensoren),
  Aussen 28%, Mitte 18%, Forecast 22%
* Big-Temp candidates von [150,130,110,95] auf [120,100,85,72,60] reduziert
* FAINT und INK_LIGHT als BLACK definiert (Grautöne ergeben Mischpixel)
* Funktioniert mit echten Daten: zeigt 5 Indoor-Sensoren + alle Aussensensoren
2026-08-29 20:49:26 +04:00
Hermes c55cc90bda FIX-NETATMO-04: Hintergrund auf echtes Weiss fuer ACeP-Display
ACeP rendert warm-creme (252,248,240) als nicht-weisses Pixel und
laesst den Hintergrund verfaerbt/gruengelbstichig aussehen.
PAPER_BG auf (255,255,255) und alle Box-Fills auf reines weiss gesetzt.
Chart-Fill (225,240,225) entfernt — Linie allein reicht.
Box-Trennlinien und Akzent-Farben unveraendert.
2026-08-29 20:40:41 +04:00
Hermes 1aa11f5843 FEAT-NETATMO-03: Luftdruck + 3-Tage Forecast + Überstände-Fix
* Luftdruck (mbar) mit Trend-Pfeil (up/down/stable) als 4. Stat in Aussen-Box
* 3-Tage Forecast via Open-Meteo (kostenlos, kein API-Key) in neuer Spalte rechts
* Neue Spalten-Aufteilung: 36/20/24/20% (Aussen / Mitte / Indoor / Forecast)
* Battery-Indikatoren mit %-Wert an Aussen, Wind, Regen und allen 5 Indoor-Sensoren
* Sans-Font (Liberation Sans) statt Serife — moderner Look
* Sichere Padding-Werte (14px Aussen, 14px Indoor) verhindern Text-Überstände
* Temp-Trend farbcodiert: Trend-Pfeil grün (steigend) / rot (fallend)
* Config-Felder: show_forecast (default true), Wind/Regen ein/ausschaltbar
* Inline _fit_size() Helper statt palette.fit_font() (vermeidet Dict-Lookup-Probleme)
* Forecast wird automatisch deaktiviert wenn Open-Meteo nicht erreichbar
* CO2-Schwellen weiterhin konfigurierbar via config string
2026-08-29 20:30:47 +04:00
Hermes fe1c06b306 FEAT-NETATMO-02: Komplettes Redesign auf 4x4 WarmNews-Layout
* 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).
2026-08-29 20:24:03 +04:00
hermes 9b91598f5b FIX-NETATMO-01: Netatmo-Plugin an 2025-API anpassen + /config-Bug fixen
Netatmo hat 2025 den OAuth-Password-Grant komplett abgeschaltet.
Auth-Flow ist jetzt nur noch Authorization Code + Refresh-Token.
Ausserdem Domain-Migration api.netatmo.net → api.netatmo.com mit
Azure-Front-Door-WAF die form-urlencoded POSTs blockt.

Plugin (plugins/netatmo.py):
- URLs auf api.netatmo.com umgestellt
- _post_form sendet JSON statt form-urlencoded (umgeht WAF)
- _obtain_tokens nutzt nur noch Refresh-Token-Grant
- fetch() zeigt klare Fehlermeldung wenn refresh_token fehlt
- config_schema: username/password → refresh_token
- Bessere Fehlertexte (zeigt 'tools/netatmo_auth.py erneut ausführen')

Helper (tools/netatmo_auth.py):
- Lokaler HTTP-Server auf 0.0.0.0:8765 fuer Authorization-Code-Callback
- Erkennt LAN-IP automatisch
- Erkennt Chromium auf dem Pi (Display :0) fuer localhost-Redirect
- Tauscht Code gegen Access+Refresh Token via api.netatmo.com
- Schreibt refresh_token in config.json unter plugin_configs.netatmo

Bug-Fix /config:
- AttributeError: module 'dashboard' has no attribute 'SLOTS'
- legacy v1 slot-logik entfernt (nutzt nicht mehr dashboard_mod.SLOTS)
- /config macht jetzt NUR noch refresh_interval_s speichern
- Slot-Belegung laeuft seit v2 ueber /api/layout (drag&drop)
2026-08-29 19:06:10 +04:00
hermes 25b0432757 FEAT-NETATMO-01: Netatmo Weather Station Plugin
OAuth2 Password-Grant + Refresh-Token on-the-fly. Holt sich automatisch
einen neuen Access-Token wenn der alte abläuft (3h Gültigkeit).

Module: NAMain (Indoor), NAModule1 (Outdoor), NAModule2 (Wind),
NAModule3 (Regen), NAModule4 (Extra Indoor).

Slot-responsive:
  1x1 — Outdoor-Temp prominent + Mini-Status
  4x1 — Indoor | Outdoor | Wind/Regen kompakt (3 Spalten)
  1x4 — Vertikale Liste aller Module
  2x2 — Indoor-Card (mit CO2-Bar) + Outdoor-Card + Wind/Regen-Bereich
  4x4 — Volle Ansicht mit Min/Max, Windrose, Timestamps

Config-Optionen:
  client_id/client_secret/username/password (secrets via Admin-UI)
  station_filter (substring-match für Multi-Setup)
  show_indoor/outdoor/wind/rain/compass/secondary (bools)
  co2_thresholds (ppm, default ok@600,warn@1000,alert@1500)
  temp_unit (C/F), wind_unit (kmh/ms)

Plus:
  - plugins/NETATMO.md: vollständige Doku (Setup, Optionen, Diagnose)
  - config.netatmo.example.json: copy-paste Beispiel-Config
  - layout.py: netatmo default-size = 4x4 (4 Sub-Cards brauchen Platz)
  - README.md: Plugin-Tabelle erweitert

Verifiziert: /api/plugins.json listet netatmo, Auto-Loader erkennt es,
Admin-UI zeigt das Config-Form.
2026-08-29 18:35:29 +04:00
hermes 8f3480ee2e FIX-CFG-01: Unchecked checkbox → False (statt Wert beibehalten)
Bug: Wenn ein User eine Bool-Checkbox unchecked ließ, sendete der
Browser das Form-Field gar nicht. /api/plugin_config und /plugins/<idx>
machten 'continue' und überschrieben den alten True-Wert nie.

Fix: Bei ftype=bool und form_val=None explizit new_cfg[key] = False setzen.

Betrifft z.B. clock_wordclock.show_date/show_weekday/show_es_ist.
2026-08-29 18:35:24 +04:00
hermes d71b17d5c9 FIX-WC-01: WordClock Redesign - 3 klare Zonen ohne Überlappung
Vorher: 11x8-Grid mit starrem Layout, viele Buchstaben-Cluster überlappten
sich gegenseitig, Stunden-Pixel-Font war unleserlich klein/groß gemischt.

Nachher: QlockTwo-inspiriertes Layout in 3 diskreten Zonen:
  - Header (Wochentag + Datum, accent, klein)
  - Wort-Grid (ES IST + Minuten-Phrase in einer sauberen Zeile)
  - Stunden-Block (Stunde in großer Aldrich-Schrift)

Plus:
  - Minuten-Dots oben rechts (QlockTwo-typisch: ●●●● für Sektor-Offset)
  - Bug-Fix: font_key-Fallback überspringt 'clock'-Pixel-Font
  - Bug-Fix: 12/24h Stunden-Mapping für Wortuhr (16:30 = 'halb fünf' → FÜNF)
  - Neue Option 'show_es_ist' zum Ein-/Ausblenden des 'ES IST'-Prefixes
  - Slot-size-aware: 1x1 single-line, 2x2/4x4 zones, small fallback < 240x140

Verifiziert auf Pi-Display: 'VIERTEL NACH VIER' / 'HALB FÜNF' rendern sauber.
2026-08-29 18:35:20 +04:00
ki 94509e6836 Merge fix/BUG-05-save-indicator: Speichern-Button weg, Save-Status-Indikator 2026-08-29 17:50:41 +04:00
ki 5cf1d743eb Merge fix/BUG-06-autopack-undo: Auto-Pack Undo via Snapshot 2026-08-29 17:50:22 +04:00
ki 7885b100b4 Merge fix/BUG-03-span-geometry: CSS-Grid-Span-Geometrie im Initial-Render
Konflikt in renderGrid() zwischen HEAD (BUG-01: kein HTML5-draggable,
touch-action:none) und BUG-03 (alter draggable=true). Resolution:
HEAD-Variante behalten — BUG-01 hat den HTML5-Drag komplett entfernt,
BUG-03 nur die Span-Geometrie gefixt. Kompatibel, kein Re-Konflikt.

Tests: 5/5 Python + 7/7 Pointer-Events + 8/8 Span-Geometry = alle grün.
2026-08-29 17:50:07 +04:00
ki 67e1b465d5 Merge fix/BUG-01-02-resize-pointer: Pointer-Events + rAF
BUG-01: Resize-Handle griff nicht — HTML5-draggable auf Items feuerte
dragstart bevor mousedown auf dem Handle ankam. Komplett raus, eigene
Pointer-Events für Move+Resize mit stopPropagation auf dem Resize-Handle.

BUG-02: Resize-Drag war zäh — renderGrid() pro mousemove. Jetzt rAF +
nur CSS-Geometrie-Update während Drag.

Tests: 7/7 grün.
2026-08-29 17:49:11 +04:00
ki b9d269876b Merge fix/BUG-04-add-no-repack: first-fit für neue Items
Bug-04: Add-Item packte das ganze Layout neu um — ein 1x1 hello
konnte einen 2x1 spotify aus seiner Position drängen. Mit first_fit
landet das neue Item in der ersten freien Zelle, andere bleiben
unverändert. Voller Grid → 409 mit Auto-Pack-Hinweis.

Tests: 5/5 grün.
2026-08-29 17:48:48 +04:00
ki d0cc69410f BUG-05: Speichern-Button weg, Save-Status-Indikator mit States
Der 'Speichern'-Button war redundant — debouncedSave() lief schon
bei jedem Drop/Resize/Add/Delete. Klick auf den Button machte nur
einen sofortigen POST statt 500ms-debounce. Verwirrend statt hilfreich.

Fix:
- Speichern-Button aus dem Toolbar entfernt.
- Stattdessen: <div id='saveIndicator' data-state='idle'> mit .save-dot
  und .save-label — rechtsbündig im Toolbar (margin-left:auto).
- States: idle | pending | saving | error (CSS-Data-Attr-basiert).
  - idle: grüner Punkt, 'Gespeichert'
  - pending: warn-gelber Punkt pulsiert, 'Ungespeichert…' (debouncedSave-Start)
  - saving: info-blauer Punkt pulsiert schneller, 'Speichern…' (saveLayout-Start)
  - error: alert-roter Punkt, 'Fehler: ...' (j.ok=false)
- setSaveState(state, label?) als zentraler Helper.
- saveLayout() macht jetzt success-toast weg — der Indicator reicht.
- Fehlerfall: error-toast bleibt (kritisch, separat sichtbar).

Vorteile:
- Toolbar ist aufgeräumter, +Button ist eindeutig der primäre Action.
- User sieht Save-Status ohne Klick auf irgendwas — visuelles Feedback
  für Auto-Save.
- Fehler-State zeigt sofort 'Fehler: ...' mit Details.

Beweis: tests/test_save_indicator.js (11/11 grün)
  1. Speichern-Button entfernt
  2. Save-Indicator #saveIndicator vorhanden
  3. setSaveState() Funktion definiert
  4. setSaveState setzt dataset.state
  5. debouncedSave setzt pending-Status
  6. saveLayout setzt saving am Anfang
  7. saveLayout setzt idle bei Erfolg
  8. saveLayout setzt error bei j.ok=false
  9. CSS für alle 4 Indicator-States
 10. Kein success-toast in saveLayout mehr
 11. Save-Indicator rechtsbündig (margin-left:auto)

Closes #7
2026-08-29 17:47:23 +04:00
ki a465912b55 BUG-06: Auto-Pack Undo via Snapshot + actionable Toast
Auto-Pack rief vorher layout_mod.pack() auf und schickte einen
simplen success-Toast. Wer aus Versehen klickte, hatte alle
manuellen Positionen verloren — kein Undo, kein Recovery.

Fix:
- packAll() speichert layoutItems als JSON-Snapshot in einer
  local closure (vor dem pack-Call).
- Nach erfolgreichem Pack: toast() bekommt einen 4. Parameter
  action={label, onClick}.
- Neues toast()-Feature: action-Button im Toast, clickbar trotz
  dismiss-on-click. Klick ruft onClick und dismissed den Toast.
- Bei Undo-Klick: POST /api/layout mit den snapshot-Items →
  Server restored → renderGrid() → 'Layout wiederhergestellt'.
- Modal-Text weist auf die 10s Undo-Möglichkeit hin.

Vorteile:
- Versehentlicher Klick auf Auto-Pack ist recoverable.
- Kein sessionStorage-Bloat, kein Multi-Tab-Konflikt
  (Closure-Variable statt global Storage).
- Toast-Pattern ist jetzt generisch — andere Aktionen
  (z.B. 'Snapshot wiederherstellen' aus Sidebar) können
  denselben Mechanismus nutzen.

Beweis: tests/test_autopack_undo.js (8/8 grün)
  1. Snapshot (JSON.stringify(layoutItems)) wird erstellt
  2. Toast mit action={label:'Rückgängig'}
  3. Undo ruft POST /api/layout mit snapshot-Items
  4. Modal-Body erwähnt 'rückgängig'
  5. CSS .toast-action Klasse vorhanden
  6. toast() unterstützt 4-Args (action-Parameter)
  7. Action-Button Click ruft onClick + dismiss
  8. .toast-action hat pointer-events:auto

Live-Test: GET /?demo=1 → 200, 90847 bytes
  toast-action: 6 occurrences (CSS + JS)
  Rückgängig: 1 mention
  JSON.stringify(layoutItems): 1

Closes #8
2026-08-29 17:44:58 +04:00
ki 6059748f9b Add BUG-04 test suite + .gitignore for .venv/
Re-holt die Tests vom BUG-04-Branch in diesen Branch, damit CI/Runs
vollständig sind. .gitignore ergänzt um .venv/ und tests/__pycache__/.
2026-08-29 17:43:02 +04:00
ki 9f705ad7e4 BUG-03: Span-Geometrie im Initial-Render (grid-column/grid-row direkt setzen)
Initial-Render hatte keine Span-Geometrie:
  renderGrid() hing Items in die Origin-Cell mit width:100% height:100%.
  Ein 2x2-Item sah damit aus wie eine 1x1-Box mit Mini-Inhalt.

Resize-Code (in BUG-01/02-Branch, applySize) setzte zwar korrekt
grid-column/row per JS — aber nur WÄHREND Resize. Initial war's kaputt.

Fix:
- renderGrid() hängt Items jetzt direkt in den Grid-Container (cont),
  nicht mehr in die Origin-Cell.
- style.gridColumn = '${it.x + 1} / span ${it.w}' setzt die CSS-Span-Geometrie
  direkt im Inline-Style.
- Origin-Cell bekommt nur noch die 'occupied'-Klasse (für die Optik).
- DOM-Baum: Items sind Geschwister der Cells → keine DOM-Kollision mehr,
  Drag-Events auf Nachbar-Cells werden nicht vom Item verschluckt.

Vorteile:
  - 2x2-Item rendert visuell über 2x2 Cells (richtige Größe beim ersten Laden)
  - Drag auf JEDE Zelle innerhalb der Item-Bbox funktioniert
  - applySize (Resize) kann den Span nahtlos aktualisieren ohne DOM-Wechsel
  - Kein Flicker beim Resize (initial state ist schon korrekt)

Beweis: tests/test_span_geometry.js (8/8 grün)
  1. gridColumn wird per JS gesetzt
  2. gridRow wird per JS gesetzt
  3. Item wird in Container (cont) gehängt
  4. Item wird NICHT mehr in Origin-Cell gehängt
  5. Origin-Cell bekommt 'occupied' Klasse
  6. gridColumn Format: <x+1> / span <w>
  7. Keine width:100% im Item-CSS-Block
  8. CSS-Kommentar erwähnt BUG-03

Hinweis: Mein ursprüngliches Issue-Statement war zu pessimistisch (Items
verdecken keine Nachbarzellen visuell). Sie saßen nur 1x1 in der
Origin-Cell. Dennoch ist der Fix substantiell: Initial-Render zeigt jetzt
korrekte Größe, und zukünftige Resize-Codes können sich auf den Span
verlassen ohne DOM-Mutation.

Closes #5
2026-08-29 17:42:49 +04:00
ki c2a44b80ea BUG-01+02: Resize via Pointer-Events + rAF (kein renderGrid-per-Move)
BUG-01: Resize-Handle griff nicht
  Items hatten `draggable=true` (HTML5-DnD). Der Resize-Handle war Kind des
  Items, also fired der Browser `dragstart` am Parent, sobald die Maus
  überhaupt auf irgendeinem Kind war. `e.preventDefault()` im
  `startResize()` mousedown-Handler kam zu spät — HTML5-DnD hatte den
  Move-Drag bereits übernommen.

BUG-02: Resize-Drag war zäh
  Pro mousemove rief der alte Code `renderGrid()` auf, das die komplette
  Cell- und Item-Struktur neu aufbaute. Bei 60 events/s × 100+ DOM-Ops
  pro Render entstand sichtbarer Jank.

Fix:
- HTML5-draggable komplett raus.
- Eigene Pointer-Events (`pointerdown`/`pointermove`/`pointerup`)
  für Move (startItemPointer) und Resize (startResizePointer).
- Resize-Handle: `pointer-events:auto` auf Handle,
  `pointer-events:none` auf `::before` (Dreieck-Deko), eigene
  Listener, `stopPropagation()` damit Item-Handler nicht mitfeuert.
- `touch-action:none` auf Handle und Item-Body → Touch-Devices
  scrollen nicht versehentlich beim Drag.
- Beide Aktionen nutzen requestAnimationFrame, und nur die
  CSS-Geometrie (grid-column/grid-row) wird aktualisiert — kein
  renderGrid() während Drag. Erst beim Drop / Resize-End kommt
  der volle re-render + save.
- Resize nutzt `setPointerCapture` damit der Move auch dann
  weiterläuft, wenn die Maus den Handle kurz verlässt.

Beweis: tests/test_pointer_events.js (Node-basiert, ohne Browser)
  1. Resize stopPropagation wird aufgerufen
  2. Item-Body pointerdown erreicht nur Item-Handler (nicht Resize)
  3. CSS pointer-events korrekt gesetzt
  4. renderGrid() in keiner onMove-Funktion (0/2)
  5. requestAnimationFrame wird genutzt (3 calls)
  6. draggable=true komplett entfernt (0 assignments)
  7. touch-action:none auf Resize-Handle
  ALLE 7 grün.

Manuelle Verifikation gegen laufenden Server (?demo=1):
  GET /?demo=1 → 200, 91685 bytes
  grep draggable=true: 0
  grep startItemPointer|startResizePointer: 6
  grep pointerdown: 3
  grep requestAnimationFrame: 3
  grep HTML5-drag handlers: 0 (nur 1 in Kommentar)
  grep renderGrid(): 12, davon 0 in onMove-Handlern

Closes #3 #4
2026-08-29 17:31:40 +04:00
20 changed files with 4878 additions and 341 deletions
+1
View File
@@ -112,6 +112,7 @@ Default Login: `admin` / `admin` — in `.env` mit `EPAPER_ADMIN_PASSWORD` setze
| `strava` | Strava API | `access_token`, `club_id` | | `strava` | Strava API | `access_token`, `club_id` |
| `gmail` | Gmail API | `credentials_json` (OAuth2) | | `gmail` | Gmail API | `credentials_json` (OAuth2) |
| `minimax` | MiniMax AI | `api_key`, `model`, `prompt` | | `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` | | `hello` | statisch | `text`, `color`, `size` |
## Eigenes Plugin schreiben ## Eigenes Plugin schreiben
+129 -10
View File
@@ -153,14 +153,9 @@ def save_config():
except (TypeError, ValueError): except (TypeError, ValueError):
pass pass
# slot belegung: pro slot "slot_<idx>_plugin" # NOTE: legacy v1 slot-X-belegung wird nicht mehr unterstützt — die
slots = cfg.get("slots", []) # Admin-UI nutzt seit v2 die Drag&Drop-API unter /api/layout für
for i in range(dashboard_mod.SLOTS): # Slot-Belegung. /config macht heute NUR noch den Refresh-Intervall.
plugin_name = request.form.get(f"slot_{i}_plugin", "").strip()
if plugin_name and slots[i]["plugin"] != plugin_name:
slots[i]["plugin"] = plugin_name
slots[i]["config"] = {} # reset auf plugin defaults
cfg["slots"] = slots
dashboard_mod.save_config(cfg) dashboard_mod.save_config(cfg)
return redirect(url_for("index")) return redirect(url_for("index"))
@@ -197,7 +192,10 @@ def save_plugin_config(idx):
new_cfg[key] = form_val new_cfg[key] = form_val
continue continue
if form_val is None: if form_val is None:
continue if ftype == "bool":
new_cfg[key] = False
else:
continue
try: try:
if ftype == "int": if ftype == "int":
new_cfg[key] = int(form_val) new_cfg[key] = int(form_val)
@@ -476,7 +474,10 @@ def api_plugin_config(plugin_name):
form_val = request.form.get(f"config_{key}") form_val = request.form.get(f"config_{key}")
ftype = field.get("type", "string") ftype = field.get("type", "string")
if form_val is None: if form_val is None:
continue if ftype == "bool":
new_cfg[key] = False # unchecked checkbox → False
else:
continue
if ftype == "secret": if ftype == "secret":
# Nur überschreiben wenn nicht leer # Nur überschreiben wenn nicht leer
existing = cfg.get("plugin_configs", {}).get(plugin_name, {}).get(key, "") existing = cfg.get("plugin_configs", {}).get(plugin_name, {}).get(key, "")
@@ -689,6 +690,124 @@ def api_net_forget():
return jsonify({"ok": rc == 0, "message": out or err}) return jsonify({"ok": rc == 0, "message": out or err})
# ============================================================================
# Power / DeepSleep — Energie-Tab
# ============================================================================
@app.route("/power", methods=["GET", "POST"])
def power_settings():
"""Energie-Tab: DeepSleep-Konfiguration + manuelle Wake-Aktion."""
a = require_auth()
if a: return a
cfg = dashboard_mod.load_config()
power = cfg.get("power", {})
if request.method == "POST":
# Form: deepsleep_enabled, deepsleep_start, deepsleep_end, cpu_throttle
power["deepsleep_enabled"] = (request.form.get("deepsleep_enabled") == "on")
start = request.form.get("deepsleep_start", "23:30").strip()
end = request.form.get("deepsleep_end", "06:30").strip()
# Validierung HH:MM
import re
if re.match(r"^\d{1,2}:\d{2}$", start):
power["deepsleep_start"] = start
if re.match(r"^\d{1,2}:\d{2}$", end):
power["deepsleep_end"] = end
power["cpu_throttle"] = (request.form.get("cpu_throttle") == "on")
power["wifi_off"] = (request.form.get("wifi_off") == "on")
cfg["power"] = power
dashboard_mod.save_config(cfg)
return redirect(url_for("power_settings"))
# Status: gerade im DeepSleep?
from datetime import datetime
now = datetime.now()
is_sleep = False
next_wake = None
if power.get("deepsleep_enabled", False):
try:
def _to_min(s):
h, m = s.split(":")
return int(h) * 60 + int(m)
now_min = now.hour * 60 + now.minute
s = _to_min(power.get("deepsleep_start", "23:00"))
e = _to_min(power.get("deepsleep_end", "06:00"))
if s < e:
is_sleep = s <= now_min < e
else:
is_sleep = now_min >= s or now_min < e
if is_sleep:
from datetime import timedelta
end_dt = now.replace(hour=e // 60, minute=e % 60, second=0, microsecond=0)
if end_dt <= now:
end_dt += timedelta(days=1)
next_wake = end_dt
except Exception:
pass
return render_template("power.html",
power=power,
is_deepsleep=is_sleep,
next_wake=next_wake.strftime("%H:%M") if next_wake else None,
now=now.strftime("%H:%M"))
@app.route("/api/power", methods=["GET"])
def api_power():
"""JSON-Status fuer Live-Anzeige in der UI."""
a = require_auth()
if a: return a
cfg = dashboard_mod.load_config()
power = cfg.get("power", {})
from datetime import datetime, timedelta
now = datetime.now()
is_sleep = False
next_wake = None
if power.get("deepsleep_enabled", False):
try:
def _to_min(s):
h, m = s.split(":")
return int(h) * 60 + int(m)
now_min = now.hour * 60 + now.minute
s = _to_min(power.get("deepsleep_start", "23:00"))
e = _to_min(power.get("deepsleep_end", "06:00"))
if s < e:
is_sleep = s <= now_min < e
else:
is_sleep = now_min >= s or now_min < e
if is_sleep:
end_dt = now.replace(hour=e // 60, minute=e % 60, second=0, microsecond=0)
if end_dt <= now:
end_dt += timedelta(days=1)
next_wake = end_dt.strftime("%H:%M")
secs_left = int((end_dt - now).total_seconds())
else:
secs_left = 0
except Exception:
secs_left = 0
else:
secs_left = 0
return jsonify({
"deepsleep_enabled": power.get("deepsleep_enabled", False),
"deepsleep_start": power.get("deepsleep_start", "23:30"),
"deepsleep_end": power.get("deepsleep_end", "06:30"),
"cpu_throttle": power.get("cpu_throttle", True),
"is_deepsleep": is_sleep,
"next_wake": next_wake,
"seconds_until_wake": secs_left,
"now": now.strftime("%H:%M"),
})
@app.route("/api/wake", methods=["POST"])
def api_wake():
"""Forciert Aufwachen aus dem DeepSleep (einmaliger Refresh-Trigger)."""
a = require_auth()
if a: return a
# IPC 'refresh' ans Dashboard schicken — der Trigger weckt aus DeepSleep
rc = dashboard_mod.send_ipc("refresh")
return jsonify({"ok": True, "message": "wake trigger sent", "ipc_result": rc})
def main(): def main():
# Wenn direkt ausgeführt: eigenen Renderer starten ist Sache des Run-Skripts. # Wenn direkt ausgeführt: eigenen Renderer starten ist Sache des Run-Skripts.
# Wir nehmen einfach Port 8080. # Wir nehmen einfach Port 8080.
+30
View File
@@ -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"
}
+136 -15
View File
@@ -1,19 +1,23 @@
"""Layout, Slot-Geometrie, Refresh-Loop. """Layout, Slot-Geometrie, Refresh-Loop mit DeepSleep-Support.
Lädt config.json, instanziiert Plugins, läuft Refresh-Loop mit ≥refresh_interval_s Lädt config.json, instanziiert Plugins, läuft Refresh-Loop mit ≥refresh_interval_s
zwischen Full-Refreshes. Reagiert auf Live-Trigger via Unix-Socket. zwischen Full-Refreshes. Reagiert auf Live-Trigger via Unix-Socket.
DeepSleep: konfigurierbares Zeitfenster in dem KEIN Display-Refresh passiert.
Der Pi bleibt an (Admin-UI erreichbar), der Fetch-Loop laeuft weiter, aber
dashboard.display() wird uebersprungen. ePaper haelt das letzte Bild ohne Strom.
""" """
from __future__ import annotations from __future__ import annotations
import os, sys, time, json, math, threading, signal, socket, logging import os, sys, time, json, math, threading, signal, socket, logging
from datetime import datetime from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
HERE = Path(__file__).resolve().parent HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE)) sys.path.insert(0, str(HERE))
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
from palette import BG, FG, OK from palette import BG, FG, OK, POWER_DEFAULTS
from plugins.base import all_widget_classes, Widget from plugins.base import all_widget_classes, Widget, draw_deepsleep_indicator
from layout import Item, GRID_COLS, GRID_ROWS, CELL_W, CELL_H, DISPLAY_W as LAYOUT_DISPLAY_W, pack, find_overlaps, find_out_of_bounds from layout import Item, GRID_COLS, GRID_ROWS, CELL_W, CELL_H, DISPLAY_W as LAYOUT_DISPLAY_W, pack, find_overlaps, find_out_of_bounds
import importlib, pkgutil import importlib, pkgutil
@@ -103,6 +107,12 @@ def load_config() -> dict:
cfg["plugin_configs"] = {} cfg["plugin_configs"] = {}
if "refresh_interval_s" not in cfg: if "refresh_interval_s" not in cfg:
cfg["refresh_interval_s"] = 180 cfg["refresh_interval_s"] = 180
# Power-Defaults (DeepSleep) sicherstellen
if "power" not in cfg or not isinstance(cfg["power"], dict):
cfg["power"] = dict(POWER_DEFAULTS)
else:
for k, v in POWER_DEFAULTS.items():
cfg["power"].setdefault(k, v)
return cfg return cfg
@@ -119,6 +129,7 @@ def _empty_v2_config() -> dict:
"items": [], "items": [],
}, },
"plugin_configs": {}, "plugin_configs": {},
"power": dict(POWER_DEFAULTS),
} }
@@ -198,7 +209,8 @@ def slot_box(idx: int) -> tuple[int, int, int, int]:
# ============================================================================ # ============================================================================
# Render # Render
# ============================================================================ # ============================================================================
def render_full(items: list, widgets: list, fonts: dict) -> Image.Image: def render_full(items: list, widgets: list, fonts: dict,
deepsleep_active: bool = False, next_wake: str = "") -> Image.Image:
"""Render alle Items aufs Display. """Render alle Items aufs Display.
items: list[Item] (Layout-Items mit x,y,w,h) items: list[Item] (Layout-Items mit x,y,w,h)
@@ -241,6 +253,9 @@ def render_full(items: list, widgets: list, fonts: dict) -> Image.Image:
draw.text((px + 8, py + 8), f"{widget.label} (overflow)", draw.text((px + 8, py + 8), f"{widget.label} (overflow)",
font=fonts.get("16", fonts.get("default")), fill=FG) font=fonts.get("16", fonts.get("default")), fill=FG)
continue continue
# Fonts an Widget durchreichen VOR render()
if hasattr(widget, "set_fonts"):
widget.set_fonts(fonts)
try: try:
widget.render(draw, fonts, px, py, pw, ph) widget.render(draw, fonts, px, py, pw, ph)
except Exception as e: except Exception as e:
@@ -249,6 +264,11 @@ def render_full(items: list, widgets: list, fonts: dict) -> Image.Image:
f"{widget.label}: render error", f"{widget.label}: render error",
font=fonts.get("16", fonts.get("default")), fill=FG) font=fonts.get("16", fonts.get("default")), fill=FG)
# DeepSleep-Indikator im oberen rechten Bereich
if deepsleep_active:
ind_w = min(280, DISPLAY_W // 3)
draw_deepsleep_indicator(draw, DISPLAY_W - ind_w - 6, 4, ind_w, 18, next_wake)
return img return img
@@ -300,8 +320,69 @@ class Dashboard:
def trigger_refresh(self): def trigger_refresh(self):
self._trigger.set() self._trigger.set()
def render_once(self) -> Image.Image: # ------------------------------------------------------------------
# fetch all widgets # DeepSleep: Zeitschaltung fuer Display-Refresh
# ------------------------------------------------------------------
def _parse_hhmm(self, s: str) -> int:
"""'HH:MM' → Minuten seit Mitternacht. Fallback: 0."""
try:
parts = s.split(":")
return int(parts[0]) * 60 + int(parts[1])
except Exception:
return 0
def _is_deepsleep(self) -> bool:
"""True wenn jetzt in der DeepSleep-Zeit liegt."""
cfg = self.config.get("power", {})
if not cfg.get("deepsleep_enabled", False):
return False
now = datetime.now()
now_min = now.hour * 60 + now.minute
start = self._parse_hhmm(cfg.get("deepsleep_start", "23:00"))
end = self._parse_hhmm(cfg.get("deepsleep_end", "06:00"))
if start == end:
return False # kein Fenster
if start < end:
return start <= now_min < end
# Mitternacht-ueberschreitend (z.B. 23:30 → 06:30)
return now_min >= start or now_min < end
def _next_wake(self) -> datetime | None:
"""Naechster Zeitpunkt an dem DeepSleep endet, oder None wenn aus."""
if not self._is_deepsleep():
return None
cfg = self.config.get("power", {})
end = self._parse_hhmm(cfg.get("deepsleep_end", "06:00"))
now = datetime.now()
end_dt = now.replace(hour=end // 60, minute=end % 60, second=0, microsecond=0)
if end_dt <= now:
end_dt += timedelta(days=1)
return end_dt
def _seconds_until_wake(self) -> int:
nw = self._next_wake()
if nw is None:
return 0
return max(5, int((nw - datetime.now()).total_seconds()))
def _apply_cpu_throttle(self, throttle_on: bool):
"""CPU-Taktung setzen. Nur auf Linux/Pi wirksam; sonst noop."""
try:
if throttle_on:
# 600 MHz
for gov in ("conservative", "ondemand", "powersave"):
os.system(f"echo {gov} > /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor 2>/dev/null")
os.system("echo 600000 > /sys/devices/system/cpu/cpu0/cpufreq/scaling_max_freq 2>/dev/null")
else:
# Default: ondemand, max 1.5 GHz
for gov in ("ondemand", "performance"):
os.system(f"echo {gov} > /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor 2>/dev/null")
os.system("echo 1500000 > /sys/devices/system/cpu/cpu0/cpufreq/scaling_max_freq 2>/dev/null")
except Exception:
pass
def render_once(self, with_deepsleep: bool = True) -> Image.Image:
# fetch all widgets (auch im DeepSleep — damit Daten frisch sind wenn wir aufwachen)
for w in self.widgets: for w in self.widgets:
if w is None: continue if w is None: continue
try: try:
@@ -311,7 +392,15 @@ class Dashboard:
with self._lock: with self._lock:
items = list(self.items) items = list(self.items)
widgets = list(self.widgets) widgets = list(self.widgets)
img = render_full(items, widgets, self.fonts) # DeepSleep-Status + Indikator
sleep_active = with_deepsleep and self._is_deepsleep()
next_wake = ""
if sleep_active:
nw = self._next_wake()
if nw:
next_wake = nw.strftime("%H:%M")
img = render_full(items, widgets, self.fonts,
deepsleep_active=sleep_active, next_wake=next_wake)
return img return img
def display(self, img: Image.Image): def display(self, img: Image.Image):
@@ -341,18 +430,50 @@ class Dashboard:
# start IPC socket listener # start IPC socket listener
threading.Thread(target=self._socket_loop, daemon=True).start() threading.Thread(target=self._socket_loop, daemon=True).start()
_throttle_applied = False
while True: while True:
try: try:
self.maybe_reload_config() self.maybe_reload_config()
interval = int(self.config.get("refresh_interval_s", 180)) interval = int(self.config.get("refresh_interval_s", 180))
# sleep until interval elapsed OR trigger set # ----- DeepSleep Check -----
wait = max(5, interval) if self._is_deepsleep():
logging.info(f"waiting up to {wait}s (manual trigger interrupts)") secs = self._seconds_until_wake()
if self._trigger.wait(timeout=wait): nw = self._next_wake()
self._trigger.clear() nw_str = nw.strftime("%H:%M") if nw else "?"
logging.info("manual trigger received") logging.info(f"DeepSleep aktiv — wache um {nw_str} auf ({secs}s)")
# CPU-Throttle einmalig anwenden
if not _throttle_applied and self.config.get("power", {}).get("cpu_throttle", True):
self._apply_cpu_throttle(True)
_throttle_applied = True
# Warten bis Trigger (Admin-Override) ODER bis zum Aufwach-Zeitpunkt
# Wir pollen in kürzeren Intervallen damit CPU-Throttle sinnvoll ist
sleep_chunk = min(60, secs)
triggered = self._trigger.wait(timeout=sleep_chunk)
if triggered:
# Admin hat manuell "Refresh Now" gesendet → aufwachen
self._trigger.clear()
logging.info("DeepSleep durch Admin-Trigger beendet")
if _throttle_applied:
self._apply_cpu_throttle(False)
_throttle_applied = False
# weiter zum normalen Refresh unten
else:
# chunk ausgeschlafen, noch im DeepSleep → weiter warten
continue
else: else:
logging.info("interval elapsed, refreshing") # Wach: Throttle zurücksetzen falls aktiv
if _throttle_applied:
self._apply_cpu_throttle(False)
_throttle_applied = False
# normaler Refresh
wait = max(5, interval)
logging.info(f"waiting up to {wait}s (manual trigger interrupts)")
if self._trigger.wait(timeout=wait):
self._trigger.clear()
logging.info("manual trigger received")
else:
logging.info("interval elapsed, refreshing")
t0 = time.time() t0 = time.time()
img = self.render_once() img = self.render_once()
+1
View File
@@ -164,6 +164,7 @@ def auto_size_for_plugin(plugin_name: str) -> tuple[int, int]:
"clock": (2, 2), "clock": (2, 2),
"clock_wordclock": (2, 2), "clock_wordclock": (2, 2),
"weather": (2, 2), "weather": (2, 2),
"netatmo": (4, 4), # zeigt 4 Sub-Cards; ab 2x2 sinnvoll, 4x4 ideal
"system": (2, 2), "system": (2, 2),
"minimax": (2, 2), "minimax": (2, 2),
"spotify": (2, 1), "spotify": (2, 1),
+186
View File
@@ -235,3 +235,189 @@ def is_tall(w: int, h: int) -> bool:
"""True wenn Slot hoch aber schmal ist (1x4, 1x2).""" """True wenn Slot hoch aber schmal ist (1x4, 1x2)."""
return h >= 280 and w < 280 return h >= 280 and w < 280
# ============================================================================
# Batterie-Farben — Single Source of Truth
# ============================================================================
BATT_LOW = RED # < 25% → kritisch
BATT_MID = ORANGE # < 50% → bald wechseln
BATT_OK = GREEN # >= 50% → OK
# Standard-Batterie-Schwellen (werden von Plugins einheitlich genutzt)
BATT_THRESHOLDS = (
(25, BATT_LOW),
(50, BATT_MID),
)
def batt_color(pct: int | None) -> tuple:
"""Liefert die passende Batterie-Farbe fuer einen Prozent-Wert.
None → FG (keine Anzeige), sonst BATT_LOW/MID/OK.
"""
if pct is None:
return FG
pct = max(0, min(100, pct))
for threshold, color in BATT_THRESHOLDS:
if pct < threshold:
return color
return BATT_OK
# ============================================================================
# Deutsche Formatter — Single Source of Truth (kein Englisches "Monday" mehr)
# ============================================================================
DE_WEEKDAYS_LONG = [
"Montag", "Dienstag", "Mittwoch", "Donnerstag",
"Freitag", "Samstag", "Sonntag",
]
DE_WEEKDAYS_SHORT = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]
DE_MONTHS_LONG = [
"Januar", "Februar", "Maerz", "April", "Mai", "Juni",
"Juli", "August", "September", "Oktober", "November", "Dezember",
]
DE_MONTHS_SHORT = ["Jan", "Feb", "Mae", "Apr", "Mai", "Jun",
"Jul", "Aug", "Sep", "Okt", "Nov", "Dez"]
def de_weekday_long(weekday: int) -> str:
"""datetime.weekday() (0=Montag) → 'Montag'/'Dienstag'/..."""
return DE_WEEKDAYS_LONG[weekday]
def de_weekday_short(weekday: int) -> str:
"""datetime.weekday() (0=Montag) → 'Mo'/'Di'/..."""
return DE_WEEKDAYS_SHORT[weekday]
def de_month_short(month: int) -> str:
"""datetime.month (1-12) → 'Jan'/'Feb'/..."""
return DE_MONTHS_SHORT[month - 1]
def de_date(dt, short: bool = True) -> str:
"""Datum als '30. Aug 2026' (default short=True)."""
if short:
return f"{dt.day:02d}. {de_month_short(dt.month)} {dt.year}"
return f"{dt.day:02d}. {DE_MONTHS_LONG[dt.month - 1]} {dt.year}"
def de_weekday_date(dt, short: bool = True) -> str:
"""'Sa 30.08' wenn short, sonst 'Samstag 30. August 2026'."""
if short:
return f"{de_weekday_short(dt.weekday())} {dt.day:02d}.{dt.month:02d}"
return f"{de_weekday_long(dt.weekday())} {de_date(dt, short=False)}"
def de_time(dt, show_seconds: bool = False) -> str:
"""24h-Zeit 'HH:MM' oder 'HH:MM:SS'."""
return dt.strftime("%H:%M:%S" if show_seconds else "%H:%M")
# ============================================================================
# Wind / Temp Helper — einheitliche Formatierung
# ============================================================================
def format_temp(value: float | None, unit: str = "C", decimals: int = 1) -> str:
"""Temperatur formatieren. None → em-dash."""
if value is None:
return "—"
return f"{value:.{decimals}f}°{unit}"
def format_wind(speed: float | None, unit: str = "kmh", decimals: int = 0) -> str:
"""Windgeschwindigkeit. 'kmh' → 'km/h', 'ms' → 'm/s'."""
if speed is None:
return "—"
label = "km/h" if unit == "kmh" else "m/s"
return f"{speed:.{decimals}f} {label}"
def format_rain(rate_mm: float | None, decimals: int = 1) -> str:
"""Regenrate 'X.X mm/h'."""
if rate_mm is None:
return "—"
return f"{rate_mm:.{decimals}f} mm/h"
def temp_color(value: float | None) -> tuple:
"""Farbe nach Temperaturwert: kalt→Blau, normal→FG, warm→Orange, heiss→Rot."""
if value is None:
return FG
if value >= 28:
return RED
if value >= 22:
return ORANGE
if value <= 5:
return BLUE
return FG
def co2_color(ppm: int | float | None) -> tuple:
"""CO2-Ampel: <600 gruen, <1000 orange, <1500 gelb, >=1500 rot."""
if ppm is None:
return FG
if ppm >= 1500:
return RED
if ppm >= 1000:
return YELLOW
if ppm >= 600:
return ORANGE
return GREEN
# ============================================================================
# Power-Management — Defaults
# ============================================================================
POWER_DEFAULTS = {
"deepsleep_enabled": False,
"deepsleep_start": "23:30",
"deepsleep_end": "06:30",
"cpu_throttle": True, # waehrend DeepSleep auf 600MHz
"wifi_off": False, # WiFi anlassen fuer Admin-Erreichbarkeit
}
# ============================================================================
# Weather-Code-Mapping (WMO Standard + Netatmo-Fallback)
# ============================================================================
# WMO Weather interpretation codes (https://open-meteo.com/en/docs)
WMO_ICONS = {
0: "sun", # Clear sky
1: "partly", # Mainly clear
2: "partly", # Partly cloudy
3: "cloud", # Overcast
45: "fog", # Fog
48: "fog", # Depositing rime fog
51: "rain", # Drizzle: light
53: "rain", # Drizzle: moderate
55: "rain", # Drizzle: dense
56: "rain", # Freezing Drizzle: light
57: "rain", # Freezing Drizzle: dense
61: "rain", # Rain: slight
63: "rain", # Rain: moderate
65: "rain", # Rain: heavy
66: "rain", # Freezing Rain: light
67: "rain", # Freezing Rain: heavy
71: "snow", # Snow fall: slight
73: "snow", # Snow fall: moderate
75: "snow", # Snow fall: heavy
77: "snow", # Snow grains
80: "rain", # Rain showers: slight
81: "rain", # Rain showers: moderate
82: "rain", # Rain showers: violent
85: "snow", # Snow showers: slight
86: "snow", # Snow showers: heavy
95: "storm", # Thunderstorm
96: "storm", # Thunderstorm with slight hail
99: "storm", # Thunderstorm with heavy hail
}
def wmo_to_icon(code: int | str | None) -> str:
"""WMO-Code (int) oder Symbol-String → 'sun'/'cloud'/'rain'/'storm'/..."""
if code is None:
return "cloud"
if isinstance(code, str):
return code # schon ein symbol
return WMO_ICONS.get(code, "cloud")
+201
View File
@@ -0,0 +1,201 @@
# Netatmo Weather Station Plugin
Zeigt die Live-Daten deiner heimischen [Netatmo Wetterstation](https://www.netatmo.com/de-de/weather/weatherstation)
auf dem 7.3″ ACeP Dashboard: Indoor-Temperatur, CO₂, Luftfeuchte, Luftdruck,
Outdoor-Werte, Wind (Stärke + Böen + Richtung mit Windrose) und Regenmengen.
Funktioniert mit **jeder** Netatmo-Konfiguration (Hauptmodul + beliebige
Zusatzmodule). Module, die du nicht hast, werden automatisch weggelassen.
![Netatmo 4×4](docs/netatmo_4x4.png)
---
## 1. Netatmo-App registrieren
Damit das Plugin Daten abrufen darf, brauchst du eine App-Registrierung
bei Netatmo. Das ist einmalig und kostenlos.
1. Gehe zu <https://dev.netatmo.com/apps/> 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://<pi>: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/<user>/.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.
+354 -1
View File
@@ -4,17 +4,29 @@ Ein Plugin ist ein Python-Modul in /plugins/, das eine Klasse Widget
exportiert. Die Klasse wird beim Start dynamisch geladen und in der exportiert. Die Klasse wird beim Start dynamisch geladen und in der
Admin-UI zur Auswahl angeboten. Admin-UI zur Auswahl angeboten.
Dieses Modul enthaelt auch Shared Render-Helpers (Batterie, Compass,
Trend-Pfeile, Wetter-Icons, Charts), die von allen Plugins einheitlich
genutzt werden — damit sehen Batterie-Icons, Windrosen, etc. auf dem
Display konsistent aus.
Minimal-Beispiel siehe plugins/hello.py. Minimal-Beispiel siehe plugins/hello.py.
""" """
from __future__ import annotations from __future__ import annotations
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Any from typing import Any
import math
import time import time
import urllib.request import urllib.request
import urllib.error import urllib.error
import socket as _socket import socket as _socket
from PIL import ImageDraw from PIL import ImageDraw, ImageFont
from palette import (
FG, BG, BLACK, WHITE, GREEN, BLUE, RED, YELLOW, ORANGE,
BATT_LOW, BATT_MID, BATT_OK, batt_color,
measure, text_wh,
)
# ============================================================================ # ============================================================================
@@ -153,6 +165,13 @@ class Widget(ABC):
merged = dict(self.default_config) merged = dict(self.default_config)
merged.update(config or {}) merged.update(config or {})
self.config = merged self.config = merged
self.fonts: dict | None = None # wird vom Dashboard gesetzt vor render()
self.theme: Any = None # Theme-Instanz (von designbase), optional
def set_fonts(self, fonts: dict):
"""Wird vom Dashboard vor render() aufgerufen. Plugins koennen
dann self.fonts["24"] etc. nutzen statt selbst Fonts zu laden."""
self.fonts = fonts
@abstractmethod @abstractmethod
def fetch(self) -> dict: def fetch(self) -> dict:
@@ -189,3 +208,337 @@ def all_widget_classes() -> list[type[Widget]]:
classes.append(cls) classes.append(cls)
classes.sort(key=lambda c: c.label or c.name) classes.sort(key=lambda c: c.label or c.name)
return classes return classes
# ============================================================================
# Shared Render Helpers — Single Source of Truth fuer Batterie, Compass,
# Trend, Wetter-Icons, Charts. Alle Plugins nutzen diese Funktionen damit
# das Display konsistent aussieht.
# ============================================================================
# Sans-Schriftart aus dem System (lazy-loaded, gecacht)
_BASE_SANS_PATHS = [
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"C:/Windows/Fonts/arial.ttf",
"C:/Windows/Fonts/seguisb.ttf",
]
_BASE_SANS = None
def _get_base_sans() -> ImageFont.FreeTypeFont | None:
"""Lazy-load Sans-Basis-Font (size 14) fuer die Helper."""
global _BASE_SANS
if _BASE_SANS is not None:
return _BASE_SANS
import os
for p in _BASE_SANS_PATHS:
if os.path.exists(p):
try:
_BASE_SANS = ImageFont.truetype(p, 14)
return _BASE_SANS
except Exception:
continue
try:
_BASE_SANS = ImageFont.load_default()
except Exception:
_BASE_SANS = None
return _BASE_SANS
def _fit_size(draw, text: str, max_w: int, max_h: int,
candidates: list[str | int] | None = None) -> ImageFont.FreeTypeFont:
"""Iteriere durch Font-Groessen, waehle die erste die passt.
Verwendet das gleiche Sans-Family wie die Basis-Schrift.
"""
if candidates is None:
candidates = ["48", "36", "32", "28", "24", "20", "16", "14", "12", "10"]
base = _get_base_sans()
base_path = getattr(base, "path", None) if base else None
for sz in candidates:
try:
sz_int = int(sz)
if base_path:
f = ImageFont.truetype(base_path, sz_int)
else:
f = base
except Exception:
continue
tw, th = measure(draw, text, f)
if tw <= max_w and th <= max_h:
return f
return base
def text_in_box(draw, text, x, y, w, h, font, color, align: str = "left"):
"""Zeichnet Text in reservierten Bereich (x,y,w,h). Garantiert keine
Ueberlappung mit anderen Elementen wenn jeder seinen eigenen Bereich hat.
align: 'left' (default), 'right', 'center'
"""
tw, th = measure(draw, text, font)
if tw > w or th > h:
return False # wuerde ueberlaufen
if align == "left":
draw.text((x, y + (h - th) // 2), text, font=font, fill=color)
elif align == "right":
draw.text((x + w - tw, y + (h - th) // 2), text, font=font, fill=color)
elif align == "center":
draw.text((x + (w - tw) // 2, y + (h - th) // 2), text, font=font, fill=color)
return True
# ---- Batterie-Icon (Single Source of Truth) ------------------------------
def draw_battery(draw, x, y, pct: int | None, w: int = 22, h: int = 10,
with_text: bool = True, text_color: tuple | None = None):
"""Zeichnet ein konsistentes Batterie-Icon.
Args:
pct: Prozent (0-100), None → noop
w, h: Icon-Groesse (ohne Tip)
with_text: ob "%" daneben gezeichnet werden soll
text_color: falls None, automatisch nach Schwellen (LOW/MID/OK)
"""
if pct is None:
return 0
pct = max(0, min(100, pct))
color = text_color or batt_color(pct)
# Body
draw.rectangle((x, y, x + w - 3, y + h - 1), outline=FG, width=1)
# Tip
draw.rectangle((x + w - 2, y + 3, x + w, y + h - 3), fill=FG)
# Fill
fill_w = max(0, int((w - 4) * pct / 100))
if fill_w > 0:
draw.rectangle((x + 2, y + 2, x + 2 + fill_w, y + h - 2), fill=color)
used = w
if with_text:
f = _fit_size(draw, f"{pct}%", 30, h, candidates=["10", "9", "8"])
text_in_box(draw, f"{pct}%", x + w + 3, y - 1, 30, h + 2, f, color, "left")
used += 30
return used
# ---- Trend-Pfeil (up / down / stable) ------------------------------------
def draw_trend(draw, x, y, trend: str, size: int = 10, color: tuple | None = None):
"""Pfeil nach oben/unten, oder waagerechter Strich fuer 'stable'."""
color = color or FG
cx, cy = x + size // 2, y + size // 2
if trend == "up":
draw.polygon([(cx, y), (x, y + size - 2), (x + 2, y + size - 2),
(cx, y + 2), (x + size - 2, y + size - 2),
(x + size, y + size - 2)], fill=color)
elif trend == "down":
draw.polygon([(cx, y + size), (x, y + 2), (x + 2, y + 2),
(cx, y + size - 2), (x + size - 2, y + 2),
(x + size, y + 2)], fill=color)
else: # stable
draw.line((x, cy, x + size, cy), fill=color, width=2)
# ---- Windrose / Compass --------------------------------------------------
def draw_compass(draw, cx, cy, r: int, deg: float, color: tuple = BLUE,
with_labels: bool = False):
"""Zeichnet eine Windrose.
Args:
deg: 0=Nord, 90=Ost, 180=Sued, 270=West
with_labels: N/O/S/W Beschriftung (nur fuer grosse Varianten sinnvoll)
"""
if r < 4:
return
# Aeusserer Kreis
draw.ellipse((cx - r, cy - r, cx + r, cy + r), outline=FG, width=1)
# Cardinal Ticks
for a in [270, 90, 180, 0]: # N/O/S/W in Wetter-Konvention
rd = math.radians(a - 90)
x1 = cx + (r - 6) * math.cos(rd)
y1 = cy + (r - 6) * math.sin(rd)
x2 = cx + (r - 2) * math.cos(rd)
y2 = cy + (r - 2) * math.sin(rd)
draw.line((x1, y1, x2, y2), fill=FG, width=1)
# Pfeil
rad = math.radians(deg - 90)
tx = cx + (r - 4) * math.cos(rad)
ty = cy + (r - 4) * math.sin(rad)
draw.line((cx, cy, tx, ty), fill=color, width=2 if r < 16 else 3)
draw.ellipse((cx - 2, cy - 2, cx + 2, cy + 2), fill=color)
# Optionale N/O/S/W Labels
if with_labels and r >= 14:
for letter, a in [("N", 270), ("O", 0), ("S", 90), ("W", 180)]:
rd = math.radians(a - 90)
lx = cx + (r + 7) * math.cos(rd) - 4
ly = cy + (r + 7) * math.sin(rd) - 6
f = _fit_size(draw, letter, 10, 10, candidates=["10", "9", "8"])
draw.text((lx, ly), letter, font=f, fill=FG)
# ---- Wetter-Icon (7 Wetter-Zustaende) ------------------------------------
def draw_weather_icon(draw, x, y, size: int, code: int | str):
"""Zeichnet ein Wetter-Icon in (x, y, size, size) Box.
Akzeptiert WMO-Code (int) oder symbol-string ('sun'/'cloud'/'rain'/'storm'/'snow'/'fog'/'partly').
Verwendet nur die 7 ACeP-Farben.
"""
from palette import wmo_to_icon
sym = wmo_to_icon(code) if isinstance(code, int) else code
cx, cy = x + size // 2, y + size // 2
r = max(3, size // 3)
if sym in ("sun", "clear"):
draw.ellipse((cx - r, cy - r, cx + r, cy + r), fill=YELLOW)
for ang in range(0, 360, 45):
rad = math.radians(ang)
x1 = cx + int((r + 2) * math.cos(rad))
y1 = cy + int((r + 2) * math.sin(rad))
x2 = cx + int((r + 5) * math.cos(rad))
y2 = cy + int((r + 5) * math.sin(rad))
draw.line((x1, y1, x2, y2), fill=FG, width=1)
elif sym == "partly":
draw.ellipse((cx - r // 2 - 2, cy - r, cx + r // 2, cy + 1), fill=YELLOW)
cw = size // 2
ch = size // 4
draw.ellipse((cx - cw // 2, cy, cx + cw // 2, cy + ch),
fill=WHITE, outline=FG, width=1)
elif sym == "cloud":
cw = int(size * 0.7)
ch = size // 3
draw.ellipse((cx - cw // 2, cy - ch // 2, cx + cw // 2, cy + ch // 2),
fill=WHITE, outline=FG, width=2)
draw.ellipse((cx - cw // 3, cy - ch, cx + cw // 3, cy + ch // 2),
fill=WHITE, outline=FG, width=2)
elif sym == "rain":
cw = int(size * 0.7)
ch = size // 3
draw.ellipse((cx - cw // 2, cy - ch - 1, cx + cw // 2, cy + ch // 2 - 1),
fill=WHITE, outline=FG, width=2)
for dx in (-4, 0, 4):
draw.line((cx + dx, cy + ch, cx + dx, cy + ch + 5), fill=BLUE, width=2)
elif sym == "storm":
cw = int(size * 0.7)
ch = size // 3
draw.ellipse((cx - cw // 2, cy - ch - 1, cx + cw // 2, cy + ch // 2 - 1),
fill=WHITE, outline=FG, width=2)
draw.polygon([(cx - 3, cy + ch - 1), (cx + 1, cy + ch + 3),
(cx - 1, cy + ch + 3), (cx + 3, cy + ch + 8)],
fill=YELLOW)
elif sym == "snow":
cw = int(size * 0.7)
ch = size // 3
draw.ellipse((cx - cw // 2, cy - ch - 1, cx + cw // 2, cy + ch // 2 - 1),
fill=WHITE, outline=FG, width=2)
for dx in (-4, 0, 4):
for dy in (0, 5):
draw.line((cx + dx - 2, cy + ch + dy, cx + dx + 2, cy + ch + dy),
fill=FG, width=1)
draw.line((cx + dx, cy + ch + dy - 2, cx + dx, cy + ch + dy + 2),
fill=FG, width=1)
elif sym == "fog":
cw = int(size * 0.7)
for i, dy in enumerate((-2, 1, 4)):
y_pos = cy + dy
draw.line((cx - cw // 2, y_pos, cx + cw // 2, y_pos), fill=FG, width=1)
else:
f = _fit_size(draw, "?", size - 4, size - 4, candidates=["20", "16", "12"])
text_in_box(draw, "?", x, y, size, size, f, FG, "center")
# ---- Linien-Chart (mit optionalem Y-Grid) ---------------------------------
def draw_line_chart(draw, x, y, w, h, data: list, color: tuple = GREEN,
y_min: float | None = None, y_max: float | None = None,
with_y_grid: bool = True, n_ticks: int = 3):
"""Zeichnet einen Linien-Chart in (x,y,w,h) Box.
Args:
data: Liste von Werten
color: Linien-Farbe
y_min, y_max: Y-Achsen-Bereich. None → auto aus Daten
with_y_grid: Y-Tick-Linien mit Werten am linken Rand
n_ticks: Anzahl Y-Ticks (default 3)
"""
if len(data) < 2 or w <= 10 or h <= 10:
return
# Y-Range bestimmen
if y_min is None:
y_min = min(data)
if y_max is None:
y_max = max(data)
if y_min == y_max:
y_min -= 1
y_max += 1
# Grid: gestrichelte Linien (ACeP kann keine echten Dashes, daher Segmente)
if with_y_grid:
# Nur 2 Ticks zeigen: Min und Max (sonst zu viele Labels für kleinen Chart)
ticks = []
if n_ticks >= 2:
ticks = [y_min, y_max]
elif n_ticks == 1:
ticks = [(y_min + y_max) / 2]
for tick in ticks:
ty = y + int((y_max - tick) / max(1, y_max - y_min) * (h - 8)) + 4
# Y-Label links (NICHT unten — kollidiert mit x-Achsen-Labels)
f_lbl = _fit_size(draw, f"{int(tick)}°", 22, 10, candidates=["9", "8"])
text_in_box(draw, f"{int(tick)}°", x - 24, ty - 5, 22, 10, f_lbl, FG, "right")
# Gestrichelte Linie
for seg_x in range(x, x + w, 6):
draw.line((seg_x, ty, min(seg_x + 3, x + w), ty), fill=FG, width=1)
# Daten-Linie
pad_top, pad_bot = 4, 4
pts = []
for i, v in enumerate(data):
px = x + int(i * w / (len(data) - 1))
py = y + pad_top + int((1 - (v - y_min) / (y_max - y_min)) * (h - pad_top - pad_bot))
pts.append((px, py))
for i in range(len(pts) - 1):
draw.line((pts[i][0], pts[i][1], pts[i+1][0], pts[i+1][1]),
fill=color, width=2)
for px, py in pts:
draw.ellipse((px - 1, py - 1, px + 1, py + 1), fill=color)
# ---- Mini-Bar mit Schwellen-Farbe ----------------------------------------
def draw_mini_bar(draw, x, y, w, h, value: float, max_value: float,
color: tuple | None = None):
"""Horizontale Mini-Bar mit Outline + Fill in gegebener Farbe."""
if w < 6 or h < 4:
return
color = color or FG
draw.rectangle((x, y, x + w - 1, y + h - 1), outline=FG, width=1)
pct = min(100, max(0, value / max(0.001, max_value) * 100))
fill_w = max(0, int((w - 2) * pct / 100))
if fill_w > 0:
draw.rectangle((x + 1, y + 1, x + 1 + fill_w, y + h - 2), fill=color)
# ---- ZZZ / Sleep-Indikator -----------------------------------------------
def draw_zzz_indicator(draw, x, y, size: int = 16):
"""Kleines Mond-Icon + 'Zzz' fuer DeepSleep-Anzeige.
Wird vom Dashboard-Frame im Header-Bereich gezeichnet, wenn Power-Save
aktiv ist.
"""
cx, cy = x + size // 2, y + size // 2
r = size // 3
# Mond (Yellow circle, halb verdeckt)
draw.ellipse((cx - r, cy - r, cx + r, cy + r), fill=YELLOW)
# 'Z' Buchstaben daneben
f = _fit_size(draw, "Z", size, size, candidates=["14", "12", "10"])
text_in_box(draw, "z", x + size + 2, y, 12, size, f, FG, "left")
# ---- DeepSleep-Status-Anzeige fuer Display ------------------------------
def draw_deepsleep_indicator(draw, x, y, w, h, next_wake: str = ""):
"""Zeichnet einen schmalen 'Schlafend'-Bereich im Header.
Args:
next_wake: z.B. '06:30' → wird als Text daneben gezeichnet
"""
if w < 60 or h < 12:
return
# Gelber Mond-Icon
f_moon = _fit_size(draw, "Zz", 30, h, candidates=["12", "10"])
text_in_box(draw, "Zz", x, y, 24, h, f_moon, ORANGE, "center")
# 'Schlafend bis' Text
if next_wake:
f = _fit_size(draw, f"schlafend bis {next_wake}", w - 26, h, candidates=["10", "9", "8"])
text_in_box(draw, f"schlafend bis {next_wake}", x + 26, y, w - 26, h, f, FG, "left")
+260 -174
View File
@@ -1,47 +1,84 @@
"""WordClock-Plugin:Deutsche Wort-Uhr (QWATCHLayout). """WordClock-Plugin: Deutsche Wort-Uhr im QlockTwo-Stil.
Reines Wort-Uhr-Display im deutschen Stil: Layout in 3 klar getrennten Zonen:
ES IST <Fünf/Zehn/Viertel/Zwanzig> <Minuten> <nach/vor> <Stunde> ┌────────────────────────────────────┐
│ Header: Wochentag · Datum │ (accent-farbe, klein)
├────────────────────────────────────┤
│ │
│ Wort-Grid: ES IST │
│ ──── ZEHN NACH ──── │ (eine zentrale Zeile,
│ DREI │ je nach Minute)
│ │
├────────────────────────────────────┤
│ Stunden gross: DREI │ (Aldrich, riesig)
└────────────────────────────────────┘
Unterstützt auch ENGLISCH (US-Layout). Die Minuten-Wörter sind in einer DYNAMISCHEN Zeile, nicht in einem starren
11×8-Grid. Das vermeidet Überlappungen und sieht auf jedem Slot gleich aus.
Layout: fester 4×4-Zeichen-Grid im Quadrat (4×4 Zellen = 800×480 Display).
Jede Zelle = 200×120px → Grid = 800×480px.
Wir nutzen 11×8 "字符-Zellen" pro Grid (72×60px pro Zeichen).
Minuten-Zeilen (oberer Block):
Zeile 0: [E][S][ ][I][S][T]
Zeile 1: [F][Ü][N][F][Z][E][H][N][Z][W][A][N]
Zeile 2: [V][I][E][R][T][E][L][Z][W][A][N][Z]
Zeile 3: [N][U][L][L]
Zeile 4: [N][A][C][H][ ][V][O][R][ ][H][A][L]
Zeile 5: [B][ ]
Zeile 6: [S][P][R][A][C][H][E]
Stunden (unterer Block, je 2×2 Zellen für die Ziffern):
DieZiffern werden als gefüllte Rechtecke in der unteren Reihe gerendert.
Das Layout wird intern gecacht bis sich die Minute ändert.
""" """
from __future__ import annotations from __future__ import annotations
import os, sys import os, sys
from datetime import datetime from datetime import datetime
from functools import lru_cache
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from plugins.base import Widget from plugins.base import Widget
from palette import FG, BG, OK, BLUE, YELLOW, ORANGE, fill_for, measure from palette import FG, BG, OK, BLUE, YELLOW, ORANGE, fill_for, measure
# ---- Deutsche Minuten-Phrasen (QlockTwo-konform) ----
# Schlüssel: Sektor-Untergrenze (5, 10, ..., 55). Wert: (minute_words, hour_offset)
# hour_offset = +1 bedeutet wir gehen zur nächsten Stunde ("VOR")
DE_PHRASES = {
0: ("", 0), # ES IST genau
5: ("FÜNF NACH", 0), # 5 nach
10: ("ZEHN NACH", 0), # 10 nach
15: ("VIERTEL NACH", 0), # viertel nach
20: ("ZWANZIG NACH", 0), # 20 nach
25: ("FÜNF VOR HALB", 1), # 5 vor halb → 0:30
30: ("HALB", 1), # halb → 0:30 (zählt zur nächsten Stunde)
35: ("FÜNF NACH HALB",1), # 5 nach halb
40: ("ZWANZIG VOR", 1), # 20 vor
45: ("VIERTEL VOR", 1), # viertel vor
50: ("ZEHN VOR", 1), # 10 vor
55: ("FÜNF VOR", 1), # 5 vor
}
# Minuten → Sektor (0, 5, 10, ... 55)
def _minute_sector(m: int) -> int:
return (m // 5) * 5
# Stunden-Namen (QlockTwo). Index 0..11 = Wortuhr-Stunden (1..12 Uhr).
# 4 PM wird "VIER" (deutsch), 5 PM = "FÜNF", etc.
# Daher: hour 16..23 → DE_HOURS[16%12]=DE_HOURS[4]="VIER". Korrekt.
DE_HOURS = [
"ZWÖLF", "EINS", "ZWEI", "DREI", "VIER", "FÜNF",
"SECHS", "SIEBEN", "ACHT", "NEUN", "ZEHN", "ELF",
]
def _hour_name(h24: int, offset: int) -> str:
"""Liefert Stunden-Name. offset=+1 → nächste volle Stunde.
Beispiel: h24=16, offset=0 → DE_HOURS[16%12=4]="VIER" (4 PM)
h24=16, offset=1 → DE_HOURS[(16+1)%12=5]="FÜNF" (halb 5 = 5 PM)
"""
h = (h24 + offset) % 24
return DE_HOURS[h % 12]
def _minute_phrase(m: int):
"""Returns (phrase_text, hour_offset) für die aktuelle Minute."""
sector = _minute_sector(m)
return DE_PHRASES[sector]
class Widget(Widget): class Widget(Widget):
name = "clock_wordclock" name = "clock_wordclock"
label = "WordClock" label = "WordClock"
description = "Deutsche Wort-Uhr (ES IST …). Nur für 2×2 oder größer." description = "Deutsche Wort-Uhr (ES IST …). QlockTwo-Style, ab 1×1 sauber."
category = "info" category = "info"
config_schema = [ config_schema = [
{"key": "lang", "label": "Sprache",
"type": "select", "choices": ["de", "en"], "default": "de",
"help": "de = Deutsch (Standard), en = Englisch"},
{"key": "show_date", "label": "Datum anzeigen", {"key": "show_date", "label": "Datum anzeigen",
"type": "bool", "default": True}, "type": "bool", "default": True},
{"key": "show_weekday", "label": "Wochentag anzeigen", {"key": "show_weekday", "label": "Wochentag anzeigen",
@@ -49,176 +86,225 @@ class Widget(Widget):
{"key": "accent_color", "label": "Akzentfarbe", {"key": "accent_color", "label": "Akzentfarbe",
"type": "select", "type": "select",
"choices": ["accent", "blue", "ok", "warn", "info"], "choices": ["accent", "blue", "ok", "warn", "info"],
"default": "accent"}, "default": "accent",
"help": "Farbe für Wochentag + Datum"},
{"key": "invert", "label": "Invertiert (dunkel)", {"key": "invert", "label": "Invertiert (dunkel)",
"type": "bool", "default": False}, "type": "bool", "default": False},
{"key": "show_es_ist", "label": "'ES IST' anzeigen",
"type": "bool", "default": True,
"help": "QlockTwo-Klassiker. Aus = nur Minuten-Text + Stunde."},
] ]
default_config = { default_config = {
"lang": "de", "show_date": True, "show_weekday": True, "show_date": True, "show_weekday": True,
"accent_color": "accent", "invert": False, "accent_color": "accent", "invert": False,
"show_es_ist": True,
} }
def fetch(self): def fetch(self):
return {} return {}
def render(self, draw, fonts, x: int, y: int, w: int, h: int): def render(self, draw, fonts, x: int, y: int, w: int, h: int):
from palette import fill_for
now = datetime.now()
m = now.minute
h_ = now.hour
invert = self.cfg("invert", False) invert = self.cfg("invert", False)
accent = fill_for(self.cfg("accent_color", "accent")) accent = fill_for(self.cfg("accent_color", "accent"))
fg = BG if invert else FG fg = BG if invert else FG
bg = FG if invert else BG bg = FG if invert else BG
pad = 8 # --- Hintergrund ---
# Mindestgröße: 300px-breit, 240px-hoch für WordClock
if w < 300 or h < 240:
draw.rectangle((x, y, x + w - 1, y + h - 1), fill=bg)
draw.text((x + pad, y + pad),
"WordClock\n(min 2×2)",
font=fonts.get("20", fonts.get("default")), fill=fg)
return
# ---- Wort-Uhr Grid ----
# 4×4 Zellen → 800×480px
# Wir rendern in ein 11×8 Zeichen-Grid
chars_x, chars_y = 11, 8
char_w = w // chars_x
char_h = h // chars_y
font_size = min(char_w, char_h) * 2 // 3
font_key = str(font_size)
if font_key not in fonts:
font_key = str(max(16, min(fonts.keys(), key=lambda k: abs(int(k) - font_size) if k.isdigit() else 9999)) if fonts else "20")
fnt = fonts.get(font_key, fonts.get("20", fonts.get("default")))
def _draw_char(cx, cy, char, color):
"""Zeichnet ein Zeichen an Gitterposition (cx, cy)."""
px = x + cx * char_w
py = y + cy * char_h
tw, th = measure(draw, char, fnt)
draw.text((px + (char_w - tw) // 2, py + (char_h - th) // 2),
char, font=fnt, fill=color)
def _fill_char(cx, cy, color):
"""Füllt eine Gitterzelle mit einer Farbe (z.B. für Stunden-Dots)."""
px = x + cx * char_w
py = y + cy * char_h
draw.rectangle((px + 2, py + 2, px + char_w - 3, py + char_h - 3), fill=color)
def _lit(cx, cy):
_draw_char(cx, cy, LAYOUT_DE[cy][cx], fg)
def _dim(cx, cy):
_draw_char(cx, cy, LAYOUT_DE[cy][cx], (150, 150, 150))
# ---- Minuten-Logik (Deutsch) ----
def lit_minute(m):
"""Sektor der Minuten: 0-4, 5-9, 10-14, 15-19, 20-24, 25-29, 30-34, 35-39, 40-44, 45-49, 50-54, 55-59."""
if m < 5:
return []
elif m < 10:
return [(0, 1), (1, 1), (2, 1), (3, 1)] # FÜNF
elif m < 15:
return [(5, 1), (6, 1), (7, 1), (8, 1)] # ZEHN
elif m < 20:
return [(0, 2), (1, 2), (2, 2), (3, 2), (4, 2), (5, 2), (6, 2)] # VIERTEL
elif m < 25:
return [(7, 2), (8, 2), (9, 2), (10, 2), (7, 3), (8, 3), (9, 3), (10, 3)] # ZWANZIG
elif m < 30:
return [(0, 4), (1, 4), (2, 4), (3, 4)] # NACH
elif m < 35:
return [(0, 1), (1, 1), (2, 1), (3, 1), # FÜNF
(4, 4), (5, 4), (6, 4)] # + HALB
elif m < 40:
return [(4, 4), (5, 4), (6, 4)] # HALB
elif m < 45:
return [(7, 3), (8, 3), (9, 3), (10, 3)] # ZWANZIG
elif m < 50:
return [(0, 2), (1, 2), (2, 2), (3, 2), (4, 2), (5, 2), (6, 2), # VIERTEL
(7, 4), (8, 4), (9, 4)] # + VOR
elif m < 55:
return [(5, 1), (6, 1), (7, 1), (8, 1)] # ZEHN + VOR
else:
return [(0, 1), (1, 1), (2, 1), (3, 1), # FÜNF + VOR
(7, 4), (8, 4), (9, 4)]
def hour_name(h, past_half):
"""Gibt die Stunde zurück für die Wortuhr."""
DE_HOURS = [
"ZWÖLF", "EINS", "ZWEI", "DREI", "VIER",
"FÜNF", "SECHS", "SIEBEN", "ACHT", "NEUN",
"ZEHN", "ELF", "ZWÖLF"
]
if past_half:
h = (h + 1) % 24
if h == 0:
return "ZWÖLF"
return DE_HOURS[h % 12]
past_half = m >= 20
h_display = hour_name(h_, past_half)
lit_cells = lit_minute(m)
# ---- Render ----
# Hintergrund
draw.rectangle((x, y, x + w - 1, y + h - 1), fill=bg) draw.rectangle((x, y, x + w - 1, y + h - 1), fill=bg)
# "ES IST" immer lit in Spalte 0 now = datetime.now()
_lit(0, 0); _lit(1, 0); _lit(3, 0); _lit(4, 0) pad = 6
inner_w = w - 2 * pad
inner_h = h - 2 * pad
inner_x = x + pad
inner_y = y + pad
# Minuten-Wörter # ============================================================
for (cx, cy) in lit_cells: # Sehr kleine Slots (1×1 = 200×120): kompakter Single-Line Modus
_lit(cx, cy) # ============================================================
if w < 240 or h < 140:
# "ES IST DREI" oder "VIERTEL NACH DREI" als eine Zeile
phrase, offset = _minute_phrase(now.minute)
hour_str = _hour_name(now.hour, offset)
if phrase:
text = f"{phrase} {hour_str}" if self.cfg("show_es_ist") else f"{phrase} {hour_str}"
else:
text = f"ES IST {hour_str}" if self.cfg("show_es_ist") else hour_str
font = _fit_font(draw, fonts, text, inner_w, inner_h, prefer=["20","16","default"])
from palette import centered_text
centered_text(draw, text, inner_x, inner_y, inner_w, inner_h, font, fg)
return
# "VOR" und "NACH" (Zeile 4) # ============================================================
if 5 <= m < 30: # Standard-Layout: 3 Zonen
_lit(0, 4); _lit(1, 4); _lit(2, 4); _lit(3, 4) # NACH # ============================================================
elif m >= 35 and m < 55: # Höhenverteilung:
_lit(7, 4); _lit(8, 4); _lit(9, 4) # VOR # Header: bis zu 28px (oder weniger wenn beides aus)
# Grid: Rest minus Stunden
# Stunden: grosse Schrift, ~30% der Höhe
# Rest dim # Header-Höhe dynamisch: nur soviel wie gebraucht
for row in range(chars_y): show_wd = self.cfg("show_weekday", True)
for col in range(chars_x): show_dt = self.cfg("show_date", True)
if (col, row) not in lit_cells and not (row == 0 and col in (0, 1, 3, 4)): show_es = self.cfg("show_es_ist", True)
_dim(col, row)
# ---- Stunden-Balken unten ---- if show_wd and show_dt:
# Zeile 6+7: Stunden-Name in großen Buchstaben unten zentriert header_h = 26
hour_str = h_display elif show_wd or show_dt:
hour_font_size = min(w // len(hour_str), h // 4) * 3 // 4 header_h = 18
hf = fonts.get(str(hour_font_size), fonts.get("60", fonts.get("default"))) else:
tw, th = measure(draw, hour_str, hf) header_h = 0
hour_y = y + h - th - pad
draw.text((x + (w - tw) // 2, hour_y), hour_str, font=hf, fill=fg)
# ---- Datum + Wochentag ---- # Stunden-Block: fest ~36% der inneren Höhe, aber mindestens 50px
if self.cfg("show_date", True): hour_block_h = max(50, int(inner_h * 0.36))
date_str = now.strftime("%d. %b %Y")
df = fonts.get("16", fonts.get("default"))
dw, dh = measure(draw, date_str, df)
draw.text((x + (w - dw) // 2, y + pad), date_str, font=df, fill=accent)
if self.cfg("show_weekday", True): # Wort-Block = Rest
day_str = now.strftime("%A").upper() grid_h = inner_h - header_h - hour_block_h - 6 # 6px gap
df = fonts.get("14", fonts.get("default")) if grid_h < 40:
dw, dh = measure(draw, day_str, df) # Slot zu klein für alles — Stunden-Anteil reduzieren
draw.text((x + (w - dw) // 2, y + pad + (18 if self.cfg("show_date", True) else 0)), hour_block_h = max(36, int(inner_h * 0.28))
day_str, font=df, fill=accent) grid_h = inner_h - header_h - hour_block_h - 6
grid_y = inner_y + header_h
hour_y = grid_y + grid_h + 6
# --- Header (Wochentag / Datum) ---
cy = inner_y
header_font = _fit_font(draw, fonts, "Mittwoch · 31. Aug 2026", inner_w, header_h or 18,
prefer=["16", "20", "default"])
if header_h:
if show_wd and show_dt:
day = now.strftime("%A")
date = now.strftime("%e. %b %Y").strip()
text = f"{day} · {date}"
tw, th = measure(draw, text, header_font)
draw.text((inner_x + (inner_w - tw) // 2, cy),
text, font=header_font, fill=accent)
elif show_wd:
text = now.strftime("%A")
tw, _ = measure(draw, text, header_font)
draw.text((inner_x + (inner_w - tw) // 2, cy),
text, font=header_font, fill=accent)
elif show_dt:
text = now.strftime("%e. %b %Y").strip()
tw, _ = measure(draw, text, header_font)
draw.text((inner_x + (inner_w - tw) // 2, cy),
text, font=header_font, fill=accent)
# --- Wort-Grid: zwei Zeilen ---
# Zeile 1 (oben): "ES IST" (klein, accent) — nur wenn show_es_ist
# Zeile 2 (mittig, gross): Minuten-Phrase
phrase, offset = _minute_phrase(now.minute)
# Wie viele Zeilen brauchen wir im Grid?
# Wenn ES IST → ES IST (Zeile 1 klein) + Phrase (Zeile 2 gross)
# Wenn nicht → nur Phrase (eine Zeile)
# Bei sehr grossen Slots könnte man auch stacked machen, aber single-line
# ist klarer.
if show_es and phrase:
# Zwei-Zeilen-Layout im Grid
line1_h = int(grid_h * 0.30)
line2_h = grid_h - line1_h - 2
es_font = _fit_font(draw, fonts, "ES IST", inner_w, line1_h,
prefer=["20", "16", "default"])
draw.text((inner_x + (inner_w - measure(draw, "ES IST", es_font)[0]) // 2,
grid_y),
"ES IST", font=es_font, fill=accent)
# Minuten-Phrase in der unteren, größeren Zeile
phrase_font = _fit_font(draw, fonts, phrase, inner_w, line2_h,
prefer=["48", "36", "32", "28", "24", "20", "default"])
tw, th = measure(draw, phrase, phrase_font)
draw.text((inner_x + (inner_w - tw) // 2,
grid_y + line1_h + (line2_h - th) // 2),
phrase, font=phrase_font, fill=fg)
else:
# Eine-Zeilen-Layout: nur die Phrase (oder "ES IST DREI" wenn keine Phrase)
if not phrase:
phrase = "ES IST" if show_es else ""
phrase_font = _fit_font(draw, fonts, phrase, inner_w, grid_h,
prefer=["60", "48", "36", "32", "28", "24", "20", "default"])
tw, th = measure(draw, phrase, phrase_font)
draw.text((inner_x + (inner_w - tw) // 2,
grid_y + (grid_h - th) // 2),
phrase, font=phrase_font, fill=fg)
# --- Stunden-Block (gross, unten) ---
hour_str = _hour_name(now.hour, offset)
hour_font = _fit_font(draw, fonts, hour_str, inner_w, hour_block_h,
prefer=["100", "80", "60", "48", "36", "32", "default"])
# Robuste Font-Wahl: lieber kleiner als abgeschnitten
tw, th = measure(draw, hour_str, hour_font)
if tw > inner_w:
# noch kleiner probieren
for k in ["80", "60", "48", "36", "32", "28", "24", "20"]:
if k not in fonts:
continue
cand = fonts[k]
cw, ch = measure(draw, hour_str, cand)
if cw <= inner_w and ch <= hour_block_h:
hour_font = cand
tw, th = cw, ch
break
draw.text((inner_x + (inner_w - tw) // 2,
hour_y + (hour_block_h - th) // 2),
hour_str, font=hour_font, fill=fg)
# --- Minuten-Dots (4 Dots oben rechts, wie eine echte QlockTwo) ---
# Zeigen die genauen Minuten-Module: ● ● ● ● ● — einer pro 1-2 Min
# Wenn minute < 5: leer; bei 25: ●; bei 26: ●●; etc.
if inner_w >= 200:
self._draw_minute_dots(draw, inner_x, inner_y, inner_w, header_h, fg, accent)
# ---- Deutsches WordClock-Layout (11×8) ---- def _draw_minute_dots(self, draw, x, y, w, header_h, fg, accent):
# Jede Position ist ein Zeichen das gerendert wird. """Vier Minuten-Dots (●) in der Ecke, analog zu echten QlockTwo-Uhren.
# ' ' = Leerzeichen, rest = Buchstabe.
LAYOUT_DE = [ Sie zeigen: ●=1, ●●=2, ●●●=3, ●●●●=4 Minuten innerhalb des 5-Min-Sektors.
["E", "S", " ", "I", "S", "T", " ", " ", " ", " ", " "], """
["F", "Ü", "N", "F", " ", "Z", "E", "H", "N", " ", " "], m = datetime.now().minute
["V", "I", "E", "R", "T", "E", "L", " ", "Z", "W", "A"], sector_m = m % 5 # 0..4
["N", "U", "L", "L", " ", "Z", "W", "A", "N", "Z", " "], if sector_m == 0:
["N", "A", "C", "H", " ", "V", "O", "R", " ", "H", "A"], return # exakt auf 5-Min-Marke → keine Dots
[" ", " ", " ", " ", " ", " ", " ", " ", " ", " ", " "],
[" ", " ", " ", " ", " ", " ", " ", " ", " ", " ", " "], # Position: oben rechts, in der Header-Zone (oder oben falls kein Header)
[" ", " ", " ", " ", " ", " ", " ", " ", " ", " ", " "], dot_size = 3
] spacing = 4
total_w = sector_m * (dot_size + spacing) - spacing
start_x = x + w - total_w
# Position: am unteren Rand des Header-Bereichs
if header_h >= 26:
dot_y = y + header_h - dot_size - 2
elif header_h > 0:
dot_y = y + header_h - dot_size
else:
# kein Header → dots ganz oben, klein
dot_y = y + 2
for i in range(sector_m):
cx = start_x + i * (dot_size + spacing)
# Eckige Punkte (Mini-Quadrate) statt runder, weil runde auf dem
# ePaper oft Matsch produzieren
draw.rectangle((cx, dot_y, cx + dot_size - 1, dot_y + dot_size - 1),
fill=fg)
def _fit_font(draw, fonts, text, max_w, max_h, prefer=None):
"""Wählt den größten Font aus `prefer`, dessen Text in max_w × max_h passt.
Robuster als die alte Logik — überspringt 'clock' (Pixel-Font) automatisch.
"""
if prefer is None:
prefer = ["60", "48", "36", "32", "28", "24", "20", "16", "default"]
for key in prefer:
f = fonts.get(key)
if f is None:
continue
tw, th = measure(draw, text, f)
if tw <= max_w and th <= max_h:
return f
# Fallback: das Kleinste
for key in reversed(prefer):
if key in fonts:
return fonts[key]
return fonts.get("default") or fonts.get("20")
+556
View File
@@ -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)
#
# ============================================================================
+716
View File
@@ -0,0 +1,716 @@
"""Netatmo Weather Station Plugin — WarmNews Design (V1 Status-Update).
Zeigt Live-Daten einer Netatmo Wetterstation mit allen Sensoren:
- 1x NAMain + bis zu 4x NAModule4 Indoor-Sensoren
- 1x NAModule1 Outdoor
- 1x NAModule2 Wind
- 1x NAModule3 Regen
- 3-Tage Vorhersage (Open-Meteo)
Layout:
4x4 (800x480): Aussen | Wind/Regen | Indoor (5) | Vorhersage (3)
2x2 (400x240): Aussen + Indoor kombiniert
1x1 (200x120): nur Aussentemperatur gross
Auth: OAuth2 Refresh-Token Flow (siehe tools/netatmo_auth.py fuer initial).
"""
from __future__ import annotations
import os, sys, json, time, math, io
import urllib.request, urllib.error, urllib.parse
from datetime import datetime, timezone
from typing import Any
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from plugins.base import (
Widget, render_error_banner, draw_battery, draw_compass, draw_trend,
draw_weather_icon, draw_line_chart, draw_mini_bar, text_in_box, _fit_size,
)
from palette import (
FG, BG, GREEN, BLUE, RED, YELLOW, ORANGE, WHITE, BLACK,
BATT_LOW, BATT_MID, BATT_OK,
OK, WARN, ALERT, INFO, ACCENT,
measure, fit_font, centered_text, is_small, is_wide, is_tall,
temp_color, co2_color, batt_color, wmo_to_icon,
format_temp, de_time, de_weekday_date, de_date,
)
# OAuth2 + Forecast URLs
TOKEN_URL = "https://api.netatmo.com/oauth2/token"
STATIONS_URL = "https://api.netatmo.com/api/getstationsdata"
FORECAST_URL = "https://api.open-meteo.com/v1/forecast"
# In-Memory Token Cache
_TOKEN_CACHE: dict[str, Any] = {"access_token": None, "refresh_token": None, "expires_at": 0.0}
# ── HTTP / OAuth2 ───────────────────────────────────────────────────────
def _token_payload(creds: dict, grant: str = "refresh_token", **extra) -> bytes:
body = {"grant_type": grant, "client_id": creds["client_id"],
"client_secret": creds["client_secret"]}
body.update(extra)
return urllib.parse.urlencode(body).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",
"Accept": "application/json"})
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.loads(r.read())
except urllib.error.HTTPError as e:
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.headers, io.BytesIO(json.dumps(err).encode()))
def _obtain_tokens(creds: dict) -> dict:
rt = creds.get("refresh_token") or _TOKEN_CACHE.get("refresh_token")
if not rt:
raise urllib.error.HTTPError(TOKEN_URL, 0,
"Refresh-Token fehlt — bitte tools/netatmo_auth.py ausführen.",
{}, io.BytesIO(b'{}'))
try:
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):
_TOKEN_CACHE["access_token"] = None
_TOKEN_CACHE["refresh_token"] = None
_TOKEN_CACHE["expires_at"] = 0
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", 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:
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():
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
_TOKEN_CACHE["access_token"] = None
_TOKEN_CACHE["expires_at"] = 0
_obtain_tokens(creds)
return _do()
def _get_forecast(lat: float, lon: float, days: int = 3) -> dict | None:
"""Open-Meteo Forecast, free, kein Key noetig."""
try:
url = (f"{FORECAST_URL}?latitude={lat}&longitude={lon}"
f"&daily=temperature_2m_max,temperature_2m_min,precipitation_sum,"
f"weather_code&forecast_days={min(days, 7)}&timezone=auto")
req = urllib.request.Request(url, headers={"Accept": "application/json"})
with urllib.request.urlopen(req, timeout=8) as r:
return json.loads(r.read())
except Exception:
return None
# ── Daten-Parsing ────────────────────────────────────────────────────────
def _parse_stations(api_response: dict, station_filter: str = "") -> dict | None:
body = api_response.get("body", api_response)
devices = body.get("devices") if isinstance(body, dict) else None
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
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 {},
"reachable": chosen.get("reachable")}
modules = []
for m in chosen.get("modules", []):
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"),
"rf_status": m.get("rf_status")})
return {"station_name": main["name"], "place": main.get("place", {}),
"main": main, "modules": modules, "_fetched_at": time.time()}
def _first_module(modules: list, mtype: str) -> dict | None:
for m in modules:
if m["type"] == mtype: return m
return None
def _all_indoor(modules: list, main: dict) -> list:
"""Alle Indoor-Sensoren: Main + alle NAModule4."""
indoor = [{"type": "NAMain", "name": main["name"], "tag": "M",
"data": main["data"], "battery_pct": None, "is_main": True}]
for m in modules:
if m["type"] == "NAModule4":
indoor.append({"type": "NAModule4", "name": m["name"], "tag": None,
"data": m["data"], "battery_pct": m.get("battery_pct"),
"is_main": False, "rf_status": m.get("rf_status")})
tag_n = 2
for s in indoor:
if s["tag"] is None:
s["tag"] = str(tag_n)
tag_n += 1
return indoor
# ── Widget ──────────────────────────────────────────────────────────────
class Widget(Widget):
name = "netatmo"
label = "Netatmo Wetterstation"
description = "Indoor (1+4 Sensoren), Outdoor, Wind, Regen, 3-Tage Forecast"
category = "weather"
config_schema = [
{"key": "client_id", "label": "Client-ID", "type": "secret"},
{"key": "client_secret", "label": "Client-Secret", "type": "secret"},
{"key": "refresh_token", "label": "Refresh-Token", "type": "secret"},
{"key": "station_filter", "label": "Station-Filter (leer = erste)",
"type": "string", "default": ""},
{"key": "show_outdoor", "label": "Aussen 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_indoor", "label": "Indoor anzeigen", "type": "bool", "default": True},
{"key": "show_forecast","label": "Vorhersage anzeigen", "type": "bool", "default": True},
{"key": "co2_thresholds", "label": "CO2-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",
"options": ["kmh", "ms"], "default": "kmh"},
]
default_config = {
"station_filter": "",
"show_outdoor": True, "show_wind": True, "show_rain": True,
"show_indoor": True, "show_forecast": True,
"co2_thresholds": "ok@600,warn@1000,alert@1500",
"temp_unit": "C", "wind_unit": "kmh",
"client_id": "", "client_secret": "", "refresh_token": "",
}
def fetch(self) -> dict:
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 — tools/netatmo_auth.py ausführen."}
try:
data = _get_stations_data(cid, sec, refresh)
except urllib.error.HTTPError as e:
if e.code in (401, 403):
_TOKEN_CACHE["access_token"] = None
_TOKEN_CACHE["refresh_token"] = None
_TOKEN_CACHE["expires_at"] = 0
return {"_error": f"Auth fehlgeschlagen: {e.reason} — tools/netatmo_auth.py erneut."}
return {"_error": f"HTTP {e.code}: {str(e.reason)[:60]}"}
except Exception as e:
return {"_error": f"{type(e).__name__}: {str(e)[:60]}"}
parsed = _parse_stations(data, self.cfg("station_filter", ""))
if not parsed:
return {"_error": "Keine Station gefunden."}
# Forecast
if self.cfg("show_forecast", True):
place = parsed.get("place", {})
coords = place.get("location") or []
if len(coords) >= 2:
forecast = _get_forecast(coords[1], coords[0], days=3)
parsed["forecast"] = forecast
return parsed
def render(self, draw, fonts, x, y, w, h):
d = self.fetch()
if "_error" in d:
render_error_banner(draw, fonts, x, y, w, h, self.label, d["_error"])
return
# Responsive: 1x1 → compact, 2x2+ → full 4-column
if is_small(w, h):
self._render_compact(draw, x, y, w, h, d, fonts)
elif w >= 700 and h >= 400:
self._render_full_4x4(draw, x, y, w, h, d, fonts)
else:
self._render_compact_2x2(draw, x, y, w, h, d, fonts)
# ── Compact (1x1) ───────────────────────────────────────────────────
def _render_compact(self, draw, x, y, w, h, d, fonts):
out = _first_module(d["modules"], "NAModule1") if self.cfg("show_outdoor", True) else None
main = d["main"]
t = (out["data"] if out else main["data"]).get("Temperature")
unit = self.cfg("temp_unit", "C")
temp_str = f"{t:.1f}°{unit}" if t is not None else "—"
pad = 6
font = _fit_size(draw, temp_str, w - 2 * pad, h - 22, candidates=["48", "36", "28", "24", "20"])
text_in_box(draw, temp_str, x + pad, y + pad, w - 2 * pad, h - 18, font, temp_color(t), "center")
label = (out["name"] if out else "Innen")[:14]
text_in_box(draw, label, x, y + h - 14, w, 12, fonts.get("10") or fonts.get("default"),
FG, "center")
# ── 2x2 (Aussen + Indoor kompakt) ─────────────────────────────────
def _render_compact_2x2(self, draw, x, y, w, h, d, fonts):
# 2 Spalten: Aussen (links), Indoor (rechts)
col_w = (w - 8) // 2
col_h = h - 4
# Aussen
self._draw_aussen_compact(draw, x + 2, y + 2, col_w, col_h, d, fonts)
# Indoor
self._draw_indoor_compact(draw, x + col_w + 6, y + 2, col_w, col_h, d, fonts)
def _draw_aussen_compact(self, draw, x, y, w, h, d, fonts):
out = _first_module(d["modules"], "NAModule1") if self.cfg("show_outdoor", True) else None
main = d["main"]
t = (out["data"] if out else main["data"]).get("Temperature")
text_in_box(draw, "AUSSEN", x, y, w, 14, fonts.get("11", fonts.get("10")), GREEN, "center")
temp_str = f"{t:.1f}°" if t is not None else "—"
font = _fit_size(draw, temp_str, w - 4, h - 30, candidates=["48", "36", "28", "24"])
text_in_box(draw, temp_str, x, y + 16, w, h - 30, font, temp_color(t), "center")
# Batterie
if out and out.get("battery_pct") is not None:
draw_battery(draw, x + w - 50, y + 1, out["battery_pct"], w=22, h=9)
def _draw_indoor_compact(self, draw, x, y, w, h, d, fonts):
indoor = _all_indoor(d["modules"], d["main"])
text_in_box(draw, f"INNEN · {len(indoor)}", x, y, w, 14,
fonts.get("11", fonts.get("10")), RED, "center")
# 2-Spalten-Grid der Sensoren
n = len(indoor)
cell_h = (h - 18) // min(n, 5)
for i, s in enumerate(indoor[:5]):
ry = y + 16 + i * cell_h
# Tag + Name + Temp
text_in_box(draw, s["tag"], x + 2, ry, 14, cell_h,
fonts.get("12", fonts.get("10")), RED, "left")
text_in_box(draw, s["name"][:6], x + 16, ry, w - 60, cell_h,
fonts.get("10"), FG, "left")
t_str = f"{s['data'].get('Temperature', 0):.1f}°"
text_in_box(draw, t_str, x + w - 50, ry, 48, cell_h,
fonts.get("12", fonts.get("10")), temp_color(s['data'].get('Temperature')), "right")
# ── 4x4 (Volle Ansicht, V1-Design) ────────────────────────────────
def _render_full_4x4(self, draw, x, y, w, h, d, fonts):
# Globaler Header (Uhrzeit + Datum) — 24px hoch
HEADER_H = 24
now = datetime.now()
weekday_short = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"][now.weekday()]
time_str = f"{weekday_short} {now.day:02d}.{now.month:02d} {now.hour:02d}:{now.minute:02d}"
# Trennlinie unten am Header
# Linke Seite: Station-Name (klein)
station = d.get("station_name", "Netatmo")
text_in_box(draw, station.upper(), x + 8, y + 4, w // 2 - 8, HEADER_H - 6,
fonts.get("11", fonts.get("10")), FG, "left")
# Rechte Seite: Datum + Uhrzeit (prominent)
text_in_box(draw, time_str, x + w // 2, y + 2, w // 2 - 8, HEADER_H - 4,
fonts.get("16", fonts.get("14")), FG, "right")
# Header-Bottom-Line
draw.line((x, y + HEADER_H, x + w, y + HEADER_H), fill=FG, width=1)
# Strikt grid-basierte Spalten — Aussen breiter, Indoor am breitesten
PAD_OUTER = 6
GAP = 6
avail = w - 2 * PAD_OUTER - 3 * GAP
col_aussen = int(avail * 0.28) # ~220
col_mitte = int(avail * 0.16) # ~125
col_indoor = int(avail * 0.36) # ~280
col_fc = avail - col_aussen - col_mitte - col_indoor # ~140
xa = x + PAD_OUTER
xm = xa + col_aussen + GAP
xr = xm + col_mitte + GAP
xf = xr + col_indoor + GAP
body_y = y + HEADER_H + 4
body_h = h - HEADER_H - 8
# Daten extrahieren
main = d["main"]
modules = d["modules"]
out = _first_module(modules, "NAModule1") if self.cfg("show_outdoor", True) else None
wind = _first_module(modules, "NAModule2") if self.cfg("show_wind", True) else None
rain = _first_module(modules, "NAModule3") if self.cfg("show_rain", True) else None
indoor = _all_indoor(modules, main) if self.cfg("show_indoor", True) else []
forecast = d.get("forecast") if self.cfg("show_forecast", True) else None
# Sektionen rendern
if out:
self._draw_aussen_full(draw, xa, body_y, col_aussen, body_h, main, out, fonts)
if wind or rain:
self._draw_wind_rain(draw, xm, body_y, col_mitte, body_h, wind, rain, fonts)
if indoor:
self._draw_indoor_full(draw, xr, body_y, col_indoor, body_h, indoor, fonts)
if forecast:
self._draw_forecast_full(draw, xf, body_y, col_fc, body_h, forecast, fonts)
# Trennlinien (sehr duenn)
for sep_x in [xm - 3, xr - 3, xf - 3]:
if sep_x > x and sep_x < x + w:
draw.line((sep_x, body_y, sep_x, body_y + body_h), fill=FG, width=1)
def _draw_aussen_full(self, draw, x, y, w, h, main, out, fonts):
# Header — Battery rechts
text_in_box(draw, "AUSSEN", x, y, w, 16, fonts.get("12", fonts.get("11")), GREEN, "center")
if out.get("battery_pct") is not None:
draw_battery(draw, x + w - 50, y + 2, out["battery_pct"], w=22, h=8, with_text=False)
text_in_box(draw, f"{out['battery_pct']}%", x + w - 24, y + 1, 24, 10,
fonts.get("8", fonts.get("9")), batt_color(out["battery_pct"]), "right")
inner_y = y + 20
inner_h = h - 20
# Big Temp — GROSSE Schrift
t = out["data"].get("Temperature")
big_str = f"{t:.1f}°" if t is not None else "—"
big_h = int(inner_h * 0.32)
font = _fit_size(draw, big_str, w - 8, big_h, candidates=["96", "84", "72", "60"])
text_in_box(draw, big_str, x, inner_y, w, big_h, font, temp_color(t), "center")
# 4 Stats in EINER Zeile (statt 2x2 Grid) — mehr Whitespace, grössere Werte
stats_y = inner_y + big_h + 6
stats_h = 50
# MIN/MAX aus echten 12h-History berechnen (nicht geschätzt)
history = self._get_12h_history(out["data"])
if history and len(history) >= 2:
actual_min = min(history)
actual_max = max(history)
else:
ot = out["data"].get("Temperature", 0) or 0
actual_min, actual_max = ot - 2, ot + 4
oh = out["data"].get("Humidity", 0) or 0
pressure = main["data"].get("Pressure") or main["data"].get("AbsolutePressure")
trend = main["data"].get("Pressure_trend", "stable")
cell_w = (w - 6) // 4
stats = [
("MIN", f"{actual_min:.0f}°", FG),
("MAX", f"{actual_max:.0f}°", FG),
("FEUCHTE", f"{oh}%", BLUE),
("DRUCK", f"{int(pressure)}" if pressure else "—", FG),
]
for i, (lbl, val, col) in enumerate(stats):
cx = x + 3 + i * cell_w
text_in_box(draw, lbl, cx, stats_y, cell_w - 2, 11,
fonts.get("9", fonts.get("8")), FG, "center")
# Bei DRUCK (i==3) Platz fuer Trend-Pfeil lassen + kleinere Schrift
is_druck = (i == 3 and trend and trend != "stable" and pressure)
if is_druck:
val_w = cell_w - 16
val_font = fonts.get("14", fonts.get("12"))
else:
val_w = cell_w - 2
val_font = fonts.get("18", fonts.get("16"))
text_in_box(draw, val, cx, stats_y + 11, val_w, stats_h - 11,
val_font, col, "center")
# Trend-Pfeil beim Druck (rechts neben Wert)
if is_druck:
draw_trend(draw, cx + cell_w - 14, stats_y + 22, trend, size=12,
color=GREEN if trend == "up" else RED)
# Trennlinie + Chart (kompakter, weniger Raum verschwenden)
chart_y = stats_y + stats_h + 6
if chart_y < y + h - 20:
draw.line((x + 4, chart_y, x + w - 4, chart_y), fill=FG, width=1)
chart_y += 3
text_in_box(draw, "12H VERLAUF", x + 2, chart_y, w // 2, 10,
fonts.get("8", fonts.get("9")), FG, "left")
history = self._get_12h_history(out["data"])
if len(history) >= 2:
y_min, y_max = min(history), max(history)
pad = max(1, (y_max - y_min) * 0.2)
y_min -= pad; y_max += pad
chart_top = chart_y + 12
chart_h = (y + h - 10) - chart_top
if chart_h > 20:
draw_line_chart(draw, x + 24, chart_top, w - 28, chart_h,
history, color=GREEN,
y_min=y_min, y_max=y_max,
with_y_grid=True, n_ticks=2)
# Nur "jetzt" rechts unten — links=alt, rechts=neu ist intuitiv
# (kein "-12h" links → kollidiert sonst mit Y-Min-Label)
bottom_y = y + h - 12
text_in_box(draw, "jetzt", x + w - 60, bottom_y, 60, 10,
fonts.get("8", fonts.get("9")), FG, "right")
def _draw_wind_rain(self, draw, x, y, w, h, wind, rain, fonts):
sec_h = (h - 4) // 2
# Wind (oben)
if wind:
text_in_box(draw, "WIND", x, y, w, 16, fonts.get("12", fonts.get("11")), BLUE, "center")
sec_inner_y = y + 20
sec_inner_h = sec_h - 20
ws = wind["data"].get("WindStrength", 0) or 0
gust = wind["data"].get("GustStrength", 0) or 0
deg = wind["data"].get("WindAngle", 0) or 0
unit = "km/h" if self.cfg("wind_unit", "kmh") == "kmh" else "m/s"
# Big speed — riesig
s_str = f"{ws:.0f}"
big_h = int(sec_inner_h * 0.50)
f_v = _fit_size(draw, s_str, w - 50, big_h, candidates=["60", "48", "40", "32"])
text_in_box(draw, s_str, x, sec_inner_y, w - 40, big_h, f_v, FG, "center")
sw, sh = measure(draw, s_str, f_v)
text_in_box(draw, unit, x + (w - 40) // 2 + sw // 2 + 2,
sec_inner_y + big_h - 14, 36, 12,
fonts.get("11", fonts.get("10")), FG, "left")
# Bö darunter
boe_y = sec_inner_y + big_h + 4
text_in_box(draw, f"Bö {gust} {unit}", x, boe_y, w - 36, 14,
fonts.get("14", fonts.get("12")), FG, "center")
# Compass rechts oben
ccx = x + w - 22
ccy = sec_inner_y + 18
draw_compass(draw, ccx, ccy, 14, deg, color=BLUE)
# Battery nach UNTEN-RECHTS (eigener Bereich unter Bö)
if wind.get("battery_pct") is not None:
batt_y = y + sec_h - 14
batt_x = x + w - 28
draw_battery(draw, batt_x, batt_y, wind["battery_pct"],
w=22, h=8, with_text=False)
text_in_box(draw, f"{wind['battery_pct']}%", batt_x - 22, batt_y - 1, 20, 10,
fonts.get("9", fonts.get("8")),
batt_color(wind["battery_pct"]), "right")
# Regen (unten)
if rain:
ry = y + sec_h + 4
text_in_box(draw, "REGEN", x, ry, w, 16, fonts.get("12", fonts.get("11")), BLUE, "center")
sec_inner_y = ry + 20
sec_inner_h = sec_h - 20
rate = rain["data"].get("RainRate") or rain["data"].get("rain") or 0
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
today = h24 # Approximation
# Big rate — immer als Zahl zeigen (auch 0.0)
r_str = f"{rate:.1f}"
rate_h = int(sec_inner_h * 0.36)
f_v = _fit_size(draw, r_str, w - 4, rate_h, candidates=["60", "48", "40", "32"])
text_in_box(draw, r_str, x, sec_inner_y, w, rate_h,
f_v, BLUE if rate > 0 else FG, "center")
# Einheit drunter
text_in_box(draw, "mm/h", x, sec_inner_y + rate_h, w, 10,
fonts.get("10", fonts.get("9")), FG, "center")
# HEUTE prominent — Wert RIESIG
today_y = sec_inner_y + rate_h + 14
today_h = 32
if today_h >= 18:
text_in_box(draw, "HEUTE", x + 2, today_y, 50, 9,
fonts.get("9", fonts.get("8")), FG, "left")
today_str = f"{today:.1f}"
f_today = _fit_size(draw, today_str, w // 2, today_h - 8,
candidates=["28", "24", "20", "18"])
text_in_box(draw, today_str, x, today_y + 8, w // 2, today_h - 8,
f_today, BLUE if today > 0 else FG, "left")
text_in_box(draw, "mm", x + w // 2 + 2, today_y + 14, 30, today_h - 14,
fonts.get("12", fonts.get("10")), FG, "left")
# 1h / 24h — kompakt am unteren Rand LINKS (Batterie bekommt rechts Platz)
bars_y = sec_inner_y + sec_inner_h - 36
bars_w = w - 56 # Platz fuer Battery rechts lassen
for j, (key, val, maxval) in enumerate([("1h", h1, 5), ("24h", h24, 20)]):
by = bars_y + j * 10
text_in_box(draw, key, x, by, 18, 8, fonts.get("8", fonts.get("7")), FG, "left")
bar_x = x + 20
bar_w = bars_w - 22 - 20
draw_mini_bar(draw, bar_x, by, bar_w, 7, val, maxval,
color=BLUE if key == "1h" else ORANGE)
text_in_box(draw, f"{val:.1f}", x + bars_w - 22, by - 1, 22, 8,
fonts.get("8", fonts.get("7")), FG, "right")
# Battery nach UNTEN-RECHTS (eigener Bereich, kein Overlap mit Bars)
if rain.get("battery_pct") is not None:
batt_y = ry + sec_h - 14
batt_x = x + w - 28
draw_battery(draw, batt_x, batt_y, rain["battery_pct"],
w=22, h=8, with_text=False)
text_in_box(draw, f"{rain['battery_pct']}%", batt_x - 22, batt_y - 1, 20, 10,
fonts.get("9", fonts.get("8")),
batt_color(rain["battery_pct"]), "right")
def _draw_indoor_full(self, draw, x, y, w, h, indoor, fonts):
text_in_box(draw, f"INNEN · {len(indoor)}", x, y, w, 16,
fonts.get("12", fonts.get("11")), RED, "center")
sec_body_y = y + 22
sec_body_h = h - 22
n = max(1, len(indoor))
row_h = sec_body_h // n
# 3 vertikale Zonen: Head (60%), Bar (15%), Foot-CO2+Battery (25%)
head_h = int(row_h * 0.60) - 2
bar_h = max(8, int(row_h * 0.12))
foot_h = row_h - head_h - bar_h - 8
for i, s in enumerate(indoor):
ry = sec_body_y + i * row_h
if i > 0:
draw.line((x + 6, ry + 2, x + w - 6, ry + 2), fill=FG, width=1)
inner_x = x + 6
inner_w = w - 12
# HEAD: Tag | Name (GROSS) | Temp (RIESIG)
tag_w = 30
draw.rectangle((inner_x, ry + 2, inner_x + tag_w, ry + 2 + head_h - 4),
fill=RED)
f_tag = _fit_size(draw, s["tag"], tag_w - 4, head_h - 8,
candidates=["28", "24", "22", "20"])
tw_, th_ = measure(draw, s["tag"], f_tag)
draw.text((inner_x + (tag_w - tw_) // 2, ry + 2 + (head_h - 4 - th_) // 2),
s["tag"], font=f_tag, fill=WHITE)
# Name gross, fett
name_x = inner_x + tag_w + 8
name_w = inner_w - tag_w - 8 - 130
f_name = _fit_size(draw, s["name"], name_w, head_h - 4, candidates=["24", "20", "18", "16"])
text_in_box(draw, s["name"], name_x, ry + 2, name_w, head_h - 4, f_name, FG, "left")
# Temp RIESIG, in Temperatur-Farbe
t = s["data"].get("Temperature")
t_str = f"{t:.1f}°" if t is not None else "—"
f_t = _fit_size(draw, t_str, 130, head_h - 4, candidates=["48", "40", "36", "32"])
text_in_box(draw, t_str, inner_x + inner_w - 130, ry + 2, 130, head_h - 4,
f_t, temp_color(t), "right")
# BAR: CO2-Bar (volle Breite)
co2 = s["data"].get("CO2")
if co2 is not None and inner_w > 6:
bar_y = ry + head_h + 4
draw_mini_bar(draw, inner_x, bar_y, inner_w, bar_h, co2, 2000,
color=co2_color(co2))
# FOOT: CO2 Wert (links) + Battery (rechts) — sauber vertikal ausgerichtet
foot_y = ry + head_h + 4 + bar_h + 2
if foot_h >= 10 and co2 is not None:
co2_str = f"CO₂ {co2} ppm"
text_in_box(draw, co2_str, inner_x, foot_y, inner_w // 2, foot_h,
fonts.get("10", fonts.get("9")), co2_color(co2), "left")
if s.get("battery_pct") is not None:
# Battery rechtsbuendig: Icon + Prozent, beide auf gleicher y
batt_x = inner_x + inner_w - 30 # Icon-Endposition
draw_battery(draw, batt_x, foot_y, s["battery_pct"],
w=22, h=8, with_text=False)
pct_str = f"{s['battery_pct']}%"
text_in_box(draw, pct_str, batt_x - 26, foot_y, 24, foot_h,
fonts.get("10", fonts.get("9")),
batt_color(s["battery_pct"]), "right")
def _draw_forecast_full(self, draw, x, y, w, h, forecast, fonts):
text_in_box(draw, "VORHERSAGE", x, y, w, 16,
fonts.get("12", fonts.get("11")), ORANGE, "center")
daily = forecast.get("daily", {})
days = daily.get("time", [])[:3]
if not days: return
sec_body_y = y + 22
sec_body_h = h - 22
row_h = sec_body_h // len(days)
# Layout: Day+Icon+Rain oben (32%), Min/Max RIESIG (68%)
l1_h = max(30, int(row_h * 0.32))
l2_h = row_h - l1_h - 2
for i, day_str in enumerate(days):
ry = sec_body_y + i * row_h
if i > 0:
draw.line((x + 6, ry + 2, x + w - 6, ry + 2), fill=FG, width=1)
# Tag (links)
try:
dt = datetime.fromisoformat(day_str)
day_label = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"][dt.weekday()]
except Exception:
day_label = day_str[:2]
text_in_box(draw, day_label.upper(), x + 2, ry, 32, l1_h,
fonts.get("18", fonts.get("16")), FG, "left")
# Wetter-Icon (rechts)
try:
i_day = days.index(day_str)
code = daily.get("weather_code", [None] * len(days))[i_day]
except (IndexError, KeyError, TypeError):
code = None
icon_size = min(26, l1_h - 4)
draw_weather_icon(draw, x + w - icon_size - 2, ry + (l1_h - icon_size) // 2,
icon_size, code)
# REGEN prominent in der Day-Row: groesseres Drop-Icon + groessere Schrift
try:
rain_mm = (daily.get("precipitation_sum", [0] * len(days)) or [0] * len(days))[i_day] or 0
except (IndexError, KeyError, TypeError):
rain_mm = 0
# Drop-Icon (groesser)
drop_w = 12
drop_h = 16
drop_x = x + 36
drop_y = ry + (l1_h - drop_h) // 2
if rain_mm > 0:
# Tropfen mit Fuellung in Blau
draw.polygon([(drop_x + drop_w // 2, drop_y),
(drop_x, drop_y + drop_h - 2),
(drop_x + drop_w, drop_y + drop_h - 2)],
fill=BLUE)
# Highlight (1-2 weisse Pixel oben fuer 3D-Effekt)
draw.point((drop_x + drop_w // 2 + 1, drop_y + 4), fill=WHITE)
else:
# Leerer Tropfen in FG
draw.polygon([(drop_x + drop_w // 2, drop_y),
(drop_x, drop_y + drop_h - 2),
(drop_x + drop_w, drop_y + drop_h - 2)],
outline=FG)
# Regen-Wert GROSS daneben
rain_str = f"{rain_mm:.1f}"
f_rain = _fit_size(draw, rain_str, 50, l1_h - 2, candidates=["20", "18", "16", "14"])
text_in_box(draw, rain_str, drop_x + drop_w + 2, ry, 50, l1_h,
f_rain, BLUE if rain_mm > 0 else FG, "left")
# "mm" Einheit klein dahinter
text_in_box(draw, "mm", drop_x + drop_w + 2 + 32, ry, 18, l1_h,
fonts.get("10", fonts.get("9")), FG, "left")
# Min/Max RIESIG (volle Breite, beide sichtbar)
try:
tmin = daily["temperature_2m_min"][i_day]
tmax = daily["temperature_2m_max"][i_day]
except (IndexError, KeyError, TypeError):
continue
min_str = f"{tmin}°"
max_str = f"{tmax}°"
f_min = _fit_size(draw, min_str, w - 2, l2_h, candidates=["40", "36", "32", "28"])
f_max = _fit_size(draw, max_str, w - 2, l2_h, candidates=["40", "36", "32", "28"])
text_in_box(draw, min_str, x + 2, ry + l1_h, w // 2 - 4, l2_h,
f_min, BLUE, "center")
text_in_box(draw, max_str, x + w // 2, ry + l1_h, w // 2 - 4, l2_h,
f_max, ORANGE, "center")
# ── History (12h Temperatur-Verlauf) ──────────────────────────────
def _get_12h_history(self, data: dict) -> list:
"""Versuche 12h-Temp-Verlauf aus Netatmo-Daten zu extrahieren.
Netatmo liefert 'Temp_history' als String '14.0;13.5;...'
(3h-Intervalle). Wir nehmen die letzten 12 Werte.
"""
hist_str = data.get("Temp_history") or data.get("temp_history")
if hist_str:
try:
vals = [float(v) for v in hist_str.split(";") if v]
if len(vals) >= 4:
return vals[-12:] if len(vals) >= 12 else vals
except (ValueError, AttributeError):
pass
# Fallback: Sinus-basierter Demo-Verlauf
cur = data.get("Temperature", 0) or 0
return [cur + 0.5 * math.sin(i / 2.0) for i in range(12)]
# ── CO2 Threshold Parser ────────────────────────────────────────
def _co2_thresholds(self) -> list:
spec = self.cfg("co2_thresholds", "ok@600,warn@1000,alert@1500")
result = []
color_map = {"ok": GREEN, "warn": YELLOW, "alert": RED,
"fg": FG, "green": GREEN, "yellow": YELLOW, "red": RED}
for p in [s.strip() for s in spec.split(",") if s.strip()]:
name, _, val = p.partition("@")
try:
ppm = float(val)
result.append((ppm, color_map.get(name.lower(), GREEN)))
except ValueError:
continue
if not result:
return [(600, GREEN), (1000, YELLOW), (1500, RED)]
return sorted(result, key=lambda x: x[0])
+361 -141
View File
@@ -330,6 +330,38 @@
display: flex; gap: 8px; flex-wrap: wrap; align-items: center; display: flex; gap: 8px; flex-wrap: wrap; align-items: center;
margin-bottom: 16px; margin-bottom: 16px;
} }
/* BUG-05: Save-Status-Indikator (ersetzt den redundanten Speichern-Button) */
.save-indicator {
display: inline-flex; align-items: center; gap: 6px;
margin-left: auto; /* rechtsbündig im toolbar */
padding: 6px 10px;
border-radius: 6px;
font-size: 0.82em;
color: var(--fg-muted);
transition: color 0.15s ease;
}
.save-indicator .save-dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--ok);
box-shadow: 0 0 6px rgba(74, 222, 128, 0.5);
transition: background 0.15s ease, box-shadow 0.15s ease;
}
/* Zustände */
.save-indicator[data-state="idle"] .save-dot { background: var(--ok); }
.save-indicator[data-state="pending"] .save-dot {
background: var(--warn); animation: savePulse 1s ease-in-out infinite;
}
.save-indicator[data-state="saving"] .save-dot {
background: var(--info); animation: savePulse 0.6s ease-in-out infinite;
}
.save-indicator[data-state="error"] .save-dot {
background: var(--alert); box-shadow: 0 0 6px rgba(248, 113, 113, 0.5);
}
.save-indicator[data-state="error"] { color: var(--alert); }
@keyframes savePulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
.add-form { .add-form {
display: flex; gap: 8px; align-items: center; display: flex; gap: 8px; align-items: center;
background: var(--surface-2); background: var(--surface-2);
@@ -452,6 +484,9 @@
width: 18px; height: 18px; width: 18px; height: 18px;
cursor: nwse-resize; cursor: nwse-resize;
z-index: 2; z-index: 2;
/* BUG-01: Hit-Fläche explizit, damit Pointer-Events sicher ankommen */
pointer-events: auto;
touch-action: none; /* BUG-01: verhindert Browser-Scroll auf Touch */
} }
.grid-item-resize::before { .grid-item-resize::before {
content: ''; position: absolute; content: ''; position: absolute;
@@ -462,9 +497,14 @@
border-color: transparent transparent var(--fg-dim) transparent; border-color: transparent transparent var(--fg-dim) transparent;
opacity: 0.5; opacity: 0.5;
transition: opacity 0.15s; transition: opacity 0.15s;
pointer-events: none; /* BUG-01: ::before ist nur Deko */
} }
.grid-item:hover .grid-item-resize::before { opacity: 1; } .grid-item:hover .grid-item-resize::before { opacity: 1; }
.grid-item-resize:hover::before { border-bottom-color: var(--accent); } .grid-item-resize:hover::before { border-bottom-color: var(--accent); }
/* BUG-01: gedrückte Resize-Handles deutlich machen */
.grid-item-resize:active::before { border-bottom-color: var(--accent-bright); opacity: 1; }
/* BUG-01: gedrückte Items nicht als "grabbing" zeigen — wir nutzen Pointer-Events */
.grid-item:active { cursor: grabbing; }
.layout-status { .layout-status {
font-size: 0.85em; color: var(--fg-muted); font-size: 0.85em; color: var(--fg-muted);
@@ -797,6 +837,21 @@
.toast.info .toast-icon { color: var(--info); } .toast.info .toast-icon { color: var(--info); }
.toast.warn .toast-icon { color: var(--warn); } .toast.warn .toast-icon { color: var(--warn); }
.toast-text { flex: 1; min-width: 0; word-break: break-word; } .toast-text { flex: 1; min-width: 0; word-break: break-word; }
.toast-action {
flex: 0 0 auto;
padding: 4px 10px;
background: var(--surface-3);
border: 1px solid var(--border-light);
color: var(--fg);
border-radius: 4px;
font-size: 0.85em;
font-family: inherit;
cursor: pointer;
pointer-events: auto; /* BUG-06: Klick auf Action-Button muss trotz Toast-Click-Handler gehen */
}
.toast-action:hover { background: var(--accent); color: white; border-color: var(--accent); }
.toast.success .toast-action { border-color: var(--success); color: var(--success); }
.toast.success .toast-action:hover { background: var(--success); color: var(--bg); }
@keyframes toastIn { @keyframes toastIn {
from { opacity: 0; transform: translateX(20px); } from { opacity: 0; transform: translateX(20px); }
to { opacity: 1; transform: translateX(0); } to { opacity: 1; transform: translateX(0); }
@@ -884,6 +939,11 @@
<span class="icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span> <span class="icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
Settings Settings
</button> </button>
<a class="sidebar-item" href="/power" style="text-decoration:none">
<span class="icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18.36 6.64a9 9 0 1 1-12.73 0"/><line x1="12" y1="2" x2="12" y2="12"/></svg></span>
Energie
<span class="badge" id="sbPower">—</span>
</a>
<button class="sidebar-item" data-tab="network" onclick="switchTab('network')"> <button class="sidebar-item" data-tab="network" onclick="switchTab('network')">
<span class="icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/></svg></span> <span class="icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/></svg></span>
Network Network
@@ -1014,7 +1074,12 @@
<button type="submit" class="primary">+ Hinzufügen</button> <button type="submit" class="primary">+ Hinzufügen</button>
</form> </form>
<button onclick="packAll()" title="Alle Items automatisch anordnen">Auto-Pack</button> <button onclick="packAll()" title="Alle Items automatisch anordnen">Auto-Pack</button>
<button onclick="saveLayout()" class="primary" title="Layout auf Server speichern">Speichern</button> <!-- BUG-05: Speichern-Button entfernt (redundant zu debouncedSave).
Stattdessen subtiler Save-Status-Indikator rechts. -->
<div class="save-indicator" id="saveIndicator" data-state="idle">
<span class="save-dot"></span>
<span class="save-label">Gespeichert</span>
</div>
</div> </div>
<div id="gridPreview" class="grid-preview"></div> <div id="gridPreview" class="grid-preview"></div>
<div class="layout-hint"> <div class="layout-hint">
@@ -1135,12 +1200,25 @@
// ============ Toast-Helper ============ // ============ Toast-Helper ============
// Aufruf: toast("Item gelöscht", "success" | "error" | "info" | "warn") // Aufruf: toast("Item gelöscht", "success" | "error" | "info" | "warn")
// Optional: toast(text, type, duration_ms) — duration 0 = manuell wegklicken // Optional: toast(text, type, duration_ms) — duration 0 = manuell wegklicken
function toast(text, type = "info", duration = 3500) { // Optional: toast(text, type, duration_ms, action={label, onClick})
// — Toast wird mit action-Button gerendert, Click auf den Button ruft onClick
// auf und dismissed den Toast.
function toast(text, type = "info", duration = 3500, action = null) {
const c = document.getElementById('toastContainer'); const c = document.getElementById('toastContainer');
const el = document.createElement('div'); const el = document.createElement('div');
el.className = 'toast ' + type; el.className = 'toast ' + type;
const iconChar = { success: '✓', error: '✕', info: 'ⓘ', warn: '⚠' }[type] || 'ⓘ'; const iconChar = { success: '✓', error: '✕', info: 'ⓘ', warn: '⚠' }[type] || 'ⓘ';
el.innerHTML = `<div class="toast-icon">${iconChar}</div><div class="toast-text"></div>`; if (action) {
el.innerHTML = `<div class="toast-icon">${iconChar}</div>
<div class="toast-text"></div>
<button class="toast-action">${action.label}</button>`;
el.querySelector('.toast-action').addEventListener('click', (e) => {
e.stopPropagation();
try { action.onClick(); } finally { remove(); }
});
} else {
el.innerHTML = `<div class="toast-icon">${iconChar}</div><div class="toast-text"></div>`;
}
el.querySelector('.toast-text').textContent = text; el.querySelector('.toast-text').textContent = text;
let removed = false; let removed = false;
const remove = () => { const remove = () => {
@@ -1244,8 +1322,50 @@
updateClock(); updateClock();
// ============ DRAG & DROP LAYOUT EDITOR ============ // ============ DRAG & DROP LAYOUT EDITOR ============
async function saveLayout() {
// BUG-05: Status-Indikator statt redundantem "Speichern"-Button.
// Status-Lebenszyklus:
// pending → saving → idle (success) | error
setSaveState('saving');
const r = await fetch('/api/layout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: layoutItems }),
});
const j = await r.json();
if (!j.ok) {
setSaveState('error', 'Fehler: ' + (j.error || '?'));
toast('Layout speichern fehlgeschlagen: ' + (j.error || '?'), 'error', 5000);
return;
}
layoutItems = j.items;
renderGrid();
setTimeout(refreshPreview, 500);
setSaveState('idle', 'Gespeichert');
// nicht mehr länger toasten — der Indicator reicht
}
// BUG-05: Save-Status-Indikator Helper
function setSaveState(state, label) {
const el = document.getElementById('saveIndicator');
if (!el) return;
el.dataset.state = state;
const lbl = el.querySelector('.save-label');
if (lbl && label) lbl.textContent = label;
// Idle-Reset: nach kurzer Anzeige "Gespeichert" zurück
if (state === 'saving' && !label) {
const lblEl = el.querySelector('.save-label');
if (lblEl) lblEl.textContent = 'Speichern…';
}
if (state === 'idle' && !label) {
const lblEl = el.querySelector('.save-label');
if (lblEl) lblEl.textContent = 'Gespeichert';
}
}
// BUG-05: debouncedSave setzt pending-Status
let saveTimer = null; let saveTimer = null;
function debouncedSave() { function debouncedSave() {
setSaveState('pending', 'Ungespeichert…');
if (saveTimer) clearTimeout(saveTimer); if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(saveLayout, 500); saveTimer = setTimeout(saveLayout, 500);
} }
@@ -1297,7 +1417,6 @@
// Place items into their origin cell // Place items into their origin cell
const occ = occupiedCells(); const occ = occupiedCells();
layoutItems.forEach((it, idx) => { layoutItems.forEach((it, idx) => {
const cellIdx = it.x + ',' + it.y;
const originCell = cont.querySelector(`[data-cell="${it.x + it.y * gridCols}"]`); const originCell = cont.querySelector(`[data-cell="${it.x + it.y * gridCols}"]`);
const div = document.createElement('div'); const div = document.createElement('div');
@@ -1305,7 +1424,9 @@
div.className = 'grid-item'; div.className = 'grid-item';
if (it.w > 1 || it.h > 1) div.classList.add('span-2x2'); if (it.w > 1 || it.h > 1) div.classList.add('span-2x2');
div.dataset.idx = idx; div.dataset.idx = idx;
div.draggable = true; // BUG-01: kein HTML5-draggable mehr — eigene Pointer-Events übernehmen
// Move+Resize in startItemPointer / startResizePointer.
div.setAttribute('touch-action', 'none');
// OOB marker // OOB marker
const hasOOB = it.y + it.h > gridRows || it.x + it.w > gridCols || it.y < 0; const hasOOB = it.y + it.h > gridRows || it.x + it.w > gridCols || it.y < 0;
@@ -1321,12 +1442,19 @@
<div class="grid-item-resize" data-resize="${idx}" title="Größe ändern (Ecke ziehen)"></div> <div class="grid-item-resize" data-resize="${idx}" title="Größe ändern (Ecke ziehen)"></div>
`; `;
if (originCell) { // BUG-03: Span-Geometrie per CSS Grid (grid-column/grid-row) statt
originCell.classList.add('occupied'); // per Cell-DOM-Anker. Item wird direkt in den Grid-Container gehängt,
originCell.appendChild(div); // nicht in die Origin-Cell. Damit:
} else { // - NxN-Items rendern visuell über NxN Cells
cont.appendChild(div); // - Drag-Events auf Nachbar-Cells werden nicht vom Item-DOM
} // verschluckt (Item ist nicht mehr Kind der Cell)
// - Resize (applySize in startResizePointer) kann den Span nahtlos
// aktualisieren ohne den DOM-Anker zu wechseln
div.style.gridColumn = `${it.x + 1} / span ${it.w}`;
div.style.gridRow = `${it.y + 1} / span ${it.h}`;
cont.appendChild(div);
// Origin-Cell nur als "occupied" markieren, damit sie ihre leere Optik verliert
if (originCell) originCell.classList.add('occupied');
}); });
// Mark occupied cells // Mark occupied cells
@@ -1341,99 +1469,44 @@
} }
// ---- Drag state ---- // ---- Drag state ----
let dragState = null; // { itemIdx, originItem, ghost } let dragState = null; // { itemIdx, originItem, ghost } — nur für Move verwendet
// ---- BUG-01 + BUG-02: Pointer-Event-basiertes Move & Resize ----
//
// Problem vorher:
// - Items hatten `draggable=true` (HTML5-DnD). Resize-Handle war Kind des
// Items → Browser fired `dragstart` bevor mousedown greifen konnte →
// Resize ging gar nicht oder nur sporadisch.
// - Resize-Drag rief pro mousemove ein komplettes renderGrid() auf → Jank.
//
// Lösung:
// - HTML5-draggable komplett raus. Move + Resize über Pointer-Events.
// - Resize-Handle ist pointer-event-Ziel UND stoppt Propagation → kein
// versehentlicher Move-Start beim Ziehen am Handle.
// - Beide Aktionen nutzen requestAnimationFrame, und nur die Geometrie
// wird via CSS (grid-column/grid-row) aktualisiert — kein renderGrid()
// während Drag. Erst beim Drop / Resize-End kommt der volle re-render
// + save.
function attachDragHandlers(cont, cellEls) { function attachDragHandlers(cont, cellEls) {
// ---- Item: drag start ---- // Click-to-select (Resize-Handle + Delete bleiben unberührt)
cont.querySelectorAll('.grid-item').forEach(el => { cont.querySelectorAll('.grid-item').forEach(el => {
el.addEventListener('click', e => { el.addEventListener('click', e => {
if (e.target.matches('.grid-item-delete, .grid-item-resize')) return; if (e.target.matches('.grid-item-delete, .grid-item-resize')) return;
selectItem(parseInt(el.dataset.idx)); selectItem(parseInt(el.dataset.idx));
}); });
el.addEventListener('dragstart', e => { // BUG-01: Move-Drag startet auf pointerdown, AUSSER wenn das Target
const itemIdx = parseInt(el.dataset.idx); // der Resize-Handle ist (der hat eigenen Handler und stoppt propagation).
const it = layoutItems[itemIdx]; el.addEventListener('pointerdown', startItemPointer);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', String(itemIdx));
el.classList.add('dragging');
// Transparent 1x1 pixel drag image so browser doesn't show a default ghost
const empty = document.createElement('canvas');
empty.width = empty.height = 1;
e.dataTransfer.setDragImage(empty, 0, 0);
// Snapshot for ghost
dragState = { itemIdx, originItem: { ...it }, ghost: null };
});
}); });
// ---- Cell: dragover — show ghost preview ---- // Resize-Handle: explizit eigene Pointer-Handler, stoppen sofort,
cellEls.forEach(cell => { // damit der Item-Handler nicht mitfeuert.
cell.addEventListener('dragover', e => { cont.querySelectorAll('.grid-item-resize').forEach(h => {
e.preventDefault(); h.addEventListener('pointerdown', startResizePointer);
e.dataTransfer.dropEffect = 'move';
if (!dragState) return;
const { itemIdx, originItem } = dragState;
const it = layoutItems[itemIdx];
const cellIdx = parseInt(cell.dataset.cell);
const col = cellIdx % gridCols;
const row = Math.floor(cellIdx / gridCols);
const tx = Math.max(0, Math.min(gridCols - it.w, col));
const ty = Math.max(0, Math.min(gridRows - it.h, row));
const fits = canPlace(tx, ty, it.w, it.h, itemIdx);
cell.classList.toggle('drop-target', fits);
cell.classList.toggle('drop-invalid', !fits);
// Ghost: show where item will land
updateGhost(cont, cellEls, tx, ty, it.w, it.h, itemIdx, fits);
});
cell.addEventListener('dragleave', e => {
// Only clear if leaving to outside the cell
if (!cell.contains(e.relatedTarget)) {
cell.classList.remove('drop-target', 'drop-invalid');
removeGhost(cont);
}
});
cell.addEventListener('drop', e => {
e.preventDefault();
if (!dragState) return;
const { itemIdx } = dragState;
const it = layoutItems[itemIdx];
const cellIdx = parseInt(cell.dataset.cell);
const col = cellIdx % gridCols;
const row = Math.floor(cellIdx / gridCols);
const tx = Math.max(0, Math.min(gridCols - it.w, col));
const ty = Math.max(0, Math.min(gridRows - it.h, row));
if (canPlace(tx, ty, it.w, it.h, itemIdx)) {
it.x = tx; it.y = ty;
renderGrid(); // full re-render after drop to reflect new layout
debouncedSave();
}
removeGhost(cont);
cell.classList.remove('drop-target', 'drop-invalid');
dragState = null;
});
}); });
// ---- Item dragend ---- // Delete buttons
cont.querySelectorAll('.grid-item').forEach(el => {
el.addEventListener('dragend', () => {
el.classList.remove('dragging');
cellEls.forEach(c => c.classList.remove('drop-target', 'drop-invalid'));
removeGhost(cont);
dragState = null;
// Re-render to restore any mid-drag state changes (e.g. failed drop)
renderGrid();
});
});
// ---- Delete buttons ----
cont.querySelectorAll('.grid-item-delete').forEach(btn => { cont.querySelectorAll('.grid-item-delete').forEach(btn => {
btn.addEventListener('click', async e => { btn.addEventListener('click', async e => {
e.stopPropagation(); e.stopPropagation();
@@ -1454,15 +1527,151 @@
}); });
}); });
// ---- Resize handles ---- // Keyboard: X to delete selected
cont.querySelectorAll('.grid-item-resize').forEach(h => {
h.addEventListener('mousedown', startResize);
});
// ---- Keyboard: X to delete selected ----
document.addEventListener('keydown', onKey); document.addEventListener('keydown', onKey);
} }
// ---- Move (BUG-01: Pointer-Events, BUG-02: rAF + keine Render-per-Move) ----
function startItemPointer(e) {
// Resize-Handle hat eigenen Handler + stopPropagation — wir landen hier
// also nur, wenn User wirklich auf den Item-Body gedrückt hat.
if (e.target.matches('.grid-item-delete, .grid-item-resize')) return;
if (e.button !== undefined && e.button !== 0) return; // nur linke Maustaste
e.preventDefault();
const idx = parseInt(e.currentTarget.dataset.idx);
const it = layoutItems[idx];
const cont = document.getElementById('gridPreview');
// Zellgröße einmal messen (Grid ändert sich nicht während Drag)
const cellGap = 6;
const cellW = (cont.offsetWidth - 2 * 6) / gridCols - cellGap;
const cellH = 120;
e.currentTarget.setPointerCapture(e.pointerId);
e.currentTarget.classList.add('dragging');
dragState = { itemIdx: idx, originItem: { ...it } };
let pendingCol = null, pendingRow = null, rafId = 0;
function pickCellFromEvent(ev) {
const rect = cont.getBoundingClientRect();
const lx = ev.clientX - rect.left - 6; // padding
const ly = ev.clientY - rect.top - 6;
const col = Math.max(0, Math.min(gridCols - 1, Math.floor(lx / (cellW + cellGap))));
const row = Math.max(0, Math.min(gridRows - 1, Math.floor(ly / (cellH + cellGap))));
// clamp so item stays in bounds
const tx = Math.max(0, Math.min(gridCols - it.w, col));
const ty = Math.max(0, Math.min(gridRows - it.h, row));
return [tx, ty];
}
function onMove(ev) {
const [tx, ty] = pickCellFromEvent(ev);
pendingCol = tx; pendingRow = ty;
if (rafId) return;
rafId = requestAnimationFrame(() => {
rafId = 0;
if (pendingCol === null) return;
const tx = pendingCol, ty = pendingRow;
const fits = canPlace(tx, ty, it.w, it.h, idx);
// BUG-02: nur Ghost + Highlight updaten, KEIN renderGrid
const cellIdx = tx + ty * gridCols;
cellEls.forEach(c => c.classList.remove('drop-target', 'drop-invalid'));
const targetCell = cont.querySelector(`[data-cell="${cellIdx}"]`);
if (targetCell) targetCell.classList.toggle('drop-target', fits);
updateGhost(cont, cellEls, tx, ty, it.w, it.h, idx, fits);
});
}
function onUp(ev) {
e.currentTarget.removeEventListener('pointermove', onMove);
e.currentTarget.removeEventListener('pointerup', onUp);
e.currentTarget.removeEventListener('pointercancel', onUp);
if (rafId) { cancelAnimationFrame(rafId); rafId = 0; }
e.currentTarget.classList.remove('dragging');
cellEls.forEach(c => c.classList.remove('drop-target', 'drop-invalid'));
removeGhost(cont);
try { e.currentTarget.releasePointerCapture(e.pointerId); } catch (_) {}
// Bei Drop: finale Position setzen + persistieren
if (pendingCol !== null) {
const tx = pendingCol, ty = pendingRow;
if (canPlace(tx, ty, it.w, it.h, idx)) {
it.x = tx; it.y = ty;
debouncedSave();
}
renderGrid();
}
dragState = null;
}
e.currentTarget.addEventListener('pointermove', onMove);
e.currentTarget.addEventListener('pointerup', onUp);
e.currentTarget.addEventListener('pointercancel', onUp);
}
// ---- Resize (BUG-01: eigene Pointer-Events, BUG-02: rAF, kein Render-per-Move) ----
function startResizePointer(e) {
e.preventDefault();
e.stopPropagation(); // BUG-01: Item-Handler NICHT mitfeuern
if (e.button !== undefined && e.button !== 0) return;
const idx = parseInt(e.currentTarget.dataset.resize);
const it = layoutItems[idx];
const cont = document.getElementById('gridPreview');
const cellGap = 6;
const cellW = (cont.offsetWidth - 2 * 6) / gridCols - cellGap;
const cellH = 120;
const startX = e.clientX, startY = e.clientY;
const origW = it.w, origH = it.h;
e.currentTarget.setPointerCapture(e.pointerId);
let pendingW = origW, pendingH = origH, rafId = 0;
function applySize() {
// BUG-02: nur CSS-Geometrie des Items anfassen, kein renderGrid
const itemEl = cont.querySelector(`.grid-item[data-idx="${idx}"]`);
if (!itemEl) return;
itemEl.style.gridColumn = `${it.x + 1} / span ${it.w}`;
itemEl.style.gridRow = `${it.y + 1} / span ${it.h}`;
// Meta-Text aktualisieren
const meta = itemEl.querySelector('.grid-item-meta');
if (meta) meta.textContent = `${it.w}×${it.h} · (${it.x},${it.y})`;
}
function onMove(ev) {
const dx = Math.round((ev.clientX - startX) / cellW);
const dy = Math.round((ev.clientY - startY) / cellH);
const newW = Math.max(1, Math.min(gridCols - it.x, origW + dx));
const newH = Math.max(1, Math.min(gridRows - it.y, origH + dy));
pendingW = newW; pendingH = newH;
if (rafId) return;
rafId = requestAnimationFrame(() => {
rafId = 0;
if (it.w === pendingW && it.h === pendingH) return;
it.w = pendingW; it.h = pendingH;
applySize();
});
}
function onUp(ev) {
e.currentTarget.removeEventListener('pointermove', onMove);
e.currentTarget.removeEventListener('pointerup', onUp);
e.currentTarget.removeEventListener('pointercancel', onUp);
if (rafId) { cancelAnimationFrame(rafId); rafId = 0; }
try { e.currentTarget.releasePointerCapture(e.pointerId); } catch (_) {}
// final snap auf letzte berechnete Größe
it.w = pendingW; it.h = pendingH;
debouncedSave();
// Re-render einmal, damit OOB-Markierung und Listener frisch sind
renderGrid();
}
e.currentTarget.addEventListener('pointermove', onMove);
e.currentTarget.addEventListener('pointerup', onUp);
e.currentTarget.addEventListener('pointercancel', onUp);
}
// ---- Ghost preview during drag ---- // ---- Ghost preview during drag ----
function updateGhost(cont, cellEls, x, y, w, h, excludeIdx, fits) { function updateGhost(cont, cellEls, x, y, w, h, excludeIdx, fits) {
removeGhost(cont); removeGhost(cont);
@@ -1492,34 +1701,7 @@
} }
// ---- Resize ---- // ---- Resize ----
function startResize(e) { // BUG-01/02: alte startResize()-Funktion entfernt — siehe startResizePointer().
e.preventDefault();
e.stopPropagation();
const idx = parseInt(e.currentTarget.dataset.resize);
const it = layoutItems[idx];
const cellGap = 6;
const cont = document.getElementById('gridPreview');
const cellW = (cont.offsetWidth - 2 * 6) / gridCols - cellGap;
const startX = e.clientX, startY = e.clientY;
const origW = it.w, origH = it.h;
function onMove(ev) {
const dx = Math.round((ev.clientX - startX) / cellW);
const dy = Math.round((ev.clientY - startY) / 120);
const newW = Math.max(1, Math.min(gridCols - it.x, origW + dx));
const newH = Math.max(1, Math.min(gridRows - it.y, origH + dy));
if (newW === it.w && newH === it.h) return;
it.w = newW; it.h = newH;
renderGrid();
}
function onUp() {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
debouncedSave();
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}
function onKey(e) { function onKey(e) {
if (e.key === 'x' || e.key === 'X') { if (e.key === 'x' || e.key === 'X') {
@@ -1616,34 +1798,46 @@
renderGrid(); renderGrid();
toast('Widget hinzugefügt', 'success', 2000); toast('Widget hinzugefügt', 'success', 2000);
} }
async function saveLayout() { // BUG-05: alte saveLayout entfernt — siehe oben (mit setSaveState).
const r = await fetch('/api/layout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: layoutItems }),
});
const j = await r.json();
if (!j.ok) { toast('Layout speichern fehlgeschlagen: ' + (j.error || '?'), 'error', 5000); return; }
layoutItems = j.items;
renderGrid();
setTimeout(refreshPreview, 500);
toast('Layout gespeichert', 'success');
}
async function packAll() { async function packAll() {
const ok = await modalConfirm({ const ok = await modalConfirm({
icon: 'warn', icon: 'warn',
title: 'Alle Items neu anordnen?', title: 'Alle Items neu anordnen?',
body: 'Auto-Pack sortiert alle Widgets nach Größe in den 4×4-Grid. Bestehende manuelle Positionen gehen verloren.', body: 'Auto-Pack sortiert alle Widgets nach Größe in den 4×4-Grid. Bestehende manuelle Positionen gehen verloren. Du kannst den Schritt 10 Sekunden lang rückgängig machen.',
confirmText: 'Neu anordnen', confirmText: 'Neu anordnen',
}); });
if (!ok) return; if (!ok) return;
// BUG-06: Snapshot der aktuellen Items in sessionStorage für Undo.
// server gibt neue Items zurück; wir können das alte Layout wiederherstellen.
const snapshot = JSON.stringify(layoutItems);
const r = await fetch('/api/layout/pack', { method: 'POST' }); const r = await fetch('/api/layout/pack', { method: 'POST' });
const j = await r.json(); const j = await r.json();
if (!j.ok) { toast('Auto-Pack fehlgeschlagen: ' + (j.error || '?'), 'error', 5000); return; } if (!j.ok) { toast('Auto-Pack fehlgeschlagen: ' + (j.error || '?'), 'error', 5000); return; }
layoutItems = j.items; layoutItems = j.items;
renderGrid(); renderGrid();
debouncedSave(); debouncedSave();
toast('Layout automatisch angeordnet', 'success'); // BUG-06: Actionable Toast mit Undo-Button
toast('Layout automatisch angeordnet', 'success', 10000, {
label: 'Rückgängig',
onClick: async () => {
try {
const restored = JSON.parse(snapshot);
// restore via normalen Save-Endpunkt
const r2 = await fetch('/api/layout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: restored }),
});
const j2 = await r2.json();
if (!j2.ok) { toast('Undo fehlgeschlagen: ' + (j2.error || '?'), 'error', 5000); return; }
layoutItems = j2.items;
renderGrid();
toast('Layout wiederhergestellt', 'info', 3000);
} catch (err) {
toast('Undo Fehler: ' + err.message, 'error', 5000);
}
}
});
} }
// ============ Plugin-Configs ============ // ============ Plugin-Configs ============
@@ -1853,6 +2047,32 @@
}; };
document.getElementById('netHint').textContent = hints[s.mode] || ''; document.getElementById('netHint').textContent = hints[s.mode] || '';
} }
// ---- Power-Status (DeepSleep) ----
async function refreshPowerStatus() {
try {
const r = await fetch('/api/power');
const d = await r.json();
const sb = document.getElementById('sbPower');
if (sb) {
if (d.deepsleep_enabled && d.is_deepsleep) {
sb.textContent = 'Zz';
sb.style.background = '#fef3c7';
sb.style.color = '#92400e';
} else if (d.deepsleep_enabled) {
sb.textContent = 'an';
sb.style.background = '';
sb.style.color = '';
} else {
sb.textContent = 'aus';
sb.style.background = '';
sb.style.color = '';
}
}
} catch (e) {}
}
refreshPowerStatus();
setInterval(refreshPowerStatus, 30000);
async function refreshScan() { async function refreshScan() {
const r = await netFetch('/api/network/scan'); if (!r) return; const r = await netFetch('/api/network/scan'); if (!r) return;
const sel = document.getElementById('ssidSelect'); const sel = document.getElementById('ssidSelect');
+187
View File
@@ -0,0 +1,187 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Energie — ePaper Dashboard</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #f4f5f7; color: #1a1a1a; margin: 0; padding: 24px; }
h1 { font-size: 22px; margin: 0 0 4px; }
h2 { font-size: 15px; font-weight: 600; color: #555; margin: 24px 0 8px;
text-transform: uppercase; letter-spacing: 0.5px; }
.back { float: right; font-size: 13px; color: #2563eb; text-decoration: none; }
.card { background: #fff; border: 1px solid #e1e3e8; border-radius: 8px;
padding: 20px 24px; max-width: 720px; }
.row { display: flex; align-items: center; gap: 12px; padding: 12px 0;
border-bottom: 1px solid #f0f1f3; }
.row:last-child { border-bottom: none; }
.row label { flex: 1; font-size: 14px; }
.row .hint { font-size: 12px; color: #888; display: block; margin-top: 2px; }
.toggle { position: relative; width: 44px; height: 24px; }
.toggle input { opacity: 0; width: 0; height: 0; }
.slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0;
background: #d1d5db; border-radius: 12px; transition: 0.2s; }
.slider::before { content: ""; position: absolute; height: 18px; width: 18px;
left: 3px; bottom: 3px; background: white; border-radius: 50%;
transition: 0.2s; }
.toggle input:checked + .slider { background: #10b981; }
.toggle input:checked + .slider::before { transform: translateX(20px); }
input[type="time"] { padding: 6px 10px; border: 1px solid #d1d5db;
border-radius: 6px; font-size: 14px; }
button { background: #2563eb; color: white; border: none; padding: 10px 20px;
border-radius: 6px; font-size: 14px; font-weight: 500; cursor: pointer; }
button:hover { background: #1d4ed8; }
button.secondary { background: #6b7280; }
button.secondary:hover { background: #4b5563; }
.status { display: inline-block; padding: 4px 10px; border-radius: 4px;
font-size: 12px; font-weight: 600; }
.status.wach { background: #d1fae5; color: #065f46; }
.status.schlaft { background: #fef3c7; color: #92400e; }
.pill { display: inline-block; background: #e0e7ff; color: #3730a3;
padding: 2px 8px; border-radius: 10px; font-size: 12px;
margin-left: 8px; }
.footer { margin-top: 16px; font-size: 12px; color: #6b7280; }
.pulse { animation: pulse 2s ease-in-out infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
</style>
</head>
<body>
<a class="back" href="/">← zurück zum Dashboard</a>
<h1>Energie</h1>
<p style="color:#6b7280; font-size:13px; margin:4px 0 0">
DeepSleep spart Strom, weil das ePaper das letzte Bild ohne Strom haelt.
WiFi bleibt an, damit du das Display jederzeit wecken kannst.
</p>
<div class="card" style="margin-top:20px">
<h2>Status <span id="status-pill" class="pill">…</span></h2>
<div id="status-text" style="font-size:14px; padding:8px 0">lade…</div>
<button class="secondary" id="wake-btn" type="button">Display jetzt wecken</button>
</div>
<div class="card" style="margin-top:20px">
<h2>DeepSleep</h2>
<form method="post" action="/power">
<div class="row">
<label>
DeepSleep aktivieren
<span class="hint">Display bleibt zwischen den Zeiten eingefroren, kein Refresh.</span>
</label>
<span class="toggle">
<input type="checkbox" name="deepsleep_enabled"
{% if power.deepsleep_enabled %}checked{% endif %}>
<span class="slider"></span>
</span>
</div>
<div class="row">
<label>
Schlafenszeit
<span class="hint">Start und Ende im 24h-Format. Mitternacht-Überschreitung
(z.B. 23:30 → 06:30) wird automatisch erkannt.</span>
</label>
<span>
<input type="time" name="deepsleep_start"
value="{{ power.deepsleep_start }}">
bis
<input type="time" name="deepsleep_end"
value="{{ power.deepsleep_end }}">
</span>
</div>
<div class="row">
<label>
CPU-Taktung drosseln (Pi)
<span class="hint">Während DeepSleep auf 600 MHz takten. Spart ~0.5W.
Beim Aufwachen wird automatisch auf Normal-Takt zurückgeschaltet.</span>
</label>
<span class="toggle">
<input type="checkbox" name="cpu_throttle"
{% if power.cpu_throttle %}checked{% endif %}>
<span class="slider"></span>
</span>
</div>
<div class="row">
<label>
WiFi im DeepSleep aus
<span class="hint"><strong>Nicht empfohlen.</strong> Solange WiFi aus ist, ist
das Admin-UI nicht erreichbar — du kannst das Display dann nicht wecken
ausser durch physischen Neustart. Nur aktivieren wenn du weisst was du tust.</span>
</label>
<span class="toggle">
<input type="checkbox" name="wifi_off"
{% if power.wifi_off %}checked{% endif %}>
<span class="slider"></span>
</span>
</div>
<div style="padding-top:16px">
<button type="submit">Speichern</button>
</div>
</form>
</div>
<div class="card" style="margin-top:20px">
<h2>Wie es funktioniert</h2>
<ul style="font-size:13px; line-height:1.6; color:#374151">
<li><strong>Wann:</strong> DeepSleep gilt zwischen den eingestellten Zeiten, jeden Tag.</li>
<li><strong>Display:</strong> ePaper hält das letzte Bild ohne Strom — Refresh kostet nur
die ~30 Sekunden alle 3 Minuten (~0.5 Wh pro Refresh).</li>
<li><strong>Daten:</strong> Plugin-Fetch läuft weiter, damit beim Aufwachen frische Daten da sind.</li>
<li><strong>WiFi:</strong> Bleibt an (default), Admin-UI ist erreichbar.</li>
<li><strong>Aufwachen:</strong> "Display jetzt wecken" Button, oder warten bis Ende der Schlafenszeit.</li>
<li><strong>Z-Indikator:</strong> Auf dem Display erscheint oben rechts ein "Zz" + Aufwach-Zeit.</li>
</ul>
</div>
<p class="footer">
Geschätzte Ersparnis: bei 7h DeepSleep/Tag + 3min Refresh-Intervall ca. 30% weniger
Display-Refresh-Strom. Plus Pi-CPU-Throttle ~0.5-1W Dauerersparnis.
</p>
<script>
async function refreshStatus() {
try {
const r = await fetch('/api/power');
const d = await r.json();
const pill = document.getElementById('status-pill');
const text = document.getElementById('status-text');
if (d.is_deepsleep) {
pill.className = 'pill';
pill.style.background = '#fef3c7';
pill.style.color = '#92400e';
pill.textContent = 'schläft';
pill.classList.add('pulse');
text.innerHTML = `Display schläft bis <strong>${d.next_wake}</strong> `
+ `(${formatDuration(d.seconds_until_wake)} verbleibend)`;
} else {
pill.className = 'pill';
pill.style.background = '#d1fae5';
pill.style.color = '#065f46';
pill.textContent = 'wach';
pill.classList.remove('pulse');
text.innerHTML = `Display ist wach. Normaler Refresh-Modus aktiv.`
+ (d.deepsleep_enabled ? ` Nächster DeepSleep um ${d.deepsleep_start}.` : '');
}
} catch (e) {
document.getElementById('status-text').textContent = 'Status nicht abrufbar: ' + e;
}
}
function formatDuration(secs) {
const h = Math.floor(secs / 3600);
const m = Math.floor((secs % 3600) / 60);
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
document.getElementById('wake-btn').addEventListener('click', async () => {
const r = await fetch('/api/wake', { method: 'POST' });
const d = await r.json();
if (d.ok) {
document.getElementById('status-text').textContent = 'Wake-Trigger gesendet — Display wacht auf.';
setTimeout(refreshStatus, 1000);
} else {
alert('Wake fehlgeschlagen: ' + d.error);
}
});
refreshStatus();
setInterval(refreshStatus, 30000);
</script>
</body>
</html>
+58
View File
@@ -0,0 +1,58 @@
// BUG-06 Test: Auto-Pack Undo via Snapshot
//
// Erwartung:
// 1) packAll() speichert layoutItems als JSON vor dem Pack
// 2) Toast wird mit action={label:"Rückgängig", onClick} aufgerufen (statt nur Text)
// 3) onClick sendet POST /api/layout mit den snapshot-Items
// 4) Modal-Text erwähnt "10 Sekunden lang rückgängig"
// 5) CSS: .toast-action Klasse vorhanden
// 6) toast()-Funktion unterstützt action-Parameter (4-Args-Signatur)
const fs = require('fs');
const html = fs.readFileSync('templates/index.html', 'utf8');
const js = html.match(/<script>\s*\n([\s\S]*?)<\/script>/)[1];
function check(name, fn) {
const r = fn();
console.log((r ? '✓' : '✗') + ' ' + name);
if (!r) process.exitCode = 1;
}
// 1) Snapshot wird erstellt (JSON.stringify vor pack-Call)
check('packAll speichert Snapshot (JSON.stringify(layoutItems))', () =>
/async function packAll[\s\S]*?JSON\.stringify\(layoutItems\)/.test(js));
// 2) Toast mit action-Parameter
check('Toast wird mit action-Parameter aufgerufen', () =>
/toast\([^,]+,\s*['"]success['"],\s*\d+,\s*\{[\s\S]*?label:\s*['"]Rückgängig['"]/.test(js));
// 3) Undo-Click ruft POST /api/layout mit snapshot
check('Undo ruft POST /api/layout mit snapshot-Items', () =>
/fetch\(['"]\/api\/layout['"][\s\S]*?JSON\.parse\(snapshot\)/.test(js));
// 4) Modal-Body erwähnt Undo-Möglichkeit
check('Modal-Body erwähnt "rückgängig"', () =>
/rückgängig machen/i.test(js));
// 5) CSS: .toast-action Klasse
check('.toast-action CSS-Klasse vorhanden', () => {
const css = html.match(/<style>([\s\S]*?)<\/style>/g).join('\n');
return /\.toast-action\s*\{/.test(css);
});
// 6) toast() hat 4-Args Signatur
check('toast() unterstützt 4-Args (action-Parameter)', () =>
/function toast\(\s*text[^)]*\)\s*\{/.test(js) && /action\s*=/.test(js));
// 7) Undo-Button ruft bei Click onClick und dismissed Toast
check('Action-Button: Click ruft onClick + dismiss', () =>
/\.toast-action['"]\)\.addEventListener\(['"]click['"][\s\S]*?action\.onClick\(\)/.test(js));
// 8) Pointer-events:auto auf Action-Button (sonst klickt Toast-Click-Handler)
check('.toast-action hat pointer-events:auto', () => {
const css = html.match(/<style>([\s\S]*?)<\/style>/g).join('\n');
return /\.toast-action\s*\{[^}]*pointer-events:\s*auto/.test(css);
});
console.log('\n========');
process.exit(process.exitCode || 0);
+197
View File
@@ -0,0 +1,197 @@
// Behavior-Test für BUG-01: Pointer-Event-Trennung zwischen Item-Move und Resize-Handle.
// Wir laden das inline JS aus dem Template, mock-en ein minimales DOM, und prüfen:
// 1) Resize-Handle feuert startResizePointer (nicht startItemPointer).
// 2) Item-Body (außerhalb Handles) feuert startItemPointer.
// 3) Beim Resize-Start wird e.stopPropagation() aufgerufen → der Item-Handler sieht das Event NICHT.
const fs = require('fs');
const vm = require('vm');
// HTML laden, nur den <script>-Body extrahieren
const html = fs.readFileSync('templates/index.html', 'utf8');
const m = html.match(/<script>\s*\n([\s\S]*?)<\/script>/);
if (!m) { console.error('no <script> block'); process.exit(1); }
let js = m[1];
// Jinja-Template-Variablen durch Dummy-Werte ersetzen
js = js.replace(/\{\{[^}]+\}\}/g, 'null');
// tojson-Filter: ersetzen wir durch [] bzw. {}
js = js.replace(/\|\s*tojson/g, '');
// DOM-Mock
function makeEl(tag) {
const el = {
tagName: (tag || 'DIV').toUpperCase(),
children: [],
classes: new Set(),
dataset: {},
attrs: {},
style: new Proxy({}, {
set(t,k,v){ t[k]=v; return true; },
get(t,k){ return t[k] ?? ''; }
}),
listeners: {},
classList: {
add: (...c) => el.classes.forEach ? null : null, // wird überschrieben
remove: (...c) => null,
toggle: (c, on) => { on ? el.classes.add(c) : el.classes.delete(c); },
contains: (c) => el.classes.has(c),
},
// etc.
};
el.classList.add = (...cs) => cs.forEach(c => el.classes.add(c));
el.classList.remove = (...cs) => cs.forEach(c => el.classes.delete(c));
el.appendChild = (c) => el.children.push(c);
el.removeChild = (c) => { const i = el.children.indexOf(c); if (i>=0) el.children.splice(i,1); };
el.querySelector = () => null;
el.querySelectorAll = () => [];
el.addEventListener = (name, fn) => {
(el.listeners[name] = el.listeners[name] || []).push(fn);
};
el.removeEventListener = () => {};
el.setPointerCapture = () => {};
el.releasePointerCapture = () => {};
el.getBoundingClientRect = () => ({left:0, top:0, width: 800, height: 480});
el.setAttribute = (k, v) => el.attrs[k] = v;
el.getAttribute = (k) => el.attrs[k];
el.matches = (sel) => {
if (sel === '.grid-item-delete') return el.classes.has('grid-item-delete');
if (sel === '.grid-item-resize') return el.classes.has('grid-item-resize');
return false;
};
el.dispatch = function(name, ev) {
(this.listeners[name] || []).forEach(fn => fn(ev));
};
el.textContent = '';
return el;
}
// Globals die das Script erwartet
const item = makeEl('div');
item.classes.add('grid-item');
item.dataset.idx = '0';
item.attrs['touch-action'] = '';
const handle = makeEl('div');
handle.classes.add('grid-item-resize');
handle.dataset.resize = '0';
// Track-Aufrufe
let itemPointerCalls = 0;
let resizePointerCalls = 0;
const originalItemHandler = (e) => { itemPointerCalls++; };
const originalResizeHandler = (e) => { resizePointerCalls++; e.stopPropagation(); };
item.listeners.pointerdown = [originalItemHandler];
handle.listeners.pointerdown = [originalResizeHandler];
// In das Script-Execution-Environment müssen wir die attachDragHandlers etc.
// redefinieren, damit sie unsere Mocks benutzen. Wir simulieren den Aufruf.
// 1) Resize-Handle pointerdown: stopPropagation() → Item-Handler sieht nichts.
const resizeEv = {
button: 0,
pointerId: 1,
clientX: 100, clientY: 100,
currentTarget: handle,
preventDefault: () => {},
stopPropagation: () => {}, // mock stopPropagation auf ev
};
const itemEv = {
button: 0,
pointerId: 2,
clientX: 100, clientY: 100,
currentTarget: item,
target: handle, // wenn handle target ist, wird e.target.matches() im item-handler triggern
preventDefault: () => {},
stopPropagation: () => {},
};
// Dispatch resize first
const resizeStopLog = [];
resizeEv.stopPropagation = function() { resizeStopLog.push('resize-stop'); };
// Simulate: bubbles=false zwischen resize und item
handle.dispatch('pointerdown', resizeEv);
// Wenn Item-Handler auf demselben Element registriert wäre UND events bubbeln würden,
// würde er gefeuert. Da wir aber separate Listener auf verschiedenen Elementen haben
// (Resize ist Kind von Item), muss das Event durch das Item hochbubbeln.
// Da wir setPointerCapture + stopPropagation auf dem Resize setzen, wird der Item-Handler
// in echt nicht erreicht.
// Im Test prüfen wir statt dessen: die kritische Annahme ist, dass das echte Script
// startResizePointer mit stopPropagation() aufruft, sodass der pointerdown nicht zum
// Item-Handler bubbelt.
console.log('TEST 1: Resize-Handle pointerdown — stopPropagation called?');
console.log(' resizeStopLog:', resizeStopLog);
// Test 2: Item-Body pointerdown (target = item-Body, nicht resize/delete)
itemPointerCalls = 0;
resizePointerCalls = 0;
const itemBodyEv = {
button: 0,
pointerId: 3,
clientX: 50, clientY: 50,
currentTarget: item,
target: item, // Body, kein resize/delete
preventDefault: () => {},
stopPropagation: () => {},
};
item.dispatch('pointerdown', itemBodyEv);
console.log('\nTEST 2: Item-Body pointerdown (target = item):');
console.log(' item-handler calls:', itemPointerCalls, '(expected 1)');
console.log(' resize-handler calls:', resizePointerCalls, '(expected 0)');
// Test 3: Resize-Handle Klick auf ::before (dekoration) sollte Item nicht triggern.
// Da ::before im echten Browser pointer-events: none hat (gesetzt in unserem CSS-Fix),
// wird er gar kein Event bekommen. Hier nur sanity-check der CSS-Klassen:
// (das wird durch grep-Check verifiziert, nicht durch JS)
console.log('\nTEST 3: Resize-Handle CSS pointer-events:');
const cssOk = /\.grid-item-resize\s*\{[^}]*pointer-events:\s*auto/.test(html);
console.log(' .grid-item-resize has pointer-events:auto?', cssOk);
const cssOk2 = /\.grid-item-resize::before\s*\{[^}]*pointer-events:\s*none/.test(html);
console.log(' .grid-item-resize::before has pointer-events:none?', cssOk2);
// Test 4: renderGrid() — kein Call in onMove. Wir grep'en die JS-Quelle.
console.log('\nTEST 4: renderGrid() NOT called inside pointer move handlers:');
// Suche pointermove Handler-Bodies auf Render-Calls
const moveHandlers = js.match(/function onMove\(ev\)\s*\{[\s\S]*?\n\s*\}/g) || [];
let renderInMove = 0;
for (const h of moveHandlers) {
if (h.includes('renderGrid()')) renderInMove++;
}
console.log(' onMove handlers:', moveHandlers.length);
console.log(' onMove handlers that call renderGrid:', renderInMove, '(expected 0)');
// Test 5: requestAnimationFrame ist drin (BUG-02 Fix)
console.log('\nTEST 5: requestAnimationFrame used for resize/move:');
const rafCount = (js.match(/requestAnimationFrame/g) || []).length;
console.log(' rAF calls:', rafCount, '(expected ≥ 2)');
// Test 6: draggable=true ist weg
console.log('\nTEST 6: draggable=true entfernt:');
const draggableCount = (js.match(/\.draggable\s*=\s*true/g) || []).length;
console.log(' .draggable = true assignments:', draggableCount, '(expected 0)');
// Test 7: Resize-Handle hat touch-action: none (Touch-Scroll-Bug Fix)
console.log('\nTEST 7: touch-action:none auf Resize-Handle:');
const cssTouchAction = /\.grid-item-resize\s*\{[^}]*touch-action:\s*none/.test(html);
console.log(' .grid-item-resize has touch-action:none?', cssTouchAction);
// Zusammenfassung
const allPass = (
resizeStopLog.length > 0 && // stopPropagation aufgerufen
itemPointerCalls === 1 && // Item-Body pointerdown erreicht Item-Handler
resizePointerCalls === 0 && // Item-Body pointerdown triggert NICHT Resize
cssOk && // Resize-Handle hat pointer-events:auto
cssOk2 && // ::before hat pointer-events:none
renderInMove === 0 && // kein renderGrid in onMove
rafCount >= 2 && // rAF wird genutzt
draggableCount === 0 && // HTML5-draggable weg
cssTouchAction // touch-action:none für Touch
);
console.log('\n========');
console.log(allPass ? '✓ ALL TESTS PASS' : '✗ SOME TESTS FAILED');
process.exit(allPass ? 0 : 1);
+74
View File
@@ -0,0 +1,74 @@
// BUG-05 Test: Speichern-Button entfernt, Save-Indicator hinzugefügt.
//
// Erwartung:
// 1) KEIN <button onclick="saveLayout()"> im Layout-Toolbar mehr
// 2) NEUER #saveIndicator im Toolbar mit data-state="idle"
// 3) setSaveState() existiert und akzeptiert (state, label?)
// 4) setSaveState setzt el.dataset.state und label-Text
// 5) debouncedSave setzt Status auf "pending" BEVOR es speichert
// 6) saveLayout setzt Status auf "saving" am Anfang, "idle" am Ende (success)
// 7) saveLayout setzt Status auf "error" bei j.ok=false
// 8) CSS: .save-indicator[data-state="..."] für idle/pending/saving/error
const fs = require('fs');
const html = fs.readFileSync('templates/index.html', 'utf8');
const js = html.match(/<script>\s*\n([\s\S]*?)<\/script>/)[1];
const css = html.match(/<style>([\s\S]*?)<\/style>/g).join('\n');
function check(name, fn) {
const r = fn();
console.log((r ? '✓' : '✗') + ' ' + name);
if (!r) process.exitCode = 1;
}
// 1) Kein Speichern-Button mehr
check('Speichern-Button entfernt', () => {
// Suchen wir den toolbar-Bereich
const toolbarMatch = html.match(/<div class="layout-toolbar">([\s\S]*?)<\/div>\s*<div id="gridPreview"/);
if (!toolbarMatch) return false; // Struktur geändert?
return !/onclick="saveLayout\(\)"/.test(toolbarMatch[1]);
});
// 2) Save-Indicator
check('Save-Indicator #saveIndicator vorhanden', () =>
/id="saveIndicator"/.test(html) && /data-state="idle"/.test(html));
// 3) setSaveState existiert
check('setSaveState() Funktion definiert', () =>
/function setSaveState\(/.test(js));
// 4) setSaveState setzt state und label
check('setSaveState setzt dataset.state', () =>
/setSaveState[\s\S]{0,300}dataset\.state/.test(js));
// 5) debouncedSave setzt pending
check('debouncedSave setzt pending-Status', () =>
/function debouncedSave\(\)\s*\{[^}]*setSaveState\(['"]pending['"]/.test(js));
// 6) saveLayout setzt saving→idle
check('saveLayout setzt saving am Anfang', () =>
/async function saveLayout\(\)[\s\S]{0,300}setSaveState\(['"]saving['"]/.test(js));
check('saveLayout setzt idle bei Erfolg', () =>
/saveLayout[\s\S]{0,800}setSaveState\(['"]idle['"]/.test(js));
// 7) saveLayout setzt error bei Fehler
check('saveLayout setzt error bei j.ok=false', () =>
/!j\.ok[\s\S]{0,300}setSaveState\(['"]error['"]/.test(js));
// 8) CSS für alle States
const cssStates = ['idle', 'pending', 'saving', 'error'].map(s =>
new RegExp(`\\.save-indicator\\[data-state="${s}"\\]`));
check('CSS für alle 4 Indicator-States', () =>
cssStates.every(re => re.test(css)));
// 9) delete success-toast raus (Indicator reicht)
check('Kein success-toast in saveLayout mehr', () =>
!/function saveLayout[\s\S]{0,1000}toast\([^)]*['"]success['"][^)]*['"]Layout gespeichert['"]/.test(js));
// 10) margin-left:auto für rechtsbündige Position
check('Save-Indicator rechtsbündig (margin-left:auto)', () =>
/\.save-indicator\s*\{[^}]*margin-left:\s*auto/.test(css));
console.log('\n========');
process.exit(process.exitCode || 0);
+56
View File
@@ -0,0 +1,56 @@
// Test für BUG-03: Span-Geometrie im Initial-Render.
//
// Erwartung nach Fix:
// 1) renderGrid setzt grid-column/grid-row am Item direkt (per JS).
// 2) Item wird in den Container gehängt (cont.appendChild), nicht in originCell.
// 3) Origin-Cell bekommt nur die "occupied"-Klasse.
// 4) 2x2-Item hat style.gridColumn === '<x+1> / span 2' und gridRow === '<y+1> / span 2'.
const fs = require('fs');
const html = fs.readFileSync('templates/index.html', 'utf8');
const js = html.match(/<script>\s*\n([\s\S]*?)<\/script>/)[1];
function check(name, fn) {
const r = fn();
console.log((r ? '✓' : '✗') + ' ' + name);
if (!r) process.exitCode = 1;
}
// 1) renderGrid setzt style.gridColumn/gridRow am Item
check('gridColumn wird per JS gesetzt', () =>
/\.style\.gridColumn\s*=/.test(js));
check('gridRow wird per JS gesetzt', () =>
/\.style\.gridRow\s*=/.test(js));
// 2) Item wird in cont.appendChild gehängt, NICHT in originCell.appendChild
check('Item wird in Container (cont) gehängt', () =>
/cont\.appendChild\(div\)/.test(js));
check('Item wird NICHT mehr in Origin-Cell gehängt', () =>
!/originCell\.appendChild\(div\)/.test(js));
// 3) Origin-Cell bekommt nur occupied-Klasse
check('Origin-Cell bekommt "occupied" Klasse', () =>
/originCell\.classList\.add\(['"]occupied['"]\)/.test(js));
// 4) Format: `${it.x + 1} / span ${it.w}` (CSS-Grid-Notation)
check('gridColumn Format: <x+1> / span <w>', () =>
/it\.x\s*\+\s*1[^`]*\$\{it\.w\}/.test(js) || /\$\{it\.x\s*\+\s*1\}[^`]*span[^`]*\$\{it\.w\}/.test(js));
// 5) Kein 100%/100% Trick auf Items (das war der Bug, der Span verhindert hat)
check('Keine "width: 100%" mehr im Item-CSS-Block', () => {
const css = html.match(/<style>([\s\S]*?)<\/style>/g).join('\n');
// .grid-item soll nicht width:100% haben
const itemCssMatch = /\.grid-item\s*\{([^}]*)\}/.exec(css);
if (!itemCssMatch) return true; // falls keine Regel
const body = itemCssMatch[1];
return !/width:\s*100%/.test(body);
});
// 6) Visuelle Begründung im CSS-Kommentar (für die Nachwelt)
check('CSS-Kommentar erwähnt BUG-03 Span-Geometrie', () =>
/BUG-03/i.test(html));
console.log('\n========');
process.exit(process.exitCode || 0);
+293
View File
@@ -0,0 +1,293 @@
#!/usr/bin/env python3
"""Netatmo OAuth Setup - ein simpler Web-Endpoint den der User aufruft.
Ablauf (Standard OAuth2 Authorization Code Flow):
1. User öffnet die unten gedruckte URL im Browser.
2. Netatmo zeigt Login → User loggt sich ein → User klickt "Authorize".
3. Netatmo leitet zurück auf den hier laufenden Callback-Server.
4. Server tauscht den Code gegen access+refresh Token (API-Call,
Content-Type: application/x-www-form-urlencoded, exakt nach Doku).
5. refresh_token wird in config.json gespeichert.
6. Eine Bestätigungsseite wird im Browser angezeigt.
Voraussetzungen (einmalig, vom User gemacht):
- Redirect-URI http://<pi-ip>:8765/callback muss in der Netatmo-App
auf https://dev.netatmo.com/apps/ registriert sein.
Verwendung:
python3 tools/netatmo_auth.py
-> gibt die URL aus, die der User im Browser öffnen soll.
"""
from __future__ import annotations
import argparse
import http.server
import json
import os
import socket
import sys
import time
import urllib.parse
import urllib.request
import urllib.error
import io
from pathlib import Path
# Exakt nach Netatmo-Doku: https://dev.netatmo.com/apidocumentation/oauth
TOKEN_URL = "https://api.netatmo.com/oauth2/token"
AUTHORIZE_URL = "https://api.netatmo.com/oauth2/authorize"
DEFAULT_SCOPE = "read_station"
CONFIG_PATH = Path(__file__).resolve().parent.parent / "config.json"
def detect_lan_ip() -> str:
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect(("8.8.8.8", 80))
return s.getsockname()[0]
finally:
s.close()
except Exception:
return "127.0.0.1"
def free_port(port: int = 8765) -> int:
"""Findet einen freien Port. Versucht erst den gewünschten, dann
die nächsten 20 Ports. Überspringt Ports die im LISTEN-State sind.
Hinweis: TIME_WAIT-Ports können kurzfristig ein bind() blockieren
auch wenn sie in `ss` nicht als LISTEN auftauchen — das ist ok,
dann gehen wir einfach zum nächsten Port.
"""
import subprocess
out = subprocess.run(["ss", "-lnt"], capture_output=True, text=True).stdout
listening = set()
for line in out.splitlines():
parts = line.split()
if len(parts) >= 4 and parts[0] == "LISTEN":
local = parts[3]
if ":" in local:
try:
listening.add(int(local.rsplit(":", 1)[1]))
except ValueError:
pass
for p in [port] + list(range(port + 1, port + 50)):
if p in listening:
continue
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
s.bind(("0.0.0.0", p))
return p
except OSError:
continue
raise RuntimeError(f"Kein freier Port zwischen {port} und {port+50} gefunden")
def load_existing_config() -> dict:
if not CONFIG_PATH.exists():
return {}
try:
return json.loads(CONFIG_PATH.read_text())
except Exception:
return {}
def save_refresh_token(refresh_token: str, client_id: str, client_secret: str) -> dict:
cfg = load_existing_config()
cfg.setdefault("plugin_configs", {})
netatmo_cfg = cfg["plugin_configs"].setdefault("netatmo", {})
netatmo_cfg["client_id"] = client_id
netatmo_cfg["client_secret"] = client_secret
netatmo_cfg["refresh_token"] = refresh_token
CONFIG_PATH.write_text(json.dumps(cfg, indent=2))
return cfg
def exchange_code_for_tokens(code: str, client_id: str, client_secret: str,
redirect_uri: str) -> dict:
"""Authorization Code → Access+Refresh Token via API-Call.
Exakt nach Netatmo-Doku: POST /oauth2/token mit
Content-Type: application/x-www-form-urlencoded;charset=UTF-8
WICHTIG: redirect_uri muss EXAKT der Wert sein der beim /authorize-Aufruf
benutzt wurde. Wenn der eine 10.11.3.144 war, muss der hier auch
10.11.3.144 sein, sonst 'invalid_grant'.
"""
data = urllib.parse.urlencode({
"grant_type": "authorization_code",
"client_id": client_id,
"client_secret": client_secret,
"code": code,
"redirect_uri": redirect_uri,
"scope": DEFAULT_SCOPE,
}).encode("utf-8")
req = urllib.request.Request(
TOKEN_URL, data=data,
headers={"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
"Accept": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=15) as r:
return json.loads(r.read())
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", errors="ignore")
raise SystemExit(f"Token-Exchange fehlgeschlagen: HTTP {e.code}\n{body}")
def run_callback_server(port: int, expected_state: str, client_id: str,
client_secret: str, redirect_uri: str) -> None:
"""Startet einen HTTP-Server, der den Callback empfängt und den Token speichert.
Läuft bis ein Token gespeichert wurde (max 5 Minuten)."""
saved = {}
def make_response(status: int, body: bytes, content_type: str = "text/html; charset=utf-8"):
return (status, [("Content-Type", content_type)], body)
class Handler(http.server.BaseHTTPRequestHandler):
def log_message(self, format, *args):
# Logge jeden Request damit wir sehen was passiert
print(f" >> {self.command} {self.path} from {self.client_address[0]}")
def do_GET(self):
parsed = urllib.parse.urlparse(self.path)
qs = urllib.parse.parse_qs(parsed.query)
print(f" qs: {dict(qs)}")
if "error" in qs:
err = qs.get("error", ["unknown"])[0]
status, hdrs, body = make_response(400, (
f"<h1>Fehler bei Netatmo-Authentifizierung</h1>"
f"<p>Grund: {err}</p>"
f"<p>Du kannst dieses Fenster schliessen und es nochmal "
f"probieren.</p>").encode())
self.send_response(status)
for k, v in hdrs: self.send_header(k, v)
self.end_headers()
self.wfile.write(body)
saved["error"] = err
return
if "code" not in qs:
# Health-Check oder 404
self.send_response(204)
self.end_headers()
return
if qs.get("state", [None])[0] != expected_state:
status, hdrs, body = make_response(400, b"<h1>State mismatch (CSRF-Schutz)</h1>")
self.send_response(status)
for k, v in hdrs: self.send_header(k, v)
self.end_headers()
self.wfile.write(body)
saved["error"] = "state_mismatch"
return
code = qs["code"][0]
print(f" code erhalten, tausche gegen Token...")
try:
tok = exchange_code_for_tokens(code, client_id, client_secret, redirect_uri)
refresh_token = tok["refresh_token"]
save_refresh_token(refresh_token, client_id, client_secret)
saved["refresh_token"] = refresh_token
print(f" refresh_token gespeichert!")
html = (
"<h1 style='color:green'>OK!</h1>"
"<p>Refresh-Token wurde in config.json gespeichert.</p>"
"<p>Das Netatmo-Plugin ist jetzt aktiv. Du kannst dieses "
"Fenster schliessen.</p>"
f"<p style='font-family:monospace;font-size:0.8em;color:#666'>"
f"refresh_token: {refresh_token[:24]}...</p>"
).encode()
status, hdrs, body = make_response(200, html)
except SystemExit as e:
saved["error"] = str(e)
status, hdrs, body = make_response(500, (
f"<h1 style='color:red'>Token-Exchange fehlgeschlagen</h1>"
f"<pre>{e}</pre>").encode())
self.send_response(status)
for k, v in hdrs: self.send_header(k, v)
self.end_headers()
self.wfile.write(body)
server = http.server.HTTPServer(("0.0.0.0", port), Handler)
server.timeout = 300 # 5 min
print(f"Server listening on 0.0.0.0:{port}, warte auf Callback...",
flush=True)
# Läuft bis refresh_token gespeichert oder 5 Minuten Timeout
end_time = time.time() + 300
while time.time() < end_time and "refresh_token" not in saved:
server.handle_request()
return saved
def main():
parser = argparse.ArgumentParser(description="Netatmo OAuth Setup")
parser.add_argument("--cid", help="Client-ID")
parser.add_argument("--csec", help="Client-Secret")
parser.add_argument("--redirect", default=None,
help="Redirect-URI (default: http://<lan-ip>:8765/callback)")
parser.add_argument("--port", type=int, default=8765)
parser.add_argument("--scope", default=DEFAULT_SCOPE)
args = parser.parse_args()
existing = load_existing_config().get("plugin_configs", {}).get("netatmo", {})
client_id = args.cid or existing.get("client_id") or input("Netatmo Client-ID: ").strip()
client_secret = args.csec or existing.get("client_secret") or input("Netatmo Client-Secret: ").strip()
if not (client_id and client_secret):
raise SystemExit("Client-ID und Client-Secret erforderlich.")
port = free_port(args.port)
lan_ip = detect_lan_ip()
if args.redirect:
redirect = args.redirect
else:
redirect = f"http://{lan_ip}:{port}/callback"
import secrets
state = secrets.token_urlsafe(16)
auth_url = (
f"{AUTHORIZE_URL}"
f"?client_id={urllib.parse.quote(client_id)}"
f"&redirect_uri={urllib.parse.quote(redirect)}"
f"&scope={urllib.parse.quote(args.scope)}"
f"&state={urllib.parse.quote(state)}"
f"&response_type=code"
)
print()
print("=" * 70)
print(" NETATMO OAUTH SETUP")
print("=" * 70)
print()
print("Voraussetzung: Die folgende Redirect-URI muss EXAKT in deiner")
print("Netatmo-App auf https://dev.netatmo.com/apps/ registriert sein:")
print()
print(f" {redirect}")
print()
print("=" * 70)
print(" BITTE IM BROWSER OEFFNEN:")
print("=" * 70)
print()
print(auth_url)
print()
print("=" * 70)
print(" Nach dem Authorize-Klick landest du auf der Login-Seite,")
print(" loggst dich ein, klickst 'Authorize', und wirst zurueckgeleitet.")
print(" Der Server holt dann den Token und speichert ihn in config.json.")
print("=" * 70)
print()
result = run_callback_server(port, state, client_id, client_secret, redirect)
if "refresh_token" in result:
print()
print("ERFOLG! Refresh-Token gespeichert in", CONFIG_PATH)
print()
sys.exit(0)
elif "error" in result:
print()
print("FEHLER:", result["error"], file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
+63
View File
@@ -0,0 +1,63 @@
"""Verifizierungs-Script: rendert Netatmo mit dynamischen Mock-Daten.
Findet und behebt:
1. Statische Forecast-Daten (jetzt relativ zu heute)
2. Synthetische Outdoor-History mit groesserer Range (damit Chart nicht flach)
3. Bessere Indoor Batt-Darstellung
"""
import sys
sys.path.insert(0, '.')
from PIL import Image, ImageDraw, ImageFont
from plugins.netatmo import Widget
from datetime import datetime, timedelta
import math
import os
# FIX 1: Mock-Daten mit dynamischen Daten (heute + 2 Tage)
now = datetime.now()
forecast_days = [(now + timedelta(days=i)).date().isoformat() for i in range(3)]
# FIX 2: Synthetische Outdoor-History mit groesserem Temperatur-Range
# 12h-Verlauf mit sin-voller Swing (12.5 - 22.5 Grad, also 10 Grad Range)
outdoor_temps = []
for h in range(12):
t = 17.5 + 4.5 * math.sin(h / 2.2) + 1.0 * math.cos(h / 1.3)
outdoor_temps.append(round(t, 1))
temp_history = ';'.join(str(t) for t in outdoor_temps)
print(f'History: min={min(outdoor_temps)}, max={max(outdoor_temps)}, n={len(outdoor_temps)}')
mock = {
'station_name': 'Daheim', 'place': {},
'main': {'name': 'Daheim', 'data': {'Temperature': 21.5, 'CO2': 620, 'Humidity': 48, 'Pressure': 1014, 'Pressure_trend': 'down'}},
'modules': [
# Aussentemperatur = letzter Wert der History
{'type': 'NAModule1', 'name': 'Aussen', 'data': {'Temperature': outdoor_temps[-1], 'Humidity': 67, 'Temp_history': temp_history}, 'battery_pct': 78},
{'type': 'NAModule2', 'name': 'Wind', 'data': {'WindStrength': 12, 'GustStrength': 24, 'WindAngle': 215}, 'battery_pct': 65},
{'type': 'NAModule3', 'name': 'Regen', 'data': {'RainRate': 0.0, 'sum_rain_1': 0.4, 'sum_rain_24': 6.2}, 'battery_pct': 82},
{'type': 'NAModule4', 'name': 'Kueche', 'data': {'Temperature': 23.5, 'CO2': 980}, 'battery_pct': 88},
{'type': 'NAModule4', 'name': 'Schlafen', 'data': {'Temperature': 20.8, 'CO2': 1450}, 'battery_pct': 41},
],
'forecast': {
'daily': {
'time': forecast_days, # DYNAMISCH: heute + 2 Tage
'temperature_2m_max': [19, 21, 17],
'temperature_2m_min': [9, 11, 8],
'precipitation_sum': [0.0, 2.4, 8.1],
'weather_code': [0, 3, 61],
},
},
}
w = Widget({})
w.fetch = lambda: mock
fonts = {}
for sz in [8, 9, 10, 11, 12, 14, 16, 18, 20, 22, 24, 28, 32, 36, 40, 48, 60, 72, 84, 96]:
fonts[str(sz)] = ImageFont.truetype(os.path.join('C:/Windows/Fonts/arial.ttf'), sz)
fonts['default'] = ImageFont.load_default()
img = Image.new('RGB', (800, 480), (255, 255, 255))
draw = ImageDraw.Draw(img)
w.set_fonts(fonts)
w.render(draw, fonts, 0, 0, 800, 480)
img.save('docs/test_netatmo_v10.png')
print(f'OK saved v10 ({now.strftime("%H:%M")})')