Compare commits
9
Commits
5de086e1b1
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb1e910f13 | ||
|
|
ee5280aa8c | ||
|
|
21b4128f19 | ||
|
|
aeaf8fdb8b | ||
|
|
4cef32b919 | ||
|
|
2073039f43 | ||
|
|
dba34a48a6 | ||
|
|
5e98b71b82 | ||
|
|
b3b0ca0ce5 |
@@ -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
|
||||||
|
```
|
||||||
@@ -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) │
|
│ plugins/ (eines pro Widget) │
|
||||||
│ clock · weather · system · spotify │
|
│ clock · clock_wordclock · weather · system │
|
||||||
│ strava · gmail · minimax · hello │
|
│ spotify · strava · gmail · minimax · │
|
||||||
├────────────────────────────────────────────┤
|
│ netatmo · hello │
|
||||||
|
├───────────────────────────────────────────────┤
|
||||||
|
│ shared: palette.py · plugins/base.py │
|
||||||
|
│ (Farben, Formatter, Render-Helper) │
|
||||||
|
├───────────────────────────────────────────────┤
|
||||||
│ network_watchdog.py │
|
│ network_watchdog.py │
|
||||||
│ (WiFi AP/Client Management) │
|
│ (WiFi AP/Client Management) │
|
||||||
├────────────────────────────────────────────┤
|
├───────────────────────────────────────────────┤
|
||||||
│ waveshare_epd/ (Vendor-Treiber) │
|
│ waveshare_epd/ (Vendor-Treiber) │
|
||||||
│ epd7in3f.py │
|
│ 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
@@ -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.
|
||||||
|
|||||||
@@ -795,14 +795,15 @@ def api_power():
|
|||||||
"next_wake": next_wake,
|
"next_wake": next_wake,
|
||||||
"seconds_until_wake": secs_left,
|
"seconds_until_wake": secs_left,
|
||||||
"now": now.strftime("%H:%M"),
|
"now": now.strftime("%H:%M"),
|
||||||
|
"wifi_off": power.get("wifi_off", False),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/wake", methods=["POST"])
|
@app.route("/api/wake", methods=["POST"])
|
||||||
def api_wake():
|
def api_wake():
|
||||||
"""Forciert Aufwachen aus dem DeepSleep (einmaliger Refresh-Trigger)."""
|
"""Forciert Aufwachen aus dem DeepSleep (einmaliger Refresh-Trigger)."""
|
||||||
a = require_auth()
|
# Kein require_auth: der Unix-Socket ist die Sicherheitsgrenze, nicht HTTP.
|
||||||
if a: return a
|
# Jeder der das API erreichen kann (z.B. vom Pi selbst), darf wecken.
|
||||||
# IPC 'refresh' ans Dashboard schicken — der Trigger weckt aus DeepSleep
|
# IPC 'refresh' ans Dashboard schicken — der Trigger weckt aus DeepSleep
|
||||||
rc = dashboard_mod.send_ipc("refresh")
|
rc = dashboard_mod.send_ipc("refresh")
|
||||||
return jsonify({"ok": True, "message": "wake trigger sent", "ipc_result": rc})
|
return jsonify({"ok": True, "message": "wake trigger sent", "ipc_result": rc})
|
||||||
|
|||||||
+93
-17
@@ -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.
|
||||||
@@ -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
@@ -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):
|
||||||
|
|||||||
+14
-4
@@ -121,7 +121,14 @@ def _get_forecast(lat: float, lon: float, days: int = 3) -> dict | None:
|
|||||||
f"weather_code&forecast_days={min(days, 7)}&timezone=auto")
|
f"weather_code&forecast_days={min(days, 7)}&timezone=auto")
|
||||||
req = urllib.request.Request(url, headers={"Accept": "application/json"})
|
req = urllib.request.Request(url, headers={"Accept": "application/json"})
|
||||||
with urllib.request.urlopen(req, timeout=8) as r:
|
with urllib.request.urlopen(req, timeout=8) as r:
|
||||||
return json.loads(r.read())
|
data = json.loads(r.read())
|
||||||
|
# Nur gueltige Antwort mit daily.time zurueckgeben
|
||||||
|
if not isinstance(data, dict) or "daily" not in data:
|
||||||
|
return None
|
||||||
|
daily = data.get("daily", {})
|
||||||
|
if not isinstance(daily, dict) or not daily.get("time"):
|
||||||
|
return None
|
||||||
|
return data
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -167,7 +174,9 @@ def _first_module(modules: list, mtype: str) -> dict | None:
|
|||||||
|
|
||||||
def _all_indoor(modules: list, main: dict) -> list:
|
def _all_indoor(modules: list, main: dict) -> list:
|
||||||
"""Alle Indoor-Sensoren: Main + alle NAModule4."""
|
"""Alle Indoor-Sensoren: Main + alle NAModule4."""
|
||||||
indoor = [{"type": "NAMain", "name": main["name"], "tag": "M",
|
main_name = main.get("name", "Station")
|
||||||
|
indoor = [{"type": "NAMain", "name": main_name,
|
||||||
|
"tag": main_name[:2].upper(), # Ersten 2 Buchstaben des Stationsnamens
|
||||||
"data": main["data"], "battery_pct": None, "is_main": True}]
|
"data": main["data"], "battery_pct": None, "is_main": True}]
|
||||||
for m in modules:
|
for m in modules:
|
||||||
if m["type"] == "NAModule4":
|
if m["type"] == "NAModule4":
|
||||||
@@ -558,7 +567,7 @@ class Widget(Widget):
|
|||||||
draw.line((x + 6, ry + 2, x + w - 6, ry + 2), fill=FG, width=1)
|
draw.line((x + 6, ry + 2, x + w - 6, ry + 2), fill=FG, width=1)
|
||||||
inner_x = x + 6
|
inner_x = x + 6
|
||||||
inner_w = w - 12
|
inner_w = w - 12
|
||||||
# HEAD: Tag | Name (GROSS) | Temp (RIESIG)
|
# HEAD: Tag-Box (30px, nur Tag) | Name (gross, daneben) | Temp (rechts)
|
||||||
tag_w = 30
|
tag_w = 30
|
||||||
draw.rectangle((inner_x, ry + 2, inner_x + tag_w, ry + 2 + head_h - 4),
|
draw.rectangle((inner_x, ry + 2, inner_x + tag_w, ry + 2 + head_h - 4),
|
||||||
fill=RED)
|
fill=RED)
|
||||||
@@ -567,9 +576,10 @@ class Widget(Widget):
|
|||||||
tw_, th_ = measure(draw, s["tag"], f_tag)
|
tw_, th_ = measure(draw, s["tag"], f_tag)
|
||||||
draw.text((inner_x + (tag_w - tw_) // 2, ry + 2 + (head_h - 4 - th_) // 2),
|
draw.text((inner_x + (tag_w - tw_) // 2, ry + 2 + (head_h - 4 - th_) // 2),
|
||||||
s["tag"], font=f_tag, fill=WHITE)
|
s["tag"], font=f_tag, fill=WHITE)
|
||||||
# Name gross, fett
|
# Name GROSS daneben, wenn genug Platz
|
||||||
name_x = inner_x + tag_w + 8
|
name_x = inner_x + tag_w + 8
|
||||||
name_w = inner_w - tag_w - 8 - 130
|
name_w = inner_w - tag_w - 8 - 130
|
||||||
|
if name_w > 20:
|
||||||
f_name = _fit_size(draw, s["name"], name_w, head_h - 4, candidates=["24", "20", "18", "16"])
|
f_name = _fit_size(draw, s["name"], name_w, head_h - 4, candidates=["24", "20", "18", "16"])
|
||||||
text_in_box(draw, s["name"], name_x, ry + 2, name_w, head_h - 4, f_name, FG, "left")
|
text_in_box(draw, s["name"], name_x, ry + 2, name_w, head_h - 4, f_name, FG, "left")
|
||||||
# Temp RIESIG, in Temperatur-Farbe
|
# Temp RIESIG, in Temperatur-Farbe
|
||||||
|
|||||||
+10
-10
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -970,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">
|
||||||
@@ -1317,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();
|
||||||
|
|||||||
+211
-126
@@ -2,184 +2,269 @@
|
|||||||
<html lang="de">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Energie — ePaper Dashboard</title>
|
<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>
|
<style>
|
||||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
:root {
|
||||||
background: #f4f5f7; color: #1a1a1a; margin: 0; padding: 24px; }
|
--bg: #0a0a0f;
|
||||||
h1 { font-size: 22px; margin: 0 0 4px; }
|
--surface: #15151c;
|
||||||
h2 { font-size: 15px; font-weight: 600; color: #555; margin: 24px 0 8px;
|
--surface-2: #1f1f28;
|
||||||
text-transform: uppercase; letter-spacing: 0.5px; }
|
--surface-3: #2a2a35;
|
||||||
.back { float: right; font-size: 13px; color: #2563eb; text-decoration: none; }
|
--border: #2e2e3a;
|
||||||
.card { background: #fff; border: 1px solid #e1e3e8; border-radius: 8px;
|
--border-light: #3a3a48;
|
||||||
padding: 20px 24px; max-width: 720px; }
|
--fg: #f0f0f5;
|
||||||
.row { display: flex; align-items: center; gap: 12px; padding: 12px 0;
|
--fg-muted: #9090a0;
|
||||||
border-bottom: 1px solid #f0f1f3; }
|
--fg-dim: #6a6a78;
|
||||||
.row:last-child { border-bottom: none; }
|
--accent: #6d8eff;
|
||||||
.row label { flex: 1; font-size: 14px; }
|
--accent-bright: #8da9ff;
|
||||||
.row .hint { font-size: 12px; color: #888; display: block; margin-top: 2px; }
|
--accent-glow: rgba(109, 142, 255, 0.15);
|
||||||
.toggle { position: relative; width: 44px; height: 24px; }
|
--ok: #4ade80;
|
||||||
.toggle input { opacity: 0; width: 0; height: 0; }
|
--warn: #fbbf24;
|
||||||
.slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0;
|
--alert: #f87171;
|
||||||
background: #d1d5db; border-radius: 12px; transition: 0.2s; }
|
--info: #60a5fa;
|
||||||
.slider::before { content: ""; position: absolute; height: 18px; width: 18px;
|
--success: #22c55e;
|
||||||
left: 3px; bottom: 3px; background: white; border-radius: 50%;
|
}
|
||||||
transition: 0.2s; }
|
* { box-sizing: border-box; }
|
||||||
.toggle input:checked + .slider { background: #10b981; }
|
body {
|
||||||
.toggle input:checked + .slider::before { transform: translateX(20px); }
|
font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, sans-serif;
|
||||||
input[type="time"] { padding: 6px 10px; border: 1px solid #d1d5db;
|
background: var(--bg); color: var(--fg);
|
||||||
border-radius: 6px; font-size: 14px; }
|
margin: 0; line-height: 1.5;
|
||||||
button { background: #2563eb; color: white; border: none; padding: 10px 20px;
|
-webkit-font-smoothing: antialiased;
|
||||||
border-radius: 6px; font-size: 14px; font-weight: 500; cursor: pointer; }
|
min-height: 100vh;
|
||||||
button:hover { background: #1d4ed8; }
|
}
|
||||||
button.secondary { background: #6b7280; }
|
.container { max-width: 1400px; margin: 0 auto; padding: 24px 32px 80px; }
|
||||||
button.secondary:hover { background: #4b5563; }
|
|
||||||
.status { display: inline-block; padding: 4px 10px; border-radius: 4px;
|
/* Header */
|
||||||
font-size: 12px; font-weight: 600; }
|
.page-header {
|
||||||
.status.wach { background: #d1fae5; color: #065f46; }
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
.status.schlaft { background: #fef3c7; color: #92400e; }
|
margin-bottom: 32px; padding-bottom: 20px;
|
||||||
.pill { display: inline-block; background: #e0e7ff; color: #3730a3;
|
border-bottom: 1px solid var(--border);
|
||||||
padding: 2px 8px; border-radius: 10px; font-size: 12px;
|
}
|
||||||
margin-left: 8px; }
|
.page-header h1 { font-size: 22px; font-weight: 700; margin: 0; color: var(--fg); }
|
||||||
.footer { margin-top: 16px; font-size: 12px; color: #6b7280; }
|
.back-btn {
|
||||||
.pulse { animation: pulse 2s ease-in-out infinite; }
|
display: inline-flex; align-items: center; gap: 8px;
|
||||||
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<a class="back" href="/">← zurück zum Dashboard</a>
|
<div class="container">
|
||||||
<h1>Energie</h1>
|
<div class="page-header">
|
||||||
<p style="color:#6b7280; font-size:13px; margin:4px 0 0">
|
<h1>⚡ Energie</h1>
|
||||||
DeepSleep spart Strom, weil das ePaper das letzte Bild ohne Strom haelt.
|
<a class="back-btn" href="/">← Dashboard</a>
|
||||||
WiFi bleibt an, damit du das Display jederzeit wecken kannst.
|
</div>
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="card" style="margin-top:20px">
|
<div class="status-banner">
|
||||||
<h2>Status <span id="status-pill" class="pill">…</span></h2>
|
<span id="status-pill" class="status-pill schlaft">—</span>
|
||||||
<div id="status-text" style="font-size:14px; padding:8px 0">lade…</div>
|
<span id="status-text" class="status-text">Lädt...</span>
|
||||||
<button class="secondary" id="wake-btn" type="button">Display jetzt wecken</button>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card" style="margin-top:20px">
|
<form method="post" action="/power" id="power-form">
|
||||||
<h2>DeepSleep</h2>
|
<div class="card">
|
||||||
<form method="post" action="/power">
|
<div class="form-row">
|
||||||
<div class="row">
|
<label for="deepsleep_enabled">
|
||||||
<label>
|
DeepSleep aktiv
|
||||||
DeepSleep aktivieren
|
<span class="hint">Display schaltet sich nachts aus, nur noch Netzwerk aktiv</span>
|
||||||
<span class="hint">Display bleibt zwischen den Zeiten eingefroren, kein Refresh.</span>
|
|
||||||
</label>
|
</label>
|
||||||
<span class="toggle">
|
<label class="toggle">
|
||||||
<input type="checkbox" name="deepsleep_enabled"
|
<input type="checkbox" name="deepsleep_enabled" id="deepsleep_enabled"
|
||||||
{% if power.deepsleep_enabled %}checked{% endif %}>
|
{% if power.deepsleep_enabled %}checked{% endif %}>
|
||||||
<span class="slider"></span>
|
<span class="slider"></span>
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<label>
|
|
||||||
Schlafenszeit
|
|
||||||
<span class="hint">Start und Ende im 24h-Format. Mitternacht-Überschreitung
|
|
||||||
(z.B. 23:30 → 06:30) wird automatisch erkannt.</span>
|
|
||||||
</label>
|
</label>
|
||||||
<span>
|
|
||||||
<input type="time" name="deepsleep_start"
|
|
||||||
value="{{ power.deepsleep_start }}">
|
|
||||||
bis
|
|
||||||
<input type="time" name="deepsleep_end"
|
|
||||||
value="{{ power.deepsleep_end }}">
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
|
||||||
<label>
|
<div class="form-row">
|
||||||
CPU-Taktung drosseln (Pi)
|
<label for="deepsleep_start">Schlafenszeit (von)</label>
|
||||||
<span class="hint">Während DeepSleep auf 600 MHz takten. Spart ~0.5W.
|
<input type="time" name="deepsleep_start" id="deepsleep_start"
|
||||||
Beim Aufwachen wird automatisch auf Normal-Takt zurückgeschaltet.</span>
|
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>
|
||||||
<span class="toggle">
|
<label class="toggle">
|
||||||
<input type="checkbox" name="cpu_throttle"
|
<input type="checkbox" name="cpu_throttle" id="cpu_throttle"
|
||||||
{% if power.cpu_throttle %}checked{% endif %}>
|
{% if power.cpu_throttle %}checked{% endif %}>
|
||||||
<span class="slider"></span>
|
<span class="slider"></span>
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<label>
|
|
||||||
WiFi im DeepSleep aus
|
|
||||||
<span class="hint"><strong>Nicht empfohlen.</strong> Solange WiFi aus ist, ist
|
|
||||||
das Admin-UI nicht erreichbar — du kannst das Display dann nicht wecken
|
|
||||||
ausser durch physischen Neustart. Nur aktivieren wenn du weisst was du tust.</span>
|
|
||||||
</label>
|
</label>
|
||||||
<span class="toggle">
|
</div>
|
||||||
<input type="checkbox" name="wifi_off"
|
|
||||||
|
<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 %}>
|
{% if power.wifi_off %}checked{% endif %}>
|
||||||
<span class="slider"></span>
|
<span class="slider"></span>
|
||||||
</span>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div style="padding-top:16px">
|
</div>
|
||||||
<button type="submit">Speichern</button>
|
|
||||||
|
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card" style="margin-top:20px">
|
<div class="info-box">
|
||||||
<h2>Wie es funktioniert</h2>
|
<p><strong>Z-Indikator:</strong> Auf dem Display erscheint oben rechts ein "Zz" + Aufwach-Zeit wenn DeepSleep aktiv ist.</p>
|
||||||
<ul style="font-size:13px; line-height:1.6; color:#374151">
|
<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>
|
||||||
<li><strong>Wann:</strong> DeepSleep gilt zwischen den eingestellten Zeiten, jeden Tag.</li>
|
</div>
|
||||||
<li><strong>Display:</strong> ePaper hält das letzte Bild ohne Strom — Refresh kostet nur
|
|
||||||
die ~30 Sekunden alle 3 Minuten (~0.5 Wh pro Refresh).</li>
|
|
||||||
<li><strong>Daten:</strong> Plugin-Fetch läuft weiter, damit beim Aufwachen frische Daten da sind.</li>
|
|
||||||
<li><strong>WiFi:</strong> Bleibt an (default), Admin-UI ist erreichbar.</li>
|
|
||||||
<li><strong>Aufwachen:</strong> "Display jetzt wecken" Button, oder warten bis Ende der Schlafenszeit.</li>
|
|
||||||
<li><strong>Z-Indikator:</strong> Auf dem Display erscheint oben rechts ein "Zz" + Aufwach-Zeit.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="footer">
|
|
||||||
Geschätzte Ersparnis: bei 7h DeepSleep/Tag + 3min Refresh-Intervall ca. 30% weniger
|
|
||||||
Display-Refresh-Strom. Plus Pi-CPU-Throttle ~0.5-1W Dauerersparnis.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
async function refreshStatus() {
|
async function refreshStatus() {
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/power');
|
const r = await fetch('/api/power', { credentials: 'include' });
|
||||||
|
if (!r.ok) throw new Error('Status nicht abrufbar');
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
const pill = document.getElementById('status-pill');
|
const pill = document.getElementById('status-pill');
|
||||||
const text = document.getElementById('status-text');
|
const text = document.getElementById('status-text');
|
||||||
if (d.is_deepsleep) {
|
if (d.is_deepsleep) {
|
||||||
pill.className = 'pill';
|
pill.className = 'status-pill schlaft';
|
||||||
pill.style.background = '#fef3c7';
|
|
||||||
pill.style.color = '#92400e';
|
|
||||||
pill.textContent = 'schläft';
|
pill.textContent = 'schläft';
|
||||||
pill.classList.add('pulse');
|
|
||||||
text.innerHTML = `Display schläft bis <strong>${d.next_wake}</strong> `
|
text.innerHTML = `Display schläft bis <strong>${d.next_wake}</strong> `
|
||||||
+ `(${formatDuration(d.seconds_until_wake)} verbleibend)`;
|
+ `(${formatDuration(d.seconds_until_wake)} verbleibend)`;
|
||||||
} else {
|
} else {
|
||||||
pill.className = 'pill';
|
pill.className = 'status-pill wach';
|
||||||
pill.style.background = '#d1fae5';
|
|
||||||
pill.style.color = '#065f46';
|
|
||||||
pill.textContent = 'wach';
|
pill.textContent = 'wach';
|
||||||
pill.classList.remove('pulse');
|
const next = d.deepsleep_enabled ? ` Nächster DeepSleep um ${d.deepsleep_start}.` : '';
|
||||||
text.innerHTML = `Display ist wach. Normaler Refresh-Modus aktiv.`
|
text.innerHTML = `Display ist wach. Normaler Refresh-Modus.${next}`;
|
||||||
+ (d.deepsleep_enabled ? ` Nächster DeepSleep um ${d.deepsleep_start}.` : '');
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
document.getElementById('status-text').textContent = 'Status nicht abrufbar: ' + e;
|
document.getElementById('status-text').textContent = 'Status nicht abrufbar (' + e.message + ')';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDuration(secs) {
|
function formatDuration(secs) {
|
||||||
const h = Math.floor(secs / 3600);
|
const h = Math.floor(secs / 3600);
|
||||||
const m = Math.floor((secs % 3600) / 60);
|
const m = Math.floor((secs % 3600) / 60);
|
||||||
if (h > 0) return `${h}h ${m}m`;
|
if (h > 0) return `${h}h ${m}m`;
|
||||||
return `${m}m`;
|
return `${m}m`;
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('wake-btn').addEventListener('click', async () => {
|
document.getElementById('wake-btn').addEventListener('click', async () => {
|
||||||
const r = await fetch('/api/wake', { method: 'POST' });
|
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();
|
const d = await r.json();
|
||||||
if (d.ok) {
|
if (d.ok) {
|
||||||
document.getElementById('status-text').textContent = 'Wake-Trigger gesendet — Display wacht auf.';
|
btn.textContent = '✓ Aufgeweckt!';
|
||||||
setTimeout(refreshStatus, 1000);
|
setTimeout(() => { btn.textContent = '⚡ Aufwecken'; btn.disabled = false; }, 2000);
|
||||||
} else {
|
} else {
|
||||||
alert('Wake fehlgeschlagen: ' + d.error);
|
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();
|
refreshStatus();
|
||||||
setInterval(refreshStatus, 30000);
|
setInterval(refreshStatus, 30000);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user