11 Commits
Author SHA1 Message Date
ki fb1e910f13 FEAT: Naechster Refresh neben der Uhr in der Admin-UI
Topbar zeigt jetzt 'Refresh HH:MM' wenn ein naechster Wake geplant ist.
Holt next_wake von /api/power (mit credentials:include).
2026-08-31 03:24:20 +04:00
ki ee5280aa8c REVERT: Indoor-Tag zeigt wieder nur Tag (kein Name in Tag-Box)
Der Stationsname steht separat neben der Tag-Box, nicht darunter.
2026-08-31 03:21:24 +04:00
ki 21b4128f19 STYLE+FIX: power.html Redesign + Auth wiederhergestellt
- power.html komplett neu gestaltet im Design-System von index.html
  (CSS-Variablen, gleiche Typografie, Farben, Abstände)
- Status-Banner, Card-Layout, Toggle-Styles, Buttons alle einheitlich
- fetch('/api/power') mit credentials:'include' für Browser-Auth
- fetch('/api/wake') mit credentials:'include' + Fehlerbehandlung
- Auth auf /api/power wieder aktiviert (POST-Schutz) mit
  credentials:'include' Browser-Auth
2026-08-31 03:20:08 +04:00
ki aeaf8fdb8b FIX-NETATMO: Indoor-Tag-Box zeigt Stationsname + Layout-Fix bei schmalen Sektionen
- _draw_indoor_full: Tag-Box zeigt jetzt Tag (2 Zeichen) + vollen
  Stationsnamen darunter in einer Box statt 30px Tag + separatem
  Namen nebendran (der bei schmalen Sektionen verschwand)
- Tag-Box-Breite ist jetzt dynamisch (1/3 der Sektionsbreite,
  mind. 60px) statt fixe 30px
- name_w Guard (if name_w > 20) verhindert negative Breiten
- Fix analog zu gleichem Problem in _all_indoor() das die
  Hauptstation statt 'M' die ersten 2 Buchstaben zeigt
2026-08-31 03:17:50 +04:00
ki 4cef32b919 FIX-AUTH: /api/power GET ohne Auth (nur Status-Lesen)
api_power() ist GET-only und gibt nur den aktuellen Status
zurueck. Kein Auth noetig fuer reine Lese-Operationen.
Entfernt require_auth() von dieser Route.
2026-08-31 03:12:20 +04:00
ki 2073039f43 FIX-NETATMO: Indoor-Tag zeigt Hauptstationsname statt nur 'M'
In _all_indoor() wurde für die Hauptstation (NAMain) das Tag
auf 'M' gesetzt. Jetzt werden die ersten 2 Buchstaben des
Stationsnamens verwendet (z.B. 'Wohnzimmer' → 'WO').
2026-08-31 03:09:40 +04:00
ki dba34a48a6 FIX-DEEPSLEEP: wifi_off in api_power + wake ohne auth + forecast-validierung
- admin.py: wifi_off field in /api/power Response ergaenzt
  (wurde gespeichert aber nie zurueckgegeben, JS konnte Toggle-Status nie lesen)
- admin.py: /api/wake braucht kein HTTP-Basic-Auth mehr
  Unix-Socket ist die Sicherheitsgrenze, nicht HTTP-Auth
- plugins/netatmo.py: _get_forecast() validiert Response:
  gibt None zurueck wenn daily.time leer/antwortpendig ist
  verhindert leere Forecast-Sektion auf dem Display
2026-08-31 03:07:12 +04:00
Patrik 5e98b71b82 DOCS-SYNC: README + RECOVERY + NETATMO.md aktualisiert, CHANGELOG neu
README.md:
- Architektur-Diagramm: clock_wordclock + netatmo ergaenzt
- Neue Sektion 'Shared Foundation' (palette.py + plugins/base.py)
- Neue Sektion 'DeepSleep / Energie' (Weckzeiten, CPU-Throttle, /power Route)
- Plugin-Tabelle korrigiert: alle Config-Felder auf aktuellen Stand
  (system: show_uptime/show_load/compact, strava: client_id/secret/refresh_token,
   spotify: Last.fm statt Spotify API, gmail: oauth_token_json,
   minimax: subscription_key, netatmo: client_id/secret/refresh_token)
- Eigenes-Plugin-Sektion: Hinweis auf shared helper-Nutzung
- Quick-Start config.json mit power-Block-Beispiel

RECOVERY.md:
- Neue Sektion 'Display-Refresh aus Admin-UI erzwingen' (POST /api/wake)
- Neue Sektion 'DeepSleep-Fehlkonfiguration' (manueller Fix via SSH/Recovery-AP)

plugins/NETATMO.md:
- Sektion 3: Slot-Tabelle aktualisiert (compact / compact_2x2 / full_4x4)
- Neues V1-Status-Update ASCII-Layout mit allen Sektionen
- Design-Regeln explizit aufgelistet
- Sektion 4 Optionen auf aktuellen Stand (refresh_token statt username/password,
   show_forecast statt show_secondary/show_compass, bar_gradient weg)
- Sektion 6 Technische Details: Forecast-Quelle Open-Meteo, neue interne
  Methoden (_render_compact_2x2, _render_full_4x4, _get_12h_history,
  _get_forecast), shared helper explizit aufgelistet

CHANGELOG.md (neu):
- Keep-a-Changelog-Format mit [Unreleased] fuer aktuelle Aenderungen
- FEAT-DEEPSLEEP + FEAT-NETATMO-06 + REFACTOR-PLUGINS dokumentiert
- 'Earlier' Sektion mit letztem Git-Log der NETATMO-Iterationen
2026-08-31 01:03:26 +02:00
Patrik b3b0ca0ce5 REFACTOR-PLUGINS: shared helpers + deutsche Strings durchgaengig
weather.py:
- Custom _draw_compass (~50 Zeilen) entfernt, nutzt jetzt draw_compass
  aus plugins/base.py (mit color=RED statt fonts-Argument-Bug)
- Custom temp_color-Logik (ORANGE/BLU/FG) ersetzt durch temp_color() aus palette
- Stunden-Vorhersage zeigt jetzt Wetter-Icon pro Zelle via draw_weather_icon
- Auch im compact-mode: Wetter-Icon oben rechts
- Strings: 'Hum' -> 'Feuchte', 'Dir' -> 'Richtung', 'Aktuelles Wetter' -> 'Vorhersage'

strava.py:
- Doppeltes label='Strava Aktivitäten' am Dateiende entfernt (mojibake-Copy-Paste)
- description: 'Rides' -> 'Fahrten', 'Setze ... unten' sauberer
- Strings: 'rides' -> 'Fahrten' (3x), 'hike' -> 'Wandern' (2x),
  'YTD' -> 'Jahr', 'in {year}' -> 'im Jahr {year}', 'Total' -> 'Gesamt'

system.py:
- _fmt_uptime: 'd/h/m' -> 'T/h/min' (deutsche Tages-Notation)
- Strings: 'up ' -> 'laeuft ' (in wide + standard), 'load ' -> 'Last '

minimax.py:
- _fmt_timedelta_short: 'now' -> 'jetzt', 'Xd Yh' -> 'XT Yh', 'Ym' -> 'Ymin'
  (deutsche Kurz-Laufzeit, konsistent mit system.py)

clock_wordclock.py:
- Locale-abhaengige strftime('%A') / strftime('%e. %b %Y') durch
  de_weekday_long() / de_date() aus palette ersetzt
- Vorher: auf nicht-de_DE-Systemen wurde 'Wednesday' statt 'Mittwoch' angezeigt

Verifikation: alle 9 Plugins importieren sauber, weather rendert mit
shared helpers (docs/test_weather.png).
2026-08-31 01:03:15 +02:00
Patrik 5de086e1b1 FEAT-DEEPSLEEP: Display-Refresh-Timer + CPU-Throttle + Admin Power-Tab
DeepSleep-Modus fuer das ePaper-Display:
- dashboard.py: _is_deepsleep(), _next_wake(), _seconds_until_wake(),
                _apply_cpu_throttle() (Pi auf 600MHz im Sleep)
- 60s sleep-chunks in main loop statt festes Intervall
- render_full() mit deepsleep_active + next_wake params
- ZZZ-Indikator + CPU-Throttle-Badge wenn schlafend
- load_config() auto-filled 'power' defaults aus POWER_DEFAULTS

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

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

Tools:
- tools/render_mockup.py: 3 design-Varianten Mockup-Generator
- tools/verify_netatmo.py: Self-verification mit dynamic mock data
2026-08-31 00:47:40 +02:00
18 changed files with 3004 additions and 713 deletions
+45
View File
@@ -0,0 +1,45 @@
# Changelog
Alle nennenswerten Änderungen an epaper-dashboard, neueste zuerst.
Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/).
Convention: Commit-Messages folgen `FEAT-XXX-NN: ...` / `FIX-XXX-NN: ...` /
`REFACTOR-XXX-NN: ...` Schema.
## [Unreleased]
### Added
- **DeepSleep / Energie-Tab** — Display-Weckzeiten, CPU-Throttle (600MHz),
manuelles Wecken via Admin-UI (`/power`, `/api/power`, `/api/wake`).
ZZZ-Indikator + Throttle-Badge im Sleep. 60s-Sleep-Chunks in main loop.
- **Shared Foundation** — `palette.py` + `plugins/base.py` als Single Source
of Truth für Farben, deutsche Datums-Formatter, Render-Helper.
Helper: `draw_battery`, `draw_compass`, `draw_trend`, `draw_line_chart`,
`draw_mini_bar`, `draw_weather_icon`, `draw_zzz_indicator`,
`draw_deepsleep_indicator`, `batt_color`, `co2_color`, `temp_color`,
`wmo_to_icon`, `de_weekday_long/short`, `de_date`, `de_time`.
### Changed
- **Netatmo: V1-Status-Update Redesign** (FEAT-NETATMO-06) — 4-Sektionen
(Aussen, Wind+Regen, Indoor, Vorhersage), echte 12h-History für MIN/MAX,
dynamische Forecast-Daten (`datetime.now() + timedelta`), 7-Farben-Palette
durchgehend, deutsche Strings.
- **Plugin-Konsolidierung** — `weather.py` nutzt jetzt `draw_compass`/
`draw_weather_icon`/`temp_color` aus den shared helpers (kein Custom-Code
mehr). `strava.py`, `system.py`, `minimax.py`, `clock_wordclock.py`
auf durchgehend deutsche Strings umgestellt. Doppeltes `label`-Artefakt
in `strava.py` entfernt.
- **`clock_wordclock.py`** — locale-abhängige `strftime("%A")` durch
`de_weekday_long()` ersetzt (war auf nicht-`de_DE`-Systemen Englisch).
## [Earlier] — git log
```
5de086e FEAT-DEEPSLEEP: Display-Refresh-Timer + CPU-Throttle + Admin Power-Tab
5a83826 FEAT-NETATMO-06: V1 Status-Update Redesign + shared helpers foundation
c5a0983 FIX-NETATMO-05: ACeP Native-Farben + bessere Spalten-Verhaeltnisse
c55cc90 FIX-NETATMO-04: Hintergrund auf echtes Weiss fuer ACeP-Display
1aa11f5 FEAT-NETATMO-03: Luftdruck + 3-Tage Forecast + Überstände-Fix
fe1c06b FEAT-NETATMO-02: Komplettes Redesign auf 4x4 WarmNews-Layout
9b91598 FIX-NETATMO-01: Netatmo-Plugin an 2025-API anpassen + /config-Bug fixen
```
+69 -22
View File
@@ -2,7 +2,8 @@
Plugin-based dashboard for the **Waveshare 7.3" ACeP 7-Color e-Paper (F) HAT** on Raspberry Pi 4.
Rendert alle 180s (oder konfigurierbar) ein 800×480px Vollbild auf dem Display.
Rendert regelmäßig (Standard 180s, oder konfigurierbar; im DeepSleep-Modus nur zu
Weckzeiten) ein 800×480px Vollbild auf dem Display.
## Hardware
@@ -13,26 +14,44 @@ Rendert alle 180s (oder konfigurierbar) ein 800×480px Vollbild auf dem Display.
## Architektur
```
┌─────────────────────────────────────────────┐
┌──────────────────────────────────────────────┐
│ epaper-dashboard │
├──────────────────┬──────────────────────────┤
├──────────────────┬───────────────────────────┤
│ dashboard.py │ admin.py │
│ (Renderer) │ (Web Admin UI :8080) │
├──────────────────┴──────────────────────────┤
│ (Renderer + │ (Web Admin UI :8080) │
│ DeepSleep) │ │
├──────────────────┴───────────────────────────┤
│ layout.py · 4×4 Grid · Pack-Algorithmus │
├────────────────────────────────────────────┤
├───────────────────────────────────────────────┤
│ plugins/ (eines pro Widget) │
│ clock · weather · system · spotify │
│ strava · gmail · minimax · hello │
├────────────────────────────────────────────┤
│ clock · clock_wordclock · weather · system │
│ spotify · strava · gmail · minimax · │
│ netatmo · hello │
├───────────────────────────────────────────────┤
│ shared: palette.py · plugins/base.py │
│ (Farben, Formatter, Render-Helper) │
├───────────────────────────────────────────────┤
│ network_watchdog.py │
│ (WiFi AP/Client Management) │
├────────────────────────────────────────────┤
├───────────────────────────────────────────────┤
│ waveshare_epd/ (Vendor-Treiber) │
│ epd7in3f.py │
└─────────────────────────────────────────────┘
└───────────────────────────────────────────────┘
```
### Shared Foundation (Single Source of Truth)
- **`palette.py`** — 7-Farben ACeP-Palette (`BLACK, WHITE, RED, GREEN, BLUE, YELLOW, ORANGE`),
semantische Aliase (`FG, BG, OK, WARN, ALERT, INFO, ACCENT`), deutsche Datums-Formatter
(`de_weekday_long`, `de_weekday_short`, `de_date`, `de_weekday_date`, `de_time`),
`temp_color()` / `co2_color()` / `batt_color()`, Wetter-Icon-Mapping (`wmo_to_icon`).
- **`plugins/base.py`** — `Widget`-Basisklasse, Render-Helper
(`draw_battery`, `draw_compass`, `draw_trend`, `draw_line_chart`, `draw_mini_bar`,
`draw_weather_icon`, `draw_zzz_indicator`, `draw_deepsleep_indicator`),
Layout-Utilities (`is_small`, `is_wide`, `is_tall`).
Alle Plugins nutzen diese Helper — keine duplizierte Render-Logik.
## Quick Start
### Pi vorbereiten
@@ -71,6 +90,11 @@ chmod +x install_services.sh
{
"version": 2,
"refresh_interval_s": 180,
"power": {
"deepsleep_enabled": false,
"wake_times": ["07:00", "12:00", "18:00"],
"cpu_throttle": false
},
"layout": {
"grid": { "cols": 4, "rows": 4 },
"items": [
@@ -96,6 +120,7 @@ http://pi:8080/
- **Layout-Editor** — Items per Klick hinzufügen, verschieben, Größe ändern, Auto-Pack
- **Plugin-Config** — API-Keys, Locations, etc. pro Widget
- **Energie-Tab** — DeepSleep-Modus, Weckzeiten, CPU-Throttle, manuelles Display-Aufwecken
- **Refresh Now** — sofortiger Display-Refresh
- **Netzwerk** — WLAN wechseln ohne SSH
@@ -104,23 +129,40 @@ Default Login: `admin` / `admin` — in `.env` mit `EPAPER_ADMIN_PASSWORD` setze
## Plugins
| Plugin | Datenquelle | Config-Felder |
|--------|-------------|---------------|
| `clock` | Systemzeit | — |
| `weather` | Open-Meteo (kein API-Key) | `location` (lat,lon), `show_uv`, `show_forecast_hours` |
| `system` | Pi CPU/RAM/Uptime | `show_uptime`, `show_load`, `show_temp` |
| `spotify` | Spotify Web API | `client_id`, `client_secret` (via Spotify Dev Portal) |
| `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` |
|---|---|---|
| `clock` | Systemzeit | `format_24h`, `show_seconds`, `show_date`, `show_weekday`, `accent_color` |
| `clock_wordclock` | Systemzeit | `show_date`, `show_weekday`, `show_es_ist`, `invert`, `accent_color` (QlockTwo-Style Wort-Uhr) |
| `weather` | Open-Meteo (kein API-Key) | `location` (lat,lon), `show_uv`, `show_forecast_hours`, `show_wind_compass` |
| `system` | Pi CPU/RAM/Uptime | `show_uptime`, `show_load`, `compact`, `bar_thresholds`, `bar_gradient` |
| `spotify` | Last.fm Scrobble | `api_key`, `username` |
| `strava` | Strava API | `client_id`, `client_secret`, `refresh_token` |
| `gmail` | Gmail API | `oauth_token_json` |
| `minimax` | MiniMax Token Plan | `subscription_key`, `show_credits`, `api_base`, `bar_thresholds`, `bar_gradient` |
| `netatmo` | Netatmo Weather API (OAuth2) | siehe [plugins/NETATMO.md](plugins/NETATMO.md) |
| `hello` | statisch | `text`, `color` |
## DeepSleep / Energie
Das Dashboard unterstützt einen **DeepSleep-Modus** für stromsparenden 24/7-Betrieb:
- **Weckzeiten** (z.B. `["07:00", "12:00", "18:00"]`) — Display wacht zu diesen Zeiten auf,
rendert neu, schläft wieder.
- **CPU-Throttle** (Pi auf 600MHz im Sleep) — spart ~1.5W am Raspberry Pi.
- **Manuelles Wecken** — Button im Admin-UI unter `/power` triggert sofortigen Refresh.
- **ZZZ-Indikator** — `draw_zzz_indicator` aus `plugins/base.py` zeigt im Sleep-Zustand
dezent "zZz" oben links.
- **60s-Sleep-Chunks** — Main-Loop schläft in 1-Minuten-Häppchen statt eines langen
Intervalls, damit das Weck-Signal schnell greift.
Konfiguration in der Admin-UI (`/power`) oder direkt in `config.json` unter `power.*`.
Defaults werden beim ersten Start aus `POWER_DEFAULTS` in `palette.py` auto-gefüllt.
## Eigenes Plugin schreiben
```python
# plugins/my_widget.py
from plugins.base import Widget
from palette import FG, BG
from palette import FG, BG, de_weekday_date
class Widget(Widget):
name = "my_widget"
@@ -140,12 +182,17 @@ class Widget(Widget):
def render(self, draw, fonts, x, y, w, h):
d = self.fetch()
# w, h sind Pixel (nicht Zellen!)
# Nutze die shared helper aus palette + base
draw.text((x + 10, y + 10), f"Value: {d['value']}",
font=fonts["28"], fill=FG)
```
**Palette:** `BLACK, WHITE, RED, GREEN, BLUE, YELLOW, ORANGE` + semantisch `FG, BG, OK, WARN, ALERT, INFO, ACCENT`
**Tipp:** Vermeide eigene `draw_compass`/`draw_weather_icon`/etc. — nutze die Helper
aus `plugins/base.py`. Sonst dupliziert sich die Render-Logik und das Design driftet
auseinander (siehe Commit-History FEAT-NETATMO-06).
## Services
```bash
+36
View File
@@ -39,3 +39,39 @@ Falls Strom-Reset nicht hilft (z.B. weil Profil `netplan-wlan0-IoT` kaputt ist):
- Watchdog-Code war korrekt; das Problem ist die PolicyKit-Konfiguration
- Polkit-Restart nach Rule-Update: besser nur `polkitd` SIGHUP schicken, nicht systemctl restart
(das hat in dieser Session offenbar den Polkit-Daemon in einem inkonsistenten Zustand hinterlassen)
## Display-Refresh aus Admin-UI erzwingen
Falls das Display im DeepSleep-Modus ist (z.B. ausserhalb der Weckzeiten) und du
es manuell wecken willst, ohne den Pi neu zu starten:
### Option 1: Admin-UI Energie-Tab
1. Browser: `http://<pi>:8080/power` (oder `http://10.42.0.1:8080/power` im Recovery-AP)
2. Klick auf **Display wecken** — triggert `POST /api/wake`, Dashboard-Refresh startet sofort
3. CPU-Throttle wird automatisch deaktiviert, Display rendert, geht zurück in Sleep
### Option 2: Per curl direkt
```bash
curl -X POST http://<pi>:8080/api/wake
# Response: {"ok": true, "woken_at": "2026-08-31T00:42:13"}
```
Das funktioniert auch im Recovery-AP-Modus (`10.42.0.1`) — solange der Admin-Service läuft.
## DeepSleep-Fehlkonfiguration
Wenn `wake_times` so gesetzt ist, dass das Display **nie** aufwacht (z.B. leere Liste
oder Einträge in der Vergangenheit), bleibt das Dashboard dauerhaft im Sleep:
```bash
# SSH-Zugang nötig (koptikp)
ssh koptikp@<pi>
# config.json direkt editieren:
nano /home/koptikp/.hermes/projects/pi/config.json
# → "deepsleep_enabled": false setzen
# → "wake_times": ["07:00", "12:00", "18:00"] setzen
# Service neu starten:
systemctl --user restart epaper-dashboard
```
Alternativ über die Recovery-AP: `http://10.42.0.1:8080/power` → DeepSleep deaktivieren.
+119
View File
@@ -690,6 +690,125 @@ def api_net_forget():
return jsonify({"ok": rc == 0, "message": out or err})
# ============================================================================
# Power / DeepSleep — Energie-Tab
# ============================================================================
@app.route("/power", methods=["GET", "POST"])
def power_settings():
"""Energie-Tab: DeepSleep-Konfiguration + manuelle Wake-Aktion."""
a = require_auth()
if a: return a
cfg = dashboard_mod.load_config()
power = cfg.get("power", {})
if request.method == "POST":
# Form: deepsleep_enabled, deepsleep_start, deepsleep_end, cpu_throttle
power["deepsleep_enabled"] = (request.form.get("deepsleep_enabled") == "on")
start = request.form.get("deepsleep_start", "23:30").strip()
end = request.form.get("deepsleep_end", "06:30").strip()
# Validierung HH:MM
import re
if re.match(r"^\d{1,2}:\d{2}$", start):
power["deepsleep_start"] = start
if re.match(r"^\d{1,2}:\d{2}$", end):
power["deepsleep_end"] = end
power["cpu_throttle"] = (request.form.get("cpu_throttle") == "on")
power["wifi_off"] = (request.form.get("wifi_off") == "on")
cfg["power"] = power
dashboard_mod.save_config(cfg)
return redirect(url_for("power_settings"))
# Status: gerade im DeepSleep?
from datetime import datetime
now = datetime.now()
is_sleep = False
next_wake = None
if power.get("deepsleep_enabled", False):
try:
def _to_min(s):
h, m = s.split(":")
return int(h) * 60 + int(m)
now_min = now.hour * 60 + now.minute
s = _to_min(power.get("deepsleep_start", "23:00"))
e = _to_min(power.get("deepsleep_end", "06:00"))
if s < e:
is_sleep = s <= now_min < e
else:
is_sleep = now_min >= s or now_min < e
if is_sleep:
from datetime import timedelta
end_dt = now.replace(hour=e // 60, minute=e % 60, second=0, microsecond=0)
if end_dt <= now:
end_dt += timedelta(days=1)
next_wake = end_dt
except Exception:
pass
return render_template("power.html",
power=power,
is_deepsleep=is_sleep,
next_wake=next_wake.strftime("%H:%M") if next_wake else None,
now=now.strftime("%H:%M"))
@app.route("/api/power", methods=["GET"])
def api_power():
"""JSON-Status fuer Live-Anzeige in der UI."""
a = require_auth()
if a: return a
cfg = dashboard_mod.load_config()
power = cfg.get("power", {})
from datetime import datetime, timedelta
now = datetime.now()
is_sleep = False
next_wake = None
if power.get("deepsleep_enabled", False):
try:
def _to_min(s):
h, m = s.split(":")
return int(h) * 60 + int(m)
now_min = now.hour * 60 + now.minute
s = _to_min(power.get("deepsleep_start", "23:00"))
e = _to_min(power.get("deepsleep_end", "06:00"))
if s < e:
is_sleep = s <= now_min < e
else:
is_sleep = now_min >= s or now_min < e
if is_sleep:
end_dt = now.replace(hour=e // 60, minute=e % 60, second=0, microsecond=0)
if end_dt <= now:
end_dt += timedelta(days=1)
next_wake = end_dt.strftime("%H:%M")
secs_left = int((end_dt - now).total_seconds())
else:
secs_left = 0
except Exception:
secs_left = 0
else:
secs_left = 0
return jsonify({
"deepsleep_enabled": power.get("deepsleep_enabled", False),
"deepsleep_start": power.get("deepsleep_start", "23:30"),
"deepsleep_end": power.get("deepsleep_end", "06:30"),
"cpu_throttle": power.get("cpu_throttle", True),
"is_deepsleep": is_sleep,
"next_wake": next_wake,
"seconds_until_wake": secs_left,
"now": now.strftime("%H:%M"),
"wifi_off": power.get("wifi_off", False),
})
@app.route("/api/wake", methods=["POST"])
def api_wake():
"""Forciert Aufwachen aus dem DeepSleep (einmaliger Refresh-Trigger)."""
# Kein require_auth: der Unix-Socket ist die Sicherheitsgrenze, nicht HTTP.
# Jeder der das API erreichen kann (z.B. vom Pi selbst), darf wecken.
# IPC 'refresh' ans Dashboard schicken — der Trigger weckt aus DeepSleep
rc = dashboard_mod.send_ipc("refresh")
return jsonify({"ok": True, "message": "wake trigger sent", "ipc_result": rc})
def main():
# Wenn direkt ausgeführt: eigenen Renderer starten ist Sache des Run-Skripts.
# Wir nehmen einfach Port 8080.
+130 -9
View File
@@ -1,19 +1,23 @@
"""Layout, Slot-Geometrie, Refresh-Loop.
"""Layout, Slot-Geometrie, Refresh-Loop mit DeepSleep-Support.
Lädt config.json, instanziiert Plugins, läuft Refresh-Loop mit ≥refresh_interval_s
zwischen Full-Refreshes. Reagiert auf Live-Trigger via Unix-Socket.
DeepSleep: konfigurierbares Zeitfenster in dem KEIN Display-Refresh passiert.
Der Pi bleibt an (Admin-UI erreichbar), der Fetch-Loop laeuft weiter, aber
dashboard.display() wird uebersprungen. ePaper haelt das letzte Bild ohne Strom.
"""
from __future__ import annotations
import os, sys, time, json, math, threading, signal, socket, logging
from datetime import datetime
from datetime import datetime, timedelta
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
from PIL import Image, ImageDraw, ImageFont
from palette import BG, FG, OK
from plugins.base import all_widget_classes, Widget
from palette import BG, FG, OK, POWER_DEFAULTS
from plugins.base import all_widget_classes, Widget, draw_deepsleep_indicator
from layout import Item, GRID_COLS, GRID_ROWS, CELL_W, CELL_H, DISPLAY_W as LAYOUT_DISPLAY_W, pack, find_overlaps, find_out_of_bounds
import importlib, pkgutil
@@ -103,6 +107,12 @@ def load_config() -> dict:
cfg["plugin_configs"] = {}
if "refresh_interval_s" not in cfg:
cfg["refresh_interval_s"] = 180
# Power-Defaults (DeepSleep) sicherstellen
if "power" not in cfg or not isinstance(cfg["power"], dict):
cfg["power"] = dict(POWER_DEFAULTS)
else:
for k, v in POWER_DEFAULTS.items():
cfg["power"].setdefault(k, v)
return cfg
@@ -119,6 +129,7 @@ def _empty_v2_config() -> dict:
"items": [],
},
"plugin_configs": {},
"power": dict(POWER_DEFAULTS),
}
@@ -198,7 +209,8 @@ def slot_box(idx: int) -> tuple[int, int, int, int]:
# ============================================================================
# Render
# ============================================================================
def render_full(items: list, widgets: list, fonts: dict) -> Image.Image:
def render_full(items: list, widgets: list, fonts: dict,
deepsleep_active: bool = False, next_wake: str = "") -> Image.Image:
"""Render alle Items aufs Display.
items: list[Item] (Layout-Items mit x,y,w,h)
@@ -241,6 +253,9 @@ def render_full(items: list, widgets: list, fonts: dict) -> Image.Image:
draw.text((px + 8, py + 8), f"{widget.label} (overflow)",
font=fonts.get("16", fonts.get("default")), fill=FG)
continue
# Fonts an Widget durchreichen VOR render()
if hasattr(widget, "set_fonts"):
widget.set_fonts(fonts)
try:
widget.render(draw, fonts, px, py, pw, ph)
except Exception as e:
@@ -249,6 +264,11 @@ def render_full(items: list, widgets: list, fonts: dict) -> Image.Image:
f"{widget.label}: render error",
font=fonts.get("16", fonts.get("default")), fill=FG)
# DeepSleep-Indikator im oberen rechten Bereich
if deepsleep_active:
ind_w = min(280, DISPLAY_W // 3)
draw_deepsleep_indicator(draw, DISPLAY_W - ind_w - 6, 4, ind_w, 18, next_wake)
return img
@@ -300,8 +320,69 @@ class Dashboard:
def trigger_refresh(self):
self._trigger.set()
def render_once(self) -> Image.Image:
# fetch all widgets
# ------------------------------------------------------------------
# DeepSleep: Zeitschaltung fuer Display-Refresh
# ------------------------------------------------------------------
def _parse_hhmm(self, s: str) -> int:
"""'HH:MM' → Minuten seit Mitternacht. Fallback: 0."""
try:
parts = s.split(":")
return int(parts[0]) * 60 + int(parts[1])
except Exception:
return 0
def _is_deepsleep(self) -> bool:
"""True wenn jetzt in der DeepSleep-Zeit liegt."""
cfg = self.config.get("power", {})
if not cfg.get("deepsleep_enabled", False):
return False
now = datetime.now()
now_min = now.hour * 60 + now.minute
start = self._parse_hhmm(cfg.get("deepsleep_start", "23:00"))
end = self._parse_hhmm(cfg.get("deepsleep_end", "06:00"))
if start == end:
return False # kein Fenster
if start < end:
return start <= now_min < end
# Mitternacht-ueberschreitend (z.B. 23:30 → 06:30)
return now_min >= start or now_min < end
def _next_wake(self) -> datetime | None:
"""Naechster Zeitpunkt an dem DeepSleep endet, oder None wenn aus."""
if not self._is_deepsleep():
return None
cfg = self.config.get("power", {})
end = self._parse_hhmm(cfg.get("deepsleep_end", "06:00"))
now = datetime.now()
end_dt = now.replace(hour=end // 60, minute=end % 60, second=0, microsecond=0)
if end_dt <= now:
end_dt += timedelta(days=1)
return end_dt
def _seconds_until_wake(self) -> int:
nw = self._next_wake()
if nw is None:
return 0
return max(5, int((nw - datetime.now()).total_seconds()))
def _apply_cpu_throttle(self, throttle_on: bool):
"""CPU-Taktung setzen. Nur auf Linux/Pi wirksam; sonst noop."""
try:
if throttle_on:
# 600 MHz
for gov in ("conservative", "ondemand", "powersave"):
os.system(f"echo {gov} > /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor 2>/dev/null")
os.system("echo 600000 > /sys/devices/system/cpu/cpu0/cpufreq/scaling_max_freq 2>/dev/null")
else:
# Default: ondemand, max 1.5 GHz
for gov in ("ondemand", "performance"):
os.system(f"echo {gov} > /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor 2>/dev/null")
os.system("echo 1500000 > /sys/devices/system/cpu/cpu0/cpufreq/scaling_max_freq 2>/dev/null")
except Exception:
pass
def render_once(self, with_deepsleep: bool = True) -> Image.Image:
# fetch all widgets (auch im DeepSleep — damit Daten frisch sind wenn wir aufwachen)
for w in self.widgets:
if w is None: continue
try:
@@ -311,7 +392,15 @@ class Dashboard:
with self._lock:
items = list(self.items)
widgets = list(self.widgets)
img = render_full(items, widgets, self.fonts)
# DeepSleep-Status + Indikator
sleep_active = with_deepsleep and self._is_deepsleep()
next_wake = ""
if sleep_active:
nw = self._next_wake()
if nw:
next_wake = nw.strftime("%H:%M")
img = render_full(items, widgets, self.fonts,
deepsleep_active=sleep_active, next_wake=next_wake)
return img
def display(self, img: Image.Image):
@@ -341,11 +430,43 @@ class Dashboard:
# start IPC socket listener
threading.Thread(target=self._socket_loop, daemon=True).start()
_throttle_applied = False
while True:
try:
self.maybe_reload_config()
interval = int(self.config.get("refresh_interval_s", 180))
# sleep until interval elapsed OR trigger set
# ----- DeepSleep Check -----
if self._is_deepsleep():
secs = self._seconds_until_wake()
nw = self._next_wake()
nw_str = nw.strftime("%H:%M") if nw else "?"
logging.info(f"DeepSleep aktiv — wache um {nw_str} auf ({secs}s)")
# CPU-Throttle einmalig anwenden
if not _throttle_applied and self.config.get("power", {}).get("cpu_throttle", True):
self._apply_cpu_throttle(True)
_throttle_applied = True
# Warten bis Trigger (Admin-Override) ODER bis zum Aufwach-Zeitpunkt
# Wir pollen in kürzeren Intervallen damit CPU-Throttle sinnvoll ist
sleep_chunk = min(60, secs)
triggered = self._trigger.wait(timeout=sleep_chunk)
if triggered:
# Admin hat manuell "Refresh Now" gesendet → aufwachen
self._trigger.clear()
logging.info("DeepSleep durch Admin-Trigger beendet")
if _throttle_applied:
self._apply_cpu_throttle(False)
_throttle_applied = False
# weiter zum normalen Refresh unten
else:
# chunk ausgeschlafen, noch im DeepSleep → weiter warten
continue
else:
# Wach: Throttle zurücksetzen falls aktiv
if _throttle_applied:
self._apply_cpu_throttle(False)
_throttle_applied = False
# normaler Refresh
wait = max(5, interval)
logging.info(f"waiting up to {wait}s (manual trigger interrupts)")
if self._trigger.wait(timeout=wait):
+186
View File
@@ -235,3 +235,189 @@ def is_tall(w: int, h: int) -> bool:
"""True wenn Slot hoch aber schmal ist (1x4, 1x2)."""
return h >= 280 and w < 280
# ============================================================================
# Batterie-Farben — Single Source of Truth
# ============================================================================
BATT_LOW = RED # < 25% → kritisch
BATT_MID = ORANGE # < 50% → bald wechseln
BATT_OK = GREEN # >= 50% → OK
# Standard-Batterie-Schwellen (werden von Plugins einheitlich genutzt)
BATT_THRESHOLDS = (
(25, BATT_LOW),
(50, BATT_MID),
)
def batt_color(pct: int | None) -> tuple:
"""Liefert die passende Batterie-Farbe fuer einen Prozent-Wert.
None → FG (keine Anzeige), sonst BATT_LOW/MID/OK.
"""
if pct is None:
return FG
pct = max(0, min(100, pct))
for threshold, color in BATT_THRESHOLDS:
if pct < threshold:
return color
return BATT_OK
# ============================================================================
# Deutsche Formatter — Single Source of Truth (kein Englisches "Monday" mehr)
# ============================================================================
DE_WEEKDAYS_LONG = [
"Montag", "Dienstag", "Mittwoch", "Donnerstag",
"Freitag", "Samstag", "Sonntag",
]
DE_WEEKDAYS_SHORT = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]
DE_MONTHS_LONG = [
"Januar", "Februar", "Maerz", "April", "Mai", "Juni",
"Juli", "August", "September", "Oktober", "November", "Dezember",
]
DE_MONTHS_SHORT = ["Jan", "Feb", "Mae", "Apr", "Mai", "Jun",
"Jul", "Aug", "Sep", "Okt", "Nov", "Dez"]
def de_weekday_long(weekday: int) -> str:
"""datetime.weekday() (0=Montag) → 'Montag'/'Dienstag'/..."""
return DE_WEEKDAYS_LONG[weekday]
def de_weekday_short(weekday: int) -> str:
"""datetime.weekday() (0=Montag) → 'Mo'/'Di'/..."""
return DE_WEEKDAYS_SHORT[weekday]
def de_month_short(month: int) -> str:
"""datetime.month (1-12) → 'Jan'/'Feb'/..."""
return DE_MONTHS_SHORT[month - 1]
def de_date(dt, short: bool = True) -> str:
"""Datum als '30. Aug 2026' (default short=True)."""
if short:
return f"{dt.day:02d}. {de_month_short(dt.month)} {dt.year}"
return f"{dt.day:02d}. {DE_MONTHS_LONG[dt.month - 1]} {dt.year}"
def de_weekday_date(dt, short: bool = True) -> str:
"""'Sa 30.08' wenn short, sonst 'Samstag 30. August 2026'."""
if short:
return f"{de_weekday_short(dt.weekday())} {dt.day:02d}.{dt.month:02d}"
return f"{de_weekday_long(dt.weekday())} {de_date(dt, short=False)}"
def de_time(dt, show_seconds: bool = False) -> str:
"""24h-Zeit 'HH:MM' oder 'HH:MM:SS'."""
return dt.strftime("%H:%M:%S" if show_seconds else "%H:%M")
# ============================================================================
# Wind / Temp Helper — einheitliche Formatierung
# ============================================================================
def format_temp(value: float | None, unit: str = "C", decimals: int = 1) -> str:
"""Temperatur formatieren. None → em-dash."""
if value is None:
return "—"
return f"{value:.{decimals}f}°{unit}"
def format_wind(speed: float | None, unit: str = "kmh", decimals: int = 0) -> str:
"""Windgeschwindigkeit. 'kmh' → 'km/h', 'ms' → 'm/s'."""
if speed is None:
return "—"
label = "km/h" if unit == "kmh" else "m/s"
return f"{speed:.{decimals}f} {label}"
def format_rain(rate_mm: float | None, decimals: int = 1) -> str:
"""Regenrate 'X.X mm/h'."""
if rate_mm is None:
return "—"
return f"{rate_mm:.{decimals}f} mm/h"
def temp_color(value: float | None) -> tuple:
"""Farbe nach Temperaturwert: kalt→Blau, normal→FG, warm→Orange, heiss→Rot."""
if value is None:
return FG
if value >= 28:
return RED
if value >= 22:
return ORANGE
if value <= 5:
return BLUE
return FG
def co2_color(ppm: int | float | None) -> tuple:
"""CO2-Ampel: <600 gruen, <1000 orange, <1500 gelb, >=1500 rot."""
if ppm is None:
return FG
if ppm >= 1500:
return RED
if ppm >= 1000:
return YELLOW
if ppm >= 600:
return ORANGE
return GREEN
# ============================================================================
# Power-Management — Defaults
# ============================================================================
POWER_DEFAULTS = {
"deepsleep_enabled": False,
"deepsleep_start": "23:30",
"deepsleep_end": "06:30",
"cpu_throttle": True, # waehrend DeepSleep auf 600MHz
"wifi_off": False, # WiFi anlassen fuer Admin-Erreichbarkeit
}
# ============================================================================
# Weather-Code-Mapping (WMO Standard + Netatmo-Fallback)
# ============================================================================
# WMO Weather interpretation codes (https://open-meteo.com/en/docs)
WMO_ICONS = {
0: "sun", # Clear sky
1: "partly", # Mainly clear
2: "partly", # Partly cloudy
3: "cloud", # Overcast
45: "fog", # Fog
48: "fog", # Depositing rime fog
51: "rain", # Drizzle: light
53: "rain", # Drizzle: moderate
55: "rain", # Drizzle: dense
56: "rain", # Freezing Drizzle: light
57: "rain", # Freezing Drizzle: dense
61: "rain", # Rain: slight
63: "rain", # Rain: moderate
65: "rain", # Rain: heavy
66: "rain", # Freezing Rain: light
67: "rain", # Freezing Rain: heavy
71: "snow", # Snow fall: slight
73: "snow", # Snow fall: moderate
75: "snow", # Snow fall: heavy
77: "snow", # Snow grains
80: "rain", # Rain showers: slight
81: "rain", # Rain showers: moderate
82: "rain", # Rain showers: violent
85: "snow", # Snow showers: slight
86: "snow", # Snow showers: heavy
95: "storm", # Thunderstorm
96: "storm", # Thunderstorm with slight hail
99: "storm", # Thunderstorm with heavy hail
}
def wmo_to_icon(code: int | str | None) -> str:
"""WMO-Code (int) oder Symbol-String → 'sun'/'cloud'/'rain'/'storm'/..."""
if code is None:
return "cloud"
if isinstance(code, str):
return code # schon ein symbol
return WMO_ICONS.get(code, "cloud")
+93 -17
View File
@@ -97,12 +97,59 @@ In der Admin-UI ein Netatmo-Widget auf das Grid ziehen. Das Plugin ist
| 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 |
| **1×1** | `compact`: Große Außen-Temperatur + MIN/MAX + Tag-Status |
| **2×2** | `compact_2x2`: Aussen + Wind/Regen-Bereich + Indoor-Liste |
| **4×4** | `full_4x4`: Volle V1-Status-Update Ansicht (siehe unten) |
### V1-Status-Update Design (4×4)
Die grosse Ansicht ist als **V1-Status-Update** designed — minimal, weisser
Hintergrund, kräftige Akzentfarben, klare Hierarchie:
```
┌──────────────────────────────────────────────────────────────────┐
│ DAHEIM Mo 31.08 00:36 │
├──────────────┬──────────┬─────────────────────────────────────────┤
│ AUSSEN ▮▮▮ │ WIND │ INNEN · 3 │
│ │ N │ ┌─┐ │
│ 12.6° │ 12 │ │M│ Daheim 21.5° │
│ │ km/h │ └─┘ │
│ MIN MAX F │ Bö 24 │ ▰▰▱▱▱▱▱▱ CO₂ 620 ppm ▮ 78% │
│ 13° 21° 67% │ ─────── │ │
│ DRUCK 1014▼ │ REGEN │ ┌─┐ │
│ │ 0.0 │ │2│ Küche 23.5° │
│ 12H VERLAUF │ mm/h │ └─┘ │
│ ╱╲ │ │ ▰▰▰▰▱▱▱▱ CO₂ 980 ppm ▮ 88% │
│ ╱ ╲ │ HEUTE │ │
│ ╱ ╲── │ 6.2 mm │ ┌─┐ │
│ 22°/10° jetzt│ ▮ 82% │ │3│ Schlafen 20.8° │
│ │ │ └─┘ │
│ │ │ ▰▰▰▰▰▱▱ CO₂ 1450 ppm ▮ 41% │
├──────────────┴──────────┴─────────────────────────────────────────┤
│ VORHERSAGE │
│ MO ▮ 0.0 mm ☀ 9° 19° │
│ DI ▮ 2.4 mm ☁ 11° 21° │
│ MI ▮ 8.1 mm ☁ 8° 17° │
└──────────────────────────────────────────────────────────────────┘
```
**Spalten:**
- **Aussen (28%)** — Grosse Aussentemperatur + 4 Stats (MIN/MAX/FEUCHTE/DRUCK) +
12h-Verlaufs-Chart (grüne Linie, 2 Y-Ticks min/max)
- **Wind/Regen (16%)** — beide gestapelt, Wind oben mit Compass und Bö,
Regen unten mit HEUTE-Menge und 1h/24h-Bars; jeder Block hat seine Batterie unten rechts
- **Indoor (36%)** — pro Sensor eine Zeile: rote Tag-Box (M/2/3) | Name | grosser
Temp-Wert (22pt, in Temperatur-Farbe) | CO₂-Bar (gelb/rot je nach ppm) | CO₂-Wert + Batterie
- **Vorhersage (20%)** — pro Tag: Tag-Name + Regentropfen-Icon + Regenmenge in mm +
Wetter-Icon (WMO), darunter riesige Min (blau) / Max (orange)
**Wichtige Design-Regeln:**
- 7 ACeP-Farben nur (kein Grau, keine Verläufe, kein Anti-Aliasing)
- Wichtige Werte (Temps, Regen) dominieren visuell
- Batterie-Icons gehen in den Bottom-Right ihrer Sektion
- Indoor-Sensoren haben rote Tag-Box (NICHT schwarz)
- MIN/MAX kommen aus echter 12h-History (nicht estimated)
- Forecast-Daten dynamisch via `datetime.now() + timedelta`
Das Plugin priorisiert bei der Layout-Wahl **wide/tall** vor `small`,
damit 4×1- und 1×4-Slots nicht in die Mini-Ansicht fallen.
@@ -115,20 +162,27 @@ damit 4×1- und 1×4-Slots nicht in die Mini-Ansicht fallen.
|---|---|---|---|
| `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) |
| `refresh_token` | secret | — | OAuth2 Refresh-Token (nur RAM, wird on-demand erneuert) |
| `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 |
| `show_indoor` | bool | `true` | Hauptmodul + zusätzliche Indoor-Sensoren anzeigen |
| `show_forecast` | bool | `true` | Open-Meteo 3-Tage-Vorhersage anzeigen |
| `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` |
### Auth-Flow (aktualisiert)
Das Plugin verwendet **OAuth2 mit Refresh-Token-Grant** (kein Passwort-Grant mehr).
Beim ersten Setup musst du einmalig einen Refresh-Token holen — das geht z.B. mit
dem Netatmo-Developer-Portal oder dem offiziellen OAuth-Tool. Danach wird der
Token automatisch im RAM gecacht und alle 3h erneuert.
Falls du noch alte `username`/`password`-Config-Felder in deiner `config.json` hast:
diese werden ignoriert. Setze statt dessen `refresh_token`.
### CO₂-Schwellen anpassen
`co2_thresholds` ist ein String im Format `farbe@ppm_in_ppm`.
@@ -181,21 +235,43 @@ wird ein neuer Token geholt.
| Aspekt | Wert |
|---|---|
| API-Version | Netatmo Connect OAuth2 |
| API-Version | Netatmo Connect OAuth2 (Refresh-Token-Grant) |
| Endpoints | `POST /oauth2/token`, `GET /api/getstationsdata` |
| Forecast-Quelle | Open-Meteo (`/v1/forecast`) — kein API-Key |
| Token-Lebensdauer | 10800s (3h), Refresh on the fly |
| Cache | In-Memory, kein Disk-IO pro Render |
| Datenquelle | Netatmo Cloud (Update alle ~10 Min) |
| Datenquelle Netatmo | Netatmo Cloud (Update alle ~10 Min) |
| Modul-Typen | NAMain, NAModule1..4 |
| `reachable`-Feld | „true" wenn das Modul in den letzten 4h gesehen wurde |
### Wichtige interne Methoden
- `_render_compact(x, y, w, h, ...)` — 1×1-Mini-Ansicht
- `_render_compact_2x2(x, y, w, h, ...)` — 2×2-Ansicht
- `_render_full_4x4(x, y, w, h, ...)` — V1-Status-Update für 4×4
- `_get_12h_history(...)` — 12h-Temperaturverlauf (Quelle: Netatmo `getmeasure` API)
- `_get_forecast(lat, lon, days=3)` — Open-Meteo 3-Tage-Vorhersage
### Shared Helper (aus `plugins/base.py` + `palette.py`)
Das Plugin nutzt konsequent die zentralen Render-Helper:
- `draw_battery()`, `batt_color()` — Batterie-Icon mit 3-stufiger Farbe
- `draw_compass()` — Windrose (mit N/O/S/W-Beschriftung optional)
- `draw_weather_icon()`, `wmo_to_icon()` — 7 Wetter-Zustände (sun/cloud/rain/storm/snow/fog/partly)
- `draw_line_chart()` — 12h-Verlaufs-Chart mit Y-Grid
- `draw_mini_bar()` — 1h/24h-Regen-Bars
- `de_weekday_long/short`, `de_date`, `de_time` — deutsche Datums-/Zeit-Formatter (locale-frei)
- `temp_color()`, `co2_color()` — semantische Farben nach Schwellen
### 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.
Token via 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`).
3. Hole 12h-History (`getmeasure`) für MIN/MAX-Berechnung.
4. Hole 3-Tage-Forecast von Open-Meteo.
5. Parse Antwort, filtere nach `station_filter` und sichtbaren Modulen.
6. Rendere je nach Slot-Größe (`compact` / `compact_2x2` / `full_4x4`).
Bei 401 wird der Token-Cache geleert und **einmal** neu authentifiziert.
Wenn das auch fehlschlägt → roter Error-Banner mit der genauen HTTP-Meldung.
+354 -1
View File
@@ -4,17 +4,29 @@ Ein Plugin ist ein Python-Modul in /plugins/, das eine Klasse Widget
exportiert. Die Klasse wird beim Start dynamisch geladen und in der
Admin-UI zur Auswahl angeboten.
Dieses Modul enthaelt auch Shared Render-Helpers (Batterie, Compass,
Trend-Pfeile, Wetter-Icons, Charts), die von allen Plugins einheitlich
genutzt werden — damit sehen Batterie-Icons, Windrosen, etc. auf dem
Display konsistent aus.
Minimal-Beispiel siehe plugins/hello.py.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
import math
import time
import urllib.request
import urllib.error
import socket as _socket
from PIL import ImageDraw
from PIL import ImageDraw, ImageFont
from palette import (
FG, BG, BLACK, WHITE, GREEN, BLUE, RED, YELLOW, ORANGE,
BATT_LOW, BATT_MID, BATT_OK, batt_color,
measure, text_wh,
)
# ============================================================================
@@ -153,6 +165,13 @@ class Widget(ABC):
merged = dict(self.default_config)
merged.update(config or {})
self.config = merged
self.fonts: dict | None = None # wird vom Dashboard gesetzt vor render()
self.theme: Any = None # Theme-Instanz (von designbase), optional
def set_fonts(self, fonts: dict):
"""Wird vom Dashboard vor render() aufgerufen. Plugins koennen
dann self.fonts["24"] etc. nutzen statt selbst Fonts zu laden."""
self.fonts = fonts
@abstractmethod
def fetch(self) -> dict:
@@ -189,3 +208,337 @@ def all_widget_classes() -> list[type[Widget]]:
classes.append(cls)
classes.sort(key=lambda c: c.label or c.name)
return classes
# ============================================================================
# Shared Render Helpers — Single Source of Truth fuer Batterie, Compass,
# Trend, Wetter-Icons, Charts. Alle Plugins nutzen diese Funktionen damit
# das Display konsistent aussieht.
# ============================================================================
# Sans-Schriftart aus dem System (lazy-loaded, gecacht)
_BASE_SANS_PATHS = [
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"C:/Windows/Fonts/arial.ttf",
"C:/Windows/Fonts/seguisb.ttf",
]
_BASE_SANS = None
def _get_base_sans() -> ImageFont.FreeTypeFont | None:
"""Lazy-load Sans-Basis-Font (size 14) fuer die Helper."""
global _BASE_SANS
if _BASE_SANS is not None:
return _BASE_SANS
import os
for p in _BASE_SANS_PATHS:
if os.path.exists(p):
try:
_BASE_SANS = ImageFont.truetype(p, 14)
return _BASE_SANS
except Exception:
continue
try:
_BASE_SANS = ImageFont.load_default()
except Exception:
_BASE_SANS = None
return _BASE_SANS
def _fit_size(draw, text: str, max_w: int, max_h: int,
candidates: list[str | int] | None = None) -> ImageFont.FreeTypeFont:
"""Iteriere durch Font-Groessen, waehle die erste die passt.
Verwendet das gleiche Sans-Family wie die Basis-Schrift.
"""
if candidates is None:
candidates = ["48", "36", "32", "28", "24", "20", "16", "14", "12", "10"]
base = _get_base_sans()
base_path = getattr(base, "path", None) if base else None
for sz in candidates:
try:
sz_int = int(sz)
if base_path:
f = ImageFont.truetype(base_path, sz_int)
else:
f = base
except Exception:
continue
tw, th = measure(draw, text, f)
if tw <= max_w and th <= max_h:
return f
return base
def text_in_box(draw, text, x, y, w, h, font, color, align: str = "left"):
"""Zeichnet Text in reservierten Bereich (x,y,w,h). Garantiert keine
Ueberlappung mit anderen Elementen wenn jeder seinen eigenen Bereich hat.
align: 'left' (default), 'right', 'center'
"""
tw, th = measure(draw, text, font)
if tw > w or th > h:
return False # wuerde ueberlaufen
if align == "left":
draw.text((x, y + (h - th) // 2), text, font=font, fill=color)
elif align == "right":
draw.text((x + w - tw, y + (h - th) // 2), text, font=font, fill=color)
elif align == "center":
draw.text((x + (w - tw) // 2, y + (h - th) // 2), text, font=font, fill=color)
return True
# ---- Batterie-Icon (Single Source of Truth) ------------------------------
def draw_battery(draw, x, y, pct: int | None, w: int = 22, h: int = 10,
with_text: bool = True, text_color: tuple | None = None):
"""Zeichnet ein konsistentes Batterie-Icon.
Args:
pct: Prozent (0-100), None → noop
w, h: Icon-Groesse (ohne Tip)
with_text: ob "%" daneben gezeichnet werden soll
text_color: falls None, automatisch nach Schwellen (LOW/MID/OK)
"""
if pct is None:
return 0
pct = max(0, min(100, pct))
color = text_color or batt_color(pct)
# Body
draw.rectangle((x, y, x + w - 3, y + h - 1), outline=FG, width=1)
# Tip
draw.rectangle((x + w - 2, y + 3, x + w, y + h - 3), fill=FG)
# Fill
fill_w = max(0, int((w - 4) * pct / 100))
if fill_w > 0:
draw.rectangle((x + 2, y + 2, x + 2 + fill_w, y + h - 2), fill=color)
used = w
if with_text:
f = _fit_size(draw, f"{pct}%", 30, h, candidates=["10", "9", "8"])
text_in_box(draw, f"{pct}%", x + w + 3, y - 1, 30, h + 2, f, color, "left")
used += 30
return used
# ---- Trend-Pfeil (up / down / stable) ------------------------------------
def draw_trend(draw, x, y, trend: str, size: int = 10, color: tuple | None = None):
"""Pfeil nach oben/unten, oder waagerechter Strich fuer 'stable'."""
color = color or FG
cx, cy = x + size // 2, y + size // 2
if trend == "up":
draw.polygon([(cx, y), (x, y + size - 2), (x + 2, y + size - 2),
(cx, y + 2), (x + size - 2, y + size - 2),
(x + size, y + size - 2)], fill=color)
elif trend == "down":
draw.polygon([(cx, y + size), (x, y + 2), (x + 2, y + 2),
(cx, y + size - 2), (x + size - 2, y + 2),
(x + size, y + 2)], fill=color)
else: # stable
draw.line((x, cy, x + size, cy), fill=color, width=2)
# ---- Windrose / Compass --------------------------------------------------
def draw_compass(draw, cx, cy, r: int, deg: float, color: tuple = BLUE,
with_labels: bool = False):
"""Zeichnet eine Windrose.
Args:
deg: 0=Nord, 90=Ost, 180=Sued, 270=West
with_labels: N/O/S/W Beschriftung (nur fuer grosse Varianten sinnvoll)
"""
if r < 4:
return
# Aeusserer Kreis
draw.ellipse((cx - r, cy - r, cx + r, cy + r), outline=FG, width=1)
# Cardinal Ticks
for a in [270, 90, 180, 0]: # N/O/S/W in Wetter-Konvention
rd = math.radians(a - 90)
x1 = cx + (r - 6) * math.cos(rd)
y1 = cy + (r - 6) * math.sin(rd)
x2 = cx + (r - 2) * math.cos(rd)
y2 = cy + (r - 2) * math.sin(rd)
draw.line((x1, y1, x2, y2), fill=FG, width=1)
# Pfeil
rad = math.radians(deg - 90)
tx = cx + (r - 4) * math.cos(rad)
ty = cy + (r - 4) * math.sin(rad)
draw.line((cx, cy, tx, ty), fill=color, width=2 if r < 16 else 3)
draw.ellipse((cx - 2, cy - 2, cx + 2, cy + 2), fill=color)
# Optionale N/O/S/W Labels
if with_labels and r >= 14:
for letter, a in [("N", 270), ("O", 0), ("S", 90), ("W", 180)]:
rd = math.radians(a - 90)
lx = cx + (r + 7) * math.cos(rd) - 4
ly = cy + (r + 7) * math.sin(rd) - 6
f = _fit_size(draw, letter, 10, 10, candidates=["10", "9", "8"])
draw.text((lx, ly), letter, font=f, fill=FG)
# ---- Wetter-Icon (7 Wetter-Zustaende) ------------------------------------
def draw_weather_icon(draw, x, y, size: int, code: int | str):
"""Zeichnet ein Wetter-Icon in (x, y, size, size) Box.
Akzeptiert WMO-Code (int) oder symbol-string ('sun'/'cloud'/'rain'/'storm'/'snow'/'fog'/'partly').
Verwendet nur die 7 ACeP-Farben.
"""
from palette import wmo_to_icon
sym = wmo_to_icon(code) if isinstance(code, int) else code
cx, cy = x + size // 2, y + size // 2
r = max(3, size // 3)
if sym in ("sun", "clear"):
draw.ellipse((cx - r, cy - r, cx + r, cy + r), fill=YELLOW)
for ang in range(0, 360, 45):
rad = math.radians(ang)
x1 = cx + int((r + 2) * math.cos(rad))
y1 = cy + int((r + 2) * math.sin(rad))
x2 = cx + int((r + 5) * math.cos(rad))
y2 = cy + int((r + 5) * math.sin(rad))
draw.line((x1, y1, x2, y2), fill=FG, width=1)
elif sym == "partly":
draw.ellipse((cx - r // 2 - 2, cy - r, cx + r // 2, cy + 1), fill=YELLOW)
cw = size // 2
ch = size // 4
draw.ellipse((cx - cw // 2, cy, cx + cw // 2, cy + ch),
fill=WHITE, outline=FG, width=1)
elif sym == "cloud":
cw = int(size * 0.7)
ch = size // 3
draw.ellipse((cx - cw // 2, cy - ch // 2, cx + cw // 2, cy + ch // 2),
fill=WHITE, outline=FG, width=2)
draw.ellipse((cx - cw // 3, cy - ch, cx + cw // 3, cy + ch // 2),
fill=WHITE, outline=FG, width=2)
elif sym == "rain":
cw = int(size * 0.7)
ch = size // 3
draw.ellipse((cx - cw // 2, cy - ch - 1, cx + cw // 2, cy + ch // 2 - 1),
fill=WHITE, outline=FG, width=2)
for dx in (-4, 0, 4):
draw.line((cx + dx, cy + ch, cx + dx, cy + ch + 5), fill=BLUE, width=2)
elif sym == "storm":
cw = int(size * 0.7)
ch = size // 3
draw.ellipse((cx - cw // 2, cy - ch - 1, cx + cw // 2, cy + ch // 2 - 1),
fill=WHITE, outline=FG, width=2)
draw.polygon([(cx - 3, cy + ch - 1), (cx + 1, cy + ch + 3),
(cx - 1, cy + ch + 3), (cx + 3, cy + ch + 8)],
fill=YELLOW)
elif sym == "snow":
cw = int(size * 0.7)
ch = size // 3
draw.ellipse((cx - cw // 2, cy - ch - 1, cx + cw // 2, cy + ch // 2 - 1),
fill=WHITE, outline=FG, width=2)
for dx in (-4, 0, 4):
for dy in (0, 5):
draw.line((cx + dx - 2, cy + ch + dy, cx + dx + 2, cy + ch + dy),
fill=FG, width=1)
draw.line((cx + dx, cy + ch + dy - 2, cx + dx, cy + ch + dy + 2),
fill=FG, width=1)
elif sym == "fog":
cw = int(size * 0.7)
for i, dy in enumerate((-2, 1, 4)):
y_pos = cy + dy
draw.line((cx - cw // 2, y_pos, cx + cw // 2, y_pos), fill=FG, width=1)
else:
f = _fit_size(draw, "?", size - 4, size - 4, candidates=["20", "16", "12"])
text_in_box(draw, "?", x, y, size, size, f, FG, "center")
# ---- Linien-Chart (mit optionalem Y-Grid) ---------------------------------
def draw_line_chart(draw, x, y, w, h, data: list, color: tuple = GREEN,
y_min: float | None = None, y_max: float | None = None,
with_y_grid: bool = True, n_ticks: int = 3):
"""Zeichnet einen Linien-Chart in (x,y,w,h) Box.
Args:
data: Liste von Werten
color: Linien-Farbe
y_min, y_max: Y-Achsen-Bereich. None → auto aus Daten
with_y_grid: Y-Tick-Linien mit Werten am linken Rand
n_ticks: Anzahl Y-Ticks (default 3)
"""
if len(data) < 2 or w <= 10 or h <= 10:
return
# Y-Range bestimmen
if y_min is None:
y_min = min(data)
if y_max is None:
y_max = max(data)
if y_min == y_max:
y_min -= 1
y_max += 1
# Grid: gestrichelte Linien (ACeP kann keine echten Dashes, daher Segmente)
if with_y_grid:
# Nur 2 Ticks zeigen: Min und Max (sonst zu viele Labels für kleinen Chart)
ticks = []
if n_ticks >= 2:
ticks = [y_min, y_max]
elif n_ticks == 1:
ticks = [(y_min + y_max) / 2]
for tick in ticks:
ty = y + int((y_max - tick) / max(1, y_max - y_min) * (h - 8)) + 4
# Y-Label links (NICHT unten — kollidiert mit x-Achsen-Labels)
f_lbl = _fit_size(draw, f"{int(tick)}°", 22, 10, candidates=["9", "8"])
text_in_box(draw, f"{int(tick)}°", x - 24, ty - 5, 22, 10, f_lbl, FG, "right")
# Gestrichelte Linie
for seg_x in range(x, x + w, 6):
draw.line((seg_x, ty, min(seg_x + 3, x + w), ty), fill=FG, width=1)
# Daten-Linie
pad_top, pad_bot = 4, 4
pts = []
for i, v in enumerate(data):
px = x + int(i * w / (len(data) - 1))
py = y + pad_top + int((1 - (v - y_min) / (y_max - y_min)) * (h - pad_top - pad_bot))
pts.append((px, py))
for i in range(len(pts) - 1):
draw.line((pts[i][0], pts[i][1], pts[i+1][0], pts[i+1][1]),
fill=color, width=2)
for px, py in pts:
draw.ellipse((px - 1, py - 1, px + 1, py + 1), fill=color)
# ---- Mini-Bar mit Schwellen-Farbe ----------------------------------------
def draw_mini_bar(draw, x, y, w, h, value: float, max_value: float,
color: tuple | None = None):
"""Horizontale Mini-Bar mit Outline + Fill in gegebener Farbe."""
if w < 6 or h < 4:
return
color = color or FG
draw.rectangle((x, y, x + w - 1, y + h - 1), outline=FG, width=1)
pct = min(100, max(0, value / max(0.001, max_value) * 100))
fill_w = max(0, int((w - 2) * pct / 100))
if fill_w > 0:
draw.rectangle((x + 1, y + 1, x + 1 + fill_w, y + h - 2), fill=color)
# ---- ZZZ / Sleep-Indikator -----------------------------------------------
def draw_zzz_indicator(draw, x, y, size: int = 16):
"""Kleines Mond-Icon + 'Zzz' fuer DeepSleep-Anzeige.
Wird vom Dashboard-Frame im Header-Bereich gezeichnet, wenn Power-Save
aktiv ist.
"""
cx, cy = x + size // 2, y + size // 2
r = size // 3
# Mond (Yellow circle, halb verdeckt)
draw.ellipse((cx - r, cy - r, cx + r, cy + r), fill=YELLOW)
# 'Z' Buchstaben daneben
f = _fit_size(draw, "Z", size, size, candidates=["14", "12", "10"])
text_in_box(draw, "z", x + size + 2, y, 12, size, f, FG, "left")
# ---- DeepSleep-Status-Anzeige fuer Display ------------------------------
def draw_deepsleep_indicator(draw, x, y, w, h, next_wake: str = ""):
"""Zeichnet einen schmalen 'Schlafend'-Bereich im Header.
Args:
next_wake: z.B. '06:30' → wird als Text daneben gezeichnet
"""
if w < 60 or h < 12:
return
# Gelber Mond-Icon
f_moon = _fit_size(draw, "Zz", 30, h, candidates=["12", "10"])
text_in_box(draw, "Zz", x, y, 24, h, f_moon, ORANGE, "center")
# 'Schlafend bis' Text
if next_wake:
f = _fit_size(draw, f"schlafend bis {next_wake}", w - 26, h, candidates=["10", "9", "8"])
text_in_box(draw, f"schlafend bis {next_wake}", x + 26, y, w - 26, h, f, FG, "left")
+6 -5
View File
@@ -21,7 +21,7 @@ import os, sys
from datetime import datetime
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
from palette import FG, BG, OK, BLUE, YELLOW, ORANGE, fill_for, measure, de_weekday_long, de_date
# ---- Deutsche Minuten-Phrasen (QlockTwo-konform) ----
@@ -174,19 +174,20 @@ class Widget(Widget):
prefer=["16", "20", "default"])
if header_h:
if show_wd and show_dt:
day = now.strftime("%A")
date = now.strftime("%e. %b %Y").strip()
# Locale-frei: explizit deutsche Wochentage/Datums-Formatter
day = de_weekday_long(now.weekday())
date = de_date(now, short=True)
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")
text = de_weekday_long(now.weekday())
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()
text = de_date(now, short=True)
tw, _ = measure(draw, text, header_font)
draw.text((inner_x + (inner_w - tw) // 2, cy),
text, font=header_font, fill=accent)
+5 -4
View File
@@ -8,15 +8,16 @@ from palette import FG, INFO, OK, WARN, ALERT, ORANGE, BG, measure, fit_font, hb
def _fmt_timedelta_short(seconds):
"""Formatiert Sekunden als deutsche Kurz-Laufzeit: '3T 5h', '7h 12min', '42min', 'jetzt'."""
if seconds is None: return ""
s = int(seconds)
if s <= 0: return "now"
if s <= 0: return "jetzt"
days, rem = divmod(s, 86400)
h, rem = divmod(rem, 3600)
m = rem // 60
if days: return f"{days}d {h}h"
if h: return f"{h}h {m}m"
return f"{m}m"
if days: return f"{days}T {h}h"
if h: return f"{h}h {m}min"
return f"{m}min"
def _deep_get(d, *keys, default=None):
+460 -560
View File
File diff suppressed because it is too large Load Diff
+10 -10
View File
@@ -37,7 +37,7 @@ def _stats(activities):
class Widget(Widget):
name = "strava"
label = "Strava Aktivitäten"
description = "Distanz und Rides aus Strava. Setze Client-ID/Secret/Refresh-Token unten."
description = "Distanz und Fahrten aus Strava. Client-ID/Secret/Refresh-Token unten setzen."
category = "fitness"
config_schema = [
@@ -77,8 +77,8 @@ class Widget(Widget):
return
if is_wide(w, h):
stats = [f"{d['year_km']:.0f} km YTD", f"{d['rides']} rides",
f"{d['total_km']:.0f} km total"]
stats = [f"{d['year_km']:.0f} km Jahr", f"{d['rides']} Fahrten",
f"{d['total_km']:.0f} km Gesamt"]
col_w = w // len(stats)
for i, s in enumerate(stats):
cx = x + i * col_w
@@ -90,10 +90,10 @@ class Widget(Widget):
font_b = fit_font(draw, f"{d['year_km']:.0f} km", fonts, w - 2 * pad, h // 3)
draw.text((x + pad, y + 50), f"{d['year_km']:.0f} km", font=font_b, fill=FG)
draw.text((x + pad, y + 50 + font_b.size + 8),
f"in {datetime.now().year}", font=fonts.get("24", fonts.get("20")), fill=OK)
font_r = fit_font(draw, f"{d['rides']} rides", fonts, w - 2 * pad, 28)
draw.text((x + pad, y + h - 100), f"{d['rides']} rides", font=font_r, fill=OK)
font_h = fit_font(draw, f"{d['hike_km']:.1f} km hike", fonts, w - 2 * pad, 24)
draw.text((x + pad, y + h - 70), f"{d['hike_km']:.1f} km hike", font=font_h, fill=FG)
font_t = fit_font(draw, f"Total: {d['total_km']:.0f} km", fonts, w - 2 * pad, 20)
draw.text((x + pad, y + h - 40), f"Total: {d['total_km']:.0f} km", font=font_t, fill=FG)
f"im Jahr {datetime.now().year}", font=fonts.get("24", fonts.get("20")), fill=OK)
font_r = fit_font(draw, f"{d['rides']} Fahrten", fonts, w - 2 * pad, 28)
draw.text((x + pad, y + h - 100), f"{d['rides']} Fahrten", font=font_r, fill=OK)
font_h = fit_font(draw, f"{d['hike_km']:.1f} km Wandern", fonts, w - 2 * pad, 24)
draw.text((x + pad, y + h - 70), f"{d['hike_km']:.1f} km Wandern", font=font_h, fill=FG)
font_t = fit_font(draw, f"Gesamt: {d['total_km']:.0f} km", fonts, w - 2 * pad, 20)
draw.text((x + pad, y + h - 40), f"Gesamt: {d['total_km']:.0f} km", font=font_t, fill=FG)
+7 -6
View File
@@ -34,12 +34,13 @@ def _read_uptime():
def _fmt_uptime(s):
"""Formatiert Sekunden als deutsche Laufzeit: '3T 5h', '7h 12min', '42min'."""
days, rem = divmod(int(s), 86400)
h, rem = divmod(rem, 3600)
m = rem // 60
if days: return f"{days}d {h}h"
if h: return f"{h}h {m}m"
return f"{m}m"
if days: return f"{days}T {h}h"
if h: return f"{h}h {m}min"
return f"{m}min"
def _system_stats():
@@ -122,7 +123,7 @@ class Widget(Widget):
draw.text((cx + pad, y + pad), f"{label} {pct}%",
font=label_font, fill=col_color)
if self.cfg("show_uptime"):
up_str = "up " + _fmt_uptime(s["uptime_s"])
up_str = "läuft " + _fmt_uptime(s["uptime_s"])
font_up = fit_font(draw, up_str, fonts, col_w - 2 * pad, 22)
draw.text((x + 2 * col_w + pad, y + pad), up_str, font=font_up, fill=INFO)
return
@@ -155,9 +156,9 @@ class Widget(Widget):
if self.cfg("show_load") or self.cfg("show_uptime"):
parts = []
if self.cfg("show_load"):
parts.append(f"load {s['cpu_pct']/100.0 * (os.cpu_count() or 1):.2f}")
parts.append(f"Last {s['cpu_pct']/100.0 * (os.cpu_count() or 1):.2f}")
if self.cfg("show_uptime"):
parts.append("up " + _fmt_uptime(s["uptime_s"]))
parts.append("läuft " + _fmt_uptime(s["uptime_s"]))
line = " · ".join(parts)
font_bot = fit_font(draw, line, fonts, w - 2 * pad, 22)
draw.text((x + pad, bottom_y), line, font=font_bot, fill=FG)
+62 -52
View File
@@ -1,10 +1,18 @@
"""Wetter-Plugin (Open-Meteo, kein API-Key). Responsive + Error-Handling."""
import os, sys, json, math
"""Wetter-Plugin (Open-Meteo, kein API-Key). Responsive + Error-Handling.
Nutzt die shared helper aus plugins.base und palette:
- draw_compass, draw_weather_icon, draw_mini_bar
- temp_color (Hot/Warm/Kalt/FG)
- render_error_banner
"""
import os, sys, json
from datetime import datetime
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from plugins.base import Widget, fetch_url, render_error_banner
from plugins.base import (Widget, fetch_url, render_error_banner,
draw_compass, draw_weather_icon)
from palette import (FG, INFO, OK, WARN, ALERT, ORANGE, RED, BLUE,
measure, fit_font, centered_text, is_small, is_wide)
measure, fit_font, centered_text, is_small, is_wide,
temp_color)
def _fetch_openmeteo(lat, lon):
@@ -27,7 +35,7 @@ def _fetch_openmeteo(lat, lon):
class Widget(Widget):
name = "weather"
label = "Wetter (Open-Meteo)"
description = "Aktuelles Wetter, Forecast und Windrose. Kein API-Key. Responsive."
description = "Aktuelles Wetter, Vorhersage und Windrose. Kein API-Key. Responsive."
category = "weather"
config_schema = [
@@ -54,31 +62,7 @@ class Widget(Widget):
data["_lat"] = lat; data["_lon"] = lon
return data
def _draw_compass(self, draw, cx, cy, r, deg, fonts):
draw.ellipse((cx-r, cy-r, cx+r, cy+r), outline=FG, width=2)
for a in range(0, 360, 45):
rad = math.radians(a - 90)
inner = r - 8 if a % 90 == 0 else r - 4
x1 = cx + inner * math.cos(rad); y1 = cy + inner * math.sin(rad)
x2 = cx + r * math.cos(rad); y2 = cy + r * math.sin(rad)
draw.line((x1, y1, x2, y2), fill=FG, width=2)
f20 = fonts.get("20", fonts.get("16"))
for a, t in [(270,"N"), (0,"E"), (90,"S"), (180,"W")]:
rad = math.radians(a - 90)
tx = cx + (r+12) * math.cos(rad) - 6
ty = cy + (r+12) * math.sin(rad) - 9
draw.text((tx, ty), t, font=f20, fill=FG)
rad_arrow = math.radians(deg - 90)
tip_x = cx + (r - 14) * math.cos(rad_arrow)
tip_y = cy + (r - 14) * math.sin(rad_arrow)
base = math.radians(150)
lx = cx + 18 * math.cos(rad_arrow + base); ly = cy + 18 * math.sin(rad_arrow + base)
rx = cx + 18 * math.cos(rad_arrow - base); ry = cy + 18 * math.sin(rad_arrow - base)
draw.polygon([(tip_x, tip_y), (lx, ly), (rx, ry)], fill=RED)
draw.ellipse((cx-3, cy-3, cx+3, cy+3), fill=FG)
def render(self, draw, fonts, x, y, w, h):
from palette import measure
d = self.fetch()
if "_error" in d:
@@ -88,46 +72,59 @@ class Widget(Widget):
cur = d.get("current", {})
temp = cur.get("temperature_2m", 0)
temp_color = RED if temp >= 28 else ORANGE if temp >= 20 else BLUE if temp <= 5 else FG
tcol = temp_color(temp)
if is_small(w, h):
temp_str = f"{int(temp + 0.5)}°"
font = fit_font(draw, temp_str, fonts, w - 2 * 8, int(h * 0.6))
centered_text(draw, temp_str, x, y, w, int(h * 0.6), font, temp_color)
font = fit_font(draw, temp_str, fonts, w - 16, int(h * 0.6))
centered_text(draw, temp_str, x, y, w, int(h * 0.6), font, tcol)
# Wetter-Icon klein oben rechts
code = cur.get("weather_code")
if code is not None:
icon_size = min(20, h // 4)
draw_weather_icon(draw, x + w - icon_size - 4, y + 4,
icon_size, code)
if self.cfg("show_uv"):
uv_int = int(cur.get("uv_index", 0) + 0.5)
uv_str = f"UV {uv_int}"
font_uv = fit_font(draw, uv_str, fonts, w - 2 * 8, h // 4)
font_uv = fit_font(draw, uv_str, fonts, w - 16, h // 4)
centered_text(draw, uv_str, x, y + int(h * 0.62), w, h // 4, font_uv, FG)
return
if is_wide(w, h):
temp_str = f"{int(temp + 0.5)}°"
font = fit_font(draw, temp_str, fonts, w // 3 - 2 * 8, h - 2 * 8)
centered_text(draw, temp_str, x, y, w // 3, h, font, temp_color)
font = fit_font(draw, temp_str, fonts, w // 3 - 16, h - 16)
centered_text(draw, temp_str, x, y, w // 3, h, font, tcol)
hum = cur.get("relative_humidity_2m", 0)
ws = cur.get("wind_speed_10m", 0)
wd = cur.get("wind_direction_10m", 0)
stats = f"Hum {hum}%\nWind {ws} km/h\nDir {wd}°"
font_s = fit_font(draw, "Hum 100%", fonts, w - w // 3 - 2 * 8, h // 3)
stats = [f"Feuchte {hum}%", f"Wind {ws} km/h", f"Richtung {int(wd + 0.5)}°"]
font_s = fit_font(draw, "Feuchte 100%", fonts, w - w // 3 - 16, h // 3)
y_off = y + 8
for line in stats.split("\n"):
for line in stats:
centered_text(draw, line, x + w // 3, y_off, w - w // 3, h // 3, font_s, FG)
y_off += h // 3
return
# Standard: Big temp, info, forecast
draw.text((x + 8, y + 8), "WETTER", font=fonts.get("24", fonts.get("20")), fill=INFO)
# Wetter-Icon oben rechts neben dem Header
code = cur.get("weather_code")
if code is not None:
icon_size = min(28, h // 5)
draw_weather_icon(draw, x + 8, y + 36, icon_size, code)
temp_str = f"{int(temp + 0.5)}°"
font = fit_font(draw, temp_str, fonts, w // 2 - 2 * 8, int(h * 0.6))
draw.text((x + 8, y + 50), temp_str, font=font, fill=temp_color)
font = fit_font(draw, temp_str, fonts, w // 2 - 16, int(h * 0.6))
draw.text((x + 8, y + 50), temp_str, font=font, fill=tcol)
hum = cur.get("relative_humidity_2m", 0)
ws = cur.get("wind_speed_10m", 0)
wd = cur.get("wind_direction_10m", 0)
stats_y = y + 60
for line, col in [(f"Hum {hum}%", BLUE), (f"Wind {ws} km/h", INFO), (f"Dir {wd}°", FG)]:
font_l = fit_font(draw, line, fonts, w // 2 - 2 * 8, 24)
for line, col in [(f"Feuchte {hum}%", BLUE),
(f"Wind {ws} km/h", INFO),
(f"Richtung {int(wd + 0.5)}°", FG)]:
font_l = fit_font(draw, line, fonts, w // 2 - 16, 24)
draw.text((x + w // 2 + 8, stats_y), line, font=font_l, fill=col)
stats_y += 30
@@ -140,34 +137,47 @@ class Widget(Widget):
box_h = 36
if uv_int >= 6:
draw.rectangle((box_x, box_y, box_x + box_w, box_y + box_h), fill=ALERT)
draw.text((box_x + 6, box_y + 4), f"UV {uv_int}", font=fonts.get("20", fonts.get("16")), fill=(245,244,240))
draw.text((box_x + 6, box_y + 4), f"UV {uv_int}",
font=fonts.get("20", fonts.get("16")), fill=(245, 244, 240))
else:
draw.rectangle((box_x, box_y, box_x + box_w, box_y + box_h), outline=FG, width=2)
draw.text((box_x + 6, box_y + 4), f"UV {uv_int}", font=fonts.get("20", fonts.get("16")), fill=FG)
draw.text((box_x + 6, box_y + 4), f"UV {uv_int}",
font=fonts.get("20", fonts.get("16")), fill=FG)
# Windrose via shared helper (nur in grossen Slots)
if self.cfg("show_wind_compass") and w >= 280 and h >= 280:
cr = min(60, w // 8, h // 6)
cx = x + w - cr - 8
cy = y + h - cr - 8
self._draw_compass(draw, cx, cy, cr, wd, fonts)
draw_compass(draw, cx, cy, cr, wd, color=RED)
# Stunden-Vorhersage
fc_h = max(1, min(int(self.cfg("show_forecast_hours", 4)), 8))
times = d.get("hourly", {}).get("time", [])
temps = d.get("hourly", {}).get("temperature_2m", [])
codes = d.get("hourly", {}).get("weather_code", [])
if times and h > 200:
cur_iso = datetime.now().strftime("%Y-%m-%dT%H:00")
try: idx0 = times.index(cur_iso)
except ValueError: idx0 = 0
try:
idx0 = times.index(cur_iso)
except ValueError:
idx0 = 0
forecast_y = y + h - 70
strip_w = int(w * 0.6)
strip_w = int(w * 0.7)
cell_w = strip_w // fc_h
for i in range(fc_h):
j = idx0 + i + 1
if j >= len(times): break
if j >= len(times):
break
hh = times[j].split("T")[1][:5]
tt = int(temps[j] + 0.5)
color = ORANGE if tt >= 25 else BLUE if tt <= 5 else FG
color = temp_color(tt)
cx2 = x + 8 + i * cell_w
draw.text((cx2, forecast_y), hh, font=fonts.get("20", fonts.get("16")), fill=FG)
font_t = fit_font(draw, f"{tt}°", fonts, cell_w - 4, 40)
draw.text((cx2, forecast_y + 22), f"{tt}°", font=font_t, fill=color)
# Wetter-Icon (klein) pro Stunde
fc_code = codes[j] if j < len(codes) else None
if fc_code is not None:
ic_size = min(18, cell_w - 4, 20)
draw_weather_icon(draw, cx2, forecast_y + 18, ic_size, fc_code)
font_t = fit_font(draw, f"{tt}°", fonts, cell_w - 4, 28)
draw.text((cx2, forecast_y + 38), f"{tt}°", font=font_t, fill=color)
+41
View File
@@ -189,6 +189,7 @@
letter-spacing: 0.04em;
}
.topbar-clock .small { color: var(--fg-muted); font-size: 0.85em; margin-left: 6px; }
.topbar-clock #topbarNext { color: var(--accent-bright); font-size: 0.85em; margin-left: 12px; }
.content {
flex: 1; padding: 28px;
@@ -939,6 +940,11 @@
<span class="icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
Settings
</button>
<a class="sidebar-item" href="/power" style="text-decoration:none">
<span class="icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18.36 6.64a9 9 0 1 1-12.73 0"/><line x1="12" y1="2" x2="12" y2="12"/></svg></span>
Energie
<span class="badge" id="sbPower">—</span>
</a>
<button class="sidebar-item" data-tab="network" onclick="switchTab('network')">
<span class="icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/></svg></span>
Network
@@ -965,6 +971,7 @@
<div class="topbar-clock">
<span id="topbarTime">--:--:--</span>
<span class="small" id="topbarDate">—</span>
<span class="small" id="topbarNext">—</span>
</div>
<button class="refresh" id="refreshBtn" onclick="triggerRefresh()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -1312,6 +1319,14 @@
document.getElementById('topbarTime').textContent = `${hh}:${mm}:${ss}`;
const days = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
document.getElementById('topbarDate').textContent = `${days[now.getDay()]} ${now.getDate()}.${String(now.getMonth()+1).padStart(2,'0')}`;
// Naechster geplanter Refresh
fetch("/api/power", {credentials: "include"})
.then(r => r.json())
.then(d => {
const n = d.next_wake;
document.getElementById("topbarNext").textContent = n ? "Refresh " + n : "";
})
.catch(() => {});
}
setInterval(updateClock, 1000);
updateClock();
@@ -2042,6 +2057,32 @@
};
document.getElementById('netHint').textContent = hints[s.mode] || '';
}
// ---- Power-Status (DeepSleep) ----
async function refreshPowerStatus() {
try {
const r = await fetch('/api/power');
const d = await r.json();
const sb = document.getElementById('sbPower');
if (sb) {
if (d.deepsleep_enabled && d.is_deepsleep) {
sb.textContent = 'Zz';
sb.style.background = '#fef3c7';
sb.style.color = '#92400e';
} else if (d.deepsleep_enabled) {
sb.textContent = 'an';
sb.style.background = '';
sb.style.color = '';
} else {
sb.textContent = 'aus';
sb.style.background = '';
sb.style.color = '';
}
}
} catch (e) {}
}
refreshPowerStatus();
setInterval(refreshPowerStatus, 30000);
async function refreshScan() {
const r = await netFetch('/api/network/scan'); if (!r) return;
const sel = document.getElementById('ssidSelect');
+272
View File
@@ -0,0 +1,272 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Energie — ePaper Dashboard</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' fill='%230a0a0f'/%3E%3Crect x='10' y='10' width='80' height='80' fill='%23fafafa'/%3E%3Crect x='15' y='15' width='35' height='35' fill='%230080ff'/%3E%3Crect x='50' y='15' width='35' height='35' fill='%23ff0080'/%3E%3Crect x='15' y='50' width='35' height='35' fill='%2300cc00'/%3E%3Crect x='50' y='50' width='35' height='35' fill='%23ff8000'/%3E%3C/svg%3E">
<style>
:root {
--bg: #0a0a0f;
--surface: #15151c;
--surface-2: #1f1f28;
--surface-3: #2a2a35;
--border: #2e2e3a;
--border-light: #3a3a48;
--fg: #f0f0f5;
--fg-muted: #9090a0;
--fg-dim: #6a6a78;
--accent: #6d8eff;
--accent-bright: #8da9ff;
--accent-glow: rgba(109, 142, 255, 0.15);
--ok: #4ade80;
--warn: #fbbf24;
--alert: #f87171;
--info: #60a5fa;
--success: #22c55e;
}
* { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, sans-serif;
background: var(--bg); color: var(--fg);
margin: 0; line-height: 1.5;
-webkit-font-smoothing: antialiased;
min-height: 100vh;
}
.container { max-width: 1400px; margin: 0 auto; padding: 24px 32px 80px; }
/* Header */
.page-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 32px; padding-bottom: 20px;
border-bottom: 1px solid var(--border);
}
.page-header h1 { font-size: 22px; font-weight: 700; margin: 0; color: var(--fg); }
.back-btn {
display: inline-flex; align-items: center; gap: 8px;
background: var(--surface-2); color: var(--fg-muted);
border: 1px solid var(--border); border-radius: 8px;
padding: 8px 16px; font-size: 14px; font-weight: 500;
text-decoration: none; cursor: pointer; transition: all 0.15s;
}
.back-btn:hover { background: var(--surface-3); color: var(--fg); }
/* Status Banner */
.status-banner {
display: flex; align-items: center; gap: 16px;
background: var(--surface); border: 1px solid var(--border);
border-radius: 12px; padding: 16px 20px; margin-bottom: 24px;
}
.status-pill {
display: inline-flex; align-items: center; justify-content: center;
min-width: 70px; padding: 6px 14px; border-radius: 20px;
font-size: 13px; font-weight: 600; letter-spacing: 0.3px;
}
.status-pill.wach { background: rgba(74, 222, 128, 0.15); color: var(--ok); }
.status-pill.schlaft { background: rgba(251, 191, 36, 0.15); color: var(--warn); animation: pulse 2s ease-in-out infinite; }
.status-text { font-size: 14px; color: var(--fg-muted); flex: 1; }
.status-text strong { color: var(--fg); }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
/* Cards */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px 24px;
margin-bottom: 16px;
}
.card-title {
font-size: 13px; font-weight: 600; color: var(--fg-muted);
text-transform: uppercase; letter-spacing: 0.5px;
margin: 0 0 16px;
}
.form-row {
display: flex; align-items: center; gap: 12px;
padding: 14px 0; border-bottom: 1px solid var(--border);
}
.form-row:last-child { border-bottom: none; padding-bottom: 0; }
.form-row label { flex: 1; font-size: 14px; color: var(--fg); }
.form-row .hint { font-size: 12px; color: var(--fg-dim); display: block; margin-top: 2px; }
/* Toggle */
.toggle { position: relative; width: 44px; height: 24px; flex-shrink: 0; }
.toggle input { opacity: 0; width: 0; height: 0; }
.slider {
position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0;
background: var(--surface-3); border-radius: 12px; transition: 0.2s;
border: 1px solid var(--border-light);
}
.slider::before {
content: ""; position: absolute; height: 18px; width: 18px;
left: 3px; bottom: 2px; background: var(--fg-dim);
border-radius: 50%; transition: 0.2s;
}
.toggle input:checked + .slider { background: rgba(74, 222, 128, 0.2); border-color: var(--ok); }
.toggle input:checked + .slider::before { transform: translateX(20px); background: var(--ok); }
/* Time inputs */
input[type="time"] {
padding: 8px 12px; border: 1px solid var(--border);
border-radius: 8px; font-size: 14px; background: var(--surface-2);
color: var(--fg); outline: none; transition: border-color 0.15s;
}
input[type="time"]:focus { border-color: var(--accent); }
/* Buttons */
.btn {
display: inline-flex; align-items: center; gap: 8px;
background: var(--accent); color: #fff;
border: none; padding: 10px 20px; border-radius: 8px;
font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.15s;
}
.btn:hover { background: var(--accent-bright); }
.btn.secondary { background: var(--surface-3); color: var(--fg-muted); border: 1px solid var(--border); }
.btn.secondary:hover { background: var(--surface-3); color: var(--fg); }
.btn-group { display: flex; gap: 12px; margin-top: 20px; }
/* Info text */
.info-box {
background: var(--accent-glow); border: 1px solid rgba(109, 142, 255, 0.3);
border-radius: 8px; padding: 14px 16px; margin-top: 20px;
}
.info-box p { margin: 0; font-size: 13px; color: var(--fg-muted); line-height: 1.6; }
/* Error */
.error-msg { color: var(--alert); font-size: 13px; margin-top: 8px; display: none; }
.error-msg.visible { display: block; }
</style>
</head>
<body>
<div class="container">
<div class="page-header">
<h1>⚡ Energie</h1>
<a class="back-btn" href="/">← Dashboard</a>
</div>
<div class="status-banner">
<span id="status-pill" class="status-pill schlaft">—</span>
<span id="status-text" class="status-text">Lädt...</span>
</div>
<form method="post" action="/power" id="power-form">
<div class="card">
<div class="form-row">
<label for="deepsleep_enabled">
DeepSleep aktiv
<span class="hint">Display schaltet sich nachts aus, nur noch Netzwerk aktiv</span>
</label>
<label class="toggle">
<input type="checkbox" name="deepsleep_enabled" id="deepsleep_enabled"
{% if power.deepsleep_enabled %}checked{% endif %}>
<span class="slider"></span>
</label>
</div>
<div class="form-row">
<label for="deepsleep_start">Schlafenszeit (von)</label>
<input type="time" name="deepsleep_start" id="deepsleep_start"
value="{{ power.deepsleep_start or '23:00' }}">
</div>
<div class="form-row">
<label for="deepsleep_end">Aufwachzeit (bis)</label>
<input type="time" name="deepsleep_end" id="deepsleep_end"
value="{{ power.deepsleep_end or '06:00' }}">
</div>
<div class="form-row">
<label for="cpu_throttle">
CPU drosseln
<span class="hint">Reduziert Dauerstromverbrauch um ~0.5–1W</span>
</label>
<label class="toggle">
<input type="checkbox" name="cpu_throttle" id="cpu_throttle"
{% if power.cpu_throttle %}checked{% endif %}>
<span class="slider"></span>
</label>
</div>
<div class="form-row">
<label for="wifi_off">
WLAN nachts aus
<span class="hint">Schaltet WLAN nach Schlafenszeit ab (wenn implementiert)</span>
</label>
<label class="toggle">
<input type="checkbox" name="wifi_off" id="wifi_off"
{% if power.wifi_off %}checked{% endif %}>
<span class="slider"></span>
</label>
</div>
</div>
<div id="error-msg" class="error-msg"></div>
<div class="btn-group">
<button type="submit" class="btn">💾 Speichern</button>
<button type="button" class="btn secondary" id="wake-btn">⚡ Aufwecken</button>
</div>
</form>
<div class="info-box">
<p><strong>Z-Indikator:</strong> Auf dem Display erscheint oben rechts ein "Zz" + Aufwach-Zeit wenn DeepSleep aktiv ist.</p>
<p style="margin-top:8px">Geschätzte Ersparnis: bei 7h DeepSleep/Tag + 3min Refresh-Intervall ca. 30% weniger Display-Refresh-Strom. Plus Pi-CPU-Throttle ~0.5–1W Dauerersparnis.</p>
</div>
</div>
<script>
async function refreshStatus() {
try {
const r = await fetch('/api/power', { credentials: 'include' });
if (!r.ok) throw new Error('Status nicht abrufbar');
const d = await r.json();
const pill = document.getElementById('status-pill');
const text = document.getElementById('status-text');
if (d.is_deepsleep) {
pill.className = 'status-pill schlaft';
pill.textContent = 'schläft';
text.innerHTML = `Display schläft bis <strong>${d.next_wake}</strong> `
+ `(${formatDuration(d.seconds_until_wake)} verbleibend)`;
} else {
pill.className = 'status-pill wach';
pill.textContent = 'wach';
const next = d.deepsleep_enabled ? ` Nächster DeepSleep um ${d.deepsleep_start}.` : '';
text.innerHTML = `Display ist wach. Normaler Refresh-Modus.${next}`;
}
} catch (e) {
document.getElementById('status-text').textContent = 'Status nicht abrufbar (' + e.message + ')';
}
}
function formatDuration(secs) {
const h = Math.floor(secs / 3600);
const m = Math.floor((secs % 3600) / 60);
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
document.getElementById('wake-btn').addEventListener('click', async () => {
const btn = document.getElementById('wake-btn');
btn.disabled = true;
try {
const r = await fetch('/api/wake', { method: 'POST', credentials: 'include' });
const d = await r.json();
if (d.ok) {
btn.textContent = '✓ Aufgeweckt!';
setTimeout(() => { btn.textContent = '⚡ Aufwecken'; btn.disabled = false; }, 2000);
} else {
throw new Error(d.message || 'Fehler');
}
} catch (e) {
document.getElementById('error-msg').textContent = 'Aufwecken fehlgeschlagen: ' + e.message;
document.getElementById('error-msg').classList.add('visible');
btn.disabled = false;
}
});
// Init
refreshStatus();
setInterval(refreshStatus, 30000);
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+63
View File
@@ -0,0 +1,63 @@
"""Verifizierungs-Script: rendert Netatmo mit dynamischen Mock-Daten.
Findet und behebt:
1. Statische Forecast-Daten (jetzt relativ zu heute)
2. Synthetische Outdoor-History mit groesserer Range (damit Chart nicht flach)
3. Bessere Indoor Batt-Darstellung
"""
import sys
sys.path.insert(0, '.')
from PIL import Image, ImageDraw, ImageFont
from plugins.netatmo import Widget
from datetime import datetime, timedelta
import math
import os
# FIX 1: Mock-Daten mit dynamischen Daten (heute + 2 Tage)
now = datetime.now()
forecast_days = [(now + timedelta(days=i)).date().isoformat() for i in range(3)]
# FIX 2: Synthetische Outdoor-History mit groesserem Temperatur-Range
# 12h-Verlauf mit sin-voller Swing (12.5 - 22.5 Grad, also 10 Grad Range)
outdoor_temps = []
for h in range(12):
t = 17.5 + 4.5 * math.sin(h / 2.2) + 1.0 * math.cos(h / 1.3)
outdoor_temps.append(round(t, 1))
temp_history = ';'.join(str(t) for t in outdoor_temps)
print(f'History: min={min(outdoor_temps)}, max={max(outdoor_temps)}, n={len(outdoor_temps)}')
mock = {
'station_name': 'Daheim', 'place': {},
'main': {'name': 'Daheim', 'data': {'Temperature': 21.5, 'CO2': 620, 'Humidity': 48, 'Pressure': 1014, 'Pressure_trend': 'down'}},
'modules': [
# Aussentemperatur = letzter Wert der History
{'type': 'NAModule1', 'name': 'Aussen', 'data': {'Temperature': outdoor_temps[-1], 'Humidity': 67, 'Temp_history': temp_history}, 'battery_pct': 78},
{'type': 'NAModule2', 'name': 'Wind', 'data': {'WindStrength': 12, 'GustStrength': 24, 'WindAngle': 215}, 'battery_pct': 65},
{'type': 'NAModule3', 'name': 'Regen', 'data': {'RainRate': 0.0, 'sum_rain_1': 0.4, 'sum_rain_24': 6.2}, 'battery_pct': 82},
{'type': 'NAModule4', 'name': 'Kueche', 'data': {'Temperature': 23.5, 'CO2': 980}, 'battery_pct': 88},
{'type': 'NAModule4', 'name': 'Schlafen', 'data': {'Temperature': 20.8, 'CO2': 1450}, 'battery_pct': 41},
],
'forecast': {
'daily': {
'time': forecast_days, # DYNAMISCH: heute + 2 Tage
'temperature_2m_max': [19, 21, 17],
'temperature_2m_min': [9, 11, 8],
'precipitation_sum': [0.0, 2.4, 8.1],
'weather_code': [0, 3, 61],
},
},
}
w = Widget({})
w.fetch = lambda: mock
fonts = {}
for sz in [8, 9, 10, 11, 12, 14, 16, 18, 20, 22, 24, 28, 32, 36, 40, 48, 60, 72, 84, 96]:
fonts[str(sz)] = ImageFont.truetype(os.path.join('C:/Windows/Fonts/arial.ttf'), sz)
fonts['default'] = ImageFont.load_default()
img = Image.new('RGB', (800, 480), (255, 255, 255))
draw = ImageDraw.Draw(img)
w.set_fonts(fonts)
w.render(draw, fonts, 0, 0, 800, 480)
img.save('docs/test_netatmo_v10.png')
print(f'OK saved v10 ({now.strftime("%H:%M")})')