Author SHA1 Message Date
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 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
ki c6919c5104 BUG-04: Add-Item ohne Re-Pack (first-fit)
Beim Hinzufügen eines neuen Widgets via /api/layout/add rief der Server
layout_mod.pack() auf alle Items auf — pack() sortiert nach Fläche
absteigend und platziert scan-line greedy. Ein 1x1 hello konnte dabei
einen 2x1 spotify aus seiner Position drängen, weil die Sort-Reihenfolge
sich ändert sobald ein neues Item im Mix ist.

Reproduktion vor dem Fix:
  Bestehende config: c1@(0,0) w1@(2,0) st1@(0,2) sp1@(2,2) sv1@(2,3)
  Add hello (1x1) → w1 wurde nach (0,2) verschoben, hello landete bei (2,0).
  Siehe RED-Test in tests/test_add_route_no_repack.py.

Fix:
- layout.py: neue Funktion first_fit(item, others) — platziert ein Item in
  der ersten freien scan-line-Zelle OHNE andere Items zu verändern.
- admin.py /api/layout/add nutzt first_fit. Wenn kein Platz: HTTP 409 mit
  {ok:false, error:'kein Platz für WxH-Item', hint:'use_auto_pack'}.
- templates/index.html addItem(): 409 als 'warn'-Toast mit Auto-Pack-Hinweis.

Akzeptanzkriterien (BUG-04):
- bestehende Items bleiben bei Add unverändert an (x,y)
- neues Item landet in erster freier Zelle
- voller Grid → 409, kein bestehendes Item verschoben
- Auto-Pack bleibt als expliziter User-Wunsch erhalten (BUG-06)

Tests:
- tests/test_layout_firstfit.py: 3 unit tests (empty, gappy, full grid)
- tests/test_add_route_no_repack.py: 2 integration tests gegen /api/layout/add
  mit gemocktem dashboard-Modul + Flask test_client

Closes #6
2026-08-29 17:20:22 +04:00
13 changed files with 2872 additions and 327 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` |
| `gmail` | Gmail API | `credentials_json` (OAuth2) |
| `minimax` | MiniMax AI | `api_key`, `model`, `prompt` |
| `netatmo` | Netatmo Weather API (OAuth2) | `client_id`, `client_secret`, `username`, `password` (Details: [NETATMO.md](plugins/NETATMO.md)) |
| `hello` | statisch | `text`, `color`, `size` |
## Eigenes Plugin schreiben
+30 -16
View File
@@ -153,14 +153,9 @@ def save_config():
except (TypeError, ValueError):
pass
# slot belegung: pro slot "slot_<idx>_plugin"
slots = cfg.get("slots", [])
for i in range(dashboard_mod.SLOTS):
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
# NOTE: legacy v1 slot-X-belegung wird nicht mehr unterstützt — die
# Admin-UI nutzt seit v2 die Drag&Drop-API unter /api/layout für
# Slot-Belegung. /config macht heute NUR noch den Refresh-Intervall.
dashboard_mod.save_config(cfg)
return redirect(url_for("index"))
@@ -197,6 +192,9 @@ def save_plugin_config(idx):
new_cfg[key] = form_val
continue
if form_val is None:
if ftype == "bool":
new_cfg[key] = False
else:
continue
try:
if ftype == "int":
@@ -280,7 +278,15 @@ def api_layout():
@app.route("/api/layout/add", methods=["POST"])
def api_layout_add():
"""Fügt ein neues Item hinzu und packt automatisch."""
"""Fügt ein neues Item hinzu OHNE bestehende Items zu verschieben (BUG-04).
BUG-04: Vorher wurde `layout_mod.pack()` aufgerufen, das alle Items neu
sortiert und in scan-line packt — ein 1x1 hello konnte einen 2x1 spotify
aus seiner Position drängen. Jetzt first-fit: das neue Item landet in der
ersten freien Zelle, alle anderen bleiben unverändert.
Falls kein Platz: HTTP 409 mit Hinweis auf den Auto-Pack-Button.
"""
a = require_auth()
if a: return a
plugin = request.form.get("plugin", "hello").strip()
@@ -297,14 +303,19 @@ def api_layout_add():
except: pass
import secrets
new_id = secrets.token_hex(4)
items = cfg.setdefault("layout", {}).setdefault("items", [])
new_item = layout_mod.Item(new_id, plugin, 0, 0, w, h).to_dict()
items.append(new_item)
# Pack alle (inkl. neue)
packed = layout_mod.pack([layout_mod.Item.from_dict(d) for d in items])
cfg["layout"]["items"] = [it.to_dict() for it in packed]
items_list = cfg.setdefault("layout", {}).setdefault("items", [])
existing = [layout_mod.Item.from_dict(d) for d in items_list]
candidate = layout_mod.Item(new_id, plugin, 0, 0, w, h)
placed = layout_mod.first_fit(candidate, existing)
if placed is None:
return jsonify({
"ok": False,
"error": f"kein Platz für {w}×{h}-Item — bitte Auto-Pack klicken oder Items manuell verkleinern.",
"hint": "use_auto_pack",
}), 409
items_list.append(placed.to_dict())
dashboard_mod.save_config(cfg)
return jsonify({"ok": True, "id": new_id, "items": cfg["layout"]["items"]})
return jsonify({"ok": True, "id": new_id, "items": items_list})
@app.route("/api/layout/item/<iid>", methods=["DELETE", "PATCH"])
@@ -463,6 +474,9 @@ def api_plugin_config(plugin_name):
form_val = request.form.get(f"config_{key}")
ftype = field.get("type", "string")
if form_val is None:
if ftype == "bool":
new_cfg[key] = False # unchecked checkbox → False
else:
continue
if ftype == "secret":
# Nur überschreiben wenn nicht leer
+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"
}
+18
View File
@@ -141,12 +141,30 @@ def pack(items: list[Item], order: Optional[list[str]] = None) -> list[Item]:
return result
def first_fit(item: Item, others: list[Item]) -> Item | None:
"""Place the item at the first free scan-line position without moving others.
Returns the item with x/y set, or None if no placement fits.
Used by /api/layout/add so a new widget does not reshuffle the grid.
"""
w = max(1, min(GRID_COLS, item.w))
h = max(1, min(GRID_ROWS, item.h))
for y in range(GRID_ROWS - h + 1):
for x in range(GRID_COLS - w + 1):
candidate = Item(item.id, item.plugin, x, y, w, h)
cells = cells_occupied(candidate)
if all(not (cells & cells_occupied(o)) for o in others):
return candidate
return None
def auto_size_for_plugin(plugin_name: str) -> tuple[int, int]:
"""Default size when user adds a new item."""
presets = {
"clock": (2, 2),
"clock_wordclock": (2, 2),
"weather": (2, 2),
"netatmo": (4, 4), # zeigt 4 Sub-Cards; ab 2x2 sinnvoll, 4x4 ideal
"system": (2, 2),
"minimax": (2, 2),
"spotify": (2, 1),
+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.
+253 -167
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:
ES IST <Fünf/Zehn/Viertel/Zwanzig> <Minuten> <nach/vor> <Stunde>
Layout in 3 klar getrennten Zonen:
┌────────────────────────────────────┐
│ 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).
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.
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.
"""
from __future__ import annotations
import os, sys
from datetime import datetime
from functools import lru_cache
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from plugins.base import Widget
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):
name = "clock_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"
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",
"type": "bool", "default": True},
{"key": "show_weekday", "label": "Wochentag anzeigen",
@@ -49,176 +86,225 @@ class Widget(Widget):
{"key": "accent_color", "label": "Akzentfarbe",
"type": "select",
"choices": ["accent", "blue", "ok", "warn", "info"],
"default": "accent"},
"default": "accent",
"help": "Farbe für Wochentag + Datum"},
{"key": "invert", "label": "Invertiert (dunkel)",
"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 = {
"lang": "de", "show_date": True, "show_weekday": True,
"show_date": True, "show_weekday": True,
"accent_color": "accent", "invert": False,
"show_es_ist": True,
}
def fetch(self):
return {}
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)
accent = fill_for(self.cfg("accent_color", "accent"))
fg = BG if invert else FG
bg = FG if invert else BG
pad = 8
# Mindestgröße: 300px-breit, 240px-hoch für WordClock
if w < 300 or h < 240:
# --- Hintergrund ---
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)
now = datetime.now()
pad = 6
inner_w = w - 2 * pad
inner_h = h - 2 * pad
inner_x = x + pad
inner_y = y + pad
# ============================================================
# Sehr kleine Slots (1×1 = 200×120): kompakter Single-Line Modus
# ============================================================
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
# ---- 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")))
# ============================================================
# Standard-Layout: 3 Zonen
# ============================================================
# Höhenverteilung:
# Header: bis zu 28px (oder weniger wenn beides aus)
# Grid: Rest minus Stunden
# Stunden: grosse Schrift, ~30% der Höhe
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)
# Header-Höhe dynamisch: nur soviel wie gebraucht
show_wd = self.cfg("show_weekday", True)
show_dt = self.cfg("show_date", True)
show_es = self.cfg("show_es_ist", True)
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
if show_wd and show_dt:
header_h = 26
elif show_wd or show_dt:
header_h = 18
else:
return [(0, 1), (1, 1), (2, 1), (3, 1), # FÜNF + VOR
(7, 4), (8, 4), (9, 4)]
header_h = 0
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]
# Stunden-Block: fest ~36% der inneren Höhe, aber mindestens 50px
hour_block_h = max(50, int(inner_h * 0.36))
past_half = m >= 20
h_display = hour_name(h_, past_half)
lit_cells = lit_minute(m)
# Wort-Block = Rest
grid_h = inner_h - header_h - hour_block_h - 6 # 6px gap
if grid_h < 40:
# Slot zu klein für alles — Stunden-Anteil reduzieren
hour_block_h = max(36, int(inner_h * 0.28))
grid_h = inner_h - header_h - hour_block_h - 6
# ---- Render ----
# Hintergrund
draw.rectangle((x, y, x + w - 1, y + h - 1), fill=bg)
grid_y = inner_y + header_h
hour_y = grid_y + grid_h + 6
# "ES IST" immer lit in Spalte 0
_lit(0, 0); _lit(1, 0); _lit(3, 0); _lit(4, 0)
# --- 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)
# Minuten-Wörter
for (cx, cy) in lit_cells:
_lit(cx, cy)
# --- 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)
# "VOR" und "NACH" (Zeile 4)
if 5 <= m < 30:
_lit(0, 4); _lit(1, 4); _lit(2, 4); _lit(3, 4) # NACH
elif m >= 35 and m < 55:
_lit(7, 4); _lit(8, 4); _lit(9, 4) # VOR
# 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.
# Rest dim
for row in range(chars_y):
for col in range(chars_x):
if (col, row) not in lit_cells and not (row == 0 and col in (0, 1, 3, 4)):
_dim(col, row)
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-Balken unten ----
# Zeile 6+7: Stunden-Name in großen Buchstaben unten zentriert
hour_str = h_display
hour_font_size = min(w // len(hour_str), h // 4) * 3 // 4
hf = fonts.get(str(hour_font_size), fonts.get("60", fonts.get("default")))
tw, th = measure(draw, hour_str, hf)
hour_y = y + h - th - pad
draw.text((x + (w - tw) // 2, hour_y), hour_str, font=hf, 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)
# ---- Datum + Wochentag ----
if self.cfg("show_date", True):
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):
day_str = now.strftime("%A").upper()
df = fonts.get("14", fonts.get("default"))
dw, dh = measure(draw, day_str, df)
draw.text((x + (w - dw) // 2, y + pad + (18 if self.cfg("show_date", True) else 0)),
day_str, font=df, fill=accent)
# --- 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) ----
# Jede Position ist ein Zeichen das gerendert wird.
# ' ' = Leerzeichen, rest = Buchstabe.
LAYOUT_DE = [
["E", "S", " ", "I", "S", "T", " ", " ", " ", " ", " "],
["F", "Ü", "N", "F", " ", "Z", "E", "H", "N", " ", " "],
["V", "I", "E", "R", "T", "E", "L", " ", "Z", "W", "A"],
["N", "U", "L", "L", " ", "Z", "W", "A", "N", "Z", " "],
["N", "A", "C", "H", " ", "V", "O", "R", " ", "H", "A"],
[" ", " ", " ", " ", " ", " ", " ", " ", " ", " ", " "],
[" ", " ", " ", " ", " ", " ", " ", " ", " ", " ", " "],
[" ", " ", " ", " ", " ", " ", " ", " ", " ", " ", " "],
]
def _draw_minute_dots(self, draw, x, y, w, header_h, fg, accent):
"""Vier Minuten-Dots (●) in der Ecke, analog zu echten QlockTwo-Uhren.
Sie zeigen: ●=1, ●●=2, ●●●=3, ●●●●=4 Minuten innerhalb des 5-Min-Sektors.
"""
m = datetime.now().minute
sector_m = m % 5 # 0..4
if sector_m == 0:
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)
#
# ============================================================================
+826
View File
@@ -0,0 +1,826 @@
"""Netatmo Weather Station Plugin — WarmNews 4x4 Layout.
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
Layout (4x4 = 800x480):
Spalte 1: Aussen (Temp gross + MIN/MAX/LUFT/DRUCK + 12h-Verlauf)
Spalte 2: Wind + Regen (gestapelt, mit 1h/24h-Bars)
Spalte 3: Indoor (5 Sensoren prominent: Tag/Name/Temp/CO2/Batt)
Spalte 4: Forecast (3 Tage)
Auth: OAuth2 Authorization Code Flow (siehe tools/netatmo_auth.py).
"""
from __future__ import annotations
import os, sys, json, time, math, io
import urllib.request, urllib.error, urllib.parse
from datetime import datetime, timezone
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from plugins.base import Widget, render_error_banner
from palette import (
FG, BG, INFO, OK, WARN, ALERT, ORANGE, BLUE, RED, GREEN,
measure, fit_font, is_small, is_wide, is_tall,
)
# ── ACeP Native Farbpalette ───────────────────────────────────────────────────
# ACeP 7.3" hat 7 Farben. Alles andere wird gedithert → matschig.
# Hex (RGB): BLACK, WHITE, GREEN, BLUE, RED, YELLOW, ORANGE
PAPER_BG = (255, 255, 255) # WHITE — echter Display-Hintergrund
INK = ( 0, 0, 0) # BLACK — Text
INK_MID = ( 0, 0, 0) # BLACK (kann nicht grau — entweder schwarz oder nichts)
INK_LIGHT = ( 0, 0, 0) # BLACK, aber kleinere Schrift für Hierarchie
FAINT = ( 0, 0, 0) # BLACK, dünner Strich
RED = (255, 0, 0) # RED — Alerts, Indoor-Akzent
GREEN = ( 0, 255, 0) # GREEN — Aussen, CO2 OK, Batt OK
BLUE = ( 0, 0, 255) # BLUE — Wind, Regen, Forecast
INFO_BL = ( 0, 0, 255) # BLUE
PURPLE = ( 0, 0, 255) # BLUE (kein Magenta in Palette — nimm Blue)
YELLOW = (255, 255, 0) # YELLOW — Forecast Sonne, Trend up
ORANGE = (255, 128, 0) # ORANGE — Batt MID, Trend warn
BATT_LOW = (255, 0, 0) # RED
BATT_MID = (255, 128, 0) # ORANGE
BATT_OK = ( 0, 255, 0) # GREEN
# ── Layout-Konstanten ─────────────────────────────────────────────────────
PAD_OUTER = 14
GAP = 10
STRIP_H = 6
LABEL_H = 22
# ── HTTP / OAuth2 ───────────────────────────────────────────────────────────
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" # free, no key
_TOKEN_CACHE = {"access_token": None, "refresh_token": None, "expires_at": 0.0}
def _token_payload(creds, grant="refresh_token", **extra):
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, body, timeout=10):
req = urllib.request.Request(url, data=body, headers={
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
"Accept": "application/json"})
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.loads(r.read())
except urllib.error.HTTPError as e:
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):
rt = creds.get("refresh_token") or _TOKEN_CACHE.get("refresh_token")
if not rt:
raise urllib.error.HTTPError(TOKEN_URL, 0,
"Kein refresh_token — bitte tools/netatmo_auth.py ausführen.", {}, io.BytesIO(b'{}'))
try:
tok = _post_form(TOKEN_URL, _token_payload(creds, "refresh_token", refresh_token=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, client_secret, refresh_token):
creds = {"client_id": client_id, "client_secret": client_secret,
"refresh_token": refresh_token}
now = time.time()
if _TOKEN_CACHE["expires_at"] <= now or not _TOKEN_CACHE["access_token"]:
_obtain_tokens(creds)
def _do():
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, lon, days=3):
"""Open-Meteo 3-Tage Forecast. Free, kein Key nötig."""
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
def _icon_for_code(code):
"""WMO Weather Code → (icon_char, color)."""
if code is None: return ("?", INK_MID)
if code == 0: return ("*", YELLOW) # clear sky
if code in (1, 2, 3): return ("~", INK_MID) # partly cloudy
if code in (45, 48): return ("~", INK_MID) # fog
if code in (51, 53, 55, 56, 57): return ("#", INFO_BL) # drizzle
if code in (61, 63, 65, 66, 67, 80, 81, 82): return ("#", INFO_BL) # rain
if code in (71, 73, 75, 77, 85, 86): return ("*", INFO_BL) # snow
if code in (95, 96, 99): return ("#", PURPLE) # thunderstorm
return ("~", INK_MID)
# ── Daten-Parsing ─────────────────────────────────────────────────────────
def _parse_stations(api_response, station_filter=""):
body = api_response.get("body", api_response)
devices = body.get("devices") if isinstance(body, dict) else None
if not devices: return None
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, mtype):
for m in modules:
if m["type"] == mtype: return m
return None
def _all_indoor(modules, main):
"""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")})
# Tags vergeben: M=Main, dann 2..N für Module4
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, 12h-Verlauf"
category = "weather"
config_schema = [
{"key": "station_filter", "label": "Station (leer = erste)",
"type": "string", "default": ""},
{"key": "show_outdoor", "label": "Outdoor anzeigen", "type": "bool", "default": True},
{"key": "show_wind", "label": "Wind anzeigen", "type": "bool", "default": True},
{"key": "show_rain", "label": "Regen anzeigen", "type": "bool", "default": True},
{"key": "show_compass", "label": "Windrose", "type": "bool", "default": True},
{"key": "show_forecast", "label": "3-Tage Forecast", "type": "bool", "default": True},
{"key": "co2_thresholds", "label": "CO₂-Schwellen (ppm)",
"type": "string", "default": "ok@600,warn@1000,alert@1500"},
{"key": "temp_unit", "label": "Temperatur", "type": "select",
"options": ["C", "F"], "default": "C"},
{"key": "wind_unit", "label": "Wind-Einheit", "type": "select",
"options": ["kmh", "ms"], "default": "kmh"},
{"key": "client_id", "label": "Client-ID", "type": "secret"},
{"key": "client_secret", "label": "Client-Secret", "type": "secret"},
{"key": "refresh_token", "label": "Refresh-Token", "type": "secret"},
]
default_config = {
"station_filter": "",
"show_outdoor": True, "show_wind": True, "show_rain": True,
"show_compass": 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):
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 (Open-Meteo, optional)
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
draw.rectangle((x, y, x + w - 1, y + h - 1), fill=PAPER_BG)
# Bei kleinen Slots: nur Aussentemperatur
if is_small(w, h) or w < 400 or h < 400:
self._render_compact(draw, x, y, w, h, d)
return
self._render_full(draw, x, y, w, h, d)
# ── Kompakt-Layout (kleine Slots) ────────────────────────────────────
def _render_compact(self, draw, x, y, w, h, d):
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 ""
font = fit_font(draw, temp_str, fonts, w - 16, h - 28,
candidates=["48", "36", "28", "24", "20"])
tw, th = measure(draw, temp_str, font)
draw.text(((w - tw) // 2 + x, (h - th) // 2 + y - 8), temp_str, font=font, fill=INK)
# Label
f_l = fonts.get("12") or fonts.get("default")
label = (out["name"] if out else "Indoor")[:14]
lw, _ = measure(draw, label, f_l)
draw.text(((w - lw) // 2 + x, h - 16 + y), label, font=f_l, fill=INK_MID)
# ── Voll-Layout (4x4 = 800x480) ─────────────────────────────────────
def _render_full(self, draw, x, y, w, h, d):
# Spalten-Berechnung — Indoor priorisiert (5 Sensoren)
total_w = w - 2 * PAD_OUTER
col_indoor = int(total_w * 0.32) # mehr Platz für 5 Sensoren
col_aussen = int(total_w * 0.28) # kompakter
col_mitte = int(total_w * 0.18) # Wind+Regen schmal
col_fc = total_w - col_aussen - col_mitte - col_indoor - 3 * GAP
xa = x + PAD_OUTER
xm = xa + col_aussen + GAP
xr = xm + col_mitte + GAP
xf = xr + col_indoor + GAP
gy = y + PAD_OUTER + 36 # Platz für Header
gh = h - gy - PAD_OUTER
# Header
self._draw_header(draw, x, y, w)
# 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)
forecast = d.get("forecast")
# Aussen
if out:
self._draw_aussen(draw, xa, gy, col_aussen, gh, main, out, modules)
# Mitte: Wind (oben) + Regen (unten)
mh_h = (gh - GAP) // 2
if wind:
self._draw_wind(draw, xm, gy, col_mitte, mh_h, wind)
if rain:
self._draw_regen(draw, xm, gy + mh_h + GAP, col_mitte, mh_h, rain)
# Indoor
self._draw_indoor(draw, xr, gy, col_indoor, gh, indoor)
# Forecast
if forecast:
self._draw_forecast(draw, xf, gy, col_fc, gh, forecast)
# ── Header ──────────────────────────────────────────────────────────
def _draw_header(self, draw, x, y, w):
from PIL import ImageFont
sans_paths = ["/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"]
f = None
for p in sans_paths:
if os.path.exists(p):
try: f = ImageFont.truetype(p, 22); break
except: pass
if f is None: f = ImageFont.load_default()
draw.text((x + PAD_OUTER, y + 14), "WETTER", font=f, fill=INK_MID)
now = datetime.now().strftime("%H:%M")
bb = draw.textbbox((0,0), now, font=f)
draw.text((x + w - PAD_OUTER - (bb[2] - bb[0]), y + 14),
now, font=f, fill=INK_MID)
draw.line((x + PAD_OUTER, y + 50, x + w - PAD_OUTER, y + 50),
fill=INK_MID, width=2)
# ── Aussen ──────────────────────────────────────────────────────────
def _draw_aussen(self, draw, x, y, w, h, main, out, modules):
# Box
draw.rectangle((x, y, x + w - 1, y + h - 1), fill=(255, 255, 255))
draw.rectangle((x, y, x + w - 1, y + STRIP_H), fill=GREEN)
self._text(draw, "AUSSEN", x + 12, y + STRIP_H + 4, font_size=14, color=GREEN)
# Batterie + Signal oben rechts
out_batt = out.get("battery_pct")
if out_batt is not None:
self._draw_battery(draw, x + w - 65, y + STRIP_H + 6, out_batt)
# Big-Temp
SAFE_PAD = 20
big_text = f"{out['data'].get('Temperature', 0):.0f}°"
big_y = y + STRIP_H + LABEL_H
big_h = 130
font_big = self._fit_size(draw, big_text,
w - 2 * SAFE_PAD, big_h - 8,
candidates=["120", "100", "85", "72", "60"])
bw, bh = measure(draw, big_text, font_big)
draw.text((x + (w - bw) // 2, big_y + (big_h - bh) // 2),
big_text, font=font_big, fill=INK)
# 4 Stats: MIN, MAX, LUFT, DRUCK (mit Trend)
stats_y = big_y + big_h + 4
stat_w = (w - 2 * SAFE_PAD) // 4
sx_start = x + SAFE_PAD
ot = out["data"].get("Temperature", 0)
oh = out["data"].get("Humidity", 0)
# Druck kommt von NAMain (Indoor-Modul hat den Druck-Sensor)
pressure = main["data"].get("Pressure") or main["data"].get("AbsolutePressure")
trend = main["data"].get("Pressure_trend", "stable")
stats = [
("MIN", f"{ot - 2:.0f}°", None, INK_MID),
("MAX", f"{ot + 4:.0f}°", None, INK_MID),
("LUFT", f"{oh}%", None, INK),
("DRUCK", f"{int(pressure)}" if pressure else "",
trend if pressure else None, INK),
]
for i, (lbl, val, trd, val_col) in enumerate(stats):
sx = sx_start + i * stat_w
f_l = self._fit_size(draw, lbl, stat_w - 4, 12,
candidates=["10", "9"])
draw.text((sx, stats_y), lbl, font=f_l, fill=INK_MID)
f_v = self._fit_size(draw, val, stat_w - 10, 22,
candidates=["18", "16", "14"])
draw.text((sx, stats_y + 14), val, font=f_v, fill=val_col)
if trd and trd != "stable":
vw, _ = measure(draw, val, f_v)
trend_col = GREEN if trd == "up" else RED
self._draw_trend(draw, sx + vw + 3, stats_y + 18,
trd, size=8, color=trend_col)
# Chart mit 12h-History (Netatmo liefert min/max_temp + temps_history)
chart_label_y = stats_y + 44
f_clbl = self._fit_size(draw, "12h Verlauf",
w - 2 * SAFE_PAD, 14, candidates=["11", "10"])
draw.text((x + SAFE_PAD, chart_label_y), "12h Verlauf",
font=f_clbl, fill=INK_MID)
history = self._get_12h_history(out["data"])
if history:
right_text = f"{min(history):.1f} - {max(history):.1f}"
self._right_text(draw, right_text, x, chart_label_y, w - SAFE_PAD, f_clbl, INK_MID)
self._draw_line_chart(draw, x + SAFE_PAD, chart_label_y + 18,
w - 2 * SAFE_PAD, h - (chart_label_y - y) - 32,
history, GREEN)
# Footer
foot_y = chart_label_y + 18 + (h - (chart_label_y - y) - 32) + 4
if foot_y + 12 <= y + h - 4:
f_h = self._fit_size(draw, "-12h", 40, 10, candidates=["10", "9"])
draw.text((x + SAFE_PAD, foot_y), "-12h", font=f_h, fill=INK_MID)
self._right_text(draw, "jetzt", x, foot_y, w - SAFE_PAD, f_h, INK_MID)
# ── Wind ────────────────────────────────────────────────────────────
def _draw_wind(self, draw, x, y, w, h, wind):
draw.rectangle((x, y, x + w - 1, y + h - 1), fill=(255, 255, 255))
draw.rectangle((x, y, x + w - 1, y + STRIP_H), fill=BLUE)
self._text(draw, "WIND", x + 12, y + STRIP_H + 4, font_size=14, color=BLUE)
if wind.get("battery_pct") is not None:
self._draw_battery(draw, x + 12, y + STRIP_H + 5, wind["battery_pct"], w=14, h=7)
ws = wind["data"].get("WindStrength", 0)
gust = wind["data"].get("GustStrength", 0)
deg = wind["data"].get("WindAngle", 0)
wind_unit = self.cfg("wind_unit", "kmh")
speed_str = f"{ws:.0f}"
speed_y = y + STRIP_H + LABEL_H
f_vw = self._fit_size(draw, speed_str, w - 50, 50,
candidates=["48", "42", "36"])
draw.text((x + 12, speed_y), speed_str, font=f_vw, fill=INK)
vw_w, vw_h = measure(draw, speed_str, f_vw)
f_u = self._fit_size(draw, "km/h" if wind_unit == "kmh" else "m/s",
40, 14, candidates=["12", "11"])
draw.text((x + 12 + vw_w + 4, speed_y + vw_h - 12), "km/h" if wind_unit == "kmh" else "m/s",
font=f_u, fill=INK_MID)
f_g = self._fit_size(draw, f"{gust}", w - 24, 14,
candidates=["12", "11"])
draw.text((x + 12, speed_y + vw_h + 4), f"{gust}", font=f_g, fill=INK_MID)
if self.cfg("show_compass", True):
cx_c = x + w - 28
cy_c = speed_y + 24
self._draw_compass(draw, cx_c, cy_c, 22, deg, BLUE)
# ── Regen ───────────────────────────────────────────────────────────
def _draw_regen(self, draw, x, y, w, h, rain):
draw.rectangle((x, y, x + w - 1, y + h - 1), fill=(255, 255, 255))
draw.rectangle((x, y, x + w - 1, y + STRIP_H), fill=INFO_BL)
self._text(draw, "REGEN", x + 12, y + STRIP_H + 4, font_size=14, color=INFO_BL)
if rain.get("battery_pct") is not None:
self._draw_battery(draw, x + 12, y + STRIP_H + 5, rain["battery_pct"], w=14, h=7)
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
rate_y = y + STRIP_H + LABEL_H
rate_str = f"{rate:.1f}"
f_vr = self._fit_size(draw, rate_str, w - 50, 50,
candidates=["48", "42", "36"])
draw.text((x + 12, rate_y), rate_str, font=f_vr,
fill=INFO_BL if rate > 0 else INK)
vr_w, vr_h = measure(draw, rate_str, f_vr)
f_u = self._fit_size(draw, "mm/h", 40, 14, candidates=["12", "11"])
draw.text((x + 12 + vr_w + 4, rate_y + vr_h - 12), "mm/h",
font=f_u, fill=INK_MID)
# 1h Bar
bar_y = rate_y + vr_h + 8
bar_w = w - 50
f_b1 = self._fit_size(draw, "1h", 16, 12, candidates=["11", "10"])
draw.text((x + 12, bar_y), "1h", font=f_b1, fill=INK_MID)
pct_1h = min(100, h1 / 5 * 100)
draw.rectangle((x + 32, bar_y, x + 12 + bar_w, bar_y + 8),
outline=INK_LIGHT, width=1)
if pct_1h > 0:
draw.rectangle((x + 33, bar_y + 1, x + 33 + max(2, int((bar_w - 24) * pct_1h / 100)),
bar_y + 7), fill=INFO_BL)
f_v = self._fit_size(draw, f"{h1:.1f}", 28, 12, candidates=["11", "10"])
self._right_text(draw, f"{h1:.1f}", x, bar_y + 8, w - 12, f_v, INK)
# 24h Bar
bar2_y = bar_y + 22
draw.text((x + 12, bar2_y), "24h", font=f_b1, fill=INK_MID)
pct_24h = min(100, h24 / 20 * 100)
draw.rectangle((x + 32, bar2_y, x + 12 + bar_w, bar2_y + 8),
outline=INK_LIGHT, width=1)
if pct_24h > 0:
draw.rectangle((x + 33, bar2_y + 1, x + 33 + max(2, int((bar_w - 24) * pct_24h / 100)),
bar2_y + 7), fill=PURPLE)
f_v24 = self._fit_size(draw, f"{h24:.1f}", 28, 12, candidates=["11", "10"])
self._right_text(draw, f"{h24:.1f}", x, bar2_y + 8, w - 12, f_v24, INK)
# ── Indoor ──────────────────────────────────────────────────────────
def _draw_indoor(self, draw, x, y, w, h, indoor):
draw.rectangle((x, y, x + w - 1, y + h - 1), fill=(255, 255, 255))
draw.rectangle((x, y, x + w - 1, y + STRIP_H), fill=RED)
self._text(draw, f"INNEN x{len(indoor)}", x + 12, y + STRIP_H + 4,
font_size=14, color=RED)
content_y = y + STRIP_H + LABEL_H
content_h = h - LABEL_H - STRIP_H - 12
n = max(1, len(indoor))
row_h = content_h // n
INDOOR_PAD = 14
# CO2-Schwellen
thresh = self._co2_thresholds()
for i, sensor in enumerate(indoor):
ry = content_y + i * row_h
if i > 0:
draw.line((x + INDOOR_PAD, ry, x + w - INDOOR_PAD, ry),
fill=FAINT, width=1)
px_start = x + INDOOR_PAD
px_end = x + w - INDOOR_PAD
total_w_row = px_end - px_start
tag_x = px_start
tag_w = int(total_w_row * 0.13)
name_x = tag_x + tag_w + 4
name_w = int(total_w_row * 0.38)
temp_x = name_x + name_w
temp_w = int(total_w_row * 0.28)
co2_x = temp_x + temp_w + 6
co2_w = px_end - co2_x
# Tag
f_tag = self._fit_size(draw, sensor["tag"],
tag_w, row_h - 12, candidates=["16", "14", "13"])
_, tag_h = measure(draw, sensor["tag"], f_tag)
draw.text((tag_x, ry + (row_h - tag_h) // 2 - 2),
sensor["tag"], font=f_tag, fill=RED)
# Name
f_n = self._fit_size(draw, sensor["name"],
name_w, 18, candidates=["14", "13", "12"])
draw.text((name_x, ry + 6), sensor["name"], font=f_n, fill=INK)
# Temp
t = sensor["data"].get("Temperature", 0)
unit = self.cfg("temp_unit", "C")
temp_str = f"{t:.1f}°"
f_tt = self._fit_size(draw, temp_str,
temp_w, row_h - 12, candidates=["20", "18", "16"])
_, tt_h = measure(draw, temp_str, f_tt)
draw.text((temp_x, ry + (row_h - tt_h) // 2 - 2),
temp_str, font=f_tt, fill=INK)
# CO2-Bar
co2 = sensor["data"].get("CO2")
bar_y = ry + 8
bar_h = 7
if co2 is not None and co2_w > 6:
co2_c = self._co2_color(co2, thresh)
pct = min(100, co2 / 2000 * 100)
draw.rectangle((co2_x, bar_y, co2_x + co2_w, bar_y + bar_h),
outline=INK_LIGHT, width=1)
draw.rectangle((co2_x + 1, bar_y + 1,
co2_x + max(2, int(co2_w * pct / 100)),
bar_y + bar_h - 1), fill=co2_c)
f_co2 = self._fit_size(draw, f"{co2}",
co2_w, 14, candidates=["11", "10"])
draw.text((co2_x, bar_y + bar_h + 2),
f"{co2}", font=f_co2, fill=co2_c)
# Batterie
bat_pct = sensor.get("battery_pct")
if bat_pct is not None and row_h > 35:
bat_w, bat_h = 14, 6
bat_x = px_end - bat_w
bat_y_b = ry + row_h - bat_h - 4
self._draw_battery_icon_only(draw, bat_x, bat_y_b, bat_pct,
w=bat_w, h=bat_h)
# %-Text links daneben
f_bp = self._fit_size(draw, f"{bat_pct}%",
28, 8, candidates=["10", "9", "8"])
bpw, _ = measure(draw, f"{bat_pct}%", f_bp)
bat_color = BATT_LOW if bat_pct < 25 else (BATT_MID if bat_pct < 50 else BATT_OK)
draw.text((bat_x - bpw - 2, bat_y_b - 1),
f"{bat_pct}%", font=f_bp, fill=bat_color)
# ── Forecast (Open-Meteo) ──────────────────────────────────────────
def _draw_forecast(self, draw, x, y, w, h, forecast):
draw.rectangle((x, y, x + w - 1, y + h - 1), fill=(255, 255, 255))
draw.rectangle((x, y, x + w - 1, y + STRIP_H), fill=PURPLE)
self._text(draw, "FORECAST", x + 12, y + STRIP_H + 4,
font_size=14, color=PURPLE)
daily = forecast.get("daily", {})
days = daily.get("time", [])[:3]
if not days: return
content_y = y + STRIP_H + LABEL_H
content_h = h - LABEL_H - STRIP_H - 12
row_h = content_h // len(days)
for i, day_str in enumerate(days):
fr = content_y + i * row_h
if i > 0:
draw.line((x + 12, fr, x + w - 12, fr), fill=FAINT, width=1)
# Tag (Wochentag-Kurz)
try:
dt = datetime.fromisoformat(day_str)
day_label = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"][dt.weekday()]
except Exception:
day_label = day_str[:2]
f_d = self._fit_size(draw, day_label,
w - 24, 16, candidates=["13", "12"])
draw.text((x + 12, fr + 4), day_label, font=f_d, fill=INK)
# Min/Max
try:
i_day = days.index(day_str)
tmin = daily["temperature_2m_min"][i_day]
tmax = daily["temperature_2m_max"][i_day]
rain_mm = daily.get("precipitation_sum", [0]*len(days))[i_day] or 0
code = daily.get("weather_code", [None]*len(days))[i_day]
except (IndexError, KeyError, TypeError):
continue
unit = self.cfg("temp_unit", "C")
f_mm = self._fit_size(draw, f"{tmin:.0f}-{tmax:.0f}",
w - 24, 28, candidates=["20", "18", "16"])
draw.text((x + 12, fr + 24),
f"{tmin:.0f}° {tmax:.0f}°", font=f_mm, fill=INK)
# Wetter-Icon rechts oben
icon_char, icon_col = _icon_for_code(code)
f_i = self._fit_size(draw, icon_char,
12, 14, candidates=["13", "12"])
self._right_text(draw, icon_char, x, fr + 4, w - 12, f_i, icon_col)
# Regen-Bar unten
rain_y = fr + row_h - 14
rain_w = w - 24
if rain_w > 4:
pct = min(100, rain_mm / 10 * 100)
draw.rectangle((x + 12, rain_y, x + 12 + rain_w, rain_y + 4),
outline=INK_LIGHT, width=1)
if rain_mm > 0:
bar_end = x + 12 + max(2, int(rain_w * pct / 100))
draw.rectangle((x + 13, rain_y + 1, bar_end, rain_y + 3),
fill=INFO_BL)
f_rv = self._fit_size(draw, f"{rain_mm:.1f}mm",
40, 11, candidates=["10", "9"])
self._right_text(draw, f"{rain_mm:.1f}", x, rain_y - 1,
w - 12, f_rv, INK_MID)
# ── Helpers ────────────────────────────────────────────────────────
_BASE_FONT = None # gecachte Sans-Basis (size=14)
def _sans_base(self):
"""Lazy-load Sans-Basis-Font (size 14)."""
if self._BASE_FONT is None:
from PIL import ImageFont
for p in ["/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"]:
if os.path.exists(p):
try:
self._BASE_FONT = ImageFont.truetype(p, 14)
break
except: pass
if self._BASE_FONT is None:
self._BASE_FONT = ImageFont.load_default()
return self._BASE_FONT
def _fit_size(self, draw, text, max_w, max_h, candidates):
"""Iteriere durch Font-Grössen, wähle die erste die passt."""
from PIL import ImageFont
base = self._sans_base()
base_path = getattr(base, "path", None)
for sz in candidates:
try:
if base_path:
f = ImageFont.truetype(base_path, int(sz))
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(self, draw, text, x, y, font_size=12, color=INK):
from PIL import ImageFont
try:
f = ImageFont.truetype(
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
font_size)
except Exception:
f = ImageFont.load_default()
draw.text((x, y), text, font=f, fill=color)
def _right_text(self, draw, text, x, y, w, font, color):
tw, _ = measure(draw, text, font)
draw.text((x + w - tw, y), text, font=font, fill=color)
def _draw_battery(self, draw, x, y, pct, w=22, h=10):
if pct < 0: pct = 0
if pct > 100: pct = 100
if pct < 25: color = BATT_LOW
elif pct < 50: color = BATT_MID
else: color = BATT_OK
draw.rectangle((x, y, x + w - 3, y + h), outline=INK, width=1)
draw.rectangle((x + w - 2, y + 3, x + w, y + h - 3), fill=INK)
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)
f_pct = self._fit_size(draw, f"{pct}%", 30, h,
candidates=["11", "10", "9"])
tw, _ = measure(draw, f"{pct}%", f_pct)
draw.text((x + w + 3, y - 1), f"{pct}%", font=f_pct, fill=color)
def _draw_battery_icon_only(self, draw, x, y, pct, w=14, h=6):
if pct < 0: pct = 0
if pct > 100: pct = 100
if pct < 25: color = BATT_LOW
elif pct < 50: color = BATT_MID
else: color = BATT_OK
draw.rectangle((x, y, x + w - 2, y + h), outline=INK, width=1)
draw.rectangle((x + w - 2, y + 1, x + w, y + h - 1), fill=INK)
fill_w = max(0, int((w - 3) * pct / 100))
if fill_w > 0:
draw.rectangle((x + 1, y + 1, x + 1 + fill_w, y + h - 1), fill=color)
def _draw_signal(self, draw, x, y, strength=3, size=8):
bw = max(1, size // 5)
for i in range(4):
bar_h = 2 + i * 2
bx = x + i * (bw + 1)
col = INK if i < strength else INK_LIGHT
draw.rectangle((bx, y + size - bar_h, bx + bw, y + size), fill=col)
def _draw_trend(self, draw, x, y, trend, size=8, color=INK):
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:
draw.line((x, cy, x + size, cy), fill=color, width=2)
def _draw_compass(self, draw, cx, cy, r, deg, color):
if r < 4: return
draw.ellipse((cx - r, cy - r, cx + r, cy + r), outline=INK_MID, width=1)
for a in [270, 90, 180, 0]:
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=INK_MID, width=1)
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=3)
draw.ellipse((cx - 3, cy - 3, cx + 3, cy + 3), fill=color)
def _draw_line_chart(self, draw, x, y, w, h, data, color, fill_color=None):
if len(data) < 2 or w <= 10 or h <= 10: return
mn, mx = min(data), max(data)
rng = mx - mn if mx != mn else 1
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 - mn) / rng) * (h - pad_top - pad_bot))
pts.append((px, py))
if fill_color:
poly = pts + [(x + w, y + h), (x, y + h)]
draw.polygon(poly, fill=fill_color)
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 - 2, py - 2, px + 2, py + 2), fill=color)
def _get_12h_history(self, data):
"""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 (36h) oder
synthetisieren wenn nicht verfügbar.
"""
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: aus min/max + aktuellem Wert + lineare Interpolation
cur = data.get("Temperature", 0)
tmin = data.get("min_temp", cur - 3)
tmax = data.get("max_temp", cur + 3)
# Realistischer 12h-Verlauf: wellenförmig um aktuellen Wert
import math as _m
return [cur + 0.5 * _m.sin(i / 2.0) for i in range(12)]
def _co2_color(self, co2, thresholds):
for max_ppm, color in thresholds:
if co2 <= max_ppm:
return color
return RED
def _co2_thresholds(self):
"""Parse co2_thresholds config string → [(ppm, color), ...] sorted."""
spec = self.cfg("co2_thresholds", "ok@600,warn@1000,alert@1500")
result = []
default_ppms = [600, 1000, 1500]
color_map = {"ok": GREEN, "warn": YELLOW, "alert": RED,
"fg": INK, "green": GREEN, "yellow": YELLOW, "red": RED}
for i, p in enumerate([s.strip() for s in spec.split(",") if s.strip()]):
name, _, val = p.partition("@")
try: ppm = float(val)
except ValueError: continue
result.append((ppm, color_map.get(name.lower(), GREEN)))
if not result:
return [(600, GREEN), (1000, YELLOW), (1500, RED)]
return sorted(result, key=lambda x: x[0])
+325 -134
View File
@@ -330,6 +330,38 @@
display: flex; gap: 8px; flex-wrap: wrap; align-items: center;
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 {
display: flex; gap: 8px; align-items: center;
background: var(--surface-2);
@@ -452,6 +484,9 @@
width: 18px; height: 18px;
cursor: nwse-resize;
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 {
content: ''; position: absolute;
@@ -462,9 +497,14 @@
border-color: transparent transparent var(--fg-dim) transparent;
opacity: 0.5;
transition: opacity 0.15s;
pointer-events: none; /* BUG-01: ::before ist nur Deko */
}
.grid-item:hover .grid-item-resize::before { opacity: 1; }
.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 {
font-size: 0.85em; color: var(--fg-muted);
@@ -797,6 +837,21 @@
.toast.info .toast-icon { color: var(--info); }
.toast.warn .toast-icon { color: var(--warn); }
.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 {
from { opacity: 0; transform: translateX(20px); }
to { opacity: 1; transform: translateX(0); }
@@ -1014,7 +1069,12 @@
<button type="submit" class="primary">+ Hinzufügen</button>
</form>
<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 id="gridPreview" class="grid-preview"></div>
<div class="layout-hint">
@@ -1135,12 +1195,25 @@
// ============ Toast-Helper ============
// Aufruf: toast("Item gelöscht", "success" | "error" | "info" | "warn")
// 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 el = document.createElement('div');
el.className = 'toast ' + type;
const iconChar = { success: '✓', error: '✕', info: 'ⓘ', warn: '⚠' }[type] || 'ⓘ';
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;
let removed = false;
const remove = () => {
@@ -1244,8 +1317,50 @@
updateClock();
// ============ 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;
function debouncedSave() {
setSaveState('pending', 'Ungespeichert…');
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(saveLayout, 500);
}
@@ -1304,7 +1419,9 @@
div.className = 'grid-item';
if (it.w > 1 || it.h > 1) div.classList.add('span-2x2');
div.dataset.idx = idx;
div.draggable = true; // legacy HTML5-DnD bleibt für Move; Resize nutzt separaten Handler
// BUG-01: kein HTML5-draggable mehr — eigene Pointer-Events übernehmen
// Move+Resize in startItemPointer / startResizePointer.
div.setAttribute('touch-action', 'none');
// OOB marker
const hasOOB = it.y + it.h > gridRows || it.x + it.w > gridCols || it.y < 0;
@@ -1347,99 +1464,44 @@
}
// ---- 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) {
// ---- Item: drag start ----
// Click-to-select (Resize-Handle + Delete bleiben unberührt)
cont.querySelectorAll('.grid-item').forEach(el => {
el.addEventListener('click', e => {
if (e.target.matches('.grid-item-delete, .grid-item-resize')) return;
selectItem(parseInt(el.dataset.idx));
});
el.addEventListener('dragstart', e => {
const itemIdx = parseInt(el.dataset.idx);
const it = layoutItems[itemIdx];
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 };
});
// BUG-01: Move-Drag startet auf pointerdown, AUSSER wenn das Target
// der Resize-Handle ist (der hat eigenen Handler und stoppt propagation).
el.addEventListener('pointerdown', startItemPointer);
});
// ---- Cell: dragover — show ghost preview ----
cellEls.forEach(cell => {
cell.addEventListener('dragover', e => {
e.preventDefault();
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);
// Resize-Handle: explizit eigene Pointer-Handler, stoppen sofort,
// damit der Item-Handler nicht mitfeuert.
cont.querySelectorAll('.grid-item-resize').forEach(h => {
h.addEventListener('pointerdown', startResizePointer);
});
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 ----
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 ----
// Delete buttons
cont.querySelectorAll('.grid-item-delete').forEach(btn => {
btn.addEventListener('click', async e => {
e.stopPropagation();
@@ -1460,15 +1522,151 @@
});
});
// ---- Resize handles ----
cont.querySelectorAll('.grid-item-resize').forEach(h => {
h.addEventListener('mousedown', startResize);
});
// ---- Keyboard: X to delete selected ----
// Keyboard: X to delete selected
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 ----
function updateGhost(cont, cellEls, x, y, w, h, excludeIdx, fits) {
removeGhost(cont);
@@ -1498,34 +1696,7 @@
}
// ---- Resize ----
function startResize(e) {
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);
}
// BUG-01/02: alte startResize()-Funktion entfernt — siehe startResizePointer().
function onKey(e) {
if (e.key === 'x' || e.key === 'X') {
@@ -1609,39 +1780,59 @@
}
const r = await fetch('/api/layout/add', { method: 'POST', body: fd });
const j = await r.json();
if (!j.ok) { toast('Fehler beim Hinzufügen: ' + (j.error || '?'), 'error', 5000); return; }
if (!j.ok) {
// BUG-04: 409 = kein Platz mehr, schlage Auto-Pack vor
if (r.status === 409 && j.hint === 'use_auto_pack') {
toast(j.error || 'Kein Platz', 'warn', 6000);
} else {
toast('Fehler beim Hinzufügen: ' + (j.error || '?'), 'error', 5000);
}
return;
}
layoutItems = j.items;
renderGrid();
toast('Widget hinzugefügt', 'success', 2000);
}
async function saveLayout() {
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');
}
// BUG-05: alte saveLayout entfernt — siehe oben (mit setSaveState).
async function packAll() {
const ok = await modalConfirm({
icon: 'warn',
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',
});
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 j = await r.json();
if (!j.ok) { toast('Auto-Pack fehlgeschlagen: ' + (j.error || '?'), 'error', 5000); return; }
layoutItems = j.items;
renderGrid();
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 ============
+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);
+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()