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
```
+78 -31
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. 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 ## Hardware
@@ -13,26 +14,44 @@ Rendert alle 180s (oder konfigurierbar) ein 800×480px Vollbild auf dem Display.
## Architektur ## Architektur
``` ```
┌─────────────────────────────────────────────┐ ┌──────────────────────────────────────────────┐
│ epaper-dashboard │ │ epaper-dashboard │
├──────────────────┬──────────────────────────┤ ├──────────────────┬───────────────────────────┤
│ dashboard.py │ admin.py │ │ dashboard.py │ admin.py │
│ (Renderer) │ (Web Admin UI :8080) │ │ (Renderer + │ (Web Admin UI :8080) │
├──────────────────┴──────────────────────────┤ │ DeepSleep) │ │
│ layout.py · 4×4 Grid · Pack-Algorithmus │ ├──────────────────┴───────────────────────────┤
├────────────────────────────────────────────┤ │ layout.py · 4×4 Grid · Pack-Algorithmus │
│ plugins/ (eines pro Widget) │ ├───────────────────────────────────────────────┤
│ clock · weather · system · spotify │ │ plugins/ (eines pro Widget) │
│ strava · gmail · minimax · hello │ │ clock · clock_wordclock · weather · system │
├────────────────────────────────────────────┤ │ spotify · strava · gmail · minimax · │
│ network_watchdog.py │ │ netatmo · hello │
│ (WiFi AP/Client Management) │ ├───────────────────────────────────────────────┤
├────────────────────────────────────────────┤ │ shared: palette.py · plugins/base.py │
│ waveshare_epd/ (Vendor-Treiber) │ │ (Farben, Formatter, Render-Helper) │
│ epd7in3f.py │ ├───────────────────────────────────────────────┤
└─────────────────────────────────────────────┘ │ 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 ## Quick Start
### Pi vorbereiten ### Pi vorbereiten
@@ -54,7 +73,7 @@ cd epaper-dashboard
cp config.example.json config.json cp config.example.json config.json
# → config.json editieren (Plugins + Layout) # → config.json editieren (Plugins + Layout)
#waveshare_epd Treiber # waveshare_epd Treiber
git clone https://github.com/waveshareteam/Waveshare_GPIO.git waveshare_epd_lib git clone https://github.com/waveshareteam/Waveshare_GPIO.git waveshare_epd_lib
# oder: den waveshare_epd Ordner vom vorherigen Setup übernehmen # oder: den waveshare_epd Ordner vom vorherigen Setup übernehmen
@@ -71,6 +90,11 @@ chmod +x install_services.sh
{ {
"version": 2, "version": 2,
"refresh_interval_s": 180, "refresh_interval_s": 180,
"power": {
"deepsleep_enabled": false,
"wake_times": ["07:00", "12:00", "18:00"],
"cpu_throttle": false
},
"layout": { "layout": {
"grid": { "cols": 4, "rows": 4 }, "grid": { "cols": 4, "rows": 4 },
"items": [ "items": [
@@ -96,6 +120,7 @@ http://pi:8080/
- **Layout-Editor** — Items per Klick hinzufügen, verschieben, Größe ändern, Auto-Pack - **Layout-Editor** — Items per Klick hinzufügen, verschieben, Größe ändern, Auto-Pack
- **Plugin-Config** — API-Keys, Locations, etc. pro Widget - **Plugin-Config** — API-Keys, Locations, etc. pro Widget
- **Energie-Tab** — DeepSleep-Modus, Weckzeiten, CPU-Throttle, manuelles Display-Aufwecken
- **Refresh Now** — sofortiger Display-Refresh - **Refresh Now** — sofortiger Display-Refresh
- **Netzwerk** — WLAN wechseln ohne SSH - **Netzwerk** — WLAN wechseln ohne SSH
@@ -104,23 +129,40 @@ Default Login: `admin` / `admin` — in `.env` mit `EPAPER_ADMIN_PASSWORD` setze
## Plugins ## Plugins
| Plugin | Datenquelle | Config-Felder | | Plugin | Datenquelle | Config-Felder |
|--------|-------------|---------------| |---|---|---|
| `clock` | Systemzeit | — | | `clock` | Systemzeit | `format_24h`, `show_seconds`, `show_date`, `show_weekday`, `accent_color` |
| `weather` | Open-Meteo (kein API-Key) | `location` (lat,lon), `show_uv`, `show_forecast_hours` | | `clock_wordclock` | Systemzeit | `show_date`, `show_weekday`, `show_es_ist`, `invert`, `accent_color` (QlockTwo-Style Wort-Uhr) |
| `system` | Pi CPU/RAM/Uptime | `show_uptime`, `show_load`, `show_temp` | | `weather` | Open-Meteo (kein API-Key) | `location` (lat,lon), `show_uv`, `show_forecast_hours`, `show_wind_compass` |
| `spotify` | Spotify Web API | `client_id`, `client_secret` (via Spotify Dev Portal) | | `system` | Pi CPU/RAM/Uptime | `show_uptime`, `show_load`, `compact`, `bar_thresholds`, `bar_gradient` |
| `strava` | Strava API | `access_token`, `club_id` | | `spotify` | Last.fm Scrobble | `api_key`, `username` |
| `gmail` | Gmail API | `credentials_json` (OAuth2) | | `strava` | Strava API | `client_id`, `client_secret`, `refresh_token` |
| `minimax` | MiniMax AI | `api_key`, `model`, `prompt` | | `gmail` | Gmail API | `oauth_token_json` |
| `netatmo` | Netatmo Weather API (OAuth2) | `client_id`, `client_secret`, `username`, `password` (Details: [NETATMO.md](plugins/NETATMO.md)) | | `minimax` | MiniMax Token Plan | `subscription_key`, `show_credits`, `api_base`, `bar_thresholds`, `bar_gradient` |
| `hello` | statisch | `text`, `color`, `size` | | `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 ## Eigenes Plugin schreiben
```python ```python
# plugins/my_widget.py # plugins/my_widget.py
from plugins.base import Widget from plugins.base import Widget
from palette import FG, BG from palette import FG, BG, de_weekday_date
class Widget(Widget): class Widget(Widget):
name = "my_widget" name = "my_widget"
@@ -140,12 +182,17 @@ class Widget(Widget):
def render(self, draw, fonts, x, y, w, h): def render(self, draw, fonts, x, y, w, h):
d = self.fetch() d = self.fetch()
# w, h sind Pixel (nicht Zellen!) # w, h sind Pixel (nicht Zellen!)
# Nutze die shared helper aus palette + base
draw.text((x + 10, y + 10), f"Value: {d['value']}", draw.text((x + 10, y + 10), f"Value: {d['value']}",
font=fonts["28"], fill=FG) font=fonts["28"], fill=FG)
``` ```
**Palette:** `BLACK, WHITE, RED, GREEN, BLUE, YELLOW, ORANGE` + semantisch `FG, BG, OK, WARN, ALERT, INFO, ACCENT` **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 ## Services
```bash ```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 - Watchdog-Code war korrekt; das Problem ist die PolicyKit-Konfiguration
- Polkit-Restart nach Rule-Update: besser nur `polkitd` SIGHUP schicken, nicht systemctl restart - 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) (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}) 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(): def main():
# Wenn direkt ausgeführt: eigenen Renderer starten ist Sache des Run-Skripts. # Wenn direkt ausgeführt: eigenen Renderer starten ist Sache des Run-Skripts.
# Wir nehmen einfach Port 8080. # Wir nehmen einfach Port 8080.
+136 -15
View File
@@ -1,19 +1,23 @@
"""Layout, Slot-Geometrie, Refresh-Loop. """Layout, Slot-Geometrie, Refresh-Loop mit DeepSleep-Support.
Lädt config.json, instanziiert Plugins, läuft Refresh-Loop mit ≥refresh_interval_s Lädt config.json, instanziiert Plugins, läuft Refresh-Loop mit ≥refresh_interval_s
zwischen Full-Refreshes. Reagiert auf Live-Trigger via Unix-Socket. zwischen Full-Refreshes. Reagiert auf Live-Trigger via Unix-Socket.
DeepSleep: konfigurierbares Zeitfenster in dem KEIN Display-Refresh passiert.
Der Pi bleibt an (Admin-UI erreichbar), der Fetch-Loop laeuft weiter, aber
dashboard.display() wird uebersprungen. ePaper haelt das letzte Bild ohne Strom.
""" """
from __future__ import annotations from __future__ import annotations
import os, sys, time, json, math, threading, signal, socket, logging import os, sys, time, json, math, threading, signal, socket, logging
from datetime import datetime from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
HERE = Path(__file__).resolve().parent HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE)) sys.path.insert(0, str(HERE))
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
from palette import BG, FG, OK from palette import BG, FG, OK, POWER_DEFAULTS
from plugins.base import all_widget_classes, Widget from plugins.base import all_widget_classes, Widget, draw_deepsleep_indicator
from layout import Item, GRID_COLS, GRID_ROWS, CELL_W, CELL_H, DISPLAY_W as LAYOUT_DISPLAY_W, pack, find_overlaps, find_out_of_bounds from layout import Item, GRID_COLS, GRID_ROWS, CELL_W, CELL_H, DISPLAY_W as LAYOUT_DISPLAY_W, pack, find_overlaps, find_out_of_bounds
import importlib, pkgutil import importlib, pkgutil
@@ -103,6 +107,12 @@ def load_config() -> dict:
cfg["plugin_configs"] = {} cfg["plugin_configs"] = {}
if "refresh_interval_s" not in cfg: if "refresh_interval_s" not in cfg:
cfg["refresh_interval_s"] = 180 cfg["refresh_interval_s"] = 180
# Power-Defaults (DeepSleep) sicherstellen
if "power" not in cfg or not isinstance(cfg["power"], dict):
cfg["power"] = dict(POWER_DEFAULTS)
else:
for k, v in POWER_DEFAULTS.items():
cfg["power"].setdefault(k, v)
return cfg return cfg
@@ -119,6 +129,7 @@ def _empty_v2_config() -> dict:
"items": [], "items": [],
}, },
"plugin_configs": {}, "plugin_configs": {},
"power": dict(POWER_DEFAULTS),
} }
@@ -198,7 +209,8 @@ def slot_box(idx: int) -> tuple[int, int, int, int]:
# ============================================================================ # ============================================================================
# Render # Render
# ============================================================================ # ============================================================================
def render_full(items: list, widgets: list, fonts: dict) -> Image.Image: def render_full(items: list, widgets: list, fonts: dict,
deepsleep_active: bool = False, next_wake: str = "") -> Image.Image:
"""Render alle Items aufs Display. """Render alle Items aufs Display.
items: list[Item] (Layout-Items mit x,y,w,h) items: list[Item] (Layout-Items mit x,y,w,h)
@@ -241,6 +253,9 @@ def render_full(items: list, widgets: list, fonts: dict) -> Image.Image:
draw.text((px + 8, py + 8), f"{widget.label} (overflow)", draw.text((px + 8, py + 8), f"{widget.label} (overflow)",
font=fonts.get("16", fonts.get("default")), fill=FG) font=fonts.get("16", fonts.get("default")), fill=FG)
continue continue
# Fonts an Widget durchreichen VOR render()
if hasattr(widget, "set_fonts"):
widget.set_fonts(fonts)
try: try:
widget.render(draw, fonts, px, py, pw, ph) widget.render(draw, fonts, px, py, pw, ph)
except Exception as e: except Exception as e:
@@ -249,6 +264,11 @@ def render_full(items: list, widgets: list, fonts: dict) -> Image.Image:
f"{widget.label}: render error", f"{widget.label}: render error",
font=fonts.get("16", fonts.get("default")), fill=FG) font=fonts.get("16", fonts.get("default")), fill=FG)
# DeepSleep-Indikator im oberen rechten Bereich
if deepsleep_active:
ind_w = min(280, DISPLAY_W // 3)
draw_deepsleep_indicator(draw, DISPLAY_W - ind_w - 6, 4, ind_w, 18, next_wake)
return img return img
@@ -300,8 +320,69 @@ class Dashboard:
def trigger_refresh(self): def trigger_refresh(self):
self._trigger.set() self._trigger.set()
def render_once(self) -> Image.Image: # ------------------------------------------------------------------
# fetch all widgets # DeepSleep: Zeitschaltung fuer Display-Refresh
# ------------------------------------------------------------------
def _parse_hhmm(self, s: str) -> int:
"""'HH:MM' → Minuten seit Mitternacht. Fallback: 0."""
try:
parts = s.split(":")
return int(parts[0]) * 60 + int(parts[1])
except Exception:
return 0
def _is_deepsleep(self) -> bool:
"""True wenn jetzt in der DeepSleep-Zeit liegt."""
cfg = self.config.get("power", {})
if not cfg.get("deepsleep_enabled", False):
return False
now = datetime.now()
now_min = now.hour * 60 + now.minute
start = self._parse_hhmm(cfg.get("deepsleep_start", "23:00"))
end = self._parse_hhmm(cfg.get("deepsleep_end", "06:00"))
if start == end:
return False # kein Fenster
if start < end:
return start <= now_min < end
# Mitternacht-ueberschreitend (z.B. 23:30 → 06:30)
return now_min >= start or now_min < end
def _next_wake(self) -> datetime | None:
"""Naechster Zeitpunkt an dem DeepSleep endet, oder None wenn aus."""
if not self._is_deepsleep():
return None
cfg = self.config.get("power", {})
end = self._parse_hhmm(cfg.get("deepsleep_end", "06:00"))
now = datetime.now()
end_dt = now.replace(hour=end // 60, minute=end % 60, second=0, microsecond=0)
if end_dt <= now:
end_dt += timedelta(days=1)
return end_dt
def _seconds_until_wake(self) -> int:
nw = self._next_wake()
if nw is None:
return 0
return max(5, int((nw - datetime.now()).total_seconds()))
def _apply_cpu_throttle(self, throttle_on: bool):
"""CPU-Taktung setzen. Nur auf Linux/Pi wirksam; sonst noop."""
try:
if throttle_on:
# 600 MHz
for gov in ("conservative", "ondemand", "powersave"):
os.system(f"echo {gov} > /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor 2>/dev/null")
os.system("echo 600000 > /sys/devices/system/cpu/cpu0/cpufreq/scaling_max_freq 2>/dev/null")
else:
# Default: ondemand, max 1.5 GHz
for gov in ("ondemand", "performance"):
os.system(f"echo {gov} > /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor 2>/dev/null")
os.system("echo 1500000 > /sys/devices/system/cpu/cpu0/cpufreq/scaling_max_freq 2>/dev/null")
except Exception:
pass
def render_once(self, with_deepsleep: bool = True) -> Image.Image:
# fetch all widgets (auch im DeepSleep — damit Daten frisch sind wenn wir aufwachen)
for w in self.widgets: for w in self.widgets:
if w is None: continue if w is None: continue
try: try:
@@ -311,7 +392,15 @@ class Dashboard:
with self._lock: with self._lock:
items = list(self.items) items = list(self.items)
widgets = list(self.widgets) widgets = list(self.widgets)
img = render_full(items, widgets, self.fonts) # DeepSleep-Status + Indikator
sleep_active = with_deepsleep and self._is_deepsleep()
next_wake = ""
if sleep_active:
nw = self._next_wake()
if nw:
next_wake = nw.strftime("%H:%M")
img = render_full(items, widgets, self.fonts,
deepsleep_active=sleep_active, next_wake=next_wake)
return img return img
def display(self, img: Image.Image): def display(self, img: Image.Image):
@@ -341,18 +430,50 @@ class Dashboard:
# start IPC socket listener # start IPC socket listener
threading.Thread(target=self._socket_loop, daemon=True).start() threading.Thread(target=self._socket_loop, daemon=True).start()
_throttle_applied = False
while True: while True:
try: try:
self.maybe_reload_config() self.maybe_reload_config()
interval = int(self.config.get("refresh_interval_s", 180)) interval = int(self.config.get("refresh_interval_s", 180))
# sleep until interval elapsed OR trigger set # ----- DeepSleep Check -----
wait = max(5, interval) if self._is_deepsleep():
logging.info(f"waiting up to {wait}s (manual trigger interrupts)") secs = self._seconds_until_wake()
if self._trigger.wait(timeout=wait): nw = self._next_wake()
self._trigger.clear() nw_str = nw.strftime("%H:%M") if nw else "?"
logging.info("manual trigger received") logging.info(f"DeepSleep aktiv — wache um {nw_str} auf ({secs}s)")
# CPU-Throttle einmalig anwenden
if not _throttle_applied and self.config.get("power", {}).get("cpu_throttle", True):
self._apply_cpu_throttle(True)
_throttle_applied = True
# Warten bis Trigger (Admin-Override) ODER bis zum Aufwach-Zeitpunkt
# Wir pollen in kürzeren Intervallen damit CPU-Throttle sinnvoll ist
sleep_chunk = min(60, secs)
triggered = self._trigger.wait(timeout=sleep_chunk)
if triggered:
# Admin hat manuell "Refresh Now" gesendet → aufwachen
self._trigger.clear()
logging.info("DeepSleep durch Admin-Trigger beendet")
if _throttle_applied:
self._apply_cpu_throttle(False)
_throttle_applied = False
# weiter zum normalen Refresh unten
else:
# chunk ausgeschlafen, noch im DeepSleep → weiter warten
continue
else: else:
logging.info("interval elapsed, refreshing") # Wach: Throttle zurücksetzen falls aktiv
if _throttle_applied:
self._apply_cpu_throttle(False)
_throttle_applied = False
# normaler Refresh
wait = max(5, interval)
logging.info(f"waiting up to {wait}s (manual trigger interrupts)")
if self._trigger.wait(timeout=wait):
self._trigger.clear()
logging.info("manual trigger received")
else:
logging.info("interval elapsed, refreshing")
t0 = time.time() t0 = time.time()
img = self.render_once() img = self.render_once()
+186
View File
@@ -235,3 +235,189 @@ def is_tall(w: int, h: int) -> bool:
"""True wenn Slot hoch aber schmal ist (1x4, 1x2).""" """True wenn Slot hoch aber schmal ist (1x4, 1x2)."""
return h >= 280 and w < 280 return h >= 280 and w < 280
# ============================================================================
# Batterie-Farben — Single Source of Truth
# ============================================================================
BATT_LOW = RED # < 25% → kritisch
BATT_MID = ORANGE # < 50% → bald wechseln
BATT_OK = GREEN # >= 50% → OK
# Standard-Batterie-Schwellen (werden von Plugins einheitlich genutzt)
BATT_THRESHOLDS = (
(25, BATT_LOW),
(50, BATT_MID),
)
def batt_color(pct: int | None) -> tuple:
"""Liefert die passende Batterie-Farbe fuer einen Prozent-Wert.
None → FG (keine Anzeige), sonst BATT_LOW/MID/OK.
"""
if pct is None:
return FG
pct = max(0, min(100, pct))
for threshold, color in BATT_THRESHOLDS:
if pct < threshold:
return color
return BATT_OK
# ============================================================================
# Deutsche Formatter — Single Source of Truth (kein Englisches "Monday" mehr)
# ============================================================================
DE_WEEKDAYS_LONG = [
"Montag", "Dienstag", "Mittwoch", "Donnerstag",
"Freitag", "Samstag", "Sonntag",
]
DE_WEEKDAYS_SHORT = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]
DE_MONTHS_LONG = [
"Januar", "Februar", "Maerz", "April", "Mai", "Juni",
"Juli", "August", "September", "Oktober", "November", "Dezember",
]
DE_MONTHS_SHORT = ["Jan", "Feb", "Mae", "Apr", "Mai", "Jun",
"Jul", "Aug", "Sep", "Okt", "Nov", "Dez"]
def de_weekday_long(weekday: int) -> str:
"""datetime.weekday() (0=Montag) → 'Montag'/'Dienstag'/..."""
return DE_WEEKDAYS_LONG[weekday]
def de_weekday_short(weekday: int) -> str:
"""datetime.weekday() (0=Montag) → 'Mo'/'Di'/..."""
return DE_WEEKDAYS_SHORT[weekday]
def de_month_short(month: int) -> str:
"""datetime.month (1-12) → 'Jan'/'Feb'/..."""
return DE_MONTHS_SHORT[month - 1]
def de_date(dt, short: bool = True) -> str:
"""Datum als '30. Aug 2026' (default short=True)."""
if short:
return f"{dt.day:02d}. {de_month_short(dt.month)} {dt.year}"
return f"{dt.day:02d}. {DE_MONTHS_LONG[dt.month - 1]} {dt.year}"
def de_weekday_date(dt, short: bool = True) -> str:
"""'Sa 30.08' wenn short, sonst 'Samstag 30. August 2026'."""
if short:
return f"{de_weekday_short(dt.weekday())} {dt.day:02d}.{dt.month:02d}"
return f"{de_weekday_long(dt.weekday())} {de_date(dt, short=False)}"
def de_time(dt, show_seconds: bool = False) -> str:
"""24h-Zeit 'HH:MM' oder 'HH:MM:SS'."""
return dt.strftime("%H:%M:%S" if show_seconds else "%H:%M")
# ============================================================================
# Wind / Temp Helper — einheitliche Formatierung
# ============================================================================
def format_temp(value: float | None, unit: str = "C", decimals: int = 1) -> str:
"""Temperatur formatieren. None → em-dash."""
if value is None:
return "—"
return f"{value:.{decimals}f}°{unit}"
def format_wind(speed: float | None, unit: str = "kmh", decimals: int = 0) -> str:
"""Windgeschwindigkeit. 'kmh' → 'km/h', 'ms' → 'm/s'."""
if speed is None:
return "—"
label = "km/h" if unit == "kmh" else "m/s"
return f"{speed:.{decimals}f} {label}"
def format_rain(rate_mm: float | None, decimals: int = 1) -> str:
"""Regenrate 'X.X mm/h'."""
if rate_mm is None:
return "—"
return f"{rate_mm:.{decimals}f} mm/h"
def temp_color(value: float | None) -> tuple:
"""Farbe nach Temperaturwert: kalt→Blau, normal→FG, warm→Orange, heiss→Rot."""
if value is None:
return FG
if value >= 28:
return RED
if value >= 22:
return ORANGE
if value <= 5:
return BLUE
return FG
def co2_color(ppm: int | float | None) -> tuple:
"""CO2-Ampel: <600 gruen, <1000 orange, <1500 gelb, >=1500 rot."""
if ppm is None:
return FG
if ppm >= 1500:
return RED
if ppm >= 1000:
return YELLOW
if ppm >= 600:
return ORANGE
return GREEN
# ============================================================================
# Power-Management — Defaults
# ============================================================================
POWER_DEFAULTS = {
"deepsleep_enabled": False,
"deepsleep_start": "23:30",
"deepsleep_end": "06:30",
"cpu_throttle": True, # waehrend DeepSleep auf 600MHz
"wifi_off": False, # WiFi anlassen fuer Admin-Erreichbarkeit
}
# ============================================================================
# Weather-Code-Mapping (WMO Standard + Netatmo-Fallback)
# ============================================================================
# WMO Weather interpretation codes (https://open-meteo.com/en/docs)
WMO_ICONS = {
0: "sun", # Clear sky
1: "partly", # Mainly clear
2: "partly", # Partly cloudy
3: "cloud", # Overcast
45: "fog", # Fog
48: "fog", # Depositing rime fog
51: "rain", # Drizzle: light
53: "rain", # Drizzle: moderate
55: "rain", # Drizzle: dense
56: "rain", # Freezing Drizzle: light
57: "rain", # Freezing Drizzle: dense
61: "rain", # Rain: slight
63: "rain", # Rain: moderate
65: "rain", # Rain: heavy
66: "rain", # Freezing Rain: light
67: "rain", # Freezing Rain: heavy
71: "snow", # Snow fall: slight
73: "snow", # Snow fall: moderate
75: "snow", # Snow fall: heavy
77: "snow", # Snow grains
80: "rain", # Rain showers: slight
81: "rain", # Rain showers: moderate
82: "rain", # Rain showers: violent
85: "snow", # Snow showers: slight
86: "snow", # Snow showers: heavy
95: "storm", # Thunderstorm
96: "storm", # Thunderstorm with slight hail
99: "storm", # Thunderstorm with heavy hail
}
def wmo_to_icon(code: int | str | None) -> str:
"""WMO-Code (int) oder Symbol-String → 'sun'/'cloud'/'rain'/'storm'/..."""
if code is None:
return "cloud"
if isinstance(code, str):
return code # schon ein symbol
return WMO_ICONS.get(code, "cloud")
+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 | | Slot | Was du siehst |
|---|---| |---|---|
| **1×1** | Große Außen-Temperatur, Mini-CO₂/Feuchte-Status | | **1×1** | `compact`: Große Außen-Temperatur + MIN/MAX + Tag-Status |
| **2×1** | Outdoor-Temperatur + Wind-Geschwindigkeit kompakt | | **2×2** | `compact_2x2`: Aussen + Wind/Regen-Bereich + Indoor-Liste |
| **4×1** | Indoor · Outdoor · Wind/Regen — drei Spalten | | **4×4** | `full_4x4`: Volle V1-Status-Update Ansicht (siehe unten) |
| **1×4** | Vertikale Liste aller Module |
| **2×2** | Indoor-Card (mit CO₂-Bar) + Outdoor-Card + Wind/Regen-Bereich | ### V1-Status-Update Design (4×4)
| **4×4** | Volle Ansicht mit allen Modulen, Min/Max, Windrose |
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`, Das Plugin priorisiert bei der Layout-Wahl **wide/tall** vor `small`,
damit 4×1- und 1×4-Slots nicht in die Mini-Ansicht fallen. 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_id` | secret | — | Netatmo App Client-ID |
| `client_secret` | secret | — | Netatmo App Client-Secret | | `client_secret` | secret | — | Netatmo App Client-Secret |
| `username` | secret | — | Netatmo Login (E-Mail) | | `refresh_token` | secret | — | OAuth2 Refresh-Token (nur RAM, wird on-demand erneuert) |
| `password` | secret | — | Netatmo Passwort (nur RAM) |
| `station_filter` | string | `""` | Substring-Filter auf Stations-/Modulname | | `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_outdoor` | bool | `true` | NAModule1 (Außen) anzeigen |
| `show_wind` | bool | `true` | NAModule2 (Wind) anzeigen | | `show_wind` | bool | `true` | NAModule2 (Wind) anzeigen |
| `show_rain` | bool | `true` | NAModule3 (Regen) anzeigen | | `show_rain` | bool | `true` | NAModule3 (Regen) anzeigen |
| `show_compass` | bool | `true` | Windrose in der Wind-Card zeichnen | | `show_indoor` | bool | `true` | Hauptmodul + zusätzliche Indoor-Sensoren anzeigen |
| `show_secondary` | bool | `true` | Min/Max + letzte Aktualisierung | | `show_forecast` | bool | `true` | Open-Meteo 3-Tage-Vorhersage anzeigen |
| `co2_thresholds` | string | `ok@600,warn@1000,alert@1500` | CO₂-Bar Schwellen (ppm) | | `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` | | `temp_unit` | select | `C` | `C` oder `F` |
| `wind_unit` | select | `kmh` | `kmh` oder `ms` | | `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 ### CO₂-Schwellen anpassen
`co2_thresholds` ist ein String im Format `farbe@ppm_in_ppm`. `co2_thresholds` ist ein String im Format `farbe@ppm_in_ppm`.
@@ -181,21 +235,43 @@ wird ein neuer Token geholt.
| Aspekt | Wert | | Aspekt | Wert |
|---|---| |---|---|
| API-Version | Netatmo Connect OAuth2 | | API-Version | Netatmo Connect OAuth2 (Refresh-Token-Grant) |
| Endpoints | `POST /oauth2/token`, `GET /api/getstationsdata` | | Endpoints | `POST /oauth2/token`, `GET /api/getstationsdata` |
| Forecast-Quelle | Open-Meteo (`/v1/forecast`) — kein API-Key |
| Token-Lebensdauer | 10800s (3h), Refresh on the fly | | Token-Lebensdauer | 10800s (3h), Refresh on the fly |
| Cache | In-Memory, kein Disk-IO pro Render | | 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 | | Modul-Typen | NAMain, NAModule1..4 |
| `reachable`-Feld | „true" wenn das Modul in den letzten 4h gesehen wurde | | `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? ### Was wird pro Refresh gemacht?
1. Prüfe Token-Cache (RAM). Wenn abgelaufen oder fehlend → hole neuen 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. 2. `GET /api/getstationsdata?get_favorites=false` mit Bearer-Token.
3. Parse Antwort, filtere nach `station_filter` und sichtbaren Modulen. 3. Hole 12h-History (`getmeasure`) für MIN/MAX-Berechnung.
4. Rendere je nach Slot-Größe (`small` / `wide` / `tall` / `standard`). 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. Bei 401 wird der Token-Cache geleert und **einmal** neu authentifiziert.
Wenn das auch fehlschlägt → roter Error-Banner mit der genauen HTTP-Meldung. Wenn das auch fehlschlägt → roter Error-Banner mit der genauen HTTP-Meldung.
+354 -1
View File
@@ -4,17 +4,29 @@ Ein Plugin ist ein Python-Modul in /plugins/, das eine Klasse Widget
exportiert. Die Klasse wird beim Start dynamisch geladen und in der exportiert. Die Klasse wird beim Start dynamisch geladen und in der
Admin-UI zur Auswahl angeboten. Admin-UI zur Auswahl angeboten.
Dieses Modul enthaelt auch Shared Render-Helpers (Batterie, Compass,
Trend-Pfeile, Wetter-Icons, Charts), die von allen Plugins einheitlich
genutzt werden — damit sehen Batterie-Icons, Windrosen, etc. auf dem
Display konsistent aus.
Minimal-Beispiel siehe plugins/hello.py. Minimal-Beispiel siehe plugins/hello.py.
""" """
from __future__ import annotations from __future__ import annotations
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Any from typing import Any
import math
import time import time
import urllib.request import urllib.request
import urllib.error import urllib.error
import socket as _socket import socket as _socket
from PIL import ImageDraw from PIL import ImageDraw, ImageFont
from palette import (
FG, BG, BLACK, WHITE, GREEN, BLUE, RED, YELLOW, ORANGE,
BATT_LOW, BATT_MID, BATT_OK, batt_color,
measure, text_wh,
)
# ============================================================================ # ============================================================================
@@ -153,6 +165,13 @@ class Widget(ABC):
merged = dict(self.default_config) merged = dict(self.default_config)
merged.update(config or {}) merged.update(config or {})
self.config = merged self.config = merged
self.fonts: dict | None = None # wird vom Dashboard gesetzt vor render()
self.theme: Any = None # Theme-Instanz (von designbase), optional
def set_fonts(self, fonts: dict):
"""Wird vom Dashboard vor render() aufgerufen. Plugins koennen
dann self.fonts["24"] etc. nutzen statt selbst Fonts zu laden."""
self.fonts = fonts
@abstractmethod @abstractmethod
def fetch(self) -> dict: def fetch(self) -> dict:
@@ -189,3 +208,337 @@ def all_widget_classes() -> list[type[Widget]]:
classes.append(cls) classes.append(cls)
classes.sort(key=lambda c: c.label or c.name) classes.sort(key=lambda c: c.label or c.name)
return classes return classes
# ============================================================================
# Shared Render Helpers — Single Source of Truth fuer Batterie, Compass,
# Trend, Wetter-Icons, Charts. Alle Plugins nutzen diese Funktionen damit
# das Display konsistent aussieht.
# ============================================================================
# Sans-Schriftart aus dem System (lazy-loaded, gecacht)
_BASE_SANS_PATHS = [
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"C:/Windows/Fonts/arial.ttf",
"C:/Windows/Fonts/seguisb.ttf",
]
_BASE_SANS = None
def _get_base_sans() -> ImageFont.FreeTypeFont | None:
"""Lazy-load Sans-Basis-Font (size 14) fuer die Helper."""
global _BASE_SANS
if _BASE_SANS is not None:
return _BASE_SANS
import os
for p in _BASE_SANS_PATHS:
if os.path.exists(p):
try:
_BASE_SANS = ImageFont.truetype(p, 14)
return _BASE_SANS
except Exception:
continue
try:
_BASE_SANS = ImageFont.load_default()
except Exception:
_BASE_SANS = None
return _BASE_SANS
def _fit_size(draw, text: str, max_w: int, max_h: int,
candidates: list[str | int] | None = None) -> ImageFont.FreeTypeFont:
"""Iteriere durch Font-Groessen, waehle die erste die passt.
Verwendet das gleiche Sans-Family wie die Basis-Schrift.
"""
if candidates is None:
candidates = ["48", "36", "32", "28", "24", "20", "16", "14", "12", "10"]
base = _get_base_sans()
base_path = getattr(base, "path", None) if base else None
for sz in candidates:
try:
sz_int = int(sz)
if base_path:
f = ImageFont.truetype(base_path, sz_int)
else:
f = base
except Exception:
continue
tw, th = measure(draw, text, f)
if tw <= max_w and th <= max_h:
return f
return base
def text_in_box(draw, text, x, y, w, h, font, color, align: str = "left"):
"""Zeichnet Text in reservierten Bereich (x,y,w,h). Garantiert keine
Ueberlappung mit anderen Elementen wenn jeder seinen eigenen Bereich hat.
align: 'left' (default), 'right', 'center'
"""
tw, th = measure(draw, text, font)
if tw > w or th > h:
return False # wuerde ueberlaufen
if align == "left":
draw.text((x, y + (h - th) // 2), text, font=font, fill=color)
elif align == "right":
draw.text((x + w - tw, y + (h - th) // 2), text, font=font, fill=color)
elif align == "center":
draw.text((x + (w - tw) // 2, y + (h - th) // 2), text, font=font, fill=color)
return True
# ---- Batterie-Icon (Single Source of Truth) ------------------------------
def draw_battery(draw, x, y, pct: int | None, w: int = 22, h: int = 10,
with_text: bool = True, text_color: tuple | None = None):
"""Zeichnet ein konsistentes Batterie-Icon.
Args:
pct: Prozent (0-100), None → noop
w, h: Icon-Groesse (ohne Tip)
with_text: ob "%" daneben gezeichnet werden soll
text_color: falls None, automatisch nach Schwellen (LOW/MID/OK)
"""
if pct is None:
return 0
pct = max(0, min(100, pct))
color = text_color or batt_color(pct)
# Body
draw.rectangle((x, y, x + w - 3, y + h - 1), outline=FG, width=1)
# Tip
draw.rectangle((x + w - 2, y + 3, x + w, y + h - 3), fill=FG)
# Fill
fill_w = max(0, int((w - 4) * pct / 100))
if fill_w > 0:
draw.rectangle((x + 2, y + 2, x + 2 + fill_w, y + h - 2), fill=color)
used = w
if with_text:
f = _fit_size(draw, f"{pct}%", 30, h, candidates=["10", "9", "8"])
text_in_box(draw, f"{pct}%", x + w + 3, y - 1, 30, h + 2, f, color, "left")
used += 30
return used
# ---- Trend-Pfeil (up / down / stable) ------------------------------------
def draw_trend(draw, x, y, trend: str, size: int = 10, color: tuple | None = None):
"""Pfeil nach oben/unten, oder waagerechter Strich fuer 'stable'."""
color = color or FG
cx, cy = x + size // 2, y + size // 2
if trend == "up":
draw.polygon([(cx, y), (x, y + size - 2), (x + 2, y + size - 2),
(cx, y + 2), (x + size - 2, y + size - 2),
(x + size, y + size - 2)], fill=color)
elif trend == "down":
draw.polygon([(cx, y + size), (x, y + 2), (x + 2, y + 2),
(cx, y + size - 2), (x + size - 2, y + 2),
(x + size, y + 2)], fill=color)
else: # stable
draw.line((x, cy, x + size, cy), fill=color, width=2)
# ---- Windrose / Compass --------------------------------------------------
def draw_compass(draw, cx, cy, r: int, deg: float, color: tuple = BLUE,
with_labels: bool = False):
"""Zeichnet eine Windrose.
Args:
deg: 0=Nord, 90=Ost, 180=Sued, 270=West
with_labels: N/O/S/W Beschriftung (nur fuer grosse Varianten sinnvoll)
"""
if r < 4:
return
# Aeusserer Kreis
draw.ellipse((cx - r, cy - r, cx + r, cy + r), outline=FG, width=1)
# Cardinal Ticks
for a in [270, 90, 180, 0]: # N/O/S/W in Wetter-Konvention
rd = math.radians(a - 90)
x1 = cx + (r - 6) * math.cos(rd)
y1 = cy + (r - 6) * math.sin(rd)
x2 = cx + (r - 2) * math.cos(rd)
y2 = cy + (r - 2) * math.sin(rd)
draw.line((x1, y1, x2, y2), fill=FG, width=1)
# Pfeil
rad = math.radians(deg - 90)
tx = cx + (r - 4) * math.cos(rad)
ty = cy + (r - 4) * math.sin(rad)
draw.line((cx, cy, tx, ty), fill=color, width=2 if r < 16 else 3)
draw.ellipse((cx - 2, cy - 2, cx + 2, cy + 2), fill=color)
# Optionale N/O/S/W Labels
if with_labels and r >= 14:
for letter, a in [("N", 270), ("O", 0), ("S", 90), ("W", 180)]:
rd = math.radians(a - 90)
lx = cx + (r + 7) * math.cos(rd) - 4
ly = cy + (r + 7) * math.sin(rd) - 6
f = _fit_size(draw, letter, 10, 10, candidates=["10", "9", "8"])
draw.text((lx, ly), letter, font=f, fill=FG)
# ---- Wetter-Icon (7 Wetter-Zustaende) ------------------------------------
def draw_weather_icon(draw, x, y, size: int, code: int | str):
"""Zeichnet ein Wetter-Icon in (x, y, size, size) Box.
Akzeptiert WMO-Code (int) oder symbol-string ('sun'/'cloud'/'rain'/'storm'/'snow'/'fog'/'partly').
Verwendet nur die 7 ACeP-Farben.
"""
from palette import wmo_to_icon
sym = wmo_to_icon(code) if isinstance(code, int) else code
cx, cy = x + size // 2, y + size // 2
r = max(3, size // 3)
if sym in ("sun", "clear"):
draw.ellipse((cx - r, cy - r, cx + r, cy + r), fill=YELLOW)
for ang in range(0, 360, 45):
rad = math.radians(ang)
x1 = cx + int((r + 2) * math.cos(rad))
y1 = cy + int((r + 2) * math.sin(rad))
x2 = cx + int((r + 5) * math.cos(rad))
y2 = cy + int((r + 5) * math.sin(rad))
draw.line((x1, y1, x2, y2), fill=FG, width=1)
elif sym == "partly":
draw.ellipse((cx - r // 2 - 2, cy - r, cx + r // 2, cy + 1), fill=YELLOW)
cw = size // 2
ch = size // 4
draw.ellipse((cx - cw // 2, cy, cx + cw // 2, cy + ch),
fill=WHITE, outline=FG, width=1)
elif sym == "cloud":
cw = int(size * 0.7)
ch = size // 3
draw.ellipse((cx - cw // 2, cy - ch // 2, cx + cw // 2, cy + ch // 2),
fill=WHITE, outline=FG, width=2)
draw.ellipse((cx - cw // 3, cy - ch, cx + cw // 3, cy + ch // 2),
fill=WHITE, outline=FG, width=2)
elif sym == "rain":
cw = int(size * 0.7)
ch = size // 3
draw.ellipse((cx - cw // 2, cy - ch - 1, cx + cw // 2, cy + ch // 2 - 1),
fill=WHITE, outline=FG, width=2)
for dx in (-4, 0, 4):
draw.line((cx + dx, cy + ch, cx + dx, cy + ch + 5), fill=BLUE, width=2)
elif sym == "storm":
cw = int(size * 0.7)
ch = size // 3
draw.ellipse((cx - cw // 2, cy - ch - 1, cx + cw // 2, cy + ch // 2 - 1),
fill=WHITE, outline=FG, width=2)
draw.polygon([(cx - 3, cy + ch - 1), (cx + 1, cy + ch + 3),
(cx - 1, cy + ch + 3), (cx + 3, cy + ch + 8)],
fill=YELLOW)
elif sym == "snow":
cw = int(size * 0.7)
ch = size // 3
draw.ellipse((cx - cw // 2, cy - ch - 1, cx + cw // 2, cy + ch // 2 - 1),
fill=WHITE, outline=FG, width=2)
for dx in (-4, 0, 4):
for dy in (0, 5):
draw.line((cx + dx - 2, cy + ch + dy, cx + dx + 2, cy + ch + dy),
fill=FG, width=1)
draw.line((cx + dx, cy + ch + dy - 2, cx + dx, cy + ch + dy + 2),
fill=FG, width=1)
elif sym == "fog":
cw = int(size * 0.7)
for i, dy in enumerate((-2, 1, 4)):
y_pos = cy + dy
draw.line((cx - cw // 2, y_pos, cx + cw // 2, y_pos), fill=FG, width=1)
else:
f = _fit_size(draw, "?", size - 4, size - 4, candidates=["20", "16", "12"])
text_in_box(draw, "?", x, y, size, size, f, FG, "center")
# ---- Linien-Chart (mit optionalem Y-Grid) ---------------------------------
def draw_line_chart(draw, x, y, w, h, data: list, color: tuple = GREEN,
y_min: float | None = None, y_max: float | None = None,
with_y_grid: bool = True, n_ticks: int = 3):
"""Zeichnet einen Linien-Chart in (x,y,w,h) Box.
Args:
data: Liste von Werten
color: Linien-Farbe
y_min, y_max: Y-Achsen-Bereich. None → auto aus Daten
with_y_grid: Y-Tick-Linien mit Werten am linken Rand
n_ticks: Anzahl Y-Ticks (default 3)
"""
if len(data) < 2 or w <= 10 or h <= 10:
return
# Y-Range bestimmen
if y_min is None:
y_min = min(data)
if y_max is None:
y_max = max(data)
if y_min == y_max:
y_min -= 1
y_max += 1
# Grid: gestrichelte Linien (ACeP kann keine echten Dashes, daher Segmente)
if with_y_grid:
# Nur 2 Ticks zeigen: Min und Max (sonst zu viele Labels für kleinen Chart)
ticks = []
if n_ticks >= 2:
ticks = [y_min, y_max]
elif n_ticks == 1:
ticks = [(y_min + y_max) / 2]
for tick in ticks:
ty = y + int((y_max - tick) / max(1, y_max - y_min) * (h - 8)) + 4
# Y-Label links (NICHT unten — kollidiert mit x-Achsen-Labels)
f_lbl = _fit_size(draw, f"{int(tick)}°", 22, 10, candidates=["9", "8"])
text_in_box(draw, f"{int(tick)}°", x - 24, ty - 5, 22, 10, f_lbl, FG, "right")
# Gestrichelte Linie
for seg_x in range(x, x + w, 6):
draw.line((seg_x, ty, min(seg_x + 3, x + w), ty), fill=FG, width=1)
# Daten-Linie
pad_top, pad_bot = 4, 4
pts = []
for i, v in enumerate(data):
px = x + int(i * w / (len(data) - 1))
py = y + pad_top + int((1 - (v - y_min) / (y_max - y_min)) * (h - pad_top - pad_bot))
pts.append((px, py))
for i in range(len(pts) - 1):
draw.line((pts[i][0], pts[i][1], pts[i+1][0], pts[i+1][1]),
fill=color, width=2)
for px, py in pts:
draw.ellipse((px - 1, py - 1, px + 1, py + 1), fill=color)
# ---- Mini-Bar mit Schwellen-Farbe ----------------------------------------
def draw_mini_bar(draw, x, y, w, h, value: float, max_value: float,
color: tuple | None = None):
"""Horizontale Mini-Bar mit Outline + Fill in gegebener Farbe."""
if w < 6 or h < 4:
return
color = color or FG
draw.rectangle((x, y, x + w - 1, y + h - 1), outline=FG, width=1)
pct = min(100, max(0, value / max(0.001, max_value) * 100))
fill_w = max(0, int((w - 2) * pct / 100))
if fill_w > 0:
draw.rectangle((x + 1, y + 1, x + 1 + fill_w, y + h - 2), fill=color)
# ---- ZZZ / Sleep-Indikator -----------------------------------------------
def draw_zzz_indicator(draw, x, y, size: int = 16):
"""Kleines Mond-Icon + 'Zzz' fuer DeepSleep-Anzeige.
Wird vom Dashboard-Frame im Header-Bereich gezeichnet, wenn Power-Save
aktiv ist.
"""
cx, cy = x + size // 2, y + size // 2
r = size // 3
# Mond (Yellow circle, halb verdeckt)
draw.ellipse((cx - r, cy - r, cx + r, cy + r), fill=YELLOW)
# 'Z' Buchstaben daneben
f = _fit_size(draw, "Z", size, size, candidates=["14", "12", "10"])
text_in_box(draw, "z", x + size + 2, y, 12, size, f, FG, "left")
# ---- DeepSleep-Status-Anzeige fuer Display ------------------------------
def draw_deepsleep_indicator(draw, x, y, w, h, next_wake: str = ""):
"""Zeichnet einen schmalen 'Schlafend'-Bereich im Header.
Args:
next_wake: z.B. '06:30' → wird als Text daneben gezeichnet
"""
if w < 60 or h < 12:
return
# Gelber Mond-Icon
f_moon = _fit_size(draw, "Zz", 30, h, candidates=["12", "10"])
text_in_box(draw, "Zz", x, y, 24, h, f_moon, ORANGE, "center")
# 'Schlafend bis' Text
if next_wake:
f = _fit_size(draw, f"schlafend bis {next_wake}", w - 26, h, candidates=["10", "9", "8"])
text_in_box(draw, f"schlafend bis {next_wake}", x + 26, y, w - 26, h, f, FG, "left")
+6 -5
View File
@@ -21,7 +21,7 @@ import os, sys
from datetime import datetime from datetime import datetime
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from plugins.base import Widget from plugins.base import Widget
from palette import FG, BG, OK, BLUE, YELLOW, ORANGE, fill_for, measure from palette import FG, BG, OK, BLUE, YELLOW, ORANGE, fill_for, measure, de_weekday_long, de_date
# ---- Deutsche Minuten-Phrasen (QlockTwo-konform) ---- # ---- Deutsche Minuten-Phrasen (QlockTwo-konform) ----
@@ -174,19 +174,20 @@ class Widget(Widget):
prefer=["16", "20", "default"]) prefer=["16", "20", "default"])
if header_h: if header_h:
if show_wd and show_dt: if show_wd and show_dt:
day = now.strftime("%A") # Locale-frei: explizit deutsche Wochentage/Datums-Formatter
date = now.strftime("%e. %b %Y").strip() day = de_weekday_long(now.weekday())
date = de_date(now, short=True)
text = f"{day} · {date}" text = f"{day} · {date}"
tw, th = measure(draw, text, header_font) tw, th = measure(draw, text, header_font)
draw.text((inner_x + (inner_w - tw) // 2, cy), draw.text((inner_x + (inner_w - tw) // 2, cy),
text, font=header_font, fill=accent) text, font=header_font, fill=accent)
elif show_wd: elif show_wd:
text = now.strftime("%A") text = de_weekday_long(now.weekday())
tw, _ = measure(draw, text, header_font) tw, _ = measure(draw, text, header_font)
draw.text((inner_x + (inner_w - tw) // 2, cy), draw.text((inner_x + (inner_w - tw) // 2, cy),
text, font=header_font, fill=accent) text, font=header_font, fill=accent)
elif show_dt: elif show_dt:
text = now.strftime("%e. %b %Y").strip() text = de_date(now, short=True)
tw, _ = measure(draw, text, header_font) tw, _ = measure(draw, text, header_font)
draw.text((inner_x + (inner_w - tw) // 2, cy), draw.text((inner_x + (inner_w - tw) // 2, cy),
text, font=header_font, fill=accent) 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): def _fmt_timedelta_short(seconds):
"""Formatiert Sekunden als deutsche Kurz-Laufzeit: '3T 5h', '7h 12min', '42min', 'jetzt'."""
if seconds is None: return "" if seconds is None: return ""
s = int(seconds) s = int(seconds)
if s <= 0: return "now" if s <= 0: return "jetzt"
days, rem = divmod(s, 86400) days, rem = divmod(s, 86400)
h, rem = divmod(rem, 3600) h, rem = divmod(rem, 3600)
m = rem // 60 m = rem // 60
if days: return f"{days}d {h}h" if days: return f"{days}T {h}h"
if h: return f"{h}h {m}m" if h: return f"{h}h {m}min"
return f"{m}m" return f"{m}min"
def _deep_get(d, *keys, default=None): def _deep_get(d, *keys, default=None):
+472 -572
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): class Widget(Widget):
name = "strava" name = "strava"
label = "Strava Aktivitäten" 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" category = "fitness"
config_schema = [ config_schema = [
@@ -77,8 +77,8 @@ class Widget(Widget):
return return
if is_wide(w, h): if is_wide(w, h):
stats = [f"{d['year_km']:.0f} km YTD", f"{d['rides']} rides", stats = [f"{d['year_km']:.0f} km Jahr", f"{d['rides']} Fahrten",
f"{d['total_km']:.0f} km total"] f"{d['total_km']:.0f} km Gesamt"]
col_w = w // len(stats) col_w = w // len(stats)
for i, s in enumerate(stats): for i, s in enumerate(stats):
cx = x + i * col_w 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) 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), f"{d['year_km']:.0f} km", font=font_b, fill=FG)
draw.text((x + pad, y + 50 + font_b.size + 8), draw.text((x + pad, y + 50 + font_b.size + 8),
f"in {datetime.now().year}", font=fonts.get("24", fonts.get("20")), fill=OK) f"im Jahr {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) font_r = fit_font(draw, f"{d['rides']} Fahrten", fonts, w - 2 * pad, 28)
draw.text((x + pad, y + h - 100), f"{d['rides']} rides", font=font_r, fill=OK) 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 hike", fonts, w - 2 * pad, 24) 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 hike", font=font_h, fill=FG) 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"Total: {d['total_km']:.0f} km", fonts, w - 2 * pad, 20) 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"Total: {d['total_km']:.0f} km", font=font_t, fill=FG) 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): def _fmt_uptime(s):
"""Formatiert Sekunden als deutsche Laufzeit: '3T 5h', '7h 12min', '42min'."""
days, rem = divmod(int(s), 86400) days, rem = divmod(int(s), 86400)
h, rem = divmod(rem, 3600) h, rem = divmod(rem, 3600)
m = rem // 60 m = rem // 60
if days: return f"{days}d {h}h" if days: return f"{days}T {h}h"
if h: return f"{h}h {m}m" if h: return f"{h}h {m}min"
return f"{m}m" return f"{m}min"
def _system_stats(): def _system_stats():
@@ -122,7 +123,7 @@ class Widget(Widget):
draw.text((cx + pad, y + pad), f"{label} {pct}%", draw.text((cx + pad, y + pad), f"{label} {pct}%",
font=label_font, fill=col_color) font=label_font, fill=col_color)
if self.cfg("show_uptime"): 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) 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) draw.text((x + 2 * col_w + pad, y + pad), up_str, font=font_up, fill=INFO)
return return
@@ -155,9 +156,9 @@ class Widget(Widget):
if self.cfg("show_load") or self.cfg("show_uptime"): if self.cfg("show_load") or self.cfg("show_uptime"):
parts = [] parts = []
if self.cfg("show_load"): 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"): 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) line = " · ".join(parts)
font_bot = fit_font(draw, line, fonts, w - 2 * pad, 22) font_bot = fit_font(draw, line, fonts, w - 2 * pad, 22)
draw.text((x + pad, bottom_y), line, font=font_bot, fill=FG) 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.""" """Wetter-Plugin (Open-Meteo, kein API-Key). Responsive + Error-Handling.
import os, sys, json, math
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 from datetime import datetime
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from plugins.base import Widget, 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, 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): def _fetch_openmeteo(lat, lon):
@@ -27,7 +35,7 @@ def _fetch_openmeteo(lat, lon):
class Widget(Widget): class Widget(Widget):
name = "weather" name = "weather"
label = "Wetter (Open-Meteo)" 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" category = "weather"
config_schema = [ config_schema = [
@@ -54,31 +62,7 @@ class Widget(Widget):
data["_lat"] = lat; data["_lon"] = lon data["_lat"] = lat; data["_lon"] = lon
return data 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): def render(self, draw, fonts, x, y, w, h):
from palette import measure
d = self.fetch() d = self.fetch()
if "_error" in d: if "_error" in d:
@@ -88,46 +72,59 @@ class Widget(Widget):
cur = d.get("current", {}) cur = d.get("current", {})
temp = cur.get("temperature_2m", 0) 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): if is_small(w, h):
temp_str = f"{int(temp + 0.5)}°" temp_str = f"{int(temp + 0.5)}°"
font = fit_font(draw, temp_str, fonts, w - 2 * 8, int(h * 0.6)) 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, temp_color) 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"): if self.cfg("show_uv"):
uv_int = int(cur.get("uv_index", 0) + 0.5) uv_int = int(cur.get("uv_index", 0) + 0.5)
uv_str = f"UV {uv_int}" 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) centered_text(draw, uv_str, x, y + int(h * 0.62), w, h // 4, font_uv, FG)
return return
if is_wide(w, h): if is_wide(w, h):
temp_str = f"{int(temp + 0.5)}°" temp_str = f"{int(temp + 0.5)}°"
font = fit_font(draw, temp_str, fonts, w // 3 - 2 * 8, h - 2 * 8) font = fit_font(draw, temp_str, fonts, w // 3 - 16, h - 16)
centered_text(draw, temp_str, x, y, w // 3, h, font, temp_color) centered_text(draw, temp_str, x, y, w // 3, h, font, tcol)
hum = cur.get("relative_humidity_2m", 0) hum = cur.get("relative_humidity_2m", 0)
ws = cur.get("wind_speed_10m", 0) ws = cur.get("wind_speed_10m", 0)
wd = cur.get("wind_direction_10m", 0) wd = cur.get("wind_direction_10m", 0)
stats = f"Hum {hum}%\nWind {ws} km/h\nDir {wd}°" stats = [f"Feuchte {hum}%", f"Wind {ws} km/h", f"Richtung {int(wd + 0.5)}°"]
font_s = fit_font(draw, "Hum 100%", fonts, w - w // 3 - 2 * 8, h // 3) font_s = fit_font(draw, "Feuchte 100%", fonts, w - w // 3 - 16, h // 3)
y_off = y + 8 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) centered_text(draw, line, x + w // 3, y_off, w - w // 3, h // 3, font_s, FG)
y_off += h // 3 y_off += h // 3
return return
# Standard: Big temp, info, forecast # Standard: Big temp, info, forecast
draw.text((x + 8, y + 8), "WETTER", font=fonts.get("24", fonts.get("20")), fill=INFO) 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)}°" temp_str = f"{int(temp + 0.5)}°"
font = fit_font(draw, temp_str, fonts, w // 2 - 2 * 8, int(h * 0.6)) 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=temp_color) draw.text((x + 8, y + 50), temp_str, font=font, fill=tcol)
hum = cur.get("relative_humidity_2m", 0) hum = cur.get("relative_humidity_2m", 0)
ws = cur.get("wind_speed_10m", 0) ws = cur.get("wind_speed_10m", 0)
wd = cur.get("wind_direction_10m", 0) wd = cur.get("wind_direction_10m", 0)
stats_y = y + 60 stats_y = y + 60
for line, col in [(f"Hum {hum}%", BLUE), (f"Wind {ws} km/h", INFO), (f"Dir {wd}°", FG)]: for line, col in [(f"Feuchte {hum}%", BLUE),
font_l = fit_font(draw, line, fonts, w // 2 - 2 * 8, 24) (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) draw.text((x + w // 2 + 8, stats_y), line, font=font_l, fill=col)
stats_y += 30 stats_y += 30
@@ -140,34 +137,47 @@ class Widget(Widget):
box_h = 36 box_h = 36
if uv_int >= 6: if uv_int >= 6:
draw.rectangle((box_x, box_y, box_x + box_w, box_y + box_h), fill=ALERT) 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: else:
draw.rectangle((box_x, box_y, box_x + box_w, box_y + box_h), outline=FG, width=2) 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: if self.cfg("show_wind_compass") and w >= 280 and h >= 280:
cr = min(60, w // 8, h // 6) cr = min(60, w // 8, h // 6)
cx = x + w - cr - 8 cx = x + w - cr - 8
cy = y + h - 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)) fc_h = max(1, min(int(self.cfg("show_forecast_hours", 4)), 8))
times = d.get("hourly", {}).get("time", []) times = d.get("hourly", {}).get("time", [])
temps = d.get("hourly", {}).get("temperature_2m", []) temps = d.get("hourly", {}).get("temperature_2m", [])
codes = d.get("hourly", {}).get("weather_code", [])
if times and h > 200: if times and h > 200:
cur_iso = datetime.now().strftime("%Y-%m-%dT%H:00") cur_iso = datetime.now().strftime("%Y-%m-%dT%H:00")
try: idx0 = times.index(cur_iso) try:
except ValueError: idx0 = 0 idx0 = times.index(cur_iso)
except ValueError:
idx0 = 0
forecast_y = y + h - 70 forecast_y = y + h - 70
strip_w = int(w * 0.6) strip_w = int(w * 0.7)
cell_w = strip_w // fc_h cell_w = strip_w // fc_h
for i in range(fc_h): for i in range(fc_h):
j = idx0 + i + 1 j = idx0 + i + 1
if j >= len(times): break if j >= len(times):
break
hh = times[j].split("T")[1][:5] hh = times[j].split("T")[1][:5]
tt = int(temps[j] + 0.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 cx2 = x + 8 + i * cell_w
draw.text((cx2, forecast_y), hh, font=fonts.get("20", fonts.get("16")), fill=FG) 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) # Wetter-Icon (klein) pro Stunde
draw.text((cx2, forecast_y + 22), f"{tt}°", font=font_t, fill=color) 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; letter-spacing: 0.04em;
} }
.topbar-clock .small { color: var(--fg-muted); font-size: 0.85em; margin-left: 6px; } .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 { .content {
flex: 1; padding: 28px; 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> <span class="icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
Settings Settings
</button> </button>
<a class="sidebar-item" href="/power" style="text-decoration:none">
<span class="icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18.36 6.64a9 9 0 1 1-12.73 0"/><line x1="12" y1="2" x2="12" y2="12"/></svg></span>
Energie
<span class="badge" id="sbPower">—</span>
</a>
<button class="sidebar-item" data-tab="network" onclick="switchTab('network')"> <button class="sidebar-item" data-tab="network" onclick="switchTab('network')">
<span class="icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/></svg></span> <span class="icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/></svg></span>
Network Network
@@ -965,6 +971,7 @@
<div class="topbar-clock"> <div class="topbar-clock">
<span id="topbarTime">--:--:--</span> <span id="topbarTime">--:--:--</span>
<span class="small" id="topbarDate">—</span> <span class="small" id="topbarDate">—</span>
<span class="small" id="topbarNext">—</span>
</div> </div>
<button class="refresh" id="refreshBtn" onclick="triggerRefresh()"> <button class="refresh" id="refreshBtn" onclick="triggerRefresh()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <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}`; document.getElementById('topbarTime').textContent = `${hh}:${mm}:${ss}`;
const days = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa']; 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')}`; 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); setInterval(updateClock, 1000);
updateClock(); updateClock();
@@ -2042,6 +2057,32 @@
}; };
document.getElementById('netHint').textContent = hints[s.mode] || ''; document.getElementById('netHint').textContent = hints[s.mode] || '';
} }
// ---- Power-Status (DeepSleep) ----
async function refreshPowerStatus() {
try {
const r = await fetch('/api/power');
const d = await r.json();
const sb = document.getElementById('sbPower');
if (sb) {
if (d.deepsleep_enabled && d.is_deepsleep) {
sb.textContent = 'Zz';
sb.style.background = '#fef3c7';
sb.style.color = '#92400e';
} else if (d.deepsleep_enabled) {
sb.textContent = 'an';
sb.style.background = '';
sb.style.color = '';
} else {
sb.textContent = 'aus';
sb.style.background = '';
sb.style.color = '';
}
}
} catch (e) {}
}
refreshPowerStatus();
setInterval(refreshPowerStatus, 30000);
async function refreshScan() { async function refreshScan() {
const r = await netFetch('/api/network/scan'); if (!r) return; const r = await netFetch('/api/network/scan'); if (!r) return;
const sel = document.getElementById('ssidSelect'); const sel = document.getElementById('ssidSelect');
+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")})')