2 Commits
Author SHA1 Message Date
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
9 changed files with 342 additions and 125 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
```
+70 -23
View File
@@ -2,7 +2,8 @@
Plugin-based dashboard for the **Waveshare 7.3" ACeP 7-Color e-Paper (F) HAT** on Raspberry Pi 4.
Rendert alle 180s (oder konfigurierbar) ein 800×480px Vollbild auf dem Display.
Rendert regelmäßig (Standard 180s, oder konfigurierbar; im DeepSleep-Modus nur zu
Weckzeiten) ein 800×480px Vollbild auf dem Display.
## Hardware
@@ -13,26 +14,44 @@ Rendert alle 180s (oder konfigurierbar) ein 800×480px Vollbild auf dem Display.
## Architektur
```
┌─────────────────────────────────────────────┐
┌──────────────────────────────────────────────┐
│ epaper-dashboard │
├──────────────────┬──────────────────────────┤
├──────────────────┬───────────────────────────┤
│ dashboard.py │ admin.py │
│ (Renderer) │ (Web Admin UI :8080) │
├──────────────────┴──────────────────────────┤
│ (Renderer + │ (Web Admin UI :8080) │
│ DeepSleep) │ │
├──────────────────┴───────────────────────────┤
│ layout.py · 4×4 Grid · Pack-Algorithmus │
├────────────────────────────────────────────┤
├───────────────────────────────────────────────┤
│ plugins/ (eines pro Widget) │
│ clock · weather · system · spotify │
│ strava · gmail · minimax · hello │
├────────────────────────────────────────────┤
│ clock · clock_wordclock · weather · system │
│ spotify · strava · gmail · minimax · │
│ netatmo · hello │
├───────────────────────────────────────────────┤
│ shared: palette.py · plugins/base.py │
│ (Farben, Formatter, Render-Helper) │
├───────────────────────────────────────────────┤
│ network_watchdog.py │
│ (WiFi AP/Client Management) │
├────────────────────────────────────────────┤
├───────────────────────────────────────────────┤
│ waveshare_epd/ (Vendor-Treiber) │
│ epd7in3f.py │
└─────────────────────────────────────────────┘
└───────────────────────────────────────────────┘
```
### Shared Foundation (Single Source of Truth)
- **`palette.py`** — 7-Farben ACeP-Palette (`BLACK, WHITE, RED, GREEN, BLUE, YELLOW, ORANGE`),
semantische Aliase (`FG, BG, OK, WARN, ALERT, INFO, ACCENT`), deutsche Datums-Formatter
(`de_weekday_long`, `de_weekday_short`, `de_date`, `de_weekday_date`, `de_time`),
`temp_color()` / `co2_color()` / `batt_color()`, Wetter-Icon-Mapping (`wmo_to_icon`).
- **`plugins/base.py`** — `Widget`-Basisklasse, Render-Helper
(`draw_battery`, `draw_compass`, `draw_trend`, `draw_line_chart`, `draw_mini_bar`,
`draw_weather_icon`, `draw_zzz_indicator`, `draw_deepsleep_indicator`),
Layout-Utilities (`is_small`, `is_wide`, `is_tall`).
Alle Plugins nutzen diese Helper — keine duplizierte Render-Logik.
## Quick Start
### Pi vorbereiten
@@ -54,7 +73,7 @@ cd epaper-dashboard
cp config.example.json config.json
# → config.json editieren (Plugins + Layout)
#waveshare_epd Treiber
# waveshare_epd Treiber
git clone https://github.com/waveshareteam/Waveshare_GPIO.git waveshare_epd_lib
# oder: den waveshare_epd Ordner vom vorherigen Setup übernehmen
@@ -71,6 +90,11 @@ chmod +x install_services.sh
{
"version": 2,
"refresh_interval_s": 180,
"power": {
"deepsleep_enabled": false,
"wake_times": ["07:00", "12:00", "18:00"],
"cpu_throttle": false
},
"layout": {
"grid": { "cols": 4, "rows": 4 },
"items": [
@@ -96,6 +120,7 @@ http://pi:8080/
- **Layout-Editor** — Items per Klick hinzufügen, verschieben, Größe ändern, Auto-Pack
- **Plugin-Config** — API-Keys, Locations, etc. pro Widget
- **Energie-Tab** — DeepSleep-Modus, Weckzeiten, CPU-Throttle, manuelles Display-Aufwecken
- **Refresh Now** — sofortiger Display-Refresh
- **Netzwerk** — WLAN wechseln ohne SSH
@@ -104,23 +129,40 @@ Default Login: `admin` / `admin` — in `.env` mit `EPAPER_ADMIN_PASSWORD` setze
## Plugins
| Plugin | Datenquelle | Config-Felder |
|--------|-------------|---------------|
| `clock` | Systemzeit | — |
| `weather` | Open-Meteo (kein API-Key) | `location` (lat,lon), `show_uv`, `show_forecast_hours` |
| `system` | Pi CPU/RAM/Uptime | `show_uptime`, `show_load`, `show_temp` |
| `spotify` | Spotify Web API | `client_id`, `client_secret` (via Spotify Dev Portal) |
| `strava` | Strava API | `access_token`, `club_id` |
| `gmail` | Gmail API | `credentials_json` (OAuth2) |
| `minimax` | MiniMax AI | `api_key`, `model`, `prompt` |
| `netatmo` | Netatmo Weather API (OAuth2) | `client_id`, `client_secret`, `username`, `password` (Details: [NETATMO.md](plugins/NETATMO.md)) |
| `hello` | statisch | `text`, `color`, `size` |
|---|---|---|
| `clock` | Systemzeit | `format_24h`, `show_seconds`, `show_date`, `show_weekday`, `accent_color` |
| `clock_wordclock` | Systemzeit | `show_date`, `show_weekday`, `show_es_ist`, `invert`, `accent_color` (QlockTwo-Style Wort-Uhr) |
| `weather` | Open-Meteo (kein API-Key) | `location` (lat,lon), `show_uv`, `show_forecast_hours`, `show_wind_compass` |
| `system` | Pi CPU/RAM/Uptime | `show_uptime`, `show_load`, `compact`, `bar_thresholds`, `bar_gradient` |
| `spotify` | Last.fm Scrobble | `api_key`, `username` |
| `strava` | Strava API | `client_id`, `client_secret`, `refresh_token` |
| `gmail` | Gmail API | `oauth_token_json` |
| `minimax` | MiniMax Token Plan | `subscription_key`, `show_credits`, `api_base`, `bar_thresholds`, `bar_gradient` |
| `netatmo` | Netatmo Weather API (OAuth2) | siehe [plugins/NETATMO.md](plugins/NETATMO.md) |
| `hello` | statisch | `text`, `color` |
## DeepSleep / Energie
Das Dashboard unterstützt einen **DeepSleep-Modus** für stromsparenden 24/7-Betrieb:
- **Weckzeiten** (z.B. `["07:00", "12:00", "18:00"]`) — Display wacht zu diesen Zeiten auf,
rendert neu, schläft wieder.
- **CPU-Throttle** (Pi auf 600MHz im Sleep) — spart ~1.5W am Raspberry Pi.
- **Manuelles Wecken** — Button im Admin-UI unter `/power` triggert sofortigen Refresh.
- **ZZZ-Indikator** — `draw_zzz_indicator` aus `plugins/base.py` zeigt im Sleep-Zustand
dezent "zZz" oben links.
- **60s-Sleep-Chunks** — Main-Loop schläft in 1-Minuten-Häppchen statt eines langen
Intervalls, damit das Weck-Signal schnell greift.
Konfiguration in der Admin-UI (`/power`) oder direkt in `config.json` unter `power.*`.
Defaults werden beim ersten Start aus `POWER_DEFAULTS` in `palette.py` auto-gefüllt.
## Eigenes Plugin schreiben
```python
# plugins/my_widget.py
from plugins.base import Widget
from palette import FG, BG
from palette import FG, BG, de_weekday_date
class Widget(Widget):
name = "my_widget"
@@ -140,12 +182,17 @@ class Widget(Widget):
def render(self, draw, fonts, x, y, w, h):
d = self.fetch()
# w, h sind Pixel (nicht Zellen!)
# Nutze die shared helper aus palette + base
draw.text((x + 10, y + 10), f"Value: {d['value']}",
font=fonts["28"], fill=FG)
```
**Palette:** `BLACK, WHITE, RED, GREEN, BLUE, YELLOW, ORANGE` + semantisch `FG, BG, OK, WARN, ALERT, INFO, ACCENT`
**Tipp:** Vermeide eigene `draw_compass`/`draw_weather_icon`/etc. — nutze die Helper
aus `plugins/base.py`. Sonst dupliziert sich die Render-Logik und das Design driftet
auseinander (siehe Commit-History FEAT-NETATMO-06).
## Services
```bash
+36
View File
@@ -39,3 +39,39 @@ Falls Strom-Reset nicht hilft (z.B. weil Profil `netplan-wlan0-IoT` kaputt ist):
- Watchdog-Code war korrekt; das Problem ist die PolicyKit-Konfiguration
- Polkit-Restart nach Rule-Update: besser nur `polkitd` SIGHUP schicken, nicht systemctl restart
(das hat in dieser Session offenbar den Polkit-Daemon in einem inkonsistenten Zustand hinterlassen)
## Display-Refresh aus Admin-UI erzwingen
Falls das Display im DeepSleep-Modus ist (z.B. ausserhalb der Weckzeiten) und du
es manuell wecken willst, ohne den Pi neu zu starten:
### Option 1: Admin-UI Energie-Tab
1. Browser: `http://<pi>:8080/power` (oder `http://10.42.0.1:8080/power` im Recovery-AP)
2. Klick auf **Display wecken** — triggert `POST /api/wake`, Dashboard-Refresh startet sofort
3. CPU-Throttle wird automatisch deaktiviert, Display rendert, geht zurück in Sleep
### Option 2: Per curl direkt
```bash
curl -X POST http://<pi>:8080/api/wake
# Response: {"ok": true, "woken_at": "2026-08-31T00:42:13"}
```
Das funktioniert auch im Recovery-AP-Modus (`10.42.0.1`) — solange der Admin-Service läuft.
## DeepSleep-Fehlkonfiguration
Wenn `wake_times` so gesetzt ist, dass das Display **nie** aufwacht (z.B. leere Liste
oder Einträge in der Vergangenheit), bleibt das Dashboard dauerhaft im Sleep:
```bash
# SSH-Zugang nötig (koptikp)
ssh koptikp@<pi>
# config.json direkt editieren:
nano /home/koptikp/.hermes/projects/pi/config.json
# → "deepsleep_enabled": false setzen
# → "wake_times": ["07:00", "12:00", "18:00"] setzen
# Service neu starten:
systemctl --user restart epaper-dashboard
```
Alternativ über die Recovery-AP: `http://10.42.0.1:8080/power` → DeepSleep deaktivieren.
+93 -17
View File
@@ -97,12 +97,59 @@ In der Admin-UI ein Netatmo-Widget auf das Grid ziehen. Das Plugin ist
| Slot | Was du siehst |
|---|---|
| **1×1** | Große Außen-Temperatur, Mini-CO₂/Feuchte-Status |
| **2×1** | Outdoor-Temperatur + Wind-Geschwindigkeit kompakt |
| **4×1** | Indoor · Outdoor · Wind/Regen — drei Spalten |
| **1×4** | Vertikale Liste aller Module |
| **2×2** | Indoor-Card (mit CO₂-Bar) + Outdoor-Card + Wind/Regen-Bereich |
| **4×4** | Volle Ansicht mit allen Modulen, Min/Max, Windrose |
| **1×1** | `compact`: Große Außen-Temperatur + MIN/MAX + Tag-Status |
| **2×2** | `compact_2x2`: Aussen + Wind/Regen-Bereich + Indoor-Liste |
| **4×4** | `full_4x4`: Volle V1-Status-Update Ansicht (siehe unten) |
### V1-Status-Update Design (4×4)
Die grosse Ansicht ist als **V1-Status-Update** designed — minimal, weisser
Hintergrund, kräftige Akzentfarben, klare Hierarchie:
```
┌──────────────────────────────────────────────────────────────────┐
│ DAHEIM Mo 31.08 00:36 │
├──────────────┬──────────┬─────────────────────────────────────────┤
│ AUSSEN ▮▮▮ │ WIND │ INNEN · 3 │
│ │ N │ ┌─┐ │
│ 12.6° │ 12 │ │M│ Daheim 21.5° │
│ │ km/h │ └─┘ │
│ MIN MAX F │ Bö 24 │ ▰▰▱▱▱▱▱▱ CO₂ 620 ppm ▮ 78% │
│ 13° 21° 67% │ ─────── │ │
│ DRUCK 1014▼ │ REGEN │ ┌─┐ │
│ │ 0.0 │ │2│ Küche 23.5° │
│ 12H VERLAUF │ mm/h │ └─┘ │
│ ╱╲ │ │ ▰▰▰▰▱▱▱▱ CO₂ 980 ppm ▮ 88% │
│ ╱ ╲ │ HEUTE │ │
│ ╱ ╲── │ 6.2 mm │ ┌─┐ │
│ 22°/10° jetzt│ ▮ 82% │ │3│ Schlafen 20.8° │
│ │ │ └─┘ │
│ │ │ ▰▰▰▰▰▱▱ CO₂ 1450 ppm ▮ 41% │
├──────────────┴──────────┴─────────────────────────────────────────┤
│ VORHERSAGE │
│ MO ▮ 0.0 mm ☀ 9° 19° │
│ DI ▮ 2.4 mm ☁ 11° 21° │
│ MI ▮ 8.1 mm ☁ 8° 17° │
└──────────────────────────────────────────────────────────────────┘
```
**Spalten:**
- **Aussen (28%)** — Grosse Aussentemperatur + 4 Stats (MIN/MAX/FEUCHTE/DRUCK) +
12h-Verlaufs-Chart (grüne Linie, 2 Y-Ticks min/max)
- **Wind/Regen (16%)** — beide gestapelt, Wind oben mit Compass und Bö,
Regen unten mit HEUTE-Menge und 1h/24h-Bars; jeder Block hat seine Batterie unten rechts
- **Indoor (36%)** — pro Sensor eine Zeile: rote Tag-Box (M/2/3) | Name | grosser
Temp-Wert (22pt, in Temperatur-Farbe) | CO₂-Bar (gelb/rot je nach ppm) | CO₂-Wert + Batterie
- **Vorhersage (20%)** — pro Tag: Tag-Name + Regentropfen-Icon + Regenmenge in mm +
Wetter-Icon (WMO), darunter riesige Min (blau) / Max (orange)
**Wichtige Design-Regeln:**
- 7 ACeP-Farben nur (kein Grau, keine Verläufe, kein Anti-Aliasing)
- Wichtige Werte (Temps, Regen) dominieren visuell
- Batterie-Icons gehen in den Bottom-Right ihrer Sektion
- Indoor-Sensoren haben rote Tag-Box (NICHT schwarz)
- MIN/MAX kommen aus echter 12h-History (nicht estimated)
- Forecast-Daten dynamisch via `datetime.now() + timedelta`
Das Plugin priorisiert bei der Layout-Wahl **wide/tall** vor `small`,
damit 4×1- und 1×4-Slots nicht in die Mini-Ansicht fallen.
@@ -115,20 +162,27 @@ damit 4×1- und 1×4-Slots nicht in die Mini-Ansicht fallen.
|---|---|---|---|
| `client_id` | secret | — | Netatmo App Client-ID |
| `client_secret` | secret | — | Netatmo App Client-Secret |
| `username` | secret | — | Netatmo Login (E-Mail) |
| `password` | secret | — | Netatmo Passwort (nur RAM) |
| `refresh_token` | secret | — | OAuth2 Refresh-Token (nur RAM, wird on-demand erneuert) |
| `station_filter` | string | `""` | Substring-Filter auf Stations-/Modulname |
| `show_indoor` | bool | `true` | Hauptmodul + zusätzliche Indoor-Sensoren anzeigen |
| `show_outdoor` | bool | `true` | NAModule1 (Außen) anzeigen |
| `show_wind` | bool | `true` | NAModule2 (Wind) anzeigen |
| `show_rain` | bool | `true` | NAModule3 (Regen) anzeigen |
| `show_compass` | bool | `true` | Windrose in der Wind-Card zeichnen |
| `show_secondary` | bool | `true` | Min/Max + letzte Aktualisierung |
| `show_indoor` | bool | `true` | Hauptmodul + zusätzliche Indoor-Sensoren anzeigen |
| `show_forecast` | bool | `true` | Open-Meteo 3-Tage-Vorhersage anzeigen |
| `co2_thresholds` | string | `ok@600,warn@1000,alert@1500` | CO₂-Bar Schwellen (ppm) |
| `bar_gradient` | bool | `true` | Verlaufsmodus der Bar |
| `temp_unit` | select | `C` | `C` oder `F` |
| `wind_unit` | select | `kmh` | `kmh` oder `ms` |
### Auth-Flow (aktualisiert)
Das Plugin verwendet **OAuth2 mit Refresh-Token-Grant** (kein Passwort-Grant mehr).
Beim ersten Setup musst du einmalig einen Refresh-Token holen — das geht z.B. mit
dem Netatmo-Developer-Portal oder dem offiziellen OAuth-Tool. Danach wird der
Token automatisch im RAM gecacht und alle 3h erneuert.
Falls du noch alte `username`/`password`-Config-Felder in deiner `config.json` hast:
diese werden ignoriert. Setze statt dessen `refresh_token`.
### CO₂-Schwellen anpassen
`co2_thresholds` ist ein String im Format `farbe@ppm_in_ppm`.
@@ -181,21 +235,43 @@ wird ein neuer Token geholt.
| Aspekt | Wert |
|---|---|
| API-Version | Netatmo Connect OAuth2 |
| API-Version | Netatmo Connect OAuth2 (Refresh-Token-Grant) |
| Endpoints | `POST /oauth2/token`, `GET /api/getstationsdata` |
| Forecast-Quelle | Open-Meteo (`/v1/forecast`) — kein API-Key |
| Token-Lebensdauer | 10800s (3h), Refresh on the fly |
| Cache | In-Memory, kein Disk-IO pro Render |
| Datenquelle | Netatmo Cloud (Update alle ~10 Min) |
| Datenquelle Netatmo | Netatmo Cloud (Update alle ~10 Min) |
| Modul-Typen | NAMain, NAModule1..4 |
| `reachable`-Feld | „true" wenn das Modul in den letzten 4h gesehen wurde |
### Wichtige interne Methoden
- `_render_compact(x, y, w, h, ...)` — 1×1-Mini-Ansicht
- `_render_compact_2x2(x, y, w, h, ...)` — 2×2-Ansicht
- `_render_full_4x4(x, y, w, h, ...)` — V1-Status-Update für 4×4
- `_get_12h_history(...)` — 12h-Temperaturverlauf (Quelle: Netatmo `getmeasure` API)
- `_get_forecast(lat, lon, days=3)` — Open-Meteo 3-Tage-Vorhersage
### Shared Helper (aus `plugins/base.py` + `palette.py`)
Das Plugin nutzt konsequent die zentralen Render-Helper:
- `draw_battery()`, `batt_color()` — Batterie-Icon mit 3-stufiger Farbe
- `draw_compass()` — Windrose (mit N/O/S/W-Beschriftung optional)
- `draw_weather_icon()`, `wmo_to_icon()` — 7 Wetter-Zustände (sun/cloud/rain/storm/snow/fog/partly)
- `draw_line_chart()` — 12h-Verlaufs-Chart mit Y-Grid
- `draw_mini_bar()` — 1h/24h-Regen-Bars
- `de_weekday_long/short`, `de_date`, `de_time` — deutsche Datums-/Zeit-Formatter (locale-frei)
- `temp_color()`, `co2_color()` — semantische Farben nach Schwellen
### Was wird pro Refresh gemacht?
1. Prüfe Token-Cache (RAM). Wenn abgelaufen oder fehlend → hole neuen
Token via Password-Grant (initial) oder Refresh-Grant.
Token via Refresh-Grant.
2. `GET /api/getstationsdata?get_favorites=false` mit Bearer-Token.
3. Parse Antwort, filtere nach `station_filter` und sichtbaren Modulen.
4. Rendere je nach Slot-Größe (`small` / `wide` / `tall` / `standard`).
3. Hole 12h-History (`getmeasure`) für MIN/MAX-Berechnung.
4. Hole 3-Tage-Forecast von Open-Meteo.
5. Parse Antwort, filtere nach `station_filter` und sichtbaren Modulen.
6. Rendere je nach Slot-Größe (`compact` / `compact_2x2` / `full_4x4`).
Bei 401 wird der Token-Cache geleert und **einmal** neu authentifiziert.
Wenn das auch fehlschlägt → roter Error-Banner mit der genauen HTTP-Meldung.
+6 -5
View File
@@ -21,7 +21,7 @@ import os, sys
from datetime import datetime
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from plugins.base import Widget
from palette import FG, BG, OK, BLUE, YELLOW, ORANGE, fill_for, measure
from palette import FG, BG, OK, BLUE, YELLOW, ORANGE, fill_for, measure, de_weekday_long, de_date
# ---- Deutsche Minuten-Phrasen (QlockTwo-konform) ----
@@ -174,19 +174,20 @@ class Widget(Widget):
prefer=["16", "20", "default"])
if header_h:
if show_wd and show_dt:
day = now.strftime("%A")
date = now.strftime("%e. %b %Y").strip()
# Locale-frei: explizit deutsche Wochentage/Datums-Formatter
day = de_weekday_long(now.weekday())
date = de_date(now, short=True)
text = f"{day} · {date}"
tw, th = measure(draw, text, header_font)
draw.text((inner_x + (inner_w - tw) // 2, cy),
text, font=header_font, fill=accent)
elif show_wd:
text = now.strftime("%A")
text = de_weekday_long(now.weekday())
tw, _ = measure(draw, text, header_font)
draw.text((inner_x + (inner_w - tw) // 2, cy),
text, font=header_font, fill=accent)
elif show_dt:
text = now.strftime("%e. %b %Y").strip()
text = de_date(now, short=True)
tw, _ = measure(draw, text, header_font)
draw.text((inner_x + (inner_w - tw) // 2, cy),
text, font=header_font, fill=accent)
+5 -4
View File
@@ -8,15 +8,16 @@ from palette import FG, INFO, OK, WARN, ALERT, ORANGE, BG, measure, fit_font, hb
def _fmt_timedelta_short(seconds):
"""Formatiert Sekunden als deutsche Kurz-Laufzeit: '3T 5h', '7h 12min', '42min', 'jetzt'."""
if seconds is None: return ""
s = int(seconds)
if s <= 0: return "now"
if s <= 0: return "jetzt"
days, rem = divmod(s, 86400)
h, rem = divmod(rem, 3600)
m = rem // 60
if days: return f"{days}d {h}h"
if h: return f"{h}h {m}m"
return f"{m}m"
if days: return f"{days}T {h}h"
if h: return f"{h}h {m}min"
return f"{m}min"
def _deep_get(d, *keys, default=None):
+10 -10
View File
@@ -37,7 +37,7 @@ def _stats(activities):
class Widget(Widget):
name = "strava"
label = "Strava Aktivitäten"
description = "Distanz und Rides aus Strava. Setze Client-ID/Secret/Refresh-Token unten."
description = "Distanz und Fahrten aus Strava. Client-ID/Secret/Refresh-Token unten setzen."
category = "fitness"
config_schema = [
@@ -77,8 +77,8 @@ class Widget(Widget):
return
if is_wide(w, h):
stats = [f"{d['year_km']:.0f} km YTD", f"{d['rides']} rides",
f"{d['total_km']:.0f} km total"]
stats = [f"{d['year_km']:.0f} km Jahr", f"{d['rides']} Fahrten",
f"{d['total_km']:.0f} km Gesamt"]
col_w = w // len(stats)
for i, s in enumerate(stats):
cx = x + i * col_w
@@ -90,10 +90,10 @@ class Widget(Widget):
font_b = fit_font(draw, f"{d['year_km']:.0f} km", fonts, w - 2 * pad, h // 3)
draw.text((x + pad, y + 50), f"{d['year_km']:.0f} km", font=font_b, fill=FG)
draw.text((x + pad, y + 50 + font_b.size + 8),
f"in {datetime.now().year}", font=fonts.get("24", fonts.get("20")), fill=OK)
font_r = fit_font(draw, f"{d['rides']} rides", fonts, w - 2 * pad, 28)
draw.text((x + pad, y + h - 100), f"{d['rides']} rides", font=font_r, fill=OK)
font_h = fit_font(draw, f"{d['hike_km']:.1f} km hike", fonts, w - 2 * pad, 24)
draw.text((x + pad, y + h - 70), f"{d['hike_km']:.1f} km hike", font=font_h, fill=FG)
font_t = fit_font(draw, f"Total: {d['total_km']:.0f} km", fonts, w - 2 * pad, 20)
draw.text((x + pad, y + h - 40), f"Total: {d['total_km']:.0f} km", font=font_t, fill=FG)
f"im Jahr {datetime.now().year}", font=fonts.get("24", fonts.get("20")), fill=OK)
font_r = fit_font(draw, f"{d['rides']} Fahrten", fonts, w - 2 * pad, 28)
draw.text((x + pad, y + h - 100), f"{d['rides']} Fahrten", font=font_r, fill=OK)
font_h = fit_font(draw, f"{d['hike_km']:.1f} km Wandern", fonts, w - 2 * pad, 24)
draw.text((x + pad, y + h - 70), f"{d['hike_km']:.1f} km Wandern", font=font_h, fill=FG)
font_t = fit_font(draw, f"Gesamt: {d['total_km']:.0f} km", fonts, w - 2 * pad, 20)
draw.text((x + pad, y + h - 40), f"Gesamt: {d['total_km']:.0f} km", font=font_t, fill=FG)
+7 -6
View File
@@ -34,12 +34,13 @@ def _read_uptime():
def _fmt_uptime(s):
"""Formatiert Sekunden als deutsche Laufzeit: '3T 5h', '7h 12min', '42min'."""
days, rem = divmod(int(s), 86400)
h, rem = divmod(rem, 3600)
m = rem // 60
if days: return f"{days}d {h}h"
if h: return f"{h}h {m}m"
return f"{m}m"
if days: return f"{days}T {h}h"
if h: return f"{h}h {m}min"
return f"{m}min"
def _system_stats():
@@ -122,7 +123,7 @@ class Widget(Widget):
draw.text((cx + pad, y + pad), f"{label} {pct}%",
font=label_font, fill=col_color)
if self.cfg("show_uptime"):
up_str = "up " + _fmt_uptime(s["uptime_s"])
up_str = "läuft " + _fmt_uptime(s["uptime_s"])
font_up = fit_font(draw, up_str, fonts, col_w - 2 * pad, 22)
draw.text((x + 2 * col_w + pad, y + pad), up_str, font=font_up, fill=INFO)
return
@@ -155,9 +156,9 @@ class Widget(Widget):
if self.cfg("show_load") or self.cfg("show_uptime"):
parts = []
if self.cfg("show_load"):
parts.append(f"load {s['cpu_pct']/100.0 * (os.cpu_count() or 1):.2f}")
parts.append(f"Last {s['cpu_pct']/100.0 * (os.cpu_count() or 1):.2f}")
if self.cfg("show_uptime"):
parts.append("up " + _fmt_uptime(s["uptime_s"]))
parts.append("läuft " + _fmt_uptime(s["uptime_s"]))
line = " · ".join(parts)
font_bot = fit_font(draw, line, fonts, w - 2 * pad, 22)
draw.text((x + pad, bottom_y), line, font=font_bot, fill=FG)
+62 -52
View File
@@ -1,10 +1,18 @@
"""Wetter-Plugin (Open-Meteo, kein API-Key). Responsive + Error-Handling."""
import os, sys, json, math
"""Wetter-Plugin (Open-Meteo, kein API-Key). Responsive + Error-Handling.
Nutzt die shared helper aus plugins.base und palette:
- draw_compass, draw_weather_icon, draw_mini_bar
- temp_color (Hot/Warm/Kalt/FG)
- render_error_banner
"""
import os, sys, json
from datetime import datetime
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from plugins.base import Widget, fetch_url, render_error_banner
from plugins.base import (Widget, fetch_url, render_error_banner,
draw_compass, draw_weather_icon)
from palette import (FG, INFO, OK, WARN, ALERT, ORANGE, RED, BLUE,
measure, fit_font, centered_text, is_small, is_wide)
measure, fit_font, centered_text, is_small, is_wide,
temp_color)
def _fetch_openmeteo(lat, lon):
@@ -27,7 +35,7 @@ def _fetch_openmeteo(lat, lon):
class Widget(Widget):
name = "weather"
label = "Wetter (Open-Meteo)"
description = "Aktuelles Wetter, Forecast und Windrose. Kein API-Key. Responsive."
description = "Aktuelles Wetter, Vorhersage und Windrose. Kein API-Key. Responsive."
category = "weather"
config_schema = [
@@ -54,31 +62,7 @@ class Widget(Widget):
data["_lat"] = lat; data["_lon"] = lon
return data
def _draw_compass(self, draw, cx, cy, r, deg, fonts):
draw.ellipse((cx-r, cy-r, cx+r, cy+r), outline=FG, width=2)
for a in range(0, 360, 45):
rad = math.radians(a - 90)
inner = r - 8 if a % 90 == 0 else r - 4
x1 = cx + inner * math.cos(rad); y1 = cy + inner * math.sin(rad)
x2 = cx + r * math.cos(rad); y2 = cy + r * math.sin(rad)
draw.line((x1, y1, x2, y2), fill=FG, width=2)
f20 = fonts.get("20", fonts.get("16"))
for a, t in [(270,"N"), (0,"E"), (90,"S"), (180,"W")]:
rad = math.radians(a - 90)
tx = cx + (r+12) * math.cos(rad) - 6
ty = cy + (r+12) * math.sin(rad) - 9
draw.text((tx, ty), t, font=f20, fill=FG)
rad_arrow = math.radians(deg - 90)
tip_x = cx + (r - 14) * math.cos(rad_arrow)
tip_y = cy + (r - 14) * math.sin(rad_arrow)
base = math.radians(150)
lx = cx + 18 * math.cos(rad_arrow + base); ly = cy + 18 * math.sin(rad_arrow + base)
rx = cx + 18 * math.cos(rad_arrow - base); ry = cy + 18 * math.sin(rad_arrow - base)
draw.polygon([(tip_x, tip_y), (lx, ly), (rx, ry)], fill=RED)
draw.ellipse((cx-3, cy-3, cx+3, cy+3), fill=FG)
def render(self, draw, fonts, x, y, w, h):
from palette import measure
d = self.fetch()
if "_error" in d:
@@ -88,46 +72,59 @@ class Widget(Widget):
cur = d.get("current", {})
temp = cur.get("temperature_2m", 0)
temp_color = RED if temp >= 28 else ORANGE if temp >= 20 else BLUE if temp <= 5 else FG
tcol = temp_color(temp)
if is_small(w, h):
temp_str = f"{int(temp + 0.5)}°"
font = fit_font(draw, temp_str, fonts, w - 2 * 8, int(h * 0.6))
centered_text(draw, temp_str, x, y, w, int(h * 0.6), font, temp_color)
font = fit_font(draw, temp_str, fonts, w - 16, int(h * 0.6))
centered_text(draw, temp_str, x, y, w, int(h * 0.6), font, tcol)
# Wetter-Icon klein oben rechts
code = cur.get("weather_code")
if code is not None:
icon_size = min(20, h // 4)
draw_weather_icon(draw, x + w - icon_size - 4, y + 4,
icon_size, code)
if self.cfg("show_uv"):
uv_int = int(cur.get("uv_index", 0) + 0.5)
uv_str = f"UV {uv_int}"
font_uv = fit_font(draw, uv_str, fonts, w - 2 * 8, h // 4)
font_uv = fit_font(draw, uv_str, fonts, w - 16, h // 4)
centered_text(draw, uv_str, x, y + int(h * 0.62), w, h // 4, font_uv, FG)
return
if is_wide(w, h):
temp_str = f"{int(temp + 0.5)}°"
font = fit_font(draw, temp_str, fonts, w // 3 - 2 * 8, h - 2 * 8)
centered_text(draw, temp_str, x, y, w // 3, h, font, temp_color)
font = fit_font(draw, temp_str, fonts, w // 3 - 16, h - 16)
centered_text(draw, temp_str, x, y, w // 3, h, font, tcol)
hum = cur.get("relative_humidity_2m", 0)
ws = cur.get("wind_speed_10m", 0)
wd = cur.get("wind_direction_10m", 0)
stats = f"Hum {hum}%\nWind {ws} km/h\nDir {wd}°"
font_s = fit_font(draw, "Hum 100%", fonts, w - w // 3 - 2 * 8, h // 3)
stats = [f"Feuchte {hum}%", f"Wind {ws} km/h", f"Richtung {int(wd + 0.5)}°"]
font_s = fit_font(draw, "Feuchte 100%", fonts, w - w // 3 - 16, h // 3)
y_off = y + 8
for line in stats.split("\n"):
for line in stats:
centered_text(draw, line, x + w // 3, y_off, w - w // 3, h // 3, font_s, FG)
y_off += h // 3
return
# Standard: Big temp, info, forecast
draw.text((x + 8, y + 8), "WETTER", font=fonts.get("24", fonts.get("20")), fill=INFO)
# Wetter-Icon oben rechts neben dem Header
code = cur.get("weather_code")
if code is not None:
icon_size = min(28, h // 5)
draw_weather_icon(draw, x + 8, y + 36, icon_size, code)
temp_str = f"{int(temp + 0.5)}°"
font = fit_font(draw, temp_str, fonts, w // 2 - 2 * 8, int(h * 0.6))
draw.text((x + 8, y + 50), temp_str, font=font, fill=temp_color)
font = fit_font(draw, temp_str, fonts, w // 2 - 16, int(h * 0.6))
draw.text((x + 8, y + 50), temp_str, font=font, fill=tcol)
hum = cur.get("relative_humidity_2m", 0)
ws = cur.get("wind_speed_10m", 0)
wd = cur.get("wind_direction_10m", 0)
stats_y = y + 60
for line, col in [(f"Hum {hum}%", BLUE), (f"Wind {ws} km/h", INFO), (f"Dir {wd}°", FG)]:
font_l = fit_font(draw, line, fonts, w // 2 - 2 * 8, 24)
for line, col in [(f"Feuchte {hum}%", BLUE),
(f"Wind {ws} km/h", INFO),
(f"Richtung {int(wd + 0.5)}°", FG)]:
font_l = fit_font(draw, line, fonts, w // 2 - 16, 24)
draw.text((x + w // 2 + 8, stats_y), line, font=font_l, fill=col)
stats_y += 30
@@ -140,34 +137,47 @@ class Widget(Widget):
box_h = 36
if uv_int >= 6:
draw.rectangle((box_x, box_y, box_x + box_w, box_y + box_h), fill=ALERT)
draw.text((box_x + 6, box_y + 4), f"UV {uv_int}", font=fonts.get("20", fonts.get("16")), fill=(245,244,240))
draw.text((box_x + 6, box_y + 4), f"UV {uv_int}",
font=fonts.get("20", fonts.get("16")), fill=(245, 244, 240))
else:
draw.rectangle((box_x, box_y, box_x + box_w, box_y + box_h), outline=FG, width=2)
draw.text((box_x + 6, box_y + 4), f"UV {uv_int}", font=fonts.get("20", fonts.get("16")), fill=FG)
draw.text((box_x + 6, box_y + 4), f"UV {uv_int}",
font=fonts.get("20", fonts.get("16")), fill=FG)
# Windrose via shared helper (nur in grossen Slots)
if self.cfg("show_wind_compass") and w >= 280 and h >= 280:
cr = min(60, w // 8, h // 6)
cx = x + w - cr - 8
cy = y + h - cr - 8
self._draw_compass(draw, cx, cy, cr, wd, fonts)
draw_compass(draw, cx, cy, cr, wd, color=RED)
# Stunden-Vorhersage
fc_h = max(1, min(int(self.cfg("show_forecast_hours", 4)), 8))
times = d.get("hourly", {}).get("time", [])
temps = d.get("hourly", {}).get("temperature_2m", [])
codes = d.get("hourly", {}).get("weather_code", [])
if times and h > 200:
cur_iso = datetime.now().strftime("%Y-%m-%dT%H:00")
try: idx0 = times.index(cur_iso)
except ValueError: idx0 = 0
try:
idx0 = times.index(cur_iso)
except ValueError:
idx0 = 0
forecast_y = y + h - 70
strip_w = int(w * 0.6)
strip_w = int(w * 0.7)
cell_w = strip_w // fc_h
for i in range(fc_h):
j = idx0 + i + 1
if j >= len(times): break
if j >= len(times):
break
hh = times[j].split("T")[1][:5]
tt = int(temps[j] + 0.5)
color = ORANGE if tt >= 25 else BLUE if tt <= 5 else FG
color = temp_color(tt)
cx2 = x + 8 + i * cell_w
draw.text((cx2, forecast_y), hh, font=fonts.get("20", fonts.get("16")), fill=FG)
font_t = fit_font(draw, f"{tt}°", fonts, cell_w - 4, 40)
draw.text((cx2, forecast_y + 22), f"{tt}°", font=font_t, fill=color)
# Wetter-Icon (klein) pro Stunde
fc_code = codes[j] if j < len(codes) else None
if fc_code is not None:
ic_size = min(18, cell_w - 4, 20)
draw_weather_icon(draw, cx2, forecast_y + 18, ic_size, fc_code)
font_t = fit_font(draw, f"{tt}°", fonts, cell_w - 4, 28)
draw.text((cx2, forecast_y + 38), f"{tt}°", font=font_t, fill=color)