Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e98b71b82 | ||
|
|
b3b0ca0ce5 | ||
|
|
5de086e1b1 | ||
|
|
5a83826aa5 | ||
|
|
c5a09833c9 | ||
|
|
c55cc90bda | ||
|
|
1aa11f5843 | ||
|
|
fe1c06b306 | ||
|
|
9b91598f5b | ||
|
|
25b0432757 | ||
|
|
8f3480ee2e | ||
|
|
d71b17d5c9 | ||
|
|
94509e6836 | ||
|
|
5cf1d743eb | ||
|
|
7885b100b4 | ||
|
|
67e1b465d5 | ||
|
|
b9d269876b | ||
|
|
d0cc69410f | ||
|
|
a465912b55 | ||
|
|
6059748f9b | ||
|
|
9f705ad7e4 | ||
|
|
c6919c5104 |
@@ -13,3 +13,5 @@ minimax_*.png
|
||||
config.json
|
||||
*.service
|
||||
.backup/
|
||||
.venv/
|
||||
tests/__pycache__/
|
||||
|
||||
@@ -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.
|
||||
|
||||
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,22 +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` |
|
||||
| `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"
|
||||
@@ -139,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
@@ -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.
|
||||
|
||||
@@ -153,14 +153,9 @@ def save_config():
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
# slot belegung: pro slot "slot_<idx>_plugin"
|
||||
slots = cfg.get("slots", [])
|
||||
for i in range(dashboard_mod.SLOTS):
|
||||
plugin_name = request.form.get(f"slot_{i}_plugin", "").strip()
|
||||
if plugin_name and slots[i]["plugin"] != plugin_name:
|
||||
slots[i]["plugin"] = plugin_name
|
||||
slots[i]["config"] = {} # reset auf plugin defaults
|
||||
cfg["slots"] = slots
|
||||
# NOTE: legacy v1 slot-X-belegung wird nicht mehr unterstützt — die
|
||||
# Admin-UI nutzt seit v2 die Drag&Drop-API unter /api/layout für
|
||||
# Slot-Belegung. /config macht heute NUR noch den Refresh-Intervall.
|
||||
dashboard_mod.save_config(cfg)
|
||||
return redirect(url_for("index"))
|
||||
|
||||
@@ -197,6 +192,9 @@ def save_plugin_config(idx):
|
||||
new_cfg[key] = form_val
|
||||
continue
|
||||
if form_val is None:
|
||||
if ftype == "bool":
|
||||
new_cfg[key] = False
|
||||
else:
|
||||
continue
|
||||
try:
|
||||
if ftype == "int":
|
||||
@@ -280,7 +278,15 @@ def api_layout():
|
||||
|
||||
@app.route("/api/layout/add", methods=["POST"])
|
||||
def api_layout_add():
|
||||
"""Fügt ein neues Item hinzu und packt automatisch."""
|
||||
"""Fügt ein neues Item hinzu OHNE bestehende Items zu verschieben (BUG-04).
|
||||
|
||||
BUG-04: Vorher wurde `layout_mod.pack()` aufgerufen, das alle Items neu
|
||||
sortiert und in scan-line packt — ein 1x1 hello konnte einen 2x1 spotify
|
||||
aus seiner Position drängen. Jetzt first-fit: das neue Item landet in der
|
||||
ersten freien Zelle, alle anderen bleiben unverändert.
|
||||
|
||||
Falls kein Platz: HTTP 409 mit Hinweis auf den Auto-Pack-Button.
|
||||
"""
|
||||
a = require_auth()
|
||||
if a: return a
|
||||
plugin = request.form.get("plugin", "hello").strip()
|
||||
@@ -297,14 +303,19 @@ def api_layout_add():
|
||||
except: pass
|
||||
import secrets
|
||||
new_id = secrets.token_hex(4)
|
||||
items = cfg.setdefault("layout", {}).setdefault("items", [])
|
||||
new_item = layout_mod.Item(new_id, plugin, 0, 0, w, h).to_dict()
|
||||
items.append(new_item)
|
||||
# Pack alle (inkl. neue)
|
||||
packed = layout_mod.pack([layout_mod.Item.from_dict(d) for d in items])
|
||||
cfg["layout"]["items"] = [it.to_dict() for it in packed]
|
||||
items_list = cfg.setdefault("layout", {}).setdefault("items", [])
|
||||
existing = [layout_mod.Item.from_dict(d) for d in items_list]
|
||||
candidate = layout_mod.Item(new_id, plugin, 0, 0, w, h)
|
||||
placed = layout_mod.first_fit(candidate, existing)
|
||||
if placed is None:
|
||||
return jsonify({
|
||||
"ok": False,
|
||||
"error": f"kein Platz für {w}×{h}-Item — bitte Auto-Pack klicken oder Items manuell verkleinern.",
|
||||
"hint": "use_auto_pack",
|
||||
}), 409
|
||||
items_list.append(placed.to_dict())
|
||||
dashboard_mod.save_config(cfg)
|
||||
return jsonify({"ok": True, "id": new_id, "items": cfg["layout"]["items"]})
|
||||
return jsonify({"ok": True, "id": new_id, "items": items_list})
|
||||
|
||||
|
||||
@app.route("/api/layout/item/<iid>", methods=["DELETE", "PATCH"])
|
||||
@@ -463,6 +474,9 @@ def api_plugin_config(plugin_name):
|
||||
form_val = request.form.get(f"config_{key}")
|
||||
ftype = field.get("type", "string")
|
||||
if form_val is None:
|
||||
if ftype == "bool":
|
||||
new_cfg[key] = False # unchecked checkbox → False
|
||||
else:
|
||||
continue
|
||||
if ftype == "secret":
|
||||
# Nur überschreiben wenn nicht leer
|
||||
@@ -676,6 +690,124 @@ def api_net_forget():
|
||||
return jsonify({"ok": rc == 0, "message": out or err})
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Power / DeepSleep — Energie-Tab
|
||||
# ============================================================================
|
||||
@app.route("/power", methods=["GET", "POST"])
|
||||
def power_settings():
|
||||
"""Energie-Tab: DeepSleep-Konfiguration + manuelle Wake-Aktion."""
|
||||
a = require_auth()
|
||||
if a: return a
|
||||
cfg = dashboard_mod.load_config()
|
||||
power = cfg.get("power", {})
|
||||
|
||||
if request.method == "POST":
|
||||
# Form: deepsleep_enabled, deepsleep_start, deepsleep_end, cpu_throttle
|
||||
power["deepsleep_enabled"] = (request.form.get("deepsleep_enabled") == "on")
|
||||
start = request.form.get("deepsleep_start", "23:30").strip()
|
||||
end = request.form.get("deepsleep_end", "06:30").strip()
|
||||
# Validierung HH:MM
|
||||
import re
|
||||
if re.match(r"^\d{1,2}:\d{2}$", start):
|
||||
power["deepsleep_start"] = start
|
||||
if re.match(r"^\d{1,2}:\d{2}$", end):
|
||||
power["deepsleep_end"] = end
|
||||
power["cpu_throttle"] = (request.form.get("cpu_throttle") == "on")
|
||||
power["wifi_off"] = (request.form.get("wifi_off") == "on")
|
||||
cfg["power"] = power
|
||||
dashboard_mod.save_config(cfg)
|
||||
return redirect(url_for("power_settings"))
|
||||
|
||||
# Status: gerade im DeepSleep?
|
||||
from datetime import datetime
|
||||
now = datetime.now()
|
||||
is_sleep = False
|
||||
next_wake = None
|
||||
if power.get("deepsleep_enabled", False):
|
||||
try:
|
||||
def _to_min(s):
|
||||
h, m = s.split(":")
|
||||
return int(h) * 60 + int(m)
|
||||
now_min = now.hour * 60 + now.minute
|
||||
s = _to_min(power.get("deepsleep_start", "23:00"))
|
||||
e = _to_min(power.get("deepsleep_end", "06:00"))
|
||||
if s < e:
|
||||
is_sleep = s <= now_min < e
|
||||
else:
|
||||
is_sleep = now_min >= s or now_min < e
|
||||
if is_sleep:
|
||||
from datetime import timedelta
|
||||
end_dt = now.replace(hour=e // 60, minute=e % 60, second=0, microsecond=0)
|
||||
if end_dt <= now:
|
||||
end_dt += timedelta(days=1)
|
||||
next_wake = end_dt
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return render_template("power.html",
|
||||
power=power,
|
||||
is_deepsleep=is_sleep,
|
||||
next_wake=next_wake.strftime("%H:%M") if next_wake else None,
|
||||
now=now.strftime("%H:%M"))
|
||||
|
||||
|
||||
@app.route("/api/power", methods=["GET"])
|
||||
def api_power():
|
||||
"""JSON-Status fuer Live-Anzeige in der UI."""
|
||||
a = require_auth()
|
||||
if a: return a
|
||||
cfg = dashboard_mod.load_config()
|
||||
power = cfg.get("power", {})
|
||||
from datetime import datetime, timedelta
|
||||
now = datetime.now()
|
||||
is_sleep = False
|
||||
next_wake = None
|
||||
if power.get("deepsleep_enabled", False):
|
||||
try:
|
||||
def _to_min(s):
|
||||
h, m = s.split(":")
|
||||
return int(h) * 60 + int(m)
|
||||
now_min = now.hour * 60 + now.minute
|
||||
s = _to_min(power.get("deepsleep_start", "23:00"))
|
||||
e = _to_min(power.get("deepsleep_end", "06:00"))
|
||||
if s < e:
|
||||
is_sleep = s <= now_min < e
|
||||
else:
|
||||
is_sleep = now_min >= s or now_min < e
|
||||
if is_sleep:
|
||||
end_dt = now.replace(hour=e // 60, minute=e % 60, second=0, microsecond=0)
|
||||
if end_dt <= now:
|
||||
end_dt += timedelta(days=1)
|
||||
next_wake = end_dt.strftime("%H:%M")
|
||||
secs_left = int((end_dt - now).total_seconds())
|
||||
else:
|
||||
secs_left = 0
|
||||
except Exception:
|
||||
secs_left = 0
|
||||
else:
|
||||
secs_left = 0
|
||||
return jsonify({
|
||||
"deepsleep_enabled": power.get("deepsleep_enabled", False),
|
||||
"deepsleep_start": power.get("deepsleep_start", "23:30"),
|
||||
"deepsleep_end": power.get("deepsleep_end", "06:30"),
|
||||
"cpu_throttle": power.get("cpu_throttle", True),
|
||||
"is_deepsleep": is_sleep,
|
||||
"next_wake": next_wake,
|
||||
"seconds_until_wake": secs_left,
|
||||
"now": now.strftime("%H:%M"),
|
||||
})
|
||||
|
||||
|
||||
@app.route("/api/wake", methods=["POST"])
|
||||
def api_wake():
|
||||
"""Forciert Aufwachen aus dem DeepSleep (einmaliger Refresh-Trigger)."""
|
||||
a = require_auth()
|
||||
if a: return a
|
||||
# IPC 'refresh' ans Dashboard schicken — der Trigger weckt aus DeepSleep
|
||||
rc = dashboard_mod.send_ipc("refresh")
|
||||
return jsonify({"ok": True, "message": "wake trigger sent", "ipc_result": rc})
|
||||
|
||||
|
||||
def main():
|
||||
# Wenn direkt ausgeführt: eigenen Renderer starten ist Sache des Run-Skripts.
|
||||
# Wir nehmen einfach Port 8080.
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"_comment": "Beispiel-Konfiguration für das Netatmo-Plugin. Diese Datei kannst du 1:1 in deine config.json unter 'plugin_configs.netatmo' kopieren und deine Werte einsetzen. Secrets wie client_secret / username / password NIEMALS in git committen — am besten direkt in der Admin-UI (http://pi:8080/) eintragen.",
|
||||
|
||||
"client_id": "REPLACE_WITH_YOUR_NETATMO_CLIENT_ID",
|
||||
"client_secret": "REPLACE_WITH_YOUR_NETATMO_CLIENT_SECRET",
|
||||
"username": "deine@email.de",
|
||||
"password": "REPLACE_WITH_YOUR_NETATMO_PASSWORD",
|
||||
|
||||
"_comment_station": "Optional: Name der Station wenn du mehrere hast. Leer = erste Station wird genommen.",
|
||||
"station_filter": "",
|
||||
|
||||
"_comment_modules": "Welche Module in der UI gezeigt werden. Bei einer typischen Wetterstation hast du NAMain (Indoor), NAModule1 (Outdoor), NAModule2 (Wind), NAModule3 (Regen). NAModule4 ist ein zusätzliches Indoor-Modul.",
|
||||
"show_indoor": true,
|
||||
"show_outdoor": true,
|
||||
"show_wind": true,
|
||||
"show_rain": true,
|
||||
|
||||
"_comment_display": "Optionale Anzeige-Elemente",
|
||||
"show_compass": true,
|
||||
"show_secondary": true,
|
||||
"_comment_secondary": "Zeigt Min/Max-Temperaturen + Timestamp der letzten Aktualisierung. Bei großen Slots (≥ 2x2) sichtbar.",
|
||||
|
||||
"_comment_co2": "Schwellen für den CO₂-Balken in ppm (parts per million). Standardwerte orientieren sich an der Netatmo-App: grün ≤ 600, gelb ≤ 1000, rot > 1500.",
|
||||
"co2_thresholds": "ok@600,warn@1000,alert@1500",
|
||||
"bar_gradient": true,
|
||||
|
||||
"_comment_units": "C oder F für Temperatur, kmh oder ms für Windgeschwindigkeit. Netatmo liefert die Daten in Celsius und km/h — Umrechnung passiert im Plugin.",
|
||||
"temp_unit": "C",
|
||||
"wind_unit": "kmh"
|
||||
}
|
||||
+130
-9
@@ -1,19 +1,23 @@
|
||||
"""Layout, Slot-Geometrie, Refresh-Loop.
|
||||
"""Layout, Slot-Geometrie, Refresh-Loop mit DeepSleep-Support.
|
||||
|
||||
Lädt config.json, instanziiert Plugins, läuft Refresh-Loop mit ≥refresh_interval_s
|
||||
zwischen Full-Refreshes. Reagiert auf Live-Trigger via Unix-Socket.
|
||||
|
||||
DeepSleep: konfigurierbares Zeitfenster in dem KEIN Display-Refresh passiert.
|
||||
Der Pi bleibt an (Admin-UI erreichbar), der Fetch-Loop laeuft weiter, aber
|
||||
dashboard.display() wird uebersprungen. ePaper haelt das letzte Bild ohne Strom.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import os, sys, time, json, math, threading, signal, socket, logging
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from palette import BG, FG, OK
|
||||
from plugins.base import all_widget_classes, Widget
|
||||
from palette import BG, FG, OK, POWER_DEFAULTS
|
||||
from plugins.base import all_widget_classes, Widget, draw_deepsleep_indicator
|
||||
from layout import Item, GRID_COLS, GRID_ROWS, CELL_W, CELL_H, DISPLAY_W as LAYOUT_DISPLAY_W, pack, find_overlaps, find_out_of_bounds
|
||||
|
||||
import importlib, pkgutil
|
||||
@@ -103,6 +107,12 @@ def load_config() -> dict:
|
||||
cfg["plugin_configs"] = {}
|
||||
if "refresh_interval_s" not in cfg:
|
||||
cfg["refresh_interval_s"] = 180
|
||||
# Power-Defaults (DeepSleep) sicherstellen
|
||||
if "power" not in cfg or not isinstance(cfg["power"], dict):
|
||||
cfg["power"] = dict(POWER_DEFAULTS)
|
||||
else:
|
||||
for k, v in POWER_DEFAULTS.items():
|
||||
cfg["power"].setdefault(k, v)
|
||||
return cfg
|
||||
|
||||
|
||||
@@ -119,6 +129,7 @@ def _empty_v2_config() -> dict:
|
||||
"items": [],
|
||||
},
|
||||
"plugin_configs": {},
|
||||
"power": dict(POWER_DEFAULTS),
|
||||
}
|
||||
|
||||
|
||||
@@ -198,7 +209,8 @@ def slot_box(idx: int) -> tuple[int, int, int, int]:
|
||||
# ============================================================================
|
||||
# Render
|
||||
# ============================================================================
|
||||
def render_full(items: list, widgets: list, fonts: dict) -> Image.Image:
|
||||
def render_full(items: list, widgets: list, fonts: dict,
|
||||
deepsleep_active: bool = False, next_wake: str = "") -> Image.Image:
|
||||
"""Render alle Items aufs Display.
|
||||
|
||||
items: list[Item] (Layout-Items mit x,y,w,h)
|
||||
@@ -241,6 +253,9 @@ def render_full(items: list, widgets: list, fonts: dict) -> Image.Image:
|
||||
draw.text((px + 8, py + 8), f"{widget.label} (overflow)",
|
||||
font=fonts.get("16", fonts.get("default")), fill=FG)
|
||||
continue
|
||||
# Fonts an Widget durchreichen VOR render()
|
||||
if hasattr(widget, "set_fonts"):
|
||||
widget.set_fonts(fonts)
|
||||
try:
|
||||
widget.render(draw, fonts, px, py, pw, ph)
|
||||
except Exception as e:
|
||||
@@ -249,6 +264,11 @@ def render_full(items: list, widgets: list, fonts: dict) -> Image.Image:
|
||||
f"{widget.label}: render error",
|
||||
font=fonts.get("16", fonts.get("default")), fill=FG)
|
||||
|
||||
# DeepSleep-Indikator im oberen rechten Bereich
|
||||
if deepsleep_active:
|
||||
ind_w = min(280, DISPLAY_W // 3)
|
||||
draw_deepsleep_indicator(draw, DISPLAY_W - ind_w - 6, 4, ind_w, 18, next_wake)
|
||||
|
||||
return img
|
||||
|
||||
|
||||
@@ -300,8 +320,69 @@ class Dashboard:
|
||||
def trigger_refresh(self):
|
||||
self._trigger.set()
|
||||
|
||||
def render_once(self) -> Image.Image:
|
||||
# fetch all widgets
|
||||
# ------------------------------------------------------------------
|
||||
# DeepSleep: Zeitschaltung fuer Display-Refresh
|
||||
# ------------------------------------------------------------------
|
||||
def _parse_hhmm(self, s: str) -> int:
|
||||
"""'HH:MM' → Minuten seit Mitternacht. Fallback: 0."""
|
||||
try:
|
||||
parts = s.split(":")
|
||||
return int(parts[0]) * 60 + int(parts[1])
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
def _is_deepsleep(self) -> bool:
|
||||
"""True wenn jetzt in der DeepSleep-Zeit liegt."""
|
||||
cfg = self.config.get("power", {})
|
||||
if not cfg.get("deepsleep_enabled", False):
|
||||
return False
|
||||
now = datetime.now()
|
||||
now_min = now.hour * 60 + now.minute
|
||||
start = self._parse_hhmm(cfg.get("deepsleep_start", "23:00"))
|
||||
end = self._parse_hhmm(cfg.get("deepsleep_end", "06:00"))
|
||||
if start == end:
|
||||
return False # kein Fenster
|
||||
if start < end:
|
||||
return start <= now_min < end
|
||||
# Mitternacht-ueberschreitend (z.B. 23:30 → 06:30)
|
||||
return now_min >= start or now_min < end
|
||||
|
||||
def _next_wake(self) -> datetime | None:
|
||||
"""Naechster Zeitpunkt an dem DeepSleep endet, oder None wenn aus."""
|
||||
if not self._is_deepsleep():
|
||||
return None
|
||||
cfg = self.config.get("power", {})
|
||||
end = self._parse_hhmm(cfg.get("deepsleep_end", "06:00"))
|
||||
now = datetime.now()
|
||||
end_dt = now.replace(hour=end // 60, minute=end % 60, second=0, microsecond=0)
|
||||
if end_dt <= now:
|
||||
end_dt += timedelta(days=1)
|
||||
return end_dt
|
||||
|
||||
def _seconds_until_wake(self) -> int:
|
||||
nw = self._next_wake()
|
||||
if nw is None:
|
||||
return 0
|
||||
return max(5, int((nw - datetime.now()).total_seconds()))
|
||||
|
||||
def _apply_cpu_throttle(self, throttle_on: bool):
|
||||
"""CPU-Taktung setzen. Nur auf Linux/Pi wirksam; sonst noop."""
|
||||
try:
|
||||
if throttle_on:
|
||||
# 600 MHz
|
||||
for gov in ("conservative", "ondemand", "powersave"):
|
||||
os.system(f"echo {gov} > /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor 2>/dev/null")
|
||||
os.system("echo 600000 > /sys/devices/system/cpu/cpu0/cpufreq/scaling_max_freq 2>/dev/null")
|
||||
else:
|
||||
# Default: ondemand, max 1.5 GHz
|
||||
for gov in ("ondemand", "performance"):
|
||||
os.system(f"echo {gov} > /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor 2>/dev/null")
|
||||
os.system("echo 1500000 > /sys/devices/system/cpu/cpu0/cpufreq/scaling_max_freq 2>/dev/null")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def render_once(self, with_deepsleep: bool = True) -> Image.Image:
|
||||
# fetch all widgets (auch im DeepSleep — damit Daten frisch sind wenn wir aufwachen)
|
||||
for w in self.widgets:
|
||||
if w is None: continue
|
||||
try:
|
||||
@@ -311,7 +392,15 @@ class Dashboard:
|
||||
with self._lock:
|
||||
items = list(self.items)
|
||||
widgets = list(self.widgets)
|
||||
img = render_full(items, widgets, self.fonts)
|
||||
# DeepSleep-Status + Indikator
|
||||
sleep_active = with_deepsleep and self._is_deepsleep()
|
||||
next_wake = ""
|
||||
if sleep_active:
|
||||
nw = self._next_wake()
|
||||
if nw:
|
||||
next_wake = nw.strftime("%H:%M")
|
||||
img = render_full(items, widgets, self.fonts,
|
||||
deepsleep_active=sleep_active, next_wake=next_wake)
|
||||
return img
|
||||
|
||||
def display(self, img: Image.Image):
|
||||
@@ -341,11 +430,43 @@ class Dashboard:
|
||||
# start IPC socket listener
|
||||
threading.Thread(target=self._socket_loop, daemon=True).start()
|
||||
|
||||
_throttle_applied = False
|
||||
|
||||
while True:
|
||||
try:
|
||||
self.maybe_reload_config()
|
||||
interval = int(self.config.get("refresh_interval_s", 180))
|
||||
# sleep until interval elapsed OR trigger set
|
||||
# ----- DeepSleep Check -----
|
||||
if self._is_deepsleep():
|
||||
secs = self._seconds_until_wake()
|
||||
nw = self._next_wake()
|
||||
nw_str = nw.strftime("%H:%M") if nw else "?"
|
||||
logging.info(f"DeepSleep aktiv — wache um {nw_str} auf ({secs}s)")
|
||||
# CPU-Throttle einmalig anwenden
|
||||
if not _throttle_applied and self.config.get("power", {}).get("cpu_throttle", True):
|
||||
self._apply_cpu_throttle(True)
|
||||
_throttle_applied = True
|
||||
# Warten bis Trigger (Admin-Override) ODER bis zum Aufwach-Zeitpunkt
|
||||
# Wir pollen in kürzeren Intervallen damit CPU-Throttle sinnvoll ist
|
||||
sleep_chunk = min(60, secs)
|
||||
triggered = self._trigger.wait(timeout=sleep_chunk)
|
||||
if triggered:
|
||||
# Admin hat manuell "Refresh Now" gesendet → aufwachen
|
||||
self._trigger.clear()
|
||||
logging.info("DeepSleep durch Admin-Trigger beendet")
|
||||
if _throttle_applied:
|
||||
self._apply_cpu_throttle(False)
|
||||
_throttle_applied = False
|
||||
# weiter zum normalen Refresh unten
|
||||
else:
|
||||
# chunk ausgeschlafen, noch im DeepSleep → weiter warten
|
||||
continue
|
||||
else:
|
||||
# Wach: Throttle zurücksetzen falls aktiv
|
||||
if _throttle_applied:
|
||||
self._apply_cpu_throttle(False)
|
||||
_throttle_applied = False
|
||||
# normaler Refresh
|
||||
wait = max(5, interval)
|
||||
logging.info(f"waiting up to {wait}s (manual trigger interrupts)")
|
||||
if self._trigger.wait(timeout=wait):
|
||||
|
||||
@@ -141,12 +141,30 @@ def pack(items: list[Item], order: Optional[list[str]] = None) -> list[Item]:
|
||||
return result
|
||||
|
||||
|
||||
def first_fit(item: Item, others: list[Item]) -> Item | None:
|
||||
"""Place the item at the first free scan-line position without moving others.
|
||||
|
||||
Returns the item with x/y set, or None if no placement fits.
|
||||
Used by /api/layout/add so a new widget does not reshuffle the grid.
|
||||
"""
|
||||
w = max(1, min(GRID_COLS, item.w))
|
||||
h = max(1, min(GRID_ROWS, item.h))
|
||||
for y in range(GRID_ROWS - h + 1):
|
||||
for x in range(GRID_COLS - w + 1):
|
||||
candidate = Item(item.id, item.plugin, x, y, w, h)
|
||||
cells = cells_occupied(candidate)
|
||||
if all(not (cells & cells_occupied(o)) for o in others):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def auto_size_for_plugin(plugin_name: str) -> tuple[int, int]:
|
||||
"""Default size when user adds a new item."""
|
||||
presets = {
|
||||
"clock": (2, 2),
|
||||
"clock_wordclock": (2, 2),
|
||||
"weather": (2, 2),
|
||||
"netatmo": (4, 4), # zeigt 4 Sub-Cards; ab 2x2 sinnvoll, 4x4 ideal
|
||||
"system": (2, 2),
|
||||
"minimax": (2, 2),
|
||||
"spotify": (2, 1),
|
||||
|
||||
+186
@@ -235,3 +235,189 @@ def is_tall(w: int, h: int) -> bool:
|
||||
"""True wenn Slot hoch aber schmal ist (1x4, 1x2)."""
|
||||
return h >= 280 and w < 280
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Batterie-Farben — Single Source of Truth
|
||||
# ============================================================================
|
||||
BATT_LOW = RED # < 25% → kritisch
|
||||
BATT_MID = ORANGE # < 50% → bald wechseln
|
||||
BATT_OK = GREEN # >= 50% → OK
|
||||
|
||||
# Standard-Batterie-Schwellen (werden von Plugins einheitlich genutzt)
|
||||
BATT_THRESHOLDS = (
|
||||
(25, BATT_LOW),
|
||||
(50, BATT_MID),
|
||||
)
|
||||
|
||||
|
||||
def batt_color(pct: int | None) -> tuple:
|
||||
"""Liefert die passende Batterie-Farbe fuer einen Prozent-Wert.
|
||||
|
||||
None → FG (keine Anzeige), sonst BATT_LOW/MID/OK.
|
||||
"""
|
||||
if pct is None:
|
||||
return FG
|
||||
pct = max(0, min(100, pct))
|
||||
for threshold, color in BATT_THRESHOLDS:
|
||||
if pct < threshold:
|
||||
return color
|
||||
return BATT_OK
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Deutsche Formatter — Single Source of Truth (kein Englisches "Monday" mehr)
|
||||
# ============================================================================
|
||||
DE_WEEKDAYS_LONG = [
|
||||
"Montag", "Dienstag", "Mittwoch", "Donnerstag",
|
||||
"Freitag", "Samstag", "Sonntag",
|
||||
]
|
||||
DE_WEEKDAYS_SHORT = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]
|
||||
DE_MONTHS_LONG = [
|
||||
"Januar", "Februar", "Maerz", "April", "Mai", "Juni",
|
||||
"Juli", "August", "September", "Oktober", "November", "Dezember",
|
||||
]
|
||||
DE_MONTHS_SHORT = ["Jan", "Feb", "Mae", "Apr", "Mai", "Jun",
|
||||
"Jul", "Aug", "Sep", "Okt", "Nov", "Dez"]
|
||||
|
||||
|
||||
def de_weekday_long(weekday: int) -> str:
|
||||
"""datetime.weekday() (0=Montag) → 'Montag'/'Dienstag'/..."""
|
||||
return DE_WEEKDAYS_LONG[weekday]
|
||||
|
||||
|
||||
def de_weekday_short(weekday: int) -> str:
|
||||
"""datetime.weekday() (0=Montag) → 'Mo'/'Di'/..."""
|
||||
return DE_WEEKDAYS_SHORT[weekday]
|
||||
|
||||
|
||||
def de_month_short(month: int) -> str:
|
||||
"""datetime.month (1-12) → 'Jan'/'Feb'/..."""
|
||||
return DE_MONTHS_SHORT[month - 1]
|
||||
|
||||
|
||||
def de_date(dt, short: bool = True) -> str:
|
||||
"""Datum als '30. Aug 2026' (default short=True)."""
|
||||
if short:
|
||||
return f"{dt.day:02d}. {de_month_short(dt.month)} {dt.year}"
|
||||
return f"{dt.day:02d}. {DE_MONTHS_LONG[dt.month - 1]} {dt.year}"
|
||||
|
||||
|
||||
def de_weekday_date(dt, short: bool = True) -> str:
|
||||
"""'Sa 30.08' wenn short, sonst 'Samstag 30. August 2026'."""
|
||||
if short:
|
||||
return f"{de_weekday_short(dt.weekday())} {dt.day:02d}.{dt.month:02d}"
|
||||
return f"{de_weekday_long(dt.weekday())} {de_date(dt, short=False)}"
|
||||
|
||||
|
||||
def de_time(dt, show_seconds: bool = False) -> str:
|
||||
"""24h-Zeit 'HH:MM' oder 'HH:MM:SS'."""
|
||||
return dt.strftime("%H:%M:%S" if show_seconds else "%H:%M")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Wind / Temp Helper — einheitliche Formatierung
|
||||
# ============================================================================
|
||||
def format_temp(value: float | None, unit: str = "C", decimals: int = 1) -> str:
|
||||
"""Temperatur formatieren. None → em-dash."""
|
||||
if value is None:
|
||||
return "—"
|
||||
return f"{value:.{decimals}f}°{unit}"
|
||||
|
||||
|
||||
def format_wind(speed: float | None, unit: str = "kmh", decimals: int = 0) -> str:
|
||||
"""Windgeschwindigkeit. 'kmh' → 'km/h', 'ms' → 'm/s'."""
|
||||
if speed is None:
|
||||
return "—"
|
||||
label = "km/h" if unit == "kmh" else "m/s"
|
||||
return f"{speed:.{decimals}f} {label}"
|
||||
|
||||
|
||||
def format_rain(rate_mm: float | None, decimals: int = 1) -> str:
|
||||
"""Regenrate 'X.X mm/h'."""
|
||||
if rate_mm is None:
|
||||
return "—"
|
||||
return f"{rate_mm:.{decimals}f} mm/h"
|
||||
|
||||
|
||||
def temp_color(value: float | None) -> tuple:
|
||||
"""Farbe nach Temperaturwert: kalt→Blau, normal→FG, warm→Orange, heiss→Rot."""
|
||||
if value is None:
|
||||
return FG
|
||||
if value >= 28:
|
||||
return RED
|
||||
if value >= 22:
|
||||
return ORANGE
|
||||
if value <= 5:
|
||||
return BLUE
|
||||
return FG
|
||||
|
||||
|
||||
def co2_color(ppm: int | float | None) -> tuple:
|
||||
"""CO2-Ampel: <600 gruen, <1000 orange, <1500 gelb, >=1500 rot."""
|
||||
if ppm is None:
|
||||
return FG
|
||||
if ppm >= 1500:
|
||||
return RED
|
||||
if ppm >= 1000:
|
||||
return YELLOW
|
||||
if ppm >= 600:
|
||||
return ORANGE
|
||||
return GREEN
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Power-Management — Defaults
|
||||
# ============================================================================
|
||||
POWER_DEFAULTS = {
|
||||
"deepsleep_enabled": False,
|
||||
"deepsleep_start": "23:30",
|
||||
"deepsleep_end": "06:30",
|
||||
"cpu_throttle": True, # waehrend DeepSleep auf 600MHz
|
||||
"wifi_off": False, # WiFi anlassen fuer Admin-Erreichbarkeit
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Weather-Code-Mapping (WMO Standard + Netatmo-Fallback)
|
||||
# ============================================================================
|
||||
# WMO Weather interpretation codes (https://open-meteo.com/en/docs)
|
||||
WMO_ICONS = {
|
||||
0: "sun", # Clear sky
|
||||
1: "partly", # Mainly clear
|
||||
2: "partly", # Partly cloudy
|
||||
3: "cloud", # Overcast
|
||||
45: "fog", # Fog
|
||||
48: "fog", # Depositing rime fog
|
||||
51: "rain", # Drizzle: light
|
||||
53: "rain", # Drizzle: moderate
|
||||
55: "rain", # Drizzle: dense
|
||||
56: "rain", # Freezing Drizzle: light
|
||||
57: "rain", # Freezing Drizzle: dense
|
||||
61: "rain", # Rain: slight
|
||||
63: "rain", # Rain: moderate
|
||||
65: "rain", # Rain: heavy
|
||||
66: "rain", # Freezing Rain: light
|
||||
67: "rain", # Freezing Rain: heavy
|
||||
71: "snow", # Snow fall: slight
|
||||
73: "snow", # Snow fall: moderate
|
||||
75: "snow", # Snow fall: heavy
|
||||
77: "snow", # Snow grains
|
||||
80: "rain", # Rain showers: slight
|
||||
81: "rain", # Rain showers: moderate
|
||||
82: "rain", # Rain showers: violent
|
||||
85: "snow", # Snow showers: slight
|
||||
86: "snow", # Snow showers: heavy
|
||||
95: "storm", # Thunderstorm
|
||||
96: "storm", # Thunderstorm with slight hail
|
||||
99: "storm", # Thunderstorm with heavy hail
|
||||
}
|
||||
|
||||
|
||||
def wmo_to_icon(code: int | str | None) -> str:
|
||||
"""WMO-Code (int) oder Symbol-String → 'sun'/'cloud'/'rain'/'storm'/..."""
|
||||
if code is None:
|
||||
return "cloud"
|
||||
if isinstance(code, str):
|
||||
return code # schon ein symbol
|
||||
return WMO_ICONS.get(code, "cloud")
|
||||
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
# Netatmo Weather Station Plugin
|
||||
|
||||
Zeigt die Live-Daten deiner heimischen [Netatmo Wetterstation](https://www.netatmo.com/de-de/weather/weatherstation)
|
||||
auf dem 7.3″ ACeP Dashboard: Indoor-Temperatur, CO₂, Luftfeuchte, Luftdruck,
|
||||
Outdoor-Werte, Wind (Stärke + Böen + Richtung mit Windrose) und Regenmengen.
|
||||
|
||||
Funktioniert mit **jeder** Netatmo-Konfiguration (Hauptmodul + beliebige
|
||||
Zusatzmodule). Module, die du nicht hast, werden automatisch weggelassen.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 1. Netatmo-App registrieren
|
||||
|
||||
Damit das Plugin Daten abrufen darf, brauchst du eine App-Registrierung
|
||||
bei Netatmo. Das ist einmalig und kostenlos.
|
||||
|
||||
1. Gehe zu <https://dev.netatmo.com/apps/> und logge dich mit deinem
|
||||
Netatmo-Account ein.
|
||||
2. Klicke **Create an App**.
|
||||
3. Fülle das Formular aus:
|
||||
- **Name**: z. B. „epaper-dashboard"
|
||||
- **Description**: z. B. „Wetterdaten auf meinem ePaper-Display"
|
||||
- **Data Protection Officer**: nicht erforderlich für eine persönliche App
|
||||
4. Bei den **Scopes** wähle mindestens **`read_station`** aus
|
||||
(Standard-Scope der Weather API). Mehr brauchst du für dieses Plugin nicht.
|
||||
5. Speichern. Du bekommst **Client-ID** und **Client-Secret** angezeigt —
|
||||
die brauchst du gleich.
|
||||
|
||||
> Hinweis: Netatmo verwendet **OAuth2 mit Password-Grant** für die
|
||||
> Weather API (kein Browser-Redirect nötig). Dein Passwort wird nur
|
||||
> lokal für den initialen Token verwendet — es wird **nie** auf der
|
||||
> Festplatte persistiert, sondern liegt nur im RAM des laufenden
|
||||
> Dashboard-Prozesses.
|
||||
|
||||
---
|
||||
|
||||
## 2. Plugin konfigurieren
|
||||
|
||||
### Option A — über die Admin-Weboberfläche (empfohlen)
|
||||
|
||||
`http://<pi>:8080/` öffnen, in der Sidebar das Widget **Netatmo**
|
||||
auswählen und die Werte eintragen:
|
||||
|
||||
| Feld | Wert |
|
||||
|---|---|
|
||||
| Client-ID | von dev.netatmo.com/apps |
|
||||
| Client-Secret | von dev.netatmo.com/apps |
|
||||
| Username | deine Netatmo-Login-E-Mail |
|
||||
| Password | dein Netatmo-Passwort |
|
||||
| Station (Filter) | leer lassen, wenn du nur eine Station hast |
|
||||
|
||||
Speichern. Klicke **Refresh Now** — wenn alles passt, sollten nach
|
||||
~30s die ersten Daten erscheinen.
|
||||
|
||||
### Option B — direkt in `config.json`
|
||||
|
||||
Unter `plugin_configs.netatmo` eintragen (Beispiel siehe
|
||||
[`config.netatmo.example.json`](config.netatmo.example.json)):
|
||||
|
||||
```json
|
||||
{
|
||||
"plugin_configs": {
|
||||
"netatmo": {
|
||||
"client_id": "abc123...",
|
||||
"client_secret": "def456...",
|
||||
"username": "du@example.com",
|
||||
"password": "GEHEIM",
|
||||
"station_filter": "",
|
||||
"show_indoor": true,
|
||||
"show_outdoor": true,
|
||||
"show_wind": true,
|
||||
"show_rain": true,
|
||||
"show_compass": true,
|
||||
"show_secondary": true,
|
||||
"co2_thresholds": "ok@600,warn@1000,alert@1500",
|
||||
"bar_gradient": true,
|
||||
"temp_unit": "C",
|
||||
"wind_unit": "kmh"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> ⚠️ Secrets in `config.json` sind auf dem Pi persistent (lokal in
|
||||
> `/home/<user>/.hermes/projects/pi/config.json`). Wenn du das
|
||||
> vermeiden willst, nimm die Admin-UI — die legt sie genauso ab, aber
|
||||
> du siehst die Werte nie im Klartext-Editor.
|
||||
|
||||
---
|
||||
|
||||
## 3. Layout zuweisen
|
||||
|
||||
In der Admin-UI ein Netatmo-Widget auf das Grid ziehen. Das Plugin ist
|
||||
**responsive**: dieselbe Konfiguration sieht auf jedem Slot gut aus.
|
||||
|
||||
| Slot | Was du siehst |
|
||||
|---|---|
|
||||
| **1×1** | `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.
|
||||
|
||||
---
|
||||
|
||||
## 4. Optionen im Detail
|
||||
|
||||
| Option | Typ | Default | Bedeutung |
|
||||
|---|---|---|---|
|
||||
| `client_id` | secret | — | Netatmo App Client-ID |
|
||||
| `client_secret` | secret | — | Netatmo App Client-Secret |
|
||||
| `refresh_token` | secret | — | OAuth2 Refresh-Token (nur RAM, wird on-demand erneuert) |
|
||||
| `station_filter` | string | `""` | Substring-Filter auf Stations-/Modulname |
|
||||
| `show_outdoor` | bool | `true` | NAModule1 (Außen) anzeigen |
|
||||
| `show_wind` | bool | `true` | NAModule2 (Wind) anzeigen |
|
||||
| `show_rain` | bool | `true` | NAModule3 (Regen) anzeigen |
|
||||
| `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) |
|
||||
| `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`.
|
||||
Mehrere Stufen durch Komma trennen:
|
||||
|
||||
- `ok@600,warn@1000,alert@1500` — Netatmo-Default-Empfehlungen
|
||||
- `ok@800,warn@1200` — nur 2 Stufen (kompakt)
|
||||
- `ok@1000,warn@1500,alert@2000` — strenger (z. B. für Schlafzimmer)
|
||||
|
||||
### Station-Filter
|
||||
|
||||
Wenn du mehrere Stationen hast (z. B. „Home" und „Office"), kannst du
|
||||
über `station_filter` einen Substring matchen. Erst wird der
|
||||
Stationsname geprüft, dann die Modulnamen. Leer = erste Station.
|
||||
|
||||
---
|
||||
|
||||
## 5. Fehlerdiagnose
|
||||
|
||||
### Auth-Fehler (HTTP 401)
|
||||
|
||||
- Stimmen Client-ID/Secret mit dem Eintrag auf dev.netatmo.com/apps überein?
|
||||
- Stimmt das Passwort (case-sensitive)?
|
||||
- Wurde deine App bei Netatmo evtl. deaktiviert?
|
||||
|
||||
→ Plugin setzt den Token-Cache automatisch zurück, beim nächsten Render
|
||||
wird ein neuer Token geholt.
|
||||
|
||||
### "Keine Station gefunden"
|
||||
|
||||
- Hat deine Station in den letzten 4h Daten an Netatmo geschickt?
|
||||
(Netatmo markiert sie sonst als offline.)
|
||||
- Stimmt der `station_filter`? Wenn du dort z. B. „Home" eingibst, aber
|
||||
die Station heißt „Home Office", passt es trotzdem (Substring-Match).
|
||||
Leer = erste Station.
|
||||
|
||||
### "API nicht erreichbar"
|
||||
|
||||
- Pi hat Internet? (`ping api.netatmo.net`)
|
||||
- DNS? (`nslookup api.netatmo.net`)
|
||||
|
||||
### Werte fehlen / sind 0
|
||||
|
||||
- Manche Datenpunkte sind nur in der **Paid**-Subscription verfügbar
|
||||
(z. B. Historische Daten > 1h). Aktuelle Werte sind immer frei.
|
||||
|
||||
---
|
||||
|
||||
## 6. Technische Details
|
||||
|
||||
| Aspekt | Wert |
|
||||
|---|---|
|
||||
| API-Version | Netatmo Connect OAuth2 (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 | 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 Refresh-Grant.
|
||||
2. `GET /api/getstationsdata?get_favorites=false` mit Bearer-Token.
|
||||
3. Hole 12h-History (`getmeasure`) für MIN/MAX-Berechnung.
|
||||
4. Hole 3-Tage-Forecast von Open-Meteo.
|
||||
5. Parse Antwort, filtere nach `station_filter` und sichtbaren Modulen.
|
||||
6. Rendere je nach Slot-Größe (`compact` / `compact_2x2` / `full_4x4`).
|
||||
|
||||
Bei 401 wird der Token-Cache geleert und **einmal** neu authentifiziert.
|
||||
Wenn das auch fehlschlägt → roter Error-Banner mit der genauen HTTP-Meldung.
|
||||
+354
-1
@@ -4,17 +4,29 @@ Ein Plugin ist ein Python-Modul in /plugins/, das eine Klasse Widget
|
||||
exportiert. Die Klasse wird beim Start dynamisch geladen und in der
|
||||
Admin-UI zur Auswahl angeboten.
|
||||
|
||||
Dieses Modul enthaelt auch Shared Render-Helpers (Batterie, Compass,
|
||||
Trend-Pfeile, Wetter-Icons, Charts), die von allen Plugins einheitlich
|
||||
genutzt werden — damit sehen Batterie-Icons, Windrosen, etc. auf dem
|
||||
Display konsistent aus.
|
||||
|
||||
Minimal-Beispiel siehe plugins/hello.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
import math
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import socket as _socket
|
||||
from PIL import ImageDraw
|
||||
from PIL import ImageDraw, ImageFont
|
||||
|
||||
from palette import (
|
||||
FG, BG, BLACK, WHITE, GREEN, BLUE, RED, YELLOW, ORANGE,
|
||||
BATT_LOW, BATT_MID, BATT_OK, batt_color,
|
||||
measure, text_wh,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -153,6 +165,13 @@ class Widget(ABC):
|
||||
merged = dict(self.default_config)
|
||||
merged.update(config or {})
|
||||
self.config = merged
|
||||
self.fonts: dict | None = None # wird vom Dashboard gesetzt vor render()
|
||||
self.theme: Any = None # Theme-Instanz (von designbase), optional
|
||||
|
||||
def set_fonts(self, fonts: dict):
|
||||
"""Wird vom Dashboard vor render() aufgerufen. Plugins koennen
|
||||
dann self.fonts["24"] etc. nutzen statt selbst Fonts zu laden."""
|
||||
self.fonts = fonts
|
||||
|
||||
@abstractmethod
|
||||
def fetch(self) -> dict:
|
||||
@@ -189,3 +208,337 @@ def all_widget_classes() -> list[type[Widget]]:
|
||||
classes.append(cls)
|
||||
classes.sort(key=lambda c: c.label or c.name)
|
||||
return classes
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Shared Render Helpers — Single Source of Truth fuer Batterie, Compass,
|
||||
# Trend, Wetter-Icons, Charts. Alle Plugins nutzen diese Funktionen damit
|
||||
# das Display konsistent aussieht.
|
||||
# ============================================================================
|
||||
|
||||
# Sans-Schriftart aus dem System (lazy-loaded, gecacht)
|
||||
_BASE_SANS_PATHS = [
|
||||
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
"C:/Windows/Fonts/arial.ttf",
|
||||
"C:/Windows/Fonts/seguisb.ttf",
|
||||
]
|
||||
_BASE_SANS = None
|
||||
|
||||
|
||||
def _get_base_sans() -> ImageFont.FreeTypeFont | None:
|
||||
"""Lazy-load Sans-Basis-Font (size 14) fuer die Helper."""
|
||||
global _BASE_SANS
|
||||
if _BASE_SANS is not None:
|
||||
return _BASE_SANS
|
||||
import os
|
||||
for p in _BASE_SANS_PATHS:
|
||||
if os.path.exists(p):
|
||||
try:
|
||||
_BASE_SANS = ImageFont.truetype(p, 14)
|
||||
return _BASE_SANS
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
_BASE_SANS = ImageFont.load_default()
|
||||
except Exception:
|
||||
_BASE_SANS = None
|
||||
return _BASE_SANS
|
||||
|
||||
|
||||
def _fit_size(draw, text: str, max_w: int, max_h: int,
|
||||
candidates: list[str | int] | None = None) -> ImageFont.FreeTypeFont:
|
||||
"""Iteriere durch Font-Groessen, waehle die erste die passt.
|
||||
|
||||
Verwendet das gleiche Sans-Family wie die Basis-Schrift.
|
||||
"""
|
||||
if candidates is None:
|
||||
candidates = ["48", "36", "32", "28", "24", "20", "16", "14", "12", "10"]
|
||||
base = _get_base_sans()
|
||||
base_path = getattr(base, "path", None) if base else None
|
||||
for sz in candidates:
|
||||
try:
|
||||
sz_int = int(sz)
|
||||
if base_path:
|
||||
f = ImageFont.truetype(base_path, sz_int)
|
||||
else:
|
||||
f = base
|
||||
except Exception:
|
||||
continue
|
||||
tw, th = measure(draw, text, f)
|
||||
if tw <= max_w and th <= max_h:
|
||||
return f
|
||||
return base
|
||||
|
||||
|
||||
def text_in_box(draw, text, x, y, w, h, font, color, align: str = "left"):
|
||||
"""Zeichnet Text in reservierten Bereich (x,y,w,h). Garantiert keine
|
||||
Ueberlappung mit anderen Elementen wenn jeder seinen eigenen Bereich hat.
|
||||
|
||||
align: 'left' (default), 'right', 'center'
|
||||
"""
|
||||
tw, th = measure(draw, text, font)
|
||||
if tw > w or th > h:
|
||||
return False # wuerde ueberlaufen
|
||||
if align == "left":
|
||||
draw.text((x, y + (h - th) // 2), text, font=font, fill=color)
|
||||
elif align == "right":
|
||||
draw.text((x + w - tw, y + (h - th) // 2), text, font=font, fill=color)
|
||||
elif align == "center":
|
||||
draw.text((x + (w - tw) // 2, y + (h - th) // 2), text, font=font, fill=color)
|
||||
return True
|
||||
|
||||
|
||||
# ---- Batterie-Icon (Single Source of Truth) ------------------------------
|
||||
def draw_battery(draw, x, y, pct: int | None, w: int = 22, h: int = 10,
|
||||
with_text: bool = True, text_color: tuple | None = None):
|
||||
"""Zeichnet ein konsistentes Batterie-Icon.
|
||||
|
||||
Args:
|
||||
pct: Prozent (0-100), None → noop
|
||||
w, h: Icon-Groesse (ohne Tip)
|
||||
with_text: ob "%" daneben gezeichnet werden soll
|
||||
text_color: falls None, automatisch nach Schwellen (LOW/MID/OK)
|
||||
"""
|
||||
if pct is None:
|
||||
return 0
|
||||
pct = max(0, min(100, pct))
|
||||
color = text_color or batt_color(pct)
|
||||
# Body
|
||||
draw.rectangle((x, y, x + w - 3, y + h - 1), outline=FG, width=1)
|
||||
# Tip
|
||||
draw.rectangle((x + w - 2, y + 3, x + w, y + h - 3), fill=FG)
|
||||
# Fill
|
||||
fill_w = max(0, int((w - 4) * pct / 100))
|
||||
if fill_w > 0:
|
||||
draw.rectangle((x + 2, y + 2, x + 2 + fill_w, y + h - 2), fill=color)
|
||||
used = w
|
||||
if with_text:
|
||||
f = _fit_size(draw, f"{pct}%", 30, h, candidates=["10", "9", "8"])
|
||||
text_in_box(draw, f"{pct}%", x + w + 3, y - 1, 30, h + 2, f, color, "left")
|
||||
used += 30
|
||||
return used
|
||||
|
||||
|
||||
# ---- Trend-Pfeil (up / down / stable) ------------------------------------
|
||||
def draw_trend(draw, x, y, trend: str, size: int = 10, color: tuple | None = None):
|
||||
"""Pfeil nach oben/unten, oder waagerechter Strich fuer 'stable'."""
|
||||
color = color or FG
|
||||
cx, cy = x + size // 2, y + size // 2
|
||||
if trend == "up":
|
||||
draw.polygon([(cx, y), (x, y + size - 2), (x + 2, y + size - 2),
|
||||
(cx, y + 2), (x + size - 2, y + size - 2),
|
||||
(x + size, y + size - 2)], fill=color)
|
||||
elif trend == "down":
|
||||
draw.polygon([(cx, y + size), (x, y + 2), (x + 2, y + 2),
|
||||
(cx, y + size - 2), (x + size - 2, y + 2),
|
||||
(x + size, y + 2)], fill=color)
|
||||
else: # stable
|
||||
draw.line((x, cy, x + size, cy), fill=color, width=2)
|
||||
|
||||
|
||||
# ---- Windrose / Compass --------------------------------------------------
|
||||
def draw_compass(draw, cx, cy, r: int, deg: float, color: tuple = BLUE,
|
||||
with_labels: bool = False):
|
||||
"""Zeichnet eine Windrose.
|
||||
|
||||
Args:
|
||||
deg: 0=Nord, 90=Ost, 180=Sued, 270=West
|
||||
with_labels: N/O/S/W Beschriftung (nur fuer grosse Varianten sinnvoll)
|
||||
"""
|
||||
if r < 4:
|
||||
return
|
||||
# Aeusserer Kreis
|
||||
draw.ellipse((cx - r, cy - r, cx + r, cy + r), outline=FG, width=1)
|
||||
# Cardinal Ticks
|
||||
for a in [270, 90, 180, 0]: # N/O/S/W in Wetter-Konvention
|
||||
rd = math.radians(a - 90)
|
||||
x1 = cx + (r - 6) * math.cos(rd)
|
||||
y1 = cy + (r - 6) * math.sin(rd)
|
||||
x2 = cx + (r - 2) * math.cos(rd)
|
||||
y2 = cy + (r - 2) * math.sin(rd)
|
||||
draw.line((x1, y1, x2, y2), fill=FG, width=1)
|
||||
# Pfeil
|
||||
rad = math.radians(deg - 90)
|
||||
tx = cx + (r - 4) * math.cos(rad)
|
||||
ty = cy + (r - 4) * math.sin(rad)
|
||||
draw.line((cx, cy, tx, ty), fill=color, width=2 if r < 16 else 3)
|
||||
draw.ellipse((cx - 2, cy - 2, cx + 2, cy + 2), fill=color)
|
||||
# Optionale N/O/S/W Labels
|
||||
if with_labels and r >= 14:
|
||||
for letter, a in [("N", 270), ("O", 0), ("S", 90), ("W", 180)]:
|
||||
rd = math.radians(a - 90)
|
||||
lx = cx + (r + 7) * math.cos(rd) - 4
|
||||
ly = cy + (r + 7) * math.sin(rd) - 6
|
||||
f = _fit_size(draw, letter, 10, 10, candidates=["10", "9", "8"])
|
||||
draw.text((lx, ly), letter, font=f, fill=FG)
|
||||
|
||||
|
||||
# ---- Wetter-Icon (7 Wetter-Zustaende) ------------------------------------
|
||||
def draw_weather_icon(draw, x, y, size: int, code: int | str):
|
||||
"""Zeichnet ein Wetter-Icon in (x, y, size, size) Box.
|
||||
|
||||
Akzeptiert WMO-Code (int) oder symbol-string ('sun'/'cloud'/'rain'/'storm'/'snow'/'fog'/'partly').
|
||||
Verwendet nur die 7 ACeP-Farben.
|
||||
"""
|
||||
from palette import wmo_to_icon
|
||||
sym = wmo_to_icon(code) if isinstance(code, int) else code
|
||||
cx, cy = x + size // 2, y + size // 2
|
||||
r = max(3, size // 3)
|
||||
if sym in ("sun", "clear"):
|
||||
draw.ellipse((cx - r, cy - r, cx + r, cy + r), fill=YELLOW)
|
||||
for ang in range(0, 360, 45):
|
||||
rad = math.radians(ang)
|
||||
x1 = cx + int((r + 2) * math.cos(rad))
|
||||
y1 = cy + int((r + 2) * math.sin(rad))
|
||||
x2 = cx + int((r + 5) * math.cos(rad))
|
||||
y2 = cy + int((r + 5) * math.sin(rad))
|
||||
draw.line((x1, y1, x2, y2), fill=FG, width=1)
|
||||
elif sym == "partly":
|
||||
draw.ellipse((cx - r // 2 - 2, cy - r, cx + r // 2, cy + 1), fill=YELLOW)
|
||||
cw = size // 2
|
||||
ch = size // 4
|
||||
draw.ellipse((cx - cw // 2, cy, cx + cw // 2, cy + ch),
|
||||
fill=WHITE, outline=FG, width=1)
|
||||
elif sym == "cloud":
|
||||
cw = int(size * 0.7)
|
||||
ch = size // 3
|
||||
draw.ellipse((cx - cw // 2, cy - ch // 2, cx + cw // 2, cy + ch // 2),
|
||||
fill=WHITE, outline=FG, width=2)
|
||||
draw.ellipse((cx - cw // 3, cy - ch, cx + cw // 3, cy + ch // 2),
|
||||
fill=WHITE, outline=FG, width=2)
|
||||
elif sym == "rain":
|
||||
cw = int(size * 0.7)
|
||||
ch = size // 3
|
||||
draw.ellipse((cx - cw // 2, cy - ch - 1, cx + cw // 2, cy + ch // 2 - 1),
|
||||
fill=WHITE, outline=FG, width=2)
|
||||
for dx in (-4, 0, 4):
|
||||
draw.line((cx + dx, cy + ch, cx + dx, cy + ch + 5), fill=BLUE, width=2)
|
||||
elif sym == "storm":
|
||||
cw = int(size * 0.7)
|
||||
ch = size // 3
|
||||
draw.ellipse((cx - cw // 2, cy - ch - 1, cx + cw // 2, cy + ch // 2 - 1),
|
||||
fill=WHITE, outline=FG, width=2)
|
||||
draw.polygon([(cx - 3, cy + ch - 1), (cx + 1, cy + ch + 3),
|
||||
(cx - 1, cy + ch + 3), (cx + 3, cy + ch + 8)],
|
||||
fill=YELLOW)
|
||||
elif sym == "snow":
|
||||
cw = int(size * 0.7)
|
||||
ch = size // 3
|
||||
draw.ellipse((cx - cw // 2, cy - ch - 1, cx + cw // 2, cy + ch // 2 - 1),
|
||||
fill=WHITE, outline=FG, width=2)
|
||||
for dx in (-4, 0, 4):
|
||||
for dy in (0, 5):
|
||||
draw.line((cx + dx - 2, cy + ch + dy, cx + dx + 2, cy + ch + dy),
|
||||
fill=FG, width=1)
|
||||
draw.line((cx + dx, cy + ch + dy - 2, cx + dx, cy + ch + dy + 2),
|
||||
fill=FG, width=1)
|
||||
elif sym == "fog":
|
||||
cw = int(size * 0.7)
|
||||
for i, dy in enumerate((-2, 1, 4)):
|
||||
y_pos = cy + dy
|
||||
draw.line((cx - cw // 2, y_pos, cx + cw // 2, y_pos), fill=FG, width=1)
|
||||
else:
|
||||
f = _fit_size(draw, "?", size - 4, size - 4, candidates=["20", "16", "12"])
|
||||
text_in_box(draw, "?", x, y, size, size, f, FG, "center")
|
||||
|
||||
|
||||
# ---- Linien-Chart (mit optionalem Y-Grid) ---------------------------------
|
||||
def draw_line_chart(draw, x, y, w, h, data: list, color: tuple = GREEN,
|
||||
y_min: float | None = None, y_max: float | None = None,
|
||||
with_y_grid: bool = True, n_ticks: int = 3):
|
||||
"""Zeichnet einen Linien-Chart in (x,y,w,h) Box.
|
||||
|
||||
Args:
|
||||
data: Liste von Werten
|
||||
color: Linien-Farbe
|
||||
y_min, y_max: Y-Achsen-Bereich. None → auto aus Daten
|
||||
with_y_grid: Y-Tick-Linien mit Werten am linken Rand
|
||||
n_ticks: Anzahl Y-Ticks (default 3)
|
||||
"""
|
||||
if len(data) < 2 or w <= 10 or h <= 10:
|
||||
return
|
||||
# Y-Range bestimmen
|
||||
if y_min is None:
|
||||
y_min = min(data)
|
||||
if y_max is None:
|
||||
y_max = max(data)
|
||||
if y_min == y_max:
|
||||
y_min -= 1
|
||||
y_max += 1
|
||||
# Grid: gestrichelte Linien (ACeP kann keine echten Dashes, daher Segmente)
|
||||
if with_y_grid:
|
||||
# Nur 2 Ticks zeigen: Min und Max (sonst zu viele Labels für kleinen Chart)
|
||||
ticks = []
|
||||
if n_ticks >= 2:
|
||||
ticks = [y_min, y_max]
|
||||
elif n_ticks == 1:
|
||||
ticks = [(y_min + y_max) / 2]
|
||||
for tick in ticks:
|
||||
ty = y + int((y_max - tick) / max(1, y_max - y_min) * (h - 8)) + 4
|
||||
# Y-Label links (NICHT unten — kollidiert mit x-Achsen-Labels)
|
||||
f_lbl = _fit_size(draw, f"{int(tick)}°", 22, 10, candidates=["9", "8"])
|
||||
text_in_box(draw, f"{int(tick)}°", x - 24, ty - 5, 22, 10, f_lbl, FG, "right")
|
||||
# Gestrichelte Linie
|
||||
for seg_x in range(x, x + w, 6):
|
||||
draw.line((seg_x, ty, min(seg_x + 3, x + w), ty), fill=FG, width=1)
|
||||
# Daten-Linie
|
||||
pad_top, pad_bot = 4, 4
|
||||
pts = []
|
||||
for i, v in enumerate(data):
|
||||
px = x + int(i * w / (len(data) - 1))
|
||||
py = y + pad_top + int((1 - (v - y_min) / (y_max - y_min)) * (h - pad_top - pad_bot))
|
||||
pts.append((px, py))
|
||||
for i in range(len(pts) - 1):
|
||||
draw.line((pts[i][0], pts[i][1], pts[i+1][0], pts[i+1][1]),
|
||||
fill=color, width=2)
|
||||
for px, py in pts:
|
||||
draw.ellipse((px - 1, py - 1, px + 1, py + 1), fill=color)
|
||||
|
||||
|
||||
# ---- Mini-Bar mit Schwellen-Farbe ----------------------------------------
|
||||
def draw_mini_bar(draw, x, y, w, h, value: float, max_value: float,
|
||||
color: tuple | None = None):
|
||||
"""Horizontale Mini-Bar mit Outline + Fill in gegebener Farbe."""
|
||||
if w < 6 or h < 4:
|
||||
return
|
||||
color = color or FG
|
||||
draw.rectangle((x, y, x + w - 1, y + h - 1), outline=FG, width=1)
|
||||
pct = min(100, max(0, value / max(0.001, max_value) * 100))
|
||||
fill_w = max(0, int((w - 2) * pct / 100))
|
||||
if fill_w > 0:
|
||||
draw.rectangle((x + 1, y + 1, x + 1 + fill_w, y + h - 2), fill=color)
|
||||
|
||||
|
||||
# ---- ZZZ / Sleep-Indikator -----------------------------------------------
|
||||
def draw_zzz_indicator(draw, x, y, size: int = 16):
|
||||
"""Kleines Mond-Icon + 'Zzz' fuer DeepSleep-Anzeige.
|
||||
|
||||
Wird vom Dashboard-Frame im Header-Bereich gezeichnet, wenn Power-Save
|
||||
aktiv ist.
|
||||
"""
|
||||
cx, cy = x + size // 2, y + size // 2
|
||||
r = size // 3
|
||||
# Mond (Yellow circle, halb verdeckt)
|
||||
draw.ellipse((cx - r, cy - r, cx + r, cy + r), fill=YELLOW)
|
||||
# 'Z' Buchstaben daneben
|
||||
f = _fit_size(draw, "Z", size, size, candidates=["14", "12", "10"])
|
||||
text_in_box(draw, "z", x + size + 2, y, 12, size, f, FG, "left")
|
||||
|
||||
|
||||
# ---- DeepSleep-Status-Anzeige fuer Display ------------------------------
|
||||
def draw_deepsleep_indicator(draw, x, y, w, h, next_wake: str = ""):
|
||||
"""Zeichnet einen schmalen 'Schlafend'-Bereich im Header.
|
||||
|
||||
Args:
|
||||
next_wake: z.B. '06:30' → wird als Text daneben gezeichnet
|
||||
"""
|
||||
if w < 60 or h < 12:
|
||||
return
|
||||
# Gelber Mond-Icon
|
||||
f_moon = _fit_size(draw, "Zz", 30, h, candidates=["12", "10"])
|
||||
text_in_box(draw, "Zz", x, y, 24, h, f_moon, ORANGE, "center")
|
||||
# 'Schlafend bis' Text
|
||||
if next_wake:
|
||||
f = _fit_size(draw, f"schlafend bis {next_wake}", w - 26, h, candidates=["10", "9", "8"])
|
||||
text_in_box(draw, f"schlafend bis {next_wake}", x + 26, y, w - 26, h, f, FG, "left")
|
||||
|
||||
+255
-168
@@ -1,47 +1,84 @@
|
||||
"""WordClock-Plugin:Deutsche Wort-Uhr (QWATCHLayout).
|
||||
"""WordClock-Plugin: Deutsche Wort-Uhr im QlockTwo-Stil.
|
||||
|
||||
Reines Wort-Uhr-Display im deutschen Stil:
|
||||
ES IST <Fünf/Zehn/Viertel/Zwanzig> <Minuten> <nach/vor> <Stunde>
|
||||
Layout in 3 klar getrennten Zonen:
|
||||
┌────────────────────────────────────┐
|
||||
│ Header: Wochentag · Datum │ (accent-farbe, klein)
|
||||
├────────────────────────────────────┤
|
||||
│ │
|
||||
│ Wort-Grid: ES IST │
|
||||
│ ──── ZEHN NACH ──── │ (eine zentrale Zeile,
|
||||
│ DREI │ je nach Minute)
|
||||
│ │
|
||||
├────────────────────────────────────┤
|
||||
│ Stunden gross: DREI │ (Aldrich, riesig)
|
||||
└────────────────────────────────────┘
|
||||
|
||||
Unterstützt auch ENGLISCH (US-Layout).
|
||||
|
||||
Layout: fester 4×4-Zeichen-Grid im Quadrat (4×4 Zellen = 800×480 Display).
|
||||
Jede Zelle = 200×120px → Grid = 800×480px.
|
||||
Wir nutzen 11×8 "字符-Zellen" pro Grid (72×60px pro Zeichen).
|
||||
|
||||
Minuten-Zeilen (oberer Block):
|
||||
Zeile 0: [E][S][ ][I][S][T]
|
||||
Zeile 1: [F][Ü][N][F][Z][E][H][N][Z][W][A][N]
|
||||
Zeile 2: [V][I][E][R][T][E][L][Z][W][A][N][Z]
|
||||
Zeile 3: [N][U][L][L]
|
||||
Zeile 4: [N][A][C][H][ ][V][O][R][ ][H][A][L]
|
||||
Zeile 5: [B][ ]
|
||||
Zeile 6: [S][P][R][A][C][H][E]
|
||||
|
||||
Stunden (unterer Block, je 2×2 Zellen für die Ziffern):
|
||||
DieZiffern werden als gefüllte Rechtecke in der unteren Reihe gerendert.
|
||||
|
||||
Das Layout wird intern gecacht bis sich die Minute ändert.
|
||||
Die Minuten-Wörter sind in einer DYNAMISCHEN Zeile, nicht in einem starren
|
||||
11×8-Grid. Das vermeidet Überlappungen und sieht auf jedem Slot gleich aus.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import os, sys
|
||||
from datetime import datetime
|
||||
from functools import lru_cache
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from plugins.base import Widget
|
||||
from palette import FG, BG, OK, BLUE, YELLOW, ORANGE, fill_for, measure
|
||||
from palette import FG, BG, OK, BLUE, YELLOW, ORANGE, fill_for, measure, de_weekday_long, de_date
|
||||
|
||||
|
||||
# ---- Deutsche Minuten-Phrasen (QlockTwo-konform) ----
|
||||
# Schlüssel: Sektor-Untergrenze (5, 10, ..., 55). Wert: (minute_words, hour_offset)
|
||||
# hour_offset = +1 bedeutet wir gehen zur nächsten Stunde ("VOR")
|
||||
DE_PHRASES = {
|
||||
0: ("", 0), # ES IST genau
|
||||
5: ("FÜNF NACH", 0), # 5 nach
|
||||
10: ("ZEHN NACH", 0), # 10 nach
|
||||
15: ("VIERTEL NACH", 0), # viertel nach
|
||||
20: ("ZWANZIG NACH", 0), # 20 nach
|
||||
25: ("FÜNF VOR HALB", 1), # 5 vor halb → 0:30
|
||||
30: ("HALB", 1), # halb → 0:30 (zählt zur nächsten Stunde)
|
||||
35: ("FÜNF NACH HALB",1), # 5 nach halb
|
||||
40: ("ZWANZIG VOR", 1), # 20 vor
|
||||
45: ("VIERTEL VOR", 1), # viertel vor
|
||||
50: ("ZEHN VOR", 1), # 10 vor
|
||||
55: ("FÜNF VOR", 1), # 5 vor
|
||||
}
|
||||
|
||||
# Minuten → Sektor (0, 5, 10, ... 55)
|
||||
def _minute_sector(m: int) -> int:
|
||||
return (m // 5) * 5
|
||||
|
||||
|
||||
# Stunden-Namen (QlockTwo). Index 0..11 = Wortuhr-Stunden (1..12 Uhr).
|
||||
# 4 PM wird "VIER" (deutsch), 5 PM = "FÜNF", etc.
|
||||
# Daher: hour 16..23 → DE_HOURS[16%12]=DE_HOURS[4]="VIER". Korrekt.
|
||||
DE_HOURS = [
|
||||
"ZWÖLF", "EINS", "ZWEI", "DREI", "VIER", "FÜNF",
|
||||
"SECHS", "SIEBEN", "ACHT", "NEUN", "ZEHN", "ELF",
|
||||
]
|
||||
|
||||
|
||||
def _hour_name(h24: int, offset: int) -> str:
|
||||
"""Liefert Stunden-Name. offset=+1 → nächste volle Stunde.
|
||||
|
||||
Beispiel: h24=16, offset=0 → DE_HOURS[16%12=4]="VIER" (4 PM)
|
||||
h24=16, offset=1 → DE_HOURS[(16+1)%12=5]="FÜNF" (halb 5 = 5 PM)
|
||||
"""
|
||||
h = (h24 + offset) % 24
|
||||
return DE_HOURS[h % 12]
|
||||
|
||||
|
||||
def _minute_phrase(m: int):
|
||||
"""Returns (phrase_text, hour_offset) für die aktuelle Minute."""
|
||||
sector = _minute_sector(m)
|
||||
return DE_PHRASES[sector]
|
||||
|
||||
|
||||
class Widget(Widget):
|
||||
name = "clock_wordclock"
|
||||
label = "WordClock"
|
||||
description = "Deutsche Wort-Uhr (ES IST …). Nur für 2×2 oder größer."
|
||||
description = "Deutsche Wort-Uhr (ES IST …). QlockTwo-Style, ab 1×1 sauber."
|
||||
category = "info"
|
||||
|
||||
config_schema = [
|
||||
{"key": "lang", "label": "Sprache",
|
||||
"type": "select", "choices": ["de", "en"], "default": "de",
|
||||
"help": "de = Deutsch (Standard), en = Englisch"},
|
||||
{"key": "show_date", "label": "Datum anzeigen",
|
||||
"type": "bool", "default": True},
|
||||
{"key": "show_weekday", "label": "Wochentag anzeigen",
|
||||
@@ -49,176 +86,226 @@ class Widget(Widget):
|
||||
{"key": "accent_color", "label": "Akzentfarbe",
|
||||
"type": "select",
|
||||
"choices": ["accent", "blue", "ok", "warn", "info"],
|
||||
"default": "accent"},
|
||||
"default": "accent",
|
||||
"help": "Farbe für Wochentag + Datum"},
|
||||
{"key": "invert", "label": "Invertiert (dunkel)",
|
||||
"type": "bool", "default": False},
|
||||
{"key": "show_es_ist", "label": "'ES IST' anzeigen",
|
||||
"type": "bool", "default": True,
|
||||
"help": "QlockTwo-Klassiker. Aus = nur Minuten-Text + Stunde."},
|
||||
]
|
||||
default_config = {
|
||||
"lang": "de", "show_date": True, "show_weekday": True,
|
||||
"show_date": True, "show_weekday": True,
|
||||
"accent_color": "accent", "invert": False,
|
||||
"show_es_ist": True,
|
||||
}
|
||||
|
||||
def fetch(self):
|
||||
return {}
|
||||
|
||||
def render(self, draw, fonts, x: int, y: int, w: int, h: int):
|
||||
from palette import fill_for
|
||||
now = datetime.now()
|
||||
m = now.minute
|
||||
h_ = now.hour
|
||||
|
||||
invert = self.cfg("invert", False)
|
||||
accent = fill_for(self.cfg("accent_color", "accent"))
|
||||
fg = BG if invert else FG
|
||||
bg = FG if invert else BG
|
||||
|
||||
pad = 8
|
||||
|
||||
# Mindestgröße: 300px-breit, 240px-hoch für WordClock
|
||||
if w < 300 or h < 240:
|
||||
# --- Hintergrund ---
|
||||
draw.rectangle((x, y, x + w - 1, y + h - 1), fill=bg)
|
||||
draw.text((x + pad, y + pad),
|
||||
"WordClock\n(min 2×2)",
|
||||
font=fonts.get("20", fonts.get("default")), fill=fg)
|
||||
|
||||
now = datetime.now()
|
||||
pad = 6
|
||||
inner_w = w - 2 * pad
|
||||
inner_h = h - 2 * pad
|
||||
inner_x = x + pad
|
||||
inner_y = y + pad
|
||||
|
||||
# ============================================================
|
||||
# Sehr kleine Slots (1×1 = 200×120): kompakter Single-Line Modus
|
||||
# ============================================================
|
||||
if w < 240 or h < 140:
|
||||
# "ES IST DREI" oder "VIERTEL NACH DREI" als eine Zeile
|
||||
phrase, offset = _minute_phrase(now.minute)
|
||||
hour_str = _hour_name(now.hour, offset)
|
||||
if phrase:
|
||||
text = f"{phrase} {hour_str}" if self.cfg("show_es_ist") else f"{phrase} {hour_str}"
|
||||
else:
|
||||
text = f"ES IST {hour_str}" if self.cfg("show_es_ist") else hour_str
|
||||
font = _fit_font(draw, fonts, text, inner_w, inner_h, prefer=["20","16","default"])
|
||||
from palette import centered_text
|
||||
centered_text(draw, text, inner_x, inner_y, inner_w, inner_h, font, fg)
|
||||
return
|
||||
|
||||
# ---- Wort-Uhr Grid ----
|
||||
# 4×4 Zellen → 800×480px
|
||||
# Wir rendern in ein 11×8 Zeichen-Grid
|
||||
chars_x, chars_y = 11, 8
|
||||
char_w = w // chars_x
|
||||
char_h = h // chars_y
|
||||
font_size = min(char_w, char_h) * 2 // 3
|
||||
font_key = str(font_size)
|
||||
if font_key not in fonts:
|
||||
font_key = str(max(16, min(fonts.keys(), key=lambda k: abs(int(k) - font_size) if k.isdigit() else 9999)) if fonts else "20")
|
||||
fnt = fonts.get(font_key, fonts.get("20", fonts.get("default")))
|
||||
# ============================================================
|
||||
# Standard-Layout: 3 Zonen
|
||||
# ============================================================
|
||||
# Höhenverteilung:
|
||||
# Header: bis zu 28px (oder weniger wenn beides aus)
|
||||
# Grid: Rest minus Stunden
|
||||
# Stunden: grosse Schrift, ~30% der Höhe
|
||||
|
||||
def _draw_char(cx, cy, char, color):
|
||||
"""Zeichnet ein Zeichen an Gitterposition (cx, cy)."""
|
||||
px = x + cx * char_w
|
||||
py = y + cy * char_h
|
||||
tw, th = measure(draw, char, fnt)
|
||||
draw.text((px + (char_w - tw) // 2, py + (char_h - th) // 2),
|
||||
char, font=fnt, fill=color)
|
||||
# Header-Höhe dynamisch: nur soviel wie gebraucht
|
||||
show_wd = self.cfg("show_weekday", True)
|
||||
show_dt = self.cfg("show_date", True)
|
||||
show_es = self.cfg("show_es_ist", True)
|
||||
|
||||
def _fill_char(cx, cy, color):
|
||||
"""Füllt eine Gitterzelle mit einer Farbe (z.B. für Stunden-Dots)."""
|
||||
px = x + cx * char_w
|
||||
py = y + cy * char_h
|
||||
draw.rectangle((px + 2, py + 2, px + char_w - 3, py + char_h - 3), fill=color)
|
||||
|
||||
def _lit(cx, cy):
|
||||
_draw_char(cx, cy, LAYOUT_DE[cy][cx], fg)
|
||||
|
||||
def _dim(cx, cy):
|
||||
_draw_char(cx, cy, LAYOUT_DE[cy][cx], (150, 150, 150))
|
||||
|
||||
# ---- Minuten-Logik (Deutsch) ----
|
||||
def lit_minute(m):
|
||||
"""Sektor der Minuten: 0-4, 5-9, 10-14, 15-19, 20-24, 25-29, 30-34, 35-39, 40-44, 45-49, 50-54, 55-59."""
|
||||
if m < 5:
|
||||
return []
|
||||
elif m < 10:
|
||||
return [(0, 1), (1, 1), (2, 1), (3, 1)] # FÜNF
|
||||
elif m < 15:
|
||||
return [(5, 1), (6, 1), (7, 1), (8, 1)] # ZEHN
|
||||
elif m < 20:
|
||||
return [(0, 2), (1, 2), (2, 2), (3, 2), (4, 2), (5, 2), (6, 2)] # VIERTEL
|
||||
elif m < 25:
|
||||
return [(7, 2), (8, 2), (9, 2), (10, 2), (7, 3), (8, 3), (9, 3), (10, 3)] # ZWANZIG
|
||||
elif m < 30:
|
||||
return [(0, 4), (1, 4), (2, 4), (3, 4)] # NACH
|
||||
elif m < 35:
|
||||
return [(0, 1), (1, 1), (2, 1), (3, 1), # FÜNF
|
||||
(4, 4), (5, 4), (6, 4)] # + HALB
|
||||
elif m < 40:
|
||||
return [(4, 4), (5, 4), (6, 4)] # HALB
|
||||
elif m < 45:
|
||||
return [(7, 3), (8, 3), (9, 3), (10, 3)] # ZWANZIG
|
||||
elif m < 50:
|
||||
return [(0, 2), (1, 2), (2, 2), (3, 2), (4, 2), (5, 2), (6, 2), # VIERTEL
|
||||
(7, 4), (8, 4), (9, 4)] # + VOR
|
||||
elif m < 55:
|
||||
return [(5, 1), (6, 1), (7, 1), (8, 1)] # ZEHN + VOR
|
||||
if show_wd and show_dt:
|
||||
header_h = 26
|
||||
elif show_wd or show_dt:
|
||||
header_h = 18
|
||||
else:
|
||||
return [(0, 1), (1, 1), (2, 1), (3, 1), # FÜNF + VOR
|
||||
(7, 4), (8, 4), (9, 4)]
|
||||
header_h = 0
|
||||
|
||||
def hour_name(h, past_half):
|
||||
"""Gibt die Stunde zurück für die Wortuhr."""
|
||||
DE_HOURS = [
|
||||
"ZWÖLF", "EINS", "ZWEI", "DREI", "VIER",
|
||||
"FÜNF", "SECHS", "SIEBEN", "ACHT", "NEUN",
|
||||
"ZEHN", "ELF", "ZWÖLF"
|
||||
]
|
||||
if past_half:
|
||||
h = (h + 1) % 24
|
||||
if h == 0:
|
||||
return "ZWÖLF"
|
||||
return DE_HOURS[h % 12]
|
||||
# Stunden-Block: fest ~36% der inneren Höhe, aber mindestens 50px
|
||||
hour_block_h = max(50, int(inner_h * 0.36))
|
||||
|
||||
past_half = m >= 20
|
||||
h_display = hour_name(h_, past_half)
|
||||
lit_cells = lit_minute(m)
|
||||
# Wort-Block = Rest
|
||||
grid_h = inner_h - header_h - hour_block_h - 6 # 6px gap
|
||||
if grid_h < 40:
|
||||
# Slot zu klein für alles — Stunden-Anteil reduzieren
|
||||
hour_block_h = max(36, int(inner_h * 0.28))
|
||||
grid_h = inner_h - header_h - hour_block_h - 6
|
||||
|
||||
# ---- Render ----
|
||||
# Hintergrund
|
||||
draw.rectangle((x, y, x + w - 1, y + h - 1), fill=bg)
|
||||
grid_y = inner_y + header_h
|
||||
hour_y = grid_y + grid_h + 6
|
||||
|
||||
# "ES IST" immer lit in Spalte 0
|
||||
_lit(0, 0); _lit(1, 0); _lit(3, 0); _lit(4, 0)
|
||||
# --- Header (Wochentag / Datum) ---
|
||||
cy = inner_y
|
||||
header_font = _fit_font(draw, fonts, "Mittwoch · 31. Aug 2026", inner_w, header_h or 18,
|
||||
prefer=["16", "20", "default"])
|
||||
if header_h:
|
||||
if show_wd and show_dt:
|
||||
# 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 = 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 = 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)
|
||||
|
||||
# Minuten-Wörter
|
||||
for (cx, cy) in lit_cells:
|
||||
_lit(cx, cy)
|
||||
# --- Wort-Grid: zwei Zeilen ---
|
||||
# Zeile 1 (oben): "ES IST" (klein, accent) — nur wenn show_es_ist
|
||||
# Zeile 2 (mittig, gross): Minuten-Phrase
|
||||
phrase, offset = _minute_phrase(now.minute)
|
||||
|
||||
# "VOR" und "NACH" (Zeile 4)
|
||||
if 5 <= m < 30:
|
||||
_lit(0, 4); _lit(1, 4); _lit(2, 4); _lit(3, 4) # NACH
|
||||
elif m >= 35 and m < 55:
|
||||
_lit(7, 4); _lit(8, 4); _lit(9, 4) # VOR
|
||||
# Wie viele Zeilen brauchen wir im Grid?
|
||||
# Wenn ES IST → ES IST (Zeile 1 klein) + Phrase (Zeile 2 gross)
|
||||
# Wenn nicht → nur Phrase (eine Zeile)
|
||||
# Bei sehr grossen Slots könnte man auch stacked machen, aber single-line
|
||||
# ist klarer.
|
||||
|
||||
# Rest dim
|
||||
for row in range(chars_y):
|
||||
for col in range(chars_x):
|
||||
if (col, row) not in lit_cells and not (row == 0 and col in (0, 1, 3, 4)):
|
||||
_dim(col, row)
|
||||
if show_es and phrase:
|
||||
# Zwei-Zeilen-Layout im Grid
|
||||
line1_h = int(grid_h * 0.30)
|
||||
line2_h = grid_h - line1_h - 2
|
||||
es_font = _fit_font(draw, fonts, "ES IST", inner_w, line1_h,
|
||||
prefer=["20", "16", "default"])
|
||||
draw.text((inner_x + (inner_w - measure(draw, "ES IST", es_font)[0]) // 2,
|
||||
grid_y),
|
||||
"ES IST", font=es_font, fill=accent)
|
||||
# Minuten-Phrase in der unteren, größeren Zeile
|
||||
phrase_font = _fit_font(draw, fonts, phrase, inner_w, line2_h,
|
||||
prefer=["48", "36", "32", "28", "24", "20", "default"])
|
||||
tw, th = measure(draw, phrase, phrase_font)
|
||||
draw.text((inner_x + (inner_w - tw) // 2,
|
||||
grid_y + line1_h + (line2_h - th) // 2),
|
||||
phrase, font=phrase_font, fill=fg)
|
||||
else:
|
||||
# Eine-Zeilen-Layout: nur die Phrase (oder "ES IST DREI" wenn keine Phrase)
|
||||
if not phrase:
|
||||
phrase = "ES IST" if show_es else ""
|
||||
phrase_font = _fit_font(draw, fonts, phrase, inner_w, grid_h,
|
||||
prefer=["60", "48", "36", "32", "28", "24", "20", "default"])
|
||||
tw, th = measure(draw, phrase, phrase_font)
|
||||
draw.text((inner_x + (inner_w - tw) // 2,
|
||||
grid_y + (grid_h - th) // 2),
|
||||
phrase, font=phrase_font, fill=fg)
|
||||
|
||||
# ---- Stunden-Balken unten ----
|
||||
# Zeile 6+7: Stunden-Name in großen Buchstaben unten zentriert
|
||||
hour_str = h_display
|
||||
hour_font_size = min(w // len(hour_str), h // 4) * 3 // 4
|
||||
hf = fonts.get(str(hour_font_size), fonts.get("60", fonts.get("default")))
|
||||
tw, th = measure(draw, hour_str, hf)
|
||||
hour_y = y + h - th - pad
|
||||
draw.text((x + (w - tw) // 2, hour_y), hour_str, font=hf, fill=fg)
|
||||
# --- Stunden-Block (gross, unten) ---
|
||||
hour_str = _hour_name(now.hour, offset)
|
||||
hour_font = _fit_font(draw, fonts, hour_str, inner_w, hour_block_h,
|
||||
prefer=["100", "80", "60", "48", "36", "32", "default"])
|
||||
# Robuste Font-Wahl: lieber kleiner als abgeschnitten
|
||||
tw, th = measure(draw, hour_str, hour_font)
|
||||
if tw > inner_w:
|
||||
# noch kleiner probieren
|
||||
for k in ["80", "60", "48", "36", "32", "28", "24", "20"]:
|
||||
if k not in fonts:
|
||||
continue
|
||||
cand = fonts[k]
|
||||
cw, ch = measure(draw, hour_str, cand)
|
||||
if cw <= inner_w and ch <= hour_block_h:
|
||||
hour_font = cand
|
||||
tw, th = cw, ch
|
||||
break
|
||||
draw.text((inner_x + (inner_w - tw) // 2,
|
||||
hour_y + (hour_block_h - th) // 2),
|
||||
hour_str, font=hour_font, fill=fg)
|
||||
|
||||
# ---- Datum + Wochentag ----
|
||||
if self.cfg("show_date", True):
|
||||
date_str = now.strftime("%d. %b %Y")
|
||||
df = fonts.get("16", fonts.get("default"))
|
||||
dw, dh = measure(draw, date_str, df)
|
||||
draw.text((x + (w - dw) // 2, y + pad), date_str, font=df, fill=accent)
|
||||
|
||||
if self.cfg("show_weekday", True):
|
||||
day_str = now.strftime("%A").upper()
|
||||
df = fonts.get("14", fonts.get("default"))
|
||||
dw, dh = measure(draw, day_str, df)
|
||||
draw.text((x + (w - dw) // 2, y + pad + (18 if self.cfg("show_date", True) else 0)),
|
||||
day_str, font=df, fill=accent)
|
||||
# --- Minuten-Dots (4 Dots oben rechts, wie eine echte QlockTwo) ---
|
||||
# Zeigen die genauen Minuten-Module: ● ● ● ● ● — einer pro 1-2 Min
|
||||
# Wenn minute < 5: leer; bei 25: ●; bei 26: ●●; etc.
|
||||
if inner_w >= 200:
|
||||
self._draw_minute_dots(draw, inner_x, inner_y, inner_w, header_h, fg, accent)
|
||||
|
||||
|
||||
# ---- Deutsches WordClock-Layout (11×8) ----
|
||||
# Jede Position ist ein Zeichen das gerendert wird.
|
||||
# ' ' = Leerzeichen, rest = Buchstabe.
|
||||
LAYOUT_DE = [
|
||||
["E", "S", " ", "I", "S", "T", " ", " ", " ", " ", " "],
|
||||
["F", "Ü", "N", "F", " ", "Z", "E", "H", "N", " ", " "],
|
||||
["V", "I", "E", "R", "T", "E", "L", " ", "Z", "W", "A"],
|
||||
["N", "U", "L", "L", " ", "Z", "W", "A", "N", "Z", " "],
|
||||
["N", "A", "C", "H", " ", "V", "O", "R", " ", "H", "A"],
|
||||
[" ", " ", " ", " ", " ", " ", " ", " ", " ", " ", " "],
|
||||
[" ", " ", " ", " ", " ", " ", " ", " ", " ", " ", " "],
|
||||
[" ", " ", " ", " ", " ", " ", " ", " ", " ", " ", " "],
|
||||
]
|
||||
def _draw_minute_dots(self, draw, x, y, w, header_h, fg, accent):
|
||||
"""Vier Minuten-Dots (●) in der Ecke, analog zu echten QlockTwo-Uhren.
|
||||
|
||||
Sie zeigen: ●=1, ●●=2, ●●●=3, ●●●●=4 Minuten innerhalb des 5-Min-Sektors.
|
||||
"""
|
||||
m = datetime.now().minute
|
||||
sector_m = m % 5 # 0..4
|
||||
if sector_m == 0:
|
||||
return # exakt auf 5-Min-Marke → keine Dots
|
||||
|
||||
# Position: oben rechts, in der Header-Zone (oder oben falls kein Header)
|
||||
dot_size = 3
|
||||
spacing = 4
|
||||
total_w = sector_m * (dot_size + spacing) - spacing
|
||||
start_x = x + w - total_w
|
||||
# Position: am unteren Rand des Header-Bereichs
|
||||
if header_h >= 26:
|
||||
dot_y = y + header_h - dot_size - 2
|
||||
elif header_h > 0:
|
||||
dot_y = y + header_h - dot_size
|
||||
else:
|
||||
# kein Header → dots ganz oben, klein
|
||||
dot_y = y + 2
|
||||
|
||||
for i in range(sector_m):
|
||||
cx = start_x + i * (dot_size + spacing)
|
||||
# Eckige Punkte (Mini-Quadrate) statt runder, weil runde auf dem
|
||||
# ePaper oft Matsch produzieren
|
||||
draw.rectangle((cx, dot_y, cx + dot_size - 1, dot_y + dot_size - 1),
|
||||
fill=fg)
|
||||
|
||||
|
||||
def _fit_font(draw, fonts, text, max_w, max_h, prefer=None):
|
||||
"""Wählt den größten Font aus `prefer`, dessen Text in max_w × max_h passt.
|
||||
|
||||
Robuster als die alte Logik — überspringt 'clock' (Pixel-Font) automatisch.
|
||||
"""
|
||||
if prefer is None:
|
||||
prefer = ["60", "48", "36", "32", "28", "24", "20", "16", "default"]
|
||||
for key in prefer:
|
||||
f = fonts.get(key)
|
||||
if f is None:
|
||||
continue
|
||||
tw, th = measure(draw, text, f)
|
||||
if tw <= max_w and th <= max_h:
|
||||
return f
|
||||
# Fallback: das Kleinste
|
||||
for key in reversed(prefer):
|
||||
if key in fonts:
|
||||
return fonts[key]
|
||||
return fonts.get("default") or fonts.get("20")
|
||||
@@ -0,0 +1,556 @@
|
||||
"""DesignBase — Plugin Design Template / Schablone.
|
||||
|
||||
Dieses Modul ist KEIN eigenständiges Widget, sondern eine SAMMLUNG
|
||||
von Design-Konzepten, Layout-Helfern und Theme-Definitionen,
|
||||
die als Vorlage für alle anderen Plugin-Renderer dienen.
|
||||
|
||||
ANATOMIE EINES DESIGN-TEMPLATES
|
||||
=================================
|
||||
|
||||
Jedes Plugin-render() folgt diesem Schema:
|
||||
|
||||
def render(self, draw, fonts, x, y, w, h):
|
||||
theme = self._theme() # Theme-Objekt mit Farben + Schriften
|
||||
data = self.fetch() # Daten holen
|
||||
if "_error" in data:
|
||||
render_error_banner(...)
|
||||
return
|
||||
layout = self._pick_layout(w, h) # Layout-Strategie wählen
|
||||
self._render_header(draw, theme, x, y, w, header_h)
|
||||
self._render_body(draw, theme, data, x, body_y, w, body_h, layout)
|
||||
if self._show_footer(w, h):
|
||||
self._render_footer(draw, theme, data, x, y+h-footer_h, w, footer_h)
|
||||
|
||||
Die 4 LAYOUT-STRATEGIEN
|
||||
========================
|
||||
|
||||
is_small (1x1): Eine einzige große Zahl / ein Icon
|
||||
is_wide (4x1/2x1): Horizontale Teilung in 2-3 Spalten
|
||||
is_tall (1x4/1x2): Vertikale Teilung in Zeilen
|
||||
standard (2x2+): Header + Content + Footer (oder Karten)
|
||||
|
||||
Die 4 THEMES
|
||||
=============
|
||||
|
||||
THEME_LIGHT — Weiß, schwarz, eine Akzentfarbe (z.B. Netatmo: blau)
|
||||
THEME_DARK — Fast schwarz, helle Farben auf dunklem Grund
|
||||
THEME_RETRO — Gelb/Orange-Schwarz (80er-Terminal-Feeling)
|
||||
THEME_MAG — Minimal, typografisch, viel Weißraum
|
||||
|
||||
Farben werden IMMER über theme.xxx bezogen, NIEMALS direkt als RGB-Tuple.
|
||||
Das erlaubt komplettes Umstyling ohne den Renderer-Code anzufassen.
|
||||
|
||||
Farben-Schema pro Theme (Palette: FG, BG, ACCENT, OK, WARN, ALERT, INFO):
|
||||
|
||||
THEME_LIGHT: BG=WHITE, FG=BLACK, ACCENT=BLUE, OK=GREEN, WARN=YELLOW, ALERT=RED
|
||||
THEME_DARK: BG=BLACK, FG=WHITE, ACCENT=CYAN, OK=GREEN, WARN=ORANGE,ALERT=RED
|
||||
THEME_RETRO: BG=BLACK, FG=YELLOW,ACCENT=ORANGE, OK=GREEN, WARN=YELLOW, ALERT=RED
|
||||
THEME_MAG: BG=WHITE, FG=BLACK, ACCENT=ORANGE, OK=GREEN, WARN=ORANGE, ALERT=BLUE
|
||||
|
||||
Beispiel-Implementierung: widgets/netatmo.py (theme='light', accent='blue')
|
||||
Beispiel-Implementierung: widgets/weather.py (theme='light', accent='orange')
|
||||
Beispiel-Implementierung: widgets/system.py (theme='light', accent='green')
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Literal
|
||||
|
||||
# Palette-Aliase (für direkten Zugriff in Templates)
|
||||
# noqa: E402 — diese Imports funktionieren weil plugins/ im Python-Path liegt
|
||||
from palette import (
|
||||
FG, BG, WHITE, BLACK, GREEN, BLUE, RED, YELLOW, ORANGE,
|
||||
INFO, OK, WARN, ALERT, ACCENT,
|
||||
measure, fit_font, centered_text, hbar, parse_thresholds,
|
||||
is_small, is_wide, is_tall,
|
||||
)
|
||||
|
||||
# noqa: E402
|
||||
from plugins.base import Widget, render_error_banner
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# THEME DEFINITIONS
|
||||
# ============================================================================
|
||||
|
||||
ThemeName = Literal["light", "dark", "retro", "mag"]
|
||||
LayoutName = Literal["small", "wide", "tall", "standard"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Theme:
|
||||
"""Alle visuellen Eigenschaften eines Designs.
|
||||
|
||||
Ein Theme-Objekt wird in render() erzeugt und an alle
|
||||
_render_*-Methoden weitergegeben. Nie direkt Farbwerte hardcoden.
|
||||
"""
|
||||
name: ThemeName
|
||||
bg: tuple[int, int, int] # Hintergrund
|
||||
fg: tuple[int, int, int] # Primärtext
|
||||
accent: tuple[int, int, int] # Akzentfarbe (Platzierung je nach Theme)
|
||||
ok: tuple[int, int, int]
|
||||
warn: tuple[int, int, int]
|
||||
alert: tuple[int, int, int]
|
||||
info: tuple[int, int, int]
|
||||
header_font_key: str = "24" # Font-Schlüssel für Headlines
|
||||
label_font_key: str = "20" # Font-Schlüssel für Modul-Labels
|
||||
body_font_key: str = "32" # Font-Schlüssel für Hauptwerte
|
||||
mono_font_key: str = "20" # Font-Schlüssel für Metadaten
|
||||
pad: int = 8 # Innenabstand
|
||||
border_w: int = 2 # Rahmendicke
|
||||
corner_r: int = 0 # Eckenradius (0 = scharf)
|
||||
|
||||
# ---- Farb-Helfer ----
|
||||
def temp_color(self, t: float | None) -> tuple[int, int, int]:
|
||||
"""Temperaturanzeige: kalt→info, warm→accent, heiß→alert."""
|
||||
if t is None:
|
||||
return self.fg
|
||||
if t >= 30:
|
||||
return self.alert
|
||||
if t >= 22:
|
||||
return self.accent
|
||||
if t <= 5:
|
||||
return self.info
|
||||
if t <= 12:
|
||||
return self.info
|
||||
return self.fg
|
||||
|
||||
def value_color(self, value: float, warn_at: float, alert_at: float) -> tuple[int, int, int]:
|
||||
"""Ampel-Helfer: value + Schwellen → passende Farbe."""
|
||||
if value >= alert_at:
|
||||
return self.alert
|
||||
if value >= warn_at:
|
||||
return self.warn
|
||||
return self.ok
|
||||
|
||||
def text(self, draw, text: str, x: int, y: int,
|
||||
font_key: str | None = None, color=None, max_w: int | None = None):
|
||||
"""Short-hand: Text zeichnen mit Theme-Farbe."""
|
||||
font = draw.font if hasattr(draw, 'font') else None
|
||||
# Actual implementation uses fonts dict from render scope
|
||||
pass # see render helpers below
|
||||
|
||||
|
||||
# Vordefinierte Themes
|
||||
THEMES: dict[ThemeName, Theme] = {
|
||||
"light": Theme(
|
||||
name="light",
|
||||
bg=WHITE, fg=BLACK,
|
||||
accent=BLUE, ok=GREEN, warn=YELLOW, alert=RED, info=BLUE,
|
||||
header_font_key="24", label_font_key="20",
|
||||
body_font_key="32", mono_font_key="20",
|
||||
pad=8, border_w=2, corner_r=0,
|
||||
),
|
||||
"dark": Theme(
|
||||
name="dark",
|
||||
bg=BLACK, fg=WHITE,
|
||||
accent=(0, 200, 220), ok=GREEN, warn=ORANGE, alert=RED, info=(0, 180, 255),
|
||||
header_font_key="24", label_font_key="20",
|
||||
body_font_key="32", mono_font_key="20",
|
||||
pad=8, border_w=2, corner_r=0,
|
||||
),
|
||||
"retro": Theme(
|
||||
name="retro",
|
||||
bg=BLACK, fg=YELLOW,
|
||||
accent=ORANGE, ok=GREEN, warn=YELLOW, alert=RED, info=ORANGE,
|
||||
header_font_key="24", label_font_key="20",
|
||||
body_font_key="32", mono_font_key="20",
|
||||
pad=8, border_w=2, corner_r=0,
|
||||
),
|
||||
"mag": Theme(
|
||||
name="mag",
|
||||
bg=WHITE, fg=BLACK,
|
||||
accent=ORANGE, ok=GREEN, warn=ORANGE, alert=RED, info=BLUE,
|
||||
header_font_key="24", label_font_key="20",
|
||||
body_font_key="32", mono_font_key="20",
|
||||
pad=16, border_w=1, corner_r=0,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# LAYOUT HELPERS
|
||||
# ============================================================================
|
||||
|
||||
def pick_layout(w: int, h: int) -> LayoutName:
|
||||
"""Wähle Layout-Strategie basierend auf Slot-Größe.
|
||||
|
||||
Reihenfolge ist wichtig: is_wide/is_tall VOR is_small prüfen,
|
||||
weil is_small zu eager matcht (w<280 or h<180).
|
||||
"""
|
||||
if is_wide(w, h):
|
||||
return "wide"
|
||||
if is_tall(w, h):
|
||||
return "tall"
|
||||
if is_small(w, h):
|
||||
return "small"
|
||||
return "standard"
|
||||
|
||||
|
||||
def header_height(layout: LayoutName) -> int:
|
||||
"""Höhe des Header-Bereichs (Label + Titel)."""
|
||||
return 32 if layout == "standard" else 28
|
||||
|
||||
|
||||
def footer_height(layout: LayoutName) -> int:
|
||||
"""Höhe des Footer-Bereichs (Metadaten, Timestamp)."""
|
||||
if layout == "small":
|
||||
return 0
|
||||
if layout in ("wide", "tall"):
|
||||
return 20
|
||||
return 18
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# RENDER HELPERS (Theme-bewusst)
|
||||
# ============================================================================
|
||||
|
||||
def th_text(draw, fonts, theme: Theme, text: str, x: int, y: int,
|
||||
font_key: str | None = None, color=None, max_w: int | None = None):
|
||||
"""Text mit Theme-Default zeichnen."""
|
||||
key = font_key or theme.header_font_key
|
||||
font = fonts.get(key) or fonts.get("default")
|
||||
c = color if color is not None else theme.fg
|
||||
draw.text((x, y), text, font=font, fill=c)
|
||||
|
||||
|
||||
def th_centered(draw, fonts, theme: Theme, text: str,
|
||||
x: int, y: int, w: int, h: int,
|
||||
font_key: str | None = None, color=None):
|
||||
"""Text in Box zentrieren mit Theme-Defaults."""
|
||||
key = font_key or theme.body_font_key
|
||||
font = fonts.get(key) or fonts.get("default")
|
||||
c = color if color is not None else theme.fg
|
||||
centered_text(draw, text, x, y, w, h, font, c)
|
||||
|
||||
|
||||
def th_rect(draw, theme: Theme, x: int, y: int, w: int, h: int,
|
||||
fill=None, outline=None, width: int = 1):
|
||||
"""Rechteck mit Theme-Defaults."""
|
||||
f = fill if fill is not None else None
|
||||
o = outline if outline is not None else theme.fg
|
||||
draw.rectangle((x, y, x + w - 1, y + h - 1),
|
||||
fill=f, outline=o, width=width)
|
||||
|
||||
|
||||
def th_header_bar(draw, fonts, theme: Theme,
|
||||
x: int, y: int, w: int, h: int,
|
||||
label: str, value: str = "", value_color=None):
|
||||
"""Header-Leiste: links Label, rechts optionaler Wert.
|
||||
|
||||
Layout: [LABEL] [VALUE]
|
||||
Farbe: accent-Bg fg
|
||||
"""
|
||||
pad = theme.pad
|
||||
# Hintergrund links: Label-Bereich
|
||||
label_w = min(measure(draw, label, fonts.get(theme.label_font_key) or fonts.get("default"))[0] + pad * 2, w // 2)
|
||||
draw.rectangle((x, y, x + label_w, y + h - 1), fill=theme.accent)
|
||||
draw.text((x + pad, y + (h - 20) // 2), label,
|
||||
font=fonts.get(theme.label_font_key) or fonts.get("default"),
|
||||
fill=theme.bg)
|
||||
# Wert rechts
|
||||
if value:
|
||||
vc = value_color if value_color is not None else theme.fg
|
||||
font_v = fonts.get(theme.label_font_key) or fonts.get("default")
|
||||
vw, _ = measure(draw, value, font_v)
|
||||
draw.text((x + w - vw - pad, y + (h - 20) // 2), value, font=font_v, fill=vc)
|
||||
|
||||
|
||||
def th_big_value(draw, fonts, theme: Theme,
|
||||
x: int, y: int, w: int, h: int,
|
||||
value: str, unit: str = "",
|
||||
color=None, align: str = "left"):
|
||||
"""Die große zentrale Kennzahl (z.B. "22°", "45%", "12.4 km").
|
||||
|
||||
Der Wert wird so groß wie möglich dargestellt, das Unit darunter oder
|
||||
daneben in kleinerer Schrift.
|
||||
"""
|
||||
c = color if color is not None else theme.fg
|
||||
candidates = [theme.body_font_key, "80", "60", "48", "36", "28", "24"]
|
||||
font_v = fit_font(draw, value, fonts, w - 2 * theme.pad,
|
||||
h - 2 * theme.pad, candidates=candidates)
|
||||
|
||||
if align == "center":
|
||||
th_centered(draw, fonts, theme, value, x, y, w, h,
|
||||
font_key=None, color=c)
|
||||
else:
|
||||
# Linksbündig, groß
|
||||
tw, th_f = measure(draw, value, font_v)
|
||||
draw.text((x + theme.pad, y + max(0, (h - th_f) // 2)),
|
||||
value, font=font_v, fill=c)
|
||||
|
||||
# Unit darunter oder daneben
|
||||
if unit:
|
||||
unit_font = fonts.get(theme.mono_font_key) or fonts.get("default")
|
||||
if align == "center":
|
||||
# Unter dem Wert
|
||||
uw, uh = measure(draw, unit, unit_font)
|
||||
draw.text((x + max(0, (w - uw) // 2), y + h // 2 + 4), unit,
|
||||
font=unit_font, fill=theme.fg)
|
||||
else:
|
||||
uw, uh = measure(draw, unit, unit_font)
|
||||
draw.text((x + theme.pad + tw + 6,
|
||||
y + max(0, (h - uh) // 2)),
|
||||
unit, font=unit_font, fill=theme.fg)
|
||||
|
||||
|
||||
def th_mini_bar(draw, fonts, theme: Theme,
|
||||
x: int, y: int, w: int, h: int,
|
||||
pct: float,
|
||||
thresholds: list | None = None,
|
||||
gradient: bool = True):
|
||||
"""Kompakter Fortschrittsbalken mit Theme-Styling.
|
||||
|
||||
thresholds: [(pct, color), ...] — z.B. [(50, ok), (80, warn), (100, alert)]
|
||||
gradient: True = zeigt alle Farbstufen gleichzeitig (e-Paper-nativ)
|
||||
"""
|
||||
if thresholds is None:
|
||||
thresholds = [(50, theme.ok), (80, theme.warn), (100, theme.alert)]
|
||||
hbar(draw, x, y, w, h, pct,
|
||||
thresholds=thresholds, gradient=gradient)
|
||||
|
||||
|
||||
def th_divider(draw, theme: Theme, x: int, y: int, w: int,
|
||||
style: str = "solid"):
|
||||
"""Horizontale Trennlinie."""
|
||||
if style == "solid":
|
||||
draw.line((x, y, x + w, y), fill=theme.fg, width=1)
|
||||
elif style == "dashed":
|
||||
# Dashed: 4px dash, 4px gap
|
||||
for dx in range(0, w, 8):
|
||||
draw.line((x + dx, y, min(x + dx + 4, x + w), y),
|
||||
fill=theme.fg, width=1)
|
||||
elif style == "accent":
|
||||
draw.line((x, y, x + w, y), fill=theme.accent, width=2)
|
||||
|
||||
|
||||
def th_corner_marker(draw, theme: Theme, x: int, y: int, size: int = 8):
|
||||
"""Kleiner Eck-Marker (L-Form) oben-links — markiert den Slot-Ursprung.
|
||||
|
||||
Optionaler visueller Anker der zeigt: "hier beginnt dieses Widget".
|
||||
"""
|
||||
draw.line((x, y, x + size, y), fill=theme.accent, width=2)
|
||||
draw.line((x, y, x, y + size), fill=theme.accent, width=2)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# LAYOUT PATTERN TEMPLATES
|
||||
# ============================================================================
|
||||
|
||||
def layout_1x1(draw, fonts, theme: Theme,
|
||||
value: str, unit: str = "",
|
||||
label: str = "", color=None):
|
||||
"""ONE BIG NUMBER — für 1x1 Slots.
|
||||
|
||||
Große zentrierte Zahl, darunter kleines Label.
|
||||
Beispiel: Gmail ungelesen, Strava Jahr-km, System CPU%
|
||||
"""
|
||||
pad = theme.pad
|
||||
# Rahmen
|
||||
th_rect(draw, theme, x=0, y=0, w=200, h=120,
|
||||
outline=theme.fg, width=theme.border_w)
|
||||
|
||||
c = color if color is not None else theme.fg
|
||||
font_val = fit_font(draw, value, fonts, 200 - 2 * pad, 80,
|
||||
candidates=[theme.body_font_key, "80", "60", "48"])
|
||||
th_centered(draw, fonts, theme, value,
|
||||
x=0, y=10, w=200, h=90, color=c)
|
||||
|
||||
if label:
|
||||
font_l = fonts.get(theme.mono_font_key) or fonts.get("default")
|
||||
lw = measure(draw, label, font_l)[0]
|
||||
draw.text(((200 - lw) // 2, 96), label, font=font_l, fill=theme.fg)
|
||||
|
||||
|
||||
def layout_wide_strip(draw, fonts, theme: Theme,
|
||||
cols: list[dict],
|
||||
header_h: int = 30):
|
||||
"""WIDE STRIP — für 4x1 / 2x1 Slots.
|
||||
|
||||
Horizontale Teilung in gleichbreite Spalten.
|
||||
Jede Spalte: [LABEL] über [VALUE] über [UNIT]
|
||||
|
||||
cols = [
|
||||
{"label": "TEMP", "value": "22°", "unit": "", "color": theme.temp_color(22)},
|
||||
{"label": "HUM", "value": "65%", "unit": "Feuchte", "color": theme.ok},
|
||||
{"label": "CO₂", "value": "820", "unit": "ppm", "color": theme.warn},
|
||||
]
|
||||
"""
|
||||
n = len(cols)
|
||||
col_w = 200 // n if n > 0 else 200
|
||||
pad = theme.pad
|
||||
|
||||
for i, col in enumerate(cols):
|
||||
cx = i * col_w
|
||||
c = col.get("color", theme.fg)
|
||||
|
||||
# Label
|
||||
lbl = col.get("label", "")
|
||||
if lbl:
|
||||
draw.text((cx + pad, 4), lbl,
|
||||
font=fonts.get(theme.label_font_key) or fonts.get("default"),
|
||||
fill=theme.accent)
|
||||
|
||||
# Value (groß)
|
||||
val = col.get("value", "—")
|
||||
font_v = fit_font(draw, val, fonts, col_w - 2 * pad, header_h + 30,
|
||||
candidates=[theme.body_font_key, "48", "36", "28"])
|
||||
draw.text((cx + pad, header_h), val, font=font_v, fill=c)
|
||||
|
||||
# Unit (klein darunter)
|
||||
unit = col.get("unit", "")
|
||||
if unit:
|
||||
font_u = fonts.get(theme.mono_font_key) or fonts.get("default")
|
||||
draw.text((cx + pad, header_h + font_v.size + 2),
|
||||
unit, font=font_u, fill=theme.fg)
|
||||
|
||||
|
||||
def layout_card_grid(draw, fonts, theme: Theme,
|
||||
cards: list[dict],
|
||||
x: int, y: int, w: int, h: int,
|
||||
cols: int = 2):
|
||||
"""CARD GRID — für 2x2+ Slots.
|
||||
|
||||
Teil den verfügbaren Raum in gleichmäßige Karten auf.
|
||||
Jede Karte hat: Border + Header-Akzent + Label + Wert + optional Bar.
|
||||
|
||||
cards = [
|
||||
{
|
||||
"label": "🏠 Indoor",
|
||||
"value": "22.5°",
|
||||
"unit": "",
|
||||
"color": theme.temp_color(22.5),
|
||||
"bar": {"pct": 45, "thresholds": [...], "gradient": True},
|
||||
"meta": "min 18° / max 26°",
|
||||
},
|
||||
...
|
||||
]
|
||||
"""
|
||||
pad = theme.pad
|
||||
rows = (len(cards) + cols - 1) // cols
|
||||
card_w = (w - (cols + 1) * pad) // cols
|
||||
card_h = (h - (rows + 1) * pad) // rows
|
||||
|
||||
for i, card in enumerate(cards):
|
||||
row = i // cols
|
||||
col = i % cols
|
||||
cx = x + pad + col * (card_w + pad)
|
||||
cy = y + pad + row * (card_h + pad)
|
||||
|
||||
# Border
|
||||
th_rect(draw, theme, cx, cy, card_w, card_h,
|
||||
outline=theme.fg, width=theme.border_w)
|
||||
|
||||
# Label-Balken oben
|
||||
lbl = card.get("label", "")
|
||||
if lbl:
|
||||
lbl_h = 24
|
||||
draw.rectangle((cx, cy, cx + card_w - 1, cy + lbl_h),
|
||||
fill=theme.accent)
|
||||
font_l = fonts.get(theme.label_font_key) or fonts.get("default")
|
||||
draw.text((cx + pad, cy + 4), lbl, font=font_l, fill=theme.bg)
|
||||
|
||||
# Value
|
||||
inner_y = cy + 28
|
||||
inner_h = card_h - 30
|
||||
val = card.get("value", "—")
|
||||
c = card.get("color", theme.fg)
|
||||
font_v = fit_font(draw, val, fonts, card_w - 2 * pad,
|
||||
inner_h // 2,
|
||||
candidates=[theme.body_font_key, "48", "36", "28"])
|
||||
draw.text((cx + pad, inner_y), val, font=font_v, fill=c)
|
||||
|
||||
# Bar (optional)
|
||||
bar = card.get("bar")
|
||||
if bar and inner_h > 60:
|
||||
bar_pct = bar.get("pct", 0)
|
||||
bar_thresholds = bar.get("thresholds") or [
|
||||
(50, theme.ok), (80, theme.warn), (100, theme.alert)]
|
||||
bar_y = cy + card_h - 28
|
||||
th_mini_bar(draw, fonts, theme,
|
||||
cx + pad, bar_y, card_w - 2 * pad, 12,
|
||||
bar_pct, bar_thresholds, bar.get("gradient", True))
|
||||
|
||||
# Meta (optional)
|
||||
meta = card.get("meta", "")
|
||||
if meta:
|
||||
font_m = fonts.get(theme.mono_font_key) or fonts.get("default")
|
||||
draw.text((cx + pad, cy + card_h - 18), meta,
|
||||
font=font_m, fill=theme.fg)
|
||||
|
||||
|
||||
def layout_poster(draw, fonts, theme: Theme,
|
||||
label: str, value: str, unit: str = "",
|
||||
sub: str = "", color=None):
|
||||
"""POSTER — eine einzelne Aussage, maximal typografisch.
|
||||
|
||||
Für 2x2+ Slots die WIRKLICH nur eine Zahl zeigen wollen.
|
||||
Beispiel: Eine gigantische Uhrzeit, eine große Temperatur.
|
||||
"""
|
||||
pad = theme.pad
|
||||
c = color if color is not None else theme.fg
|
||||
|
||||
# Dünne Rahmenlinie
|
||||
th_rect(draw, theme, x=0, y=0, w=400, h=240,
|
||||
outline=theme.accent, width=1)
|
||||
|
||||
# Label oben links
|
||||
if label:
|
||||
font_l = fonts.get(theme.label_font_key) or fonts.get("default")
|
||||
draw.text((pad, pad), label, font=font_l, fill=theme.accent)
|
||||
|
||||
# Value zentriert, RIESIG
|
||||
font_v = fit_font(draw, value, fonts, 400 - 2 * pad, 180,
|
||||
candidates=["80", "60", "48", theme.body_font_key])
|
||||
tw, th_f = measure(draw, value, font_v)
|
||||
draw.text(((400 - tw) // 2, 40 + max(0, (180 - th_f) // 2)),
|
||||
value, font=font_v, fill=c)
|
||||
|
||||
# Unit darunter
|
||||
if unit:
|
||||
font_u = fonts.get(theme.mono_font_key) or fonts.get("default")
|
||||
uw, _ = measure(draw, unit, font_u)
|
||||
draw.text(((400 - uw) // 2, 40 + 180 - th_f // 2 + 4),
|
||||
unit, font=font_u, fill=theme.fg)
|
||||
|
||||
# Sub / Metatext unten
|
||||
if sub:
|
||||
font_s = fonts.get(theme.mono_font_key) or fonts.get("default")
|
||||
sw, _ = measure(draw, sub, font_s)
|
||||
draw.text(((400 - sw) // 2, 220), sub, font=font_s, fill=theme.fg)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# EXAMPLE: So wird ein Plugin-Design daraus gebaut
|
||||
# ============================================================================
|
||||
#
|
||||
# class Widget(Widget):
|
||||
# name = "myplugin"
|
||||
# default_config = {
|
||||
# "theme": "light", # light | dark | retro | mag
|
||||
# "accent_color": "blue", # blue | green | orange | red
|
||||
# }
|
||||
#
|
||||
# def render(self, draw, fonts, x, y, w, h):
|
||||
# theme = THEMES[self.cfg("theme", "light")]
|
||||
# data = self.fetch()
|
||||
# if "_error" in data:
|
||||
# render_error_banner(draw, fonts, x, y, w, h, self.label, data["_error"])
|
||||
# return
|
||||
#
|
||||
# layout = pick_layout(w, h)
|
||||
# hdr_h = header_height(layout)
|
||||
#
|
||||
# if layout == "small":
|
||||
# th_corner_marker(draw, theme, x, y)
|
||||
# layout_1x1(draw, fonts, theme, data["value"], data.get("unit", ""))
|
||||
# elif layout == "wide":
|
||||
# layout_wide_strip(draw, fonts, theme, data["cols"])
|
||||
# else:
|
||||
# # standard: Card-Grid
|
||||
# cards = [
|
||||
# {"label": k, "value": v, "color": theme.fg}
|
||||
# for k, v in data["cards"].items()
|
||||
# ]
|
||||
# layout_card_grid(draw, fonts, theme, cards, x, y, w, h)
|
||||
#
|
||||
# ============================================================================
|
||||
+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):
|
||||
"""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):
|
||||
|
||||
@@ -0,0 +1,716 @@
|
||||
"""Netatmo Weather Station Plugin — WarmNews Design (V1 Status-Update).
|
||||
|
||||
Zeigt Live-Daten einer Netatmo Wetterstation mit allen Sensoren:
|
||||
- 1x NAMain + bis zu 4x NAModule4 Indoor-Sensoren
|
||||
- 1x NAModule1 Outdoor
|
||||
- 1x NAModule2 Wind
|
||||
- 1x NAModule3 Regen
|
||||
- 3-Tage Vorhersage (Open-Meteo)
|
||||
|
||||
Layout:
|
||||
4x4 (800x480): Aussen | Wind/Regen | Indoor (5) | Vorhersage (3)
|
||||
2x2 (400x240): Aussen + Indoor kombiniert
|
||||
1x1 (200x120): nur Aussentemperatur gross
|
||||
|
||||
Auth: OAuth2 Refresh-Token Flow (siehe tools/netatmo_auth.py fuer initial).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import os, sys, json, time, math, io
|
||||
import urllib.request, urllib.error, urllib.parse
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from plugins.base import (
|
||||
Widget, render_error_banner, draw_battery, draw_compass, draw_trend,
|
||||
draw_weather_icon, draw_line_chart, draw_mini_bar, text_in_box, _fit_size,
|
||||
)
|
||||
from palette import (
|
||||
FG, BG, GREEN, BLUE, RED, YELLOW, ORANGE, WHITE, BLACK,
|
||||
BATT_LOW, BATT_MID, BATT_OK,
|
||||
OK, WARN, ALERT, INFO, ACCENT,
|
||||
measure, fit_font, centered_text, is_small, is_wide, is_tall,
|
||||
temp_color, co2_color, batt_color, wmo_to_icon,
|
||||
format_temp, de_time, de_weekday_date, de_date,
|
||||
)
|
||||
|
||||
# OAuth2 + Forecast URLs
|
||||
TOKEN_URL = "https://api.netatmo.com/oauth2/token"
|
||||
STATIONS_URL = "https://api.netatmo.com/api/getstationsdata"
|
||||
FORECAST_URL = "https://api.open-meteo.com/v1/forecast"
|
||||
|
||||
# In-Memory Token Cache
|
||||
_TOKEN_CACHE: dict[str, Any] = {"access_token": None, "refresh_token": None, "expires_at": 0.0}
|
||||
|
||||
|
||||
# ── HTTP / OAuth2 ───────────────────────────────────────────────────────
|
||||
def _token_payload(creds: dict, grant: str = "refresh_token", **extra) -> bytes:
|
||||
body = {"grant_type": grant, "client_id": creds["client_id"],
|
||||
"client_secret": creds["client_secret"]}
|
||||
body.update(extra)
|
||||
return urllib.parse.urlencode(body).encode("utf-8")
|
||||
|
||||
|
||||
def _post_form(url: str, body: bytes, timeout: int = 10) -> dict:
|
||||
req = urllib.request.Request(url, data=body, headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
|
||||
"Accept": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
return json.loads(r.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
body_text = e.read().decode("utf-8", "ignore")[:200]
|
||||
try: err = json.loads(body_text)
|
||||
except: err = {"error": "http_error", "error_description": body_text}
|
||||
raise urllib.error.HTTPError(
|
||||
url, e.code, err.get("error_description", err.get("error", "")),
|
||||
e.headers, io.BytesIO(json.dumps(err).encode()))
|
||||
|
||||
|
||||
def _obtain_tokens(creds: dict) -> dict:
|
||||
rt = creds.get("refresh_token") or _TOKEN_CACHE.get("refresh_token")
|
||||
if not rt:
|
||||
raise urllib.error.HTTPError(TOKEN_URL, 0,
|
||||
"Refresh-Token fehlt — bitte tools/netatmo_auth.py ausführen.",
|
||||
{}, io.BytesIO(b'{}'))
|
||||
try:
|
||||
tok = _post_form(TOKEN_URL, _token_payload(creds, "refresh_token", refresh_token=rt))
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code in (400, 401):
|
||||
_TOKEN_CACHE["access_token"] = None
|
||||
_TOKEN_CACHE["refresh_token"] = None
|
||||
_TOKEN_CACHE["expires_at"] = 0
|
||||
raise urllib.error.HTTPError(TOKEN_URL, 401,
|
||||
"Token abgelaufen — tools/netatmo_auth.py erneut ausführen.",
|
||||
{}, io.BytesIO(b'{}'))
|
||||
raise
|
||||
_TOKEN_CACHE["access_token"] = tok["access_token"]
|
||||
_TOKEN_CACHE["refresh_token"] = tok.get("refresh_token", rt)
|
||||
_TOKEN_CACHE["expires_at"] = time.time() + tok.get("expires_in", 10800) - 300
|
||||
return tok
|
||||
|
||||
|
||||
def _get_stations_data(client_id: str, client_secret: str, refresh_token: str) -> dict:
|
||||
creds = {"client_id": client_id, "client_secret": client_secret, "refresh_token": refresh_token}
|
||||
now = time.time()
|
||||
if _TOKEN_CACHE["expires_at"] <= now or not _TOKEN_CACHE["access_token"]:
|
||||
_obtain_tokens(creds)
|
||||
def _do():
|
||||
req = urllib.request.Request(
|
||||
f"{STATIONS_URL}?get_favorites=false",
|
||||
headers={"Authorization": f"Bearer {_TOKEN_CACHE['access_token']}",
|
||||
"Accept": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
return json.loads(r.read())
|
||||
try:
|
||||
return _do()
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code != 401: raise
|
||||
_TOKEN_CACHE["access_token"] = None
|
||||
_TOKEN_CACHE["expires_at"] = 0
|
||||
_obtain_tokens(creds)
|
||||
return _do()
|
||||
|
||||
|
||||
def _get_forecast(lat: float, lon: float, days: int = 3) -> dict | None:
|
||||
"""Open-Meteo Forecast, free, kein Key noetig."""
|
||||
try:
|
||||
url = (f"{FORECAST_URL}?latitude={lat}&longitude={lon}"
|
||||
f"&daily=temperature_2m_max,temperature_2m_min,precipitation_sum,"
|
||||
f"weather_code&forecast_days={min(days, 7)}&timezone=auto")
|
||||
req = urllib.request.Request(url, headers={"Accept": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=8) as r:
|
||||
return json.loads(r.read())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# ── Daten-Parsing ────────────────────────────────────────────────────────
|
||||
def _parse_stations(api_response: dict, station_filter: str = "") -> dict | None:
|
||||
body = api_response.get("body", api_response)
|
||||
devices = body.get("devices") if isinstance(body, dict) else None
|
||||
if not devices:
|
||||
return None
|
||||
chosen = None
|
||||
if station_filter:
|
||||
sf = station_filter.strip().lower()
|
||||
for dev in devices:
|
||||
if sf in (dev.get("station_name") or "").lower():
|
||||
chosen = dev; break
|
||||
for m in dev.get("modules", []):
|
||||
if sf in (m.get("module_name") or "").lower():
|
||||
chosen = dev; break
|
||||
if chosen: break
|
||||
if chosen is None: chosen = devices[0]
|
||||
main = {"type": "NAMain", "name": chosen.get("station_name", "Station"),
|
||||
"data": chosen.get("dashboard_data") or {},
|
||||
"place": chosen.get("place") or {},
|
||||
"reachable": chosen.get("reachable")}
|
||||
modules = []
|
||||
for m in chosen.get("modules", []):
|
||||
modules.append({"type": m.get("type", ""), "name": m.get("module_name", ""),
|
||||
"id": m.get("_id"), "data": m.get("dashboard_data") or {},
|
||||
"battery_pct": m.get("battery_percent"),
|
||||
"reachable": m.get("reachable"),
|
||||
"last_seen": m.get("last_seen"),
|
||||
"rf_status": m.get("rf_status")})
|
||||
return {"station_name": main["name"], "place": main.get("place", {}),
|
||||
"main": main, "modules": modules, "_fetched_at": time.time()}
|
||||
|
||||
|
||||
def _first_module(modules: list, mtype: str) -> dict | None:
|
||||
for m in modules:
|
||||
if m["type"] == mtype: return m
|
||||
return None
|
||||
|
||||
|
||||
def _all_indoor(modules: list, main: dict) -> list:
|
||||
"""Alle Indoor-Sensoren: Main + alle NAModule4."""
|
||||
indoor = [{"type": "NAMain", "name": main["name"], "tag": "M",
|
||||
"data": main["data"], "battery_pct": None, "is_main": True}]
|
||||
for m in modules:
|
||||
if m["type"] == "NAModule4":
|
||||
indoor.append({"type": "NAModule4", "name": m["name"], "tag": None,
|
||||
"data": m["data"], "battery_pct": m.get("battery_pct"),
|
||||
"is_main": False, "rf_status": m.get("rf_status")})
|
||||
tag_n = 2
|
||||
for s in indoor:
|
||||
if s["tag"] is None:
|
||||
s["tag"] = str(tag_n)
|
||||
tag_n += 1
|
||||
return indoor
|
||||
|
||||
|
||||
# ── Widget ──────────────────────────────────────────────────────────────
|
||||
class Widget(Widget):
|
||||
name = "netatmo"
|
||||
label = "Netatmo Wetterstation"
|
||||
description = "Indoor (1+4 Sensoren), Outdoor, Wind, Regen, 3-Tage Forecast"
|
||||
category = "weather"
|
||||
|
||||
config_schema = [
|
||||
{"key": "client_id", "label": "Client-ID", "type": "secret"},
|
||||
{"key": "client_secret", "label": "Client-Secret", "type": "secret"},
|
||||
{"key": "refresh_token", "label": "Refresh-Token", "type": "secret"},
|
||||
{"key": "station_filter", "label": "Station-Filter (leer = erste)",
|
||||
"type": "string", "default": ""},
|
||||
{"key": "show_outdoor", "label": "Aussen anzeigen", "type": "bool", "default": True},
|
||||
{"key": "show_wind", "label": "Wind anzeigen", "type": "bool", "default": True},
|
||||
{"key": "show_rain", "label": "Regen anzeigen", "type": "bool", "default": True},
|
||||
{"key": "show_indoor", "label": "Indoor anzeigen", "type": "bool", "default": True},
|
||||
{"key": "show_forecast","label": "Vorhersage anzeigen", "type": "bool", "default": True},
|
||||
{"key": "co2_thresholds", "label": "CO2-Schwellen (ppm)",
|
||||
"type": "string", "default": "ok@600,warn@1000,alert@1500"},
|
||||
{"key": "temp_unit", "label": "Temperatur", "type": "select",
|
||||
"options": ["C", "F"], "default": "C"},
|
||||
{"key": "wind_unit", "label": "Wind-Einheit", "type": "select",
|
||||
"options": ["kmh", "ms"], "default": "kmh"},
|
||||
]
|
||||
default_config = {
|
||||
"station_filter": "",
|
||||
"show_outdoor": True, "show_wind": True, "show_rain": True,
|
||||
"show_indoor": True, "show_forecast": True,
|
||||
"co2_thresholds": "ok@600,warn@1000,alert@1500",
|
||||
"temp_unit": "C", "wind_unit": "kmh",
|
||||
"client_id": "", "client_secret": "", "refresh_token": "",
|
||||
}
|
||||
|
||||
def fetch(self) -> dict:
|
||||
cid = self.cfg("client_id"); sec = self.cfg("client_secret")
|
||||
refresh = self.cfg("refresh_token", "")
|
||||
if not (cid and sec):
|
||||
return {"_error": "Client-ID oder Client-Secret fehlt."}
|
||||
if not refresh:
|
||||
return {"_error": "Refresh-Token fehlt — tools/netatmo_auth.py ausführen."}
|
||||
try:
|
||||
data = _get_stations_data(cid, sec, refresh)
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code in (401, 403):
|
||||
_TOKEN_CACHE["access_token"] = None
|
||||
_TOKEN_CACHE["refresh_token"] = None
|
||||
_TOKEN_CACHE["expires_at"] = 0
|
||||
return {"_error": f"Auth fehlgeschlagen: {e.reason} — tools/netatmo_auth.py erneut."}
|
||||
return {"_error": f"HTTP {e.code}: {str(e.reason)[:60]}"}
|
||||
except Exception as e:
|
||||
return {"_error": f"{type(e).__name__}: {str(e)[:60]}"}
|
||||
parsed = _parse_stations(data, self.cfg("station_filter", ""))
|
||||
if not parsed:
|
||||
return {"_error": "Keine Station gefunden."}
|
||||
# Forecast
|
||||
if self.cfg("show_forecast", True):
|
||||
place = parsed.get("place", {})
|
||||
coords = place.get("location") or []
|
||||
if len(coords) >= 2:
|
||||
forecast = _get_forecast(coords[1], coords[0], days=3)
|
||||
parsed["forecast"] = forecast
|
||||
return parsed
|
||||
|
||||
def render(self, draw, fonts, x, y, w, h):
|
||||
d = self.fetch()
|
||||
if "_error" in d:
|
||||
render_error_banner(draw, fonts, x, y, w, h, self.label, d["_error"])
|
||||
return
|
||||
# Responsive: 1x1 → compact, 2x2+ → full 4-column
|
||||
if is_small(w, h):
|
||||
self._render_compact(draw, x, y, w, h, d, fonts)
|
||||
elif w >= 700 and h >= 400:
|
||||
self._render_full_4x4(draw, x, y, w, h, d, fonts)
|
||||
else:
|
||||
self._render_compact_2x2(draw, x, y, w, h, d, fonts)
|
||||
|
||||
# ── Compact (1x1) ───────────────────────────────────────────────────
|
||||
def _render_compact(self, draw, x, y, w, h, d, fonts):
|
||||
out = _first_module(d["modules"], "NAModule1") if self.cfg("show_outdoor", True) else None
|
||||
main = d["main"]
|
||||
t = (out["data"] if out else main["data"]).get("Temperature")
|
||||
unit = self.cfg("temp_unit", "C")
|
||||
temp_str = f"{t:.1f}°{unit}" if t is not None else "—"
|
||||
pad = 6
|
||||
font = _fit_size(draw, temp_str, w - 2 * pad, h - 22, candidates=["48", "36", "28", "24", "20"])
|
||||
text_in_box(draw, temp_str, x + pad, y + pad, w - 2 * pad, h - 18, font, temp_color(t), "center")
|
||||
label = (out["name"] if out else "Innen")[:14]
|
||||
text_in_box(draw, label, x, y + h - 14, w, 12, fonts.get("10") or fonts.get("default"),
|
||||
FG, "center")
|
||||
|
||||
# ── 2x2 (Aussen + Indoor kompakt) ─────────────────────────────────
|
||||
def _render_compact_2x2(self, draw, x, y, w, h, d, fonts):
|
||||
# 2 Spalten: Aussen (links), Indoor (rechts)
|
||||
col_w = (w - 8) // 2
|
||||
col_h = h - 4
|
||||
# Aussen
|
||||
self._draw_aussen_compact(draw, x + 2, y + 2, col_w, col_h, d, fonts)
|
||||
# Indoor
|
||||
self._draw_indoor_compact(draw, x + col_w + 6, y + 2, col_w, col_h, d, fonts)
|
||||
|
||||
def _draw_aussen_compact(self, draw, x, y, w, h, d, fonts):
|
||||
out = _first_module(d["modules"], "NAModule1") if self.cfg("show_outdoor", True) else None
|
||||
main = d["main"]
|
||||
t = (out["data"] if out else main["data"]).get("Temperature")
|
||||
text_in_box(draw, "AUSSEN", x, y, w, 14, fonts.get("11", fonts.get("10")), GREEN, "center")
|
||||
temp_str = f"{t:.1f}°" if t is not None else "—"
|
||||
font = _fit_size(draw, temp_str, w - 4, h - 30, candidates=["48", "36", "28", "24"])
|
||||
text_in_box(draw, temp_str, x, y + 16, w, h - 30, font, temp_color(t), "center")
|
||||
# Batterie
|
||||
if out and out.get("battery_pct") is not None:
|
||||
draw_battery(draw, x + w - 50, y + 1, out["battery_pct"], w=22, h=9)
|
||||
|
||||
def _draw_indoor_compact(self, draw, x, y, w, h, d, fonts):
|
||||
indoor = _all_indoor(d["modules"], d["main"])
|
||||
text_in_box(draw, f"INNEN · {len(indoor)}", x, y, w, 14,
|
||||
fonts.get("11", fonts.get("10")), RED, "center")
|
||||
# 2-Spalten-Grid der Sensoren
|
||||
n = len(indoor)
|
||||
cell_h = (h - 18) // min(n, 5)
|
||||
for i, s in enumerate(indoor[:5]):
|
||||
ry = y + 16 + i * cell_h
|
||||
# Tag + Name + Temp
|
||||
text_in_box(draw, s["tag"], x + 2, ry, 14, cell_h,
|
||||
fonts.get("12", fonts.get("10")), RED, "left")
|
||||
text_in_box(draw, s["name"][:6], x + 16, ry, w - 60, cell_h,
|
||||
fonts.get("10"), FG, "left")
|
||||
t_str = f"{s['data'].get('Temperature', 0):.1f}°"
|
||||
text_in_box(draw, t_str, x + w - 50, ry, 48, cell_h,
|
||||
fonts.get("12", fonts.get("10")), temp_color(s['data'].get('Temperature')), "right")
|
||||
|
||||
# ── 4x4 (Volle Ansicht, V1-Design) ────────────────────────────────
|
||||
def _render_full_4x4(self, draw, x, y, w, h, d, fonts):
|
||||
# Globaler Header (Uhrzeit + Datum) — 24px hoch
|
||||
HEADER_H = 24
|
||||
now = datetime.now()
|
||||
weekday_short = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"][now.weekday()]
|
||||
time_str = f"{weekday_short} {now.day:02d}.{now.month:02d} {now.hour:02d}:{now.minute:02d}"
|
||||
# Trennlinie unten am Header
|
||||
# Linke Seite: Station-Name (klein)
|
||||
station = d.get("station_name", "Netatmo")
|
||||
text_in_box(draw, station.upper(), x + 8, y + 4, w // 2 - 8, HEADER_H - 6,
|
||||
fonts.get("11", fonts.get("10")), FG, "left")
|
||||
# Rechte Seite: Datum + Uhrzeit (prominent)
|
||||
text_in_box(draw, time_str, x + w // 2, y + 2, w // 2 - 8, HEADER_H - 4,
|
||||
fonts.get("16", fonts.get("14")), FG, "right")
|
||||
# Header-Bottom-Line
|
||||
draw.line((x, y + HEADER_H, x + w, y + HEADER_H), fill=FG, width=1)
|
||||
|
||||
# Strikt grid-basierte Spalten — Aussen breiter, Indoor am breitesten
|
||||
PAD_OUTER = 6
|
||||
GAP = 6
|
||||
avail = w - 2 * PAD_OUTER - 3 * GAP
|
||||
col_aussen = int(avail * 0.28) # ~220
|
||||
col_mitte = int(avail * 0.16) # ~125
|
||||
col_indoor = int(avail * 0.36) # ~280
|
||||
col_fc = avail - col_aussen - col_mitte - col_indoor # ~140
|
||||
|
||||
xa = x + PAD_OUTER
|
||||
xm = xa + col_aussen + GAP
|
||||
xr = xm + col_mitte + GAP
|
||||
xf = xr + col_indoor + GAP
|
||||
body_y = y + HEADER_H + 4
|
||||
body_h = h - HEADER_H - 8
|
||||
|
||||
# Daten extrahieren
|
||||
main = d["main"]
|
||||
modules = d["modules"]
|
||||
out = _first_module(modules, "NAModule1") if self.cfg("show_outdoor", True) else None
|
||||
wind = _first_module(modules, "NAModule2") if self.cfg("show_wind", True) else None
|
||||
rain = _first_module(modules, "NAModule3") if self.cfg("show_rain", True) else None
|
||||
indoor = _all_indoor(modules, main) if self.cfg("show_indoor", True) else []
|
||||
forecast = d.get("forecast") if self.cfg("show_forecast", True) else None
|
||||
|
||||
# Sektionen rendern
|
||||
if out:
|
||||
self._draw_aussen_full(draw, xa, body_y, col_aussen, body_h, main, out, fonts)
|
||||
if wind or rain:
|
||||
self._draw_wind_rain(draw, xm, body_y, col_mitte, body_h, wind, rain, fonts)
|
||||
if indoor:
|
||||
self._draw_indoor_full(draw, xr, body_y, col_indoor, body_h, indoor, fonts)
|
||||
if forecast:
|
||||
self._draw_forecast_full(draw, xf, body_y, col_fc, body_h, forecast, fonts)
|
||||
|
||||
# Trennlinien (sehr duenn)
|
||||
for sep_x in [xm - 3, xr - 3, xf - 3]:
|
||||
if sep_x > x and sep_x < x + w:
|
||||
draw.line((sep_x, body_y, sep_x, body_y + body_h), fill=FG, width=1)
|
||||
|
||||
def _draw_aussen_full(self, draw, x, y, w, h, main, out, fonts):
|
||||
# Header — Battery rechts
|
||||
text_in_box(draw, "AUSSEN", x, y, w, 16, fonts.get("12", fonts.get("11")), GREEN, "center")
|
||||
if out.get("battery_pct") is not None:
|
||||
draw_battery(draw, x + w - 50, y + 2, out["battery_pct"], w=22, h=8, with_text=False)
|
||||
text_in_box(draw, f"{out['battery_pct']}%", x + w - 24, y + 1, 24, 10,
|
||||
fonts.get("8", fonts.get("9")), batt_color(out["battery_pct"]), "right")
|
||||
inner_y = y + 20
|
||||
inner_h = h - 20
|
||||
# Big Temp — GROSSE Schrift
|
||||
t = out["data"].get("Temperature")
|
||||
big_str = f"{t:.1f}°" if t is not None else "—"
|
||||
big_h = int(inner_h * 0.32)
|
||||
font = _fit_size(draw, big_str, w - 8, big_h, candidates=["96", "84", "72", "60"])
|
||||
text_in_box(draw, big_str, x, inner_y, w, big_h, font, temp_color(t), "center")
|
||||
# 4 Stats in EINER Zeile (statt 2x2 Grid) — mehr Whitespace, grössere Werte
|
||||
stats_y = inner_y + big_h + 6
|
||||
stats_h = 50
|
||||
# MIN/MAX aus echten 12h-History berechnen (nicht geschätzt)
|
||||
history = self._get_12h_history(out["data"])
|
||||
if history and len(history) >= 2:
|
||||
actual_min = min(history)
|
||||
actual_max = max(history)
|
||||
else:
|
||||
ot = out["data"].get("Temperature", 0) or 0
|
||||
actual_min, actual_max = ot - 2, ot + 4
|
||||
oh = out["data"].get("Humidity", 0) or 0
|
||||
pressure = main["data"].get("Pressure") or main["data"].get("AbsolutePressure")
|
||||
trend = main["data"].get("Pressure_trend", "stable")
|
||||
cell_w = (w - 6) // 4
|
||||
stats = [
|
||||
("MIN", f"{actual_min:.0f}°", FG),
|
||||
("MAX", f"{actual_max:.0f}°", FG),
|
||||
("FEUCHTE", f"{oh}%", BLUE),
|
||||
("DRUCK", f"{int(pressure)}" if pressure else "—", FG),
|
||||
]
|
||||
for i, (lbl, val, col) in enumerate(stats):
|
||||
cx = x + 3 + i * cell_w
|
||||
text_in_box(draw, lbl, cx, stats_y, cell_w - 2, 11,
|
||||
fonts.get("9", fonts.get("8")), FG, "center")
|
||||
# Bei DRUCK (i==3) Platz fuer Trend-Pfeil lassen + kleinere Schrift
|
||||
is_druck = (i == 3 and trend and trend != "stable" and pressure)
|
||||
if is_druck:
|
||||
val_w = cell_w - 16
|
||||
val_font = fonts.get("14", fonts.get("12"))
|
||||
else:
|
||||
val_w = cell_w - 2
|
||||
val_font = fonts.get("18", fonts.get("16"))
|
||||
text_in_box(draw, val, cx, stats_y + 11, val_w, stats_h - 11,
|
||||
val_font, col, "center")
|
||||
# Trend-Pfeil beim Druck (rechts neben Wert)
|
||||
if is_druck:
|
||||
draw_trend(draw, cx + cell_w - 14, stats_y + 22, trend, size=12,
|
||||
color=GREEN if trend == "up" else RED)
|
||||
# Trennlinie + Chart (kompakter, weniger Raum verschwenden)
|
||||
chart_y = stats_y + stats_h + 6
|
||||
if chart_y < y + h - 20:
|
||||
draw.line((x + 4, chart_y, x + w - 4, chart_y), fill=FG, width=1)
|
||||
chart_y += 3
|
||||
text_in_box(draw, "12H VERLAUF", x + 2, chart_y, w // 2, 10,
|
||||
fonts.get("8", fonts.get("9")), FG, "left")
|
||||
history = self._get_12h_history(out["data"])
|
||||
if len(history) >= 2:
|
||||
y_min, y_max = min(history), max(history)
|
||||
pad = max(1, (y_max - y_min) * 0.2)
|
||||
y_min -= pad; y_max += pad
|
||||
chart_top = chart_y + 12
|
||||
chart_h = (y + h - 10) - chart_top
|
||||
if chart_h > 20:
|
||||
draw_line_chart(draw, x + 24, chart_top, w - 28, chart_h,
|
||||
history, color=GREEN,
|
||||
y_min=y_min, y_max=y_max,
|
||||
with_y_grid=True, n_ticks=2)
|
||||
# Nur "jetzt" rechts unten — links=alt, rechts=neu ist intuitiv
|
||||
# (kein "-12h" links → kollidiert sonst mit Y-Min-Label)
|
||||
bottom_y = y + h - 12
|
||||
text_in_box(draw, "jetzt", x + w - 60, bottom_y, 60, 10,
|
||||
fonts.get("8", fonts.get("9")), FG, "right")
|
||||
|
||||
def _draw_wind_rain(self, draw, x, y, w, h, wind, rain, fonts):
|
||||
sec_h = (h - 4) // 2
|
||||
# Wind (oben)
|
||||
if wind:
|
||||
text_in_box(draw, "WIND", x, y, w, 16, fonts.get("12", fonts.get("11")), BLUE, "center")
|
||||
sec_inner_y = y + 20
|
||||
sec_inner_h = sec_h - 20
|
||||
ws = wind["data"].get("WindStrength", 0) or 0
|
||||
gust = wind["data"].get("GustStrength", 0) or 0
|
||||
deg = wind["data"].get("WindAngle", 0) or 0
|
||||
unit = "km/h" if self.cfg("wind_unit", "kmh") == "kmh" else "m/s"
|
||||
# Big speed — riesig
|
||||
s_str = f"{ws:.0f}"
|
||||
big_h = int(sec_inner_h * 0.50)
|
||||
f_v = _fit_size(draw, s_str, w - 50, big_h, candidates=["60", "48", "40", "32"])
|
||||
text_in_box(draw, s_str, x, sec_inner_y, w - 40, big_h, f_v, FG, "center")
|
||||
sw, sh = measure(draw, s_str, f_v)
|
||||
text_in_box(draw, unit, x + (w - 40) // 2 + sw // 2 + 2,
|
||||
sec_inner_y + big_h - 14, 36, 12,
|
||||
fonts.get("11", fonts.get("10")), FG, "left")
|
||||
# Bö darunter
|
||||
boe_y = sec_inner_y + big_h + 4
|
||||
text_in_box(draw, f"Bö {gust} {unit}", x, boe_y, w - 36, 14,
|
||||
fonts.get("14", fonts.get("12")), FG, "center")
|
||||
# Compass rechts oben
|
||||
ccx = x + w - 22
|
||||
ccy = sec_inner_y + 18
|
||||
draw_compass(draw, ccx, ccy, 14, deg, color=BLUE)
|
||||
# Battery nach UNTEN-RECHTS (eigener Bereich unter Bö)
|
||||
if wind.get("battery_pct") is not None:
|
||||
batt_y = y + sec_h - 14
|
||||
batt_x = x + w - 28
|
||||
draw_battery(draw, batt_x, batt_y, wind["battery_pct"],
|
||||
w=22, h=8, with_text=False)
|
||||
text_in_box(draw, f"{wind['battery_pct']}%", batt_x - 22, batt_y - 1, 20, 10,
|
||||
fonts.get("9", fonts.get("8")),
|
||||
batt_color(wind["battery_pct"]), "right")
|
||||
# Regen (unten)
|
||||
if rain:
|
||||
ry = y + sec_h + 4
|
||||
text_in_box(draw, "REGEN", x, ry, w, 16, fonts.get("12", fonts.get("11")), BLUE, "center")
|
||||
sec_inner_y = ry + 20
|
||||
sec_inner_h = sec_h - 20
|
||||
rate = rain["data"].get("RainRate") or rain["data"].get("rain") or 0
|
||||
h1 = rain["data"].get("sum_rain_1") or rain["data"].get("rain_hour") or 0
|
||||
h24 = rain["data"].get("sum_rain_24") or rain["data"].get("rain_day") or 0
|
||||
today = h24 # Approximation
|
||||
# Big rate — immer als Zahl zeigen (auch 0.0)
|
||||
r_str = f"{rate:.1f}"
|
||||
rate_h = int(sec_inner_h * 0.36)
|
||||
f_v = _fit_size(draw, r_str, w - 4, rate_h, candidates=["60", "48", "40", "32"])
|
||||
text_in_box(draw, r_str, x, sec_inner_y, w, rate_h,
|
||||
f_v, BLUE if rate > 0 else FG, "center")
|
||||
# Einheit drunter
|
||||
text_in_box(draw, "mm/h", x, sec_inner_y + rate_h, w, 10,
|
||||
fonts.get("10", fonts.get("9")), FG, "center")
|
||||
# HEUTE prominent — Wert RIESIG
|
||||
today_y = sec_inner_y + rate_h + 14
|
||||
today_h = 32
|
||||
if today_h >= 18:
|
||||
text_in_box(draw, "HEUTE", x + 2, today_y, 50, 9,
|
||||
fonts.get("9", fonts.get("8")), FG, "left")
|
||||
today_str = f"{today:.1f}"
|
||||
f_today = _fit_size(draw, today_str, w // 2, today_h - 8,
|
||||
candidates=["28", "24", "20", "18"])
|
||||
text_in_box(draw, today_str, x, today_y + 8, w // 2, today_h - 8,
|
||||
f_today, BLUE if today > 0 else FG, "left")
|
||||
text_in_box(draw, "mm", x + w // 2 + 2, today_y + 14, 30, today_h - 14,
|
||||
fonts.get("12", fonts.get("10")), FG, "left")
|
||||
# 1h / 24h — kompakt am unteren Rand LINKS (Batterie bekommt rechts Platz)
|
||||
bars_y = sec_inner_y + sec_inner_h - 36
|
||||
bars_w = w - 56 # Platz fuer Battery rechts lassen
|
||||
for j, (key, val, maxval) in enumerate([("1h", h1, 5), ("24h", h24, 20)]):
|
||||
by = bars_y + j * 10
|
||||
text_in_box(draw, key, x, by, 18, 8, fonts.get("8", fonts.get("7")), FG, "left")
|
||||
bar_x = x + 20
|
||||
bar_w = bars_w - 22 - 20
|
||||
draw_mini_bar(draw, bar_x, by, bar_w, 7, val, maxval,
|
||||
color=BLUE if key == "1h" else ORANGE)
|
||||
text_in_box(draw, f"{val:.1f}", x + bars_w - 22, by - 1, 22, 8,
|
||||
fonts.get("8", fonts.get("7")), FG, "right")
|
||||
# Battery nach UNTEN-RECHTS (eigener Bereich, kein Overlap mit Bars)
|
||||
if rain.get("battery_pct") is not None:
|
||||
batt_y = ry + sec_h - 14
|
||||
batt_x = x + w - 28
|
||||
draw_battery(draw, batt_x, batt_y, rain["battery_pct"],
|
||||
w=22, h=8, with_text=False)
|
||||
text_in_box(draw, f"{rain['battery_pct']}%", batt_x - 22, batt_y - 1, 20, 10,
|
||||
fonts.get("9", fonts.get("8")),
|
||||
batt_color(rain["battery_pct"]), "right")
|
||||
|
||||
def _draw_indoor_full(self, draw, x, y, w, h, indoor, fonts):
|
||||
text_in_box(draw, f"INNEN · {len(indoor)}", x, y, w, 16,
|
||||
fonts.get("12", fonts.get("11")), RED, "center")
|
||||
sec_body_y = y + 22
|
||||
sec_body_h = h - 22
|
||||
n = max(1, len(indoor))
|
||||
row_h = sec_body_h // n
|
||||
# 3 vertikale Zonen: Head (60%), Bar (15%), Foot-CO2+Battery (25%)
|
||||
head_h = int(row_h * 0.60) - 2
|
||||
bar_h = max(8, int(row_h * 0.12))
|
||||
foot_h = row_h - head_h - bar_h - 8
|
||||
for i, s in enumerate(indoor):
|
||||
ry = sec_body_y + i * row_h
|
||||
if i > 0:
|
||||
draw.line((x + 6, ry + 2, x + w - 6, ry + 2), fill=FG, width=1)
|
||||
inner_x = x + 6
|
||||
inner_w = w - 12
|
||||
# HEAD: Tag | Name (GROSS) | Temp (RIESIG)
|
||||
tag_w = 30
|
||||
draw.rectangle((inner_x, ry + 2, inner_x + tag_w, ry + 2 + head_h - 4),
|
||||
fill=RED)
|
||||
f_tag = _fit_size(draw, s["tag"], tag_w - 4, head_h - 8,
|
||||
candidates=["28", "24", "22", "20"])
|
||||
tw_, th_ = measure(draw, s["tag"], f_tag)
|
||||
draw.text((inner_x + (tag_w - tw_) // 2, ry + 2 + (head_h - 4 - th_) // 2),
|
||||
s["tag"], font=f_tag, fill=WHITE)
|
||||
# Name gross, fett
|
||||
name_x = inner_x + tag_w + 8
|
||||
name_w = inner_w - tag_w - 8 - 130
|
||||
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")
|
||||
# Temp RIESIG, in Temperatur-Farbe
|
||||
t = s["data"].get("Temperature")
|
||||
t_str = f"{t:.1f}°" if t is not None else "—"
|
||||
f_t = _fit_size(draw, t_str, 130, head_h - 4, candidates=["48", "40", "36", "32"])
|
||||
text_in_box(draw, t_str, inner_x + inner_w - 130, ry + 2, 130, head_h - 4,
|
||||
f_t, temp_color(t), "right")
|
||||
# BAR: CO2-Bar (volle Breite)
|
||||
co2 = s["data"].get("CO2")
|
||||
if co2 is not None and inner_w > 6:
|
||||
bar_y = ry + head_h + 4
|
||||
draw_mini_bar(draw, inner_x, bar_y, inner_w, bar_h, co2, 2000,
|
||||
color=co2_color(co2))
|
||||
# FOOT: CO2 Wert (links) + Battery (rechts) — sauber vertikal ausgerichtet
|
||||
foot_y = ry + head_h + 4 + bar_h + 2
|
||||
if foot_h >= 10 and co2 is not None:
|
||||
co2_str = f"CO₂ {co2} ppm"
|
||||
text_in_box(draw, co2_str, inner_x, foot_y, inner_w // 2, foot_h,
|
||||
fonts.get("10", fonts.get("9")), co2_color(co2), "left")
|
||||
if s.get("battery_pct") is not None:
|
||||
# Battery rechtsbuendig: Icon + Prozent, beide auf gleicher y
|
||||
batt_x = inner_x + inner_w - 30 # Icon-Endposition
|
||||
draw_battery(draw, batt_x, foot_y, s["battery_pct"],
|
||||
w=22, h=8, with_text=False)
|
||||
pct_str = f"{s['battery_pct']}%"
|
||||
text_in_box(draw, pct_str, batt_x - 26, foot_y, 24, foot_h,
|
||||
fonts.get("10", fonts.get("9")),
|
||||
batt_color(s["battery_pct"]), "right")
|
||||
|
||||
def _draw_forecast_full(self, draw, x, y, w, h, forecast, fonts):
|
||||
text_in_box(draw, "VORHERSAGE", x, y, w, 16,
|
||||
fonts.get("12", fonts.get("11")), ORANGE, "center")
|
||||
daily = forecast.get("daily", {})
|
||||
days = daily.get("time", [])[:3]
|
||||
if not days: return
|
||||
sec_body_y = y + 22
|
||||
sec_body_h = h - 22
|
||||
row_h = sec_body_h // len(days)
|
||||
# Layout: Day+Icon+Rain oben (32%), Min/Max RIESIG (68%)
|
||||
l1_h = max(30, int(row_h * 0.32))
|
||||
l2_h = row_h - l1_h - 2
|
||||
for i, day_str in enumerate(days):
|
||||
ry = sec_body_y + i * row_h
|
||||
if i > 0:
|
||||
draw.line((x + 6, ry + 2, x + w - 6, ry + 2), fill=FG, width=1)
|
||||
# Tag (links)
|
||||
try:
|
||||
dt = datetime.fromisoformat(day_str)
|
||||
day_label = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"][dt.weekday()]
|
||||
except Exception:
|
||||
day_label = day_str[:2]
|
||||
text_in_box(draw, day_label.upper(), x + 2, ry, 32, l1_h,
|
||||
fonts.get("18", fonts.get("16")), FG, "left")
|
||||
# Wetter-Icon (rechts)
|
||||
try:
|
||||
i_day = days.index(day_str)
|
||||
code = daily.get("weather_code", [None] * len(days))[i_day]
|
||||
except (IndexError, KeyError, TypeError):
|
||||
code = None
|
||||
icon_size = min(26, l1_h - 4)
|
||||
draw_weather_icon(draw, x + w - icon_size - 2, ry + (l1_h - icon_size) // 2,
|
||||
icon_size, code)
|
||||
# REGEN prominent in der Day-Row: groesseres Drop-Icon + groessere Schrift
|
||||
try:
|
||||
rain_mm = (daily.get("precipitation_sum", [0] * len(days)) or [0] * len(days))[i_day] or 0
|
||||
except (IndexError, KeyError, TypeError):
|
||||
rain_mm = 0
|
||||
# Drop-Icon (groesser)
|
||||
drop_w = 12
|
||||
drop_h = 16
|
||||
drop_x = x + 36
|
||||
drop_y = ry + (l1_h - drop_h) // 2
|
||||
if rain_mm > 0:
|
||||
# Tropfen mit Fuellung in Blau
|
||||
draw.polygon([(drop_x + drop_w // 2, drop_y),
|
||||
(drop_x, drop_y + drop_h - 2),
|
||||
(drop_x + drop_w, drop_y + drop_h - 2)],
|
||||
fill=BLUE)
|
||||
# Highlight (1-2 weisse Pixel oben fuer 3D-Effekt)
|
||||
draw.point((drop_x + drop_w // 2 + 1, drop_y + 4), fill=WHITE)
|
||||
else:
|
||||
# Leerer Tropfen in FG
|
||||
draw.polygon([(drop_x + drop_w // 2, drop_y),
|
||||
(drop_x, drop_y + drop_h - 2),
|
||||
(drop_x + drop_w, drop_y + drop_h - 2)],
|
||||
outline=FG)
|
||||
# Regen-Wert GROSS daneben
|
||||
rain_str = f"{rain_mm:.1f}"
|
||||
f_rain = _fit_size(draw, rain_str, 50, l1_h - 2, candidates=["20", "18", "16", "14"])
|
||||
text_in_box(draw, rain_str, drop_x + drop_w + 2, ry, 50, l1_h,
|
||||
f_rain, BLUE if rain_mm > 0 else FG, "left")
|
||||
# "mm" Einheit klein dahinter
|
||||
text_in_box(draw, "mm", drop_x + drop_w + 2 + 32, ry, 18, l1_h,
|
||||
fonts.get("10", fonts.get("9")), FG, "left")
|
||||
# Min/Max RIESIG (volle Breite, beide sichtbar)
|
||||
try:
|
||||
tmin = daily["temperature_2m_min"][i_day]
|
||||
tmax = daily["temperature_2m_max"][i_day]
|
||||
except (IndexError, KeyError, TypeError):
|
||||
continue
|
||||
min_str = f"{tmin}°"
|
||||
max_str = f"{tmax}°"
|
||||
f_min = _fit_size(draw, min_str, w - 2, l2_h, candidates=["40", "36", "32", "28"])
|
||||
f_max = _fit_size(draw, max_str, w - 2, l2_h, candidates=["40", "36", "32", "28"])
|
||||
text_in_box(draw, min_str, x + 2, ry + l1_h, w // 2 - 4, l2_h,
|
||||
f_min, BLUE, "center")
|
||||
text_in_box(draw, max_str, x + w // 2, ry + l1_h, w // 2 - 4, l2_h,
|
||||
f_max, ORANGE, "center")
|
||||
|
||||
# ── History (12h Temperatur-Verlauf) ──────────────────────────────
|
||||
def _get_12h_history(self, data: dict) -> list:
|
||||
"""Versuche 12h-Temp-Verlauf aus Netatmo-Daten zu extrahieren.
|
||||
Netatmo liefert 'Temp_history' als String '14.0;13.5;...'
|
||||
(3h-Intervalle). Wir nehmen die letzten 12 Werte.
|
||||
"""
|
||||
hist_str = data.get("Temp_history") or data.get("temp_history")
|
||||
if hist_str:
|
||||
try:
|
||||
vals = [float(v) for v in hist_str.split(";") if v]
|
||||
if len(vals) >= 4:
|
||||
return vals[-12:] if len(vals) >= 12 else vals
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
# Fallback: Sinus-basierter Demo-Verlauf
|
||||
cur = data.get("Temperature", 0) or 0
|
||||
return [cur + 0.5 * math.sin(i / 2.0) for i in range(12)]
|
||||
|
||||
# ── CO2 Threshold Parser ────────────────────────────────────────
|
||||
def _co2_thresholds(self) -> list:
|
||||
spec = self.cfg("co2_thresholds", "ok@600,warn@1000,alert@1500")
|
||||
result = []
|
||||
color_map = {"ok": GREEN, "warn": YELLOW, "alert": RED,
|
||||
"fg": FG, "green": GREEN, "yellow": YELLOW, "red": RED}
|
||||
for p in [s.strip() for s in spec.split(",") if s.strip()]:
|
||||
name, _, val = p.partition("@")
|
||||
try:
|
||||
ppm = float(val)
|
||||
result.append((ppm, color_map.get(name.lower(), GREEN)))
|
||||
except ValueError:
|
||||
continue
|
||||
if not result:
|
||||
return [(600, GREEN), (1000, YELLOW), (1500, RED)]
|
||||
return sorted(result, key=lambda x: x[0])
|
||||
+10
-10
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
+188
-24
@@ -330,6 +330,38 @@
|
||||
display: flex; gap: 8px; flex-wrap: wrap; align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
/* BUG-05: Save-Status-Indikator (ersetzt den redundanten Speichern-Button) */
|
||||
.save-indicator {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
margin-left: auto; /* rechtsbündig im toolbar */
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.82em;
|
||||
color: var(--fg-muted);
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
.save-indicator .save-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
background: var(--ok);
|
||||
box-shadow: 0 0 6px rgba(74, 222, 128, 0.5);
|
||||
transition: background 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
/* Zustände */
|
||||
.save-indicator[data-state="idle"] .save-dot { background: var(--ok); }
|
||||
.save-indicator[data-state="pending"] .save-dot {
|
||||
background: var(--warn); animation: savePulse 1s ease-in-out infinite;
|
||||
}
|
||||
.save-indicator[data-state="saving"] .save-dot {
|
||||
background: var(--info); animation: savePulse 0.6s ease-in-out infinite;
|
||||
}
|
||||
.save-indicator[data-state="error"] .save-dot {
|
||||
background: var(--alert); box-shadow: 0 0 6px rgba(248, 113, 113, 0.5);
|
||||
}
|
||||
.save-indicator[data-state="error"] { color: var(--alert); }
|
||||
@keyframes savePulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.35; }
|
||||
}
|
||||
.add-form {
|
||||
display: flex; gap: 8px; align-items: center;
|
||||
background: var(--surface-2);
|
||||
@@ -805,6 +837,21 @@
|
||||
.toast.info .toast-icon { color: var(--info); }
|
||||
.toast.warn .toast-icon { color: var(--warn); }
|
||||
.toast-text { flex: 1; min-width: 0; word-break: break-word; }
|
||||
.toast-action {
|
||||
flex: 0 0 auto;
|
||||
padding: 4px 10px;
|
||||
background: var(--surface-3);
|
||||
border: 1px solid var(--border-light);
|
||||
color: var(--fg);
|
||||
border-radius: 4px;
|
||||
font-size: 0.85em;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
pointer-events: auto; /* BUG-06: Klick auf Action-Button muss trotz Toast-Click-Handler gehen */
|
||||
}
|
||||
.toast-action:hover { background: var(--accent); color: white; border-color: var(--accent); }
|
||||
.toast.success .toast-action { border-color: var(--success); color: var(--success); }
|
||||
.toast.success .toast-action:hover { background: var(--success); color: var(--bg); }
|
||||
@keyframes toastIn {
|
||||
from { opacity: 0; transform: translateX(20px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
@@ -892,6 +939,11 @@
|
||||
<span class="icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
|
||||
Settings
|
||||
</button>
|
||||
<a class="sidebar-item" href="/power" style="text-decoration:none">
|
||||
<span class="icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18.36 6.64a9 9 0 1 1-12.73 0"/><line x1="12" y1="2" x2="12" y2="12"/></svg></span>
|
||||
Energie
|
||||
<span class="badge" id="sbPower">—</span>
|
||||
</a>
|
||||
<button class="sidebar-item" data-tab="network" onclick="switchTab('network')">
|
||||
<span class="icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/></svg></span>
|
||||
Network
|
||||
@@ -1022,7 +1074,12 @@
|
||||
<button type="submit" class="primary">+ Hinzufügen</button>
|
||||
</form>
|
||||
<button onclick="packAll()" title="Alle Items automatisch anordnen">Auto-Pack</button>
|
||||
<button onclick="saveLayout()" class="primary" title="Layout auf Server speichern">Speichern</button>
|
||||
<!-- BUG-05: Speichern-Button entfernt (redundant zu debouncedSave).
|
||||
Stattdessen subtiler Save-Status-Indikator rechts. -->
|
||||
<div class="save-indicator" id="saveIndicator" data-state="idle">
|
||||
<span class="save-dot"></span>
|
||||
<span class="save-label">Gespeichert</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="gridPreview" class="grid-preview"></div>
|
||||
<div class="layout-hint">
|
||||
@@ -1143,12 +1200,25 @@
|
||||
// ============ Toast-Helper ============
|
||||
// Aufruf: toast("Item gelöscht", "success" | "error" | "info" | "warn")
|
||||
// Optional: toast(text, type, duration_ms) — duration 0 = manuell wegklicken
|
||||
function toast(text, type = "info", duration = 3500) {
|
||||
// Optional: toast(text, type, duration_ms, action={label, onClick})
|
||||
// — Toast wird mit action-Button gerendert, Click auf den Button ruft onClick
|
||||
// auf und dismissed den Toast.
|
||||
function toast(text, type = "info", duration = 3500, action = null) {
|
||||
const c = document.getElementById('toastContainer');
|
||||
const el = document.createElement('div');
|
||||
el.className = 'toast ' + type;
|
||||
const iconChar = { success: '✓', error: '✕', info: 'ⓘ', warn: '⚠' }[type] || 'ⓘ';
|
||||
if (action) {
|
||||
el.innerHTML = `<div class="toast-icon">${iconChar}</div>
|
||||
<div class="toast-text"></div>
|
||||
<button class="toast-action">${action.label}</button>`;
|
||||
el.querySelector('.toast-action').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
try { action.onClick(); } finally { remove(); }
|
||||
});
|
||||
} else {
|
||||
el.innerHTML = `<div class="toast-icon">${iconChar}</div><div class="toast-text"></div>`;
|
||||
}
|
||||
el.querySelector('.toast-text').textContent = text;
|
||||
let removed = false;
|
||||
const remove = () => {
|
||||
@@ -1252,8 +1322,50 @@
|
||||
updateClock();
|
||||
|
||||
// ============ DRAG & DROP LAYOUT EDITOR ============
|
||||
async function saveLayout() {
|
||||
// BUG-05: Status-Indikator statt redundantem "Speichern"-Button.
|
||||
// Status-Lebenszyklus:
|
||||
// pending → saving → idle (success) | error
|
||||
setSaveState('saving');
|
||||
const r = await fetch('/api/layout', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ items: layoutItems }),
|
||||
});
|
||||
const j = await r.json();
|
||||
if (!j.ok) {
|
||||
setSaveState('error', 'Fehler: ' + (j.error || '?'));
|
||||
toast('Layout speichern fehlgeschlagen: ' + (j.error || '?'), 'error', 5000);
|
||||
return;
|
||||
}
|
||||
layoutItems = j.items;
|
||||
renderGrid();
|
||||
setTimeout(refreshPreview, 500);
|
||||
setSaveState('idle', 'Gespeichert');
|
||||
// nicht mehr länger toasten — der Indicator reicht
|
||||
}
|
||||
|
||||
// BUG-05: Save-Status-Indikator Helper
|
||||
function setSaveState(state, label) {
|
||||
const el = document.getElementById('saveIndicator');
|
||||
if (!el) return;
|
||||
el.dataset.state = state;
|
||||
const lbl = el.querySelector('.save-label');
|
||||
if (lbl && label) lbl.textContent = label;
|
||||
// Idle-Reset: nach kurzer Anzeige "Gespeichert" zurück
|
||||
if (state === 'saving' && !label) {
|
||||
const lblEl = el.querySelector('.save-label');
|
||||
if (lblEl) lblEl.textContent = 'Speichern…';
|
||||
}
|
||||
if (state === 'idle' && !label) {
|
||||
const lblEl = el.querySelector('.save-label');
|
||||
if (lblEl) lblEl.textContent = 'Gespeichert';
|
||||
}
|
||||
}
|
||||
// BUG-05: debouncedSave setzt pending-Status
|
||||
let saveTimer = null;
|
||||
function debouncedSave() {
|
||||
setSaveState('pending', 'Ungespeichert…');
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(saveLayout, 500);
|
||||
}
|
||||
@@ -1305,7 +1417,6 @@
|
||||
// Place items into their origin cell
|
||||
const occ = occupiedCells();
|
||||
layoutItems.forEach((it, idx) => {
|
||||
const cellIdx = it.x + ',' + it.y;
|
||||
const originCell = cont.querySelector(`[data-cell="${it.x + it.y * gridCols}"]`);
|
||||
|
||||
const div = document.createElement('div');
|
||||
@@ -1331,12 +1442,19 @@
|
||||
<div class="grid-item-resize" data-resize="${idx}" title="Größe ändern (Ecke ziehen)"></div>
|
||||
`;
|
||||
|
||||
if (originCell) {
|
||||
originCell.classList.add('occupied');
|
||||
originCell.appendChild(div);
|
||||
} else {
|
||||
// BUG-03: Span-Geometrie per CSS Grid (grid-column/grid-row) statt
|
||||
// per Cell-DOM-Anker. Item wird direkt in den Grid-Container gehängt,
|
||||
// nicht in die Origin-Cell. Damit:
|
||||
// - NxN-Items rendern visuell über NxN Cells
|
||||
// - Drag-Events auf Nachbar-Cells werden nicht vom Item-DOM
|
||||
// verschluckt (Item ist nicht mehr Kind der Cell)
|
||||
// - Resize (applySize in startResizePointer) kann den Span nahtlos
|
||||
// aktualisieren ohne den DOM-Anker zu wechseln
|
||||
div.style.gridColumn = `${it.x + 1} / span ${it.w}`;
|
||||
div.style.gridRow = `${it.y + 1} / span ${it.h}`;
|
||||
cont.appendChild(div);
|
||||
}
|
||||
// Origin-Cell nur als "occupied" markieren, damit sie ihre leere Optik verliert
|
||||
if (originCell) originCell.classList.add('occupied');
|
||||
});
|
||||
|
||||
// Mark occupied cells
|
||||
@@ -1667,39 +1785,59 @@
|
||||
}
|
||||
const r = await fetch('/api/layout/add', { method: 'POST', body: fd });
|
||||
const j = await r.json();
|
||||
if (!j.ok) { toast('Fehler beim Hinzufügen: ' + (j.error || '?'), 'error', 5000); return; }
|
||||
if (!j.ok) {
|
||||
// BUG-04: 409 = kein Platz mehr, schlage Auto-Pack vor
|
||||
if (r.status === 409 && j.hint === 'use_auto_pack') {
|
||||
toast(j.error || 'Kein Platz', 'warn', 6000);
|
||||
} else {
|
||||
toast('Fehler beim Hinzufügen: ' + (j.error || '?'), 'error', 5000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
layoutItems = j.items;
|
||||
renderGrid();
|
||||
toast('Widget hinzugefügt', 'success', 2000);
|
||||
}
|
||||
async function saveLayout() {
|
||||
const r = await fetch('/api/layout', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ items: layoutItems }),
|
||||
});
|
||||
const j = await r.json();
|
||||
if (!j.ok) { toast('Layout speichern fehlgeschlagen: ' + (j.error || '?'), 'error', 5000); return; }
|
||||
layoutItems = j.items;
|
||||
renderGrid();
|
||||
setTimeout(refreshPreview, 500);
|
||||
toast('Layout gespeichert', 'success');
|
||||
}
|
||||
// BUG-05: alte saveLayout entfernt — siehe oben (mit setSaveState).
|
||||
async function packAll() {
|
||||
const ok = await modalConfirm({
|
||||
icon: 'warn',
|
||||
title: 'Alle Items neu anordnen?',
|
||||
body: 'Auto-Pack sortiert alle Widgets nach Größe in den 4×4-Grid. Bestehende manuelle Positionen gehen verloren.',
|
||||
body: 'Auto-Pack sortiert alle Widgets nach Größe in den 4×4-Grid. Bestehende manuelle Positionen gehen verloren. Du kannst den Schritt 10 Sekunden lang rückgängig machen.',
|
||||
confirmText: 'Neu anordnen',
|
||||
});
|
||||
if (!ok) return;
|
||||
// BUG-06: Snapshot der aktuellen Items in sessionStorage für Undo.
|
||||
// server gibt neue Items zurück; wir können das alte Layout wiederherstellen.
|
||||
const snapshot = JSON.stringify(layoutItems);
|
||||
const r = await fetch('/api/layout/pack', { method: 'POST' });
|
||||
const j = await r.json();
|
||||
if (!j.ok) { toast('Auto-Pack fehlgeschlagen: ' + (j.error || '?'), 'error', 5000); return; }
|
||||
layoutItems = j.items;
|
||||
renderGrid();
|
||||
debouncedSave();
|
||||
toast('Layout automatisch angeordnet', 'success');
|
||||
// BUG-06: Actionable Toast mit Undo-Button
|
||||
toast('Layout automatisch angeordnet', 'success', 10000, {
|
||||
label: 'Rückgängig',
|
||||
onClick: async () => {
|
||||
try {
|
||||
const restored = JSON.parse(snapshot);
|
||||
// restore via normalen Save-Endpunkt
|
||||
const r2 = await fetch('/api/layout', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ items: restored }),
|
||||
});
|
||||
const j2 = await r2.json();
|
||||
if (!j2.ok) { toast('Undo fehlgeschlagen: ' + (j2.error || '?'), 'error', 5000); return; }
|
||||
layoutItems = j2.items;
|
||||
renderGrid();
|
||||
toast('Layout wiederhergestellt', 'info', 3000);
|
||||
} catch (err) {
|
||||
toast('Undo Fehler: ' + err.message, 'error', 5000);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ============ Plugin-Configs ============
|
||||
@@ -1909,6 +2047,32 @@
|
||||
};
|
||||
document.getElementById('netHint').textContent = hints[s.mode] || '';
|
||||
}
|
||||
|
||||
// ---- Power-Status (DeepSleep) ----
|
||||
async function refreshPowerStatus() {
|
||||
try {
|
||||
const r = await fetch('/api/power');
|
||||
const d = await r.json();
|
||||
const sb = document.getElementById('sbPower');
|
||||
if (sb) {
|
||||
if (d.deepsleep_enabled && d.is_deepsleep) {
|
||||
sb.textContent = 'Zz';
|
||||
sb.style.background = '#fef3c7';
|
||||
sb.style.color = '#92400e';
|
||||
} else if (d.deepsleep_enabled) {
|
||||
sb.textContent = 'an';
|
||||
sb.style.background = '';
|
||||
sb.style.color = '';
|
||||
} else {
|
||||
sb.textContent = 'aus';
|
||||
sb.style.background = '';
|
||||
sb.style.color = '';
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
refreshPowerStatus();
|
||||
setInterval(refreshPowerStatus, 30000);
|
||||
async function refreshScan() {
|
||||
const r = await netFetch('/api/network/scan'); if (!r) return;
|
||||
const sel = document.getElementById('ssidSelect');
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Energie — ePaper Dashboard</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: #f4f5f7; color: #1a1a1a; margin: 0; padding: 24px; }
|
||||
h1 { font-size: 22px; margin: 0 0 4px; }
|
||||
h2 { font-size: 15px; font-weight: 600; color: #555; margin: 24px 0 8px;
|
||||
text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.back { float: right; font-size: 13px; color: #2563eb; text-decoration: none; }
|
||||
.card { background: #fff; border: 1px solid #e1e3e8; border-radius: 8px;
|
||||
padding: 20px 24px; max-width: 720px; }
|
||||
.row { display: flex; align-items: center; gap: 12px; padding: 12px 0;
|
||||
border-bottom: 1px solid #f0f1f3; }
|
||||
.row:last-child { border-bottom: none; }
|
||||
.row label { flex: 1; font-size: 14px; }
|
||||
.row .hint { font-size: 12px; color: #888; display: block; margin-top: 2px; }
|
||||
.toggle { position: relative; width: 44px; height: 24px; }
|
||||
.toggle input { opacity: 0; width: 0; height: 0; }
|
||||
.slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: #d1d5db; border-radius: 12px; transition: 0.2s; }
|
||||
.slider::before { content: ""; position: absolute; height: 18px; width: 18px;
|
||||
left: 3px; bottom: 3px; background: white; border-radius: 50%;
|
||||
transition: 0.2s; }
|
||||
.toggle input:checked + .slider { background: #10b981; }
|
||||
.toggle input:checked + .slider::before { transform: translateX(20px); }
|
||||
input[type="time"] { padding: 6px 10px; border: 1px solid #d1d5db;
|
||||
border-radius: 6px; font-size: 14px; }
|
||||
button { background: #2563eb; color: white; border: none; padding: 10px 20px;
|
||||
border-radius: 6px; font-size: 14px; font-weight: 500; cursor: pointer; }
|
||||
button:hover { background: #1d4ed8; }
|
||||
button.secondary { background: #6b7280; }
|
||||
button.secondary:hover { background: #4b5563; }
|
||||
.status { display: inline-block; padding: 4px 10px; border-radius: 4px;
|
||||
font-size: 12px; font-weight: 600; }
|
||||
.status.wach { background: #d1fae5; color: #065f46; }
|
||||
.status.schlaft { background: #fef3c7; color: #92400e; }
|
||||
.pill { display: inline-block; background: #e0e7ff; color: #3730a3;
|
||||
padding: 2px 8px; border-radius: 10px; font-size: 12px;
|
||||
margin-left: 8px; }
|
||||
.footer { margin-top: 16px; font-size: 12px; color: #6b7280; }
|
||||
.pulse { animation: pulse 2s ease-in-out infinite; }
|
||||
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<a class="back" href="/">← zurück zum Dashboard</a>
|
||||
<h1>Energie</h1>
|
||||
<p style="color:#6b7280; font-size:13px; margin:4px 0 0">
|
||||
DeepSleep spart Strom, weil das ePaper das letzte Bild ohne Strom haelt.
|
||||
WiFi bleibt an, damit du das Display jederzeit wecken kannst.
|
||||
</p>
|
||||
|
||||
<div class="card" style="margin-top:20px">
|
||||
<h2>Status <span id="status-pill" class="pill">…</span></h2>
|
||||
<div id="status-text" style="font-size:14px; padding:8px 0">lade…</div>
|
||||
<button class="secondary" id="wake-btn" type="button">Display jetzt wecken</button>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top:20px">
|
||||
<h2>DeepSleep</h2>
|
||||
<form method="post" action="/power">
|
||||
<div class="row">
|
||||
<label>
|
||||
DeepSleep aktivieren
|
||||
<span class="hint">Display bleibt zwischen den Zeiten eingefroren, kein Refresh.</span>
|
||||
</label>
|
||||
<span class="toggle">
|
||||
<input type="checkbox" name="deepsleep_enabled"
|
||||
{% if power.deepsleep_enabled %}checked{% endif %}>
|
||||
<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>
|
||||
<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 class="row">
|
||||
<label>
|
||||
CPU-Taktung drosseln (Pi)
|
||||
<span class="hint">Während DeepSleep auf 600 MHz takten. Spart ~0.5W.
|
||||
Beim Aufwachen wird automatisch auf Normal-Takt zurückgeschaltet.</span>
|
||||
</label>
|
||||
<span class="toggle">
|
||||
<input type="checkbox" name="cpu_throttle"
|
||||
{% if power.cpu_throttle %}checked{% endif %}>
|
||||
<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>
|
||||
<span class="toggle">
|
||||
<input type="checkbox" name="wifi_off"
|
||||
{% if power.wifi_off %}checked{% endif %}>
|
||||
<span class="slider"></span>
|
||||
</span>
|
||||
</div>
|
||||
<div style="padding-top:16px">
|
||||
<button type="submit">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top:20px">
|
||||
<h2>Wie es funktioniert</h2>
|
||||
<ul style="font-size:13px; line-height:1.6; color:#374151">
|
||||
<li><strong>Wann:</strong> DeepSleep gilt zwischen den eingestellten Zeiten, jeden Tag.</li>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
async function refreshStatus() {
|
||||
try {
|
||||
const r = await fetch('/api/power');
|
||||
const d = await r.json();
|
||||
const pill = document.getElementById('status-pill');
|
||||
const text = document.getElementById('status-text');
|
||||
if (d.is_deepsleep) {
|
||||
pill.className = 'pill';
|
||||
pill.style.background = '#fef3c7';
|
||||
pill.style.color = '#92400e';
|
||||
pill.textContent = 'schläft';
|
||||
pill.classList.add('pulse');
|
||||
text.innerHTML = `Display schläft bis <strong>${d.next_wake}</strong> `
|
||||
+ `(${formatDuration(d.seconds_until_wake)} verbleibend)`;
|
||||
} else {
|
||||
pill.className = 'pill';
|
||||
pill.style.background = '#d1fae5';
|
||||
pill.style.color = '#065f46';
|
||||
pill.textContent = 'wach';
|
||||
pill.classList.remove('pulse');
|
||||
text.innerHTML = `Display ist wach. Normaler Refresh-Modus aktiv.`
|
||||
+ (d.deepsleep_enabled ? ` Nächster DeepSleep um ${d.deepsleep_start}.` : '');
|
||||
}
|
||||
} catch (e) {
|
||||
document.getElementById('status-text').textContent = 'Status nicht abrufbar: ' + e;
|
||||
}
|
||||
}
|
||||
function formatDuration(secs) {
|
||||
const h = Math.floor(secs / 3600);
|
||||
const m = Math.floor((secs % 3600) / 60);
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
return `${m}m`;
|
||||
}
|
||||
document.getElementById('wake-btn').addEventListener('click', async () => {
|
||||
const r = await fetch('/api/wake', { method: 'POST' });
|
||||
const d = await r.json();
|
||||
if (d.ok) {
|
||||
document.getElementById('status-text').textContent = 'Wake-Trigger gesendet — Display wacht auf.';
|
||||
setTimeout(refreshStatus, 1000);
|
||||
} else {
|
||||
alert('Wake fehlgeschlagen: ' + d.error);
|
||||
}
|
||||
});
|
||||
refreshStatus();
|
||||
setInterval(refreshStatus, 30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,125 @@
|
||||
"""RED-Test für BUG-04: Verifiziert dass die echte admin.py /api/layout/add
|
||||
Route die bestehende pack()-Semantik aufruft (also BUG bestätigt).
|
||||
|
||||
Verwendet Flask test_client, kein Live-Server.
|
||||
"""
|
||||
import sys, os, json, tempfile
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
# admin.py benutzt dashboard_mod.load_config etc. — wir mocken das minimal.
|
||||
import unittest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
|
||||
def make_admin_app():
|
||||
"""Importiert admin mit gemockten dashboard-Funktionen. Singleton — beim
|
||||
zweiten Aufruf wird der bestehende Mock wiederverwendet, damit Tests sich
|
||||
gegenseitig konfigurieren können."""
|
||||
import importlib
|
||||
import types
|
||||
|
||||
# bestehender Mock? dann wiederverwenden
|
||||
existing = sys.modules.get("dashboard")
|
||||
if existing is None:
|
||||
fake_dashboard = types.ModuleType("dashboard")
|
||||
fake_dashboard.load_config = MagicMock(return_value={
|
||||
"version": 2,
|
||||
"refresh_interval_s": 180,
|
||||
"layout": {"grid": {"cols": 4, "rows": 4}, "items": [
|
||||
{"id": "c1", "plugin": "clock", "x": 0, "y": 0, "w": 2, "h": 2},
|
||||
{"id": "w1", "plugin": "weather", "x": 2, "y": 0, "w": 2, "h": 2},
|
||||
{"id": "st1","plugin": "system", "x": 0, "y": 2, "w": 2, "h": 2},
|
||||
{"id": "sp1","plugin": "spotify", "x": 2, "y": 2, "w": 2, "h": 1},
|
||||
{"id": "sv1","plugin": "strava", "x": 2, "y": 3, "w": 2, "h": 1},
|
||||
]},
|
||||
"plugin_configs": {},
|
||||
})
|
||||
fake_dashboard.save_config = MagicMock()
|
||||
fake_dashboard.get_widget_classes = MagicMock(return_value={"hello": MagicMock()})
|
||||
sys.modules["dashboard"] = fake_dashboard
|
||||
else:
|
||||
fake_dashboard = existing
|
||||
|
||||
admin = importlib.import_module("admin")
|
||||
admin.app.config["TESTING"] = True
|
||||
admin.require_auth = lambda: None
|
||||
return admin, fake_dashboard
|
||||
|
||||
|
||||
class TestAddRouteDoesNotRepack(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# Reset Mock-Return auf den Default vor jedem Test
|
||||
admin, fake = make_admin_app()
|
||||
self._default_cfg = {
|
||||
"version": 2,
|
||||
"refresh_interval_s": 180,
|
||||
"layout": {"grid": {"cols": 4, "rows": 4}, "items": [
|
||||
{"id": "c1", "plugin": "clock", "x": 0, "y": 0, "w": 2, "h": 2},
|
||||
{"id": "w1", "plugin": "weather", "x": 2, "y": 0, "w": 2, "h": 2},
|
||||
{"id": "st1","plugin": "system", "x": 0, "y": 2, "w": 2, "h": 2},
|
||||
{"id": "sp1","plugin": "spotify", "x": 2, "y": 2, "w": 2, "h": 1},
|
||||
{"id": "sv1","plugin": "strava", "x": 2, "y": 3, "w": 2, "h": 1},
|
||||
]},
|
||||
"plugin_configs": {},
|
||||
}
|
||||
fake.load_config.return_value = self._default_cfg
|
||||
fake.save_config.reset_mock()
|
||||
self.admin, self.fake = admin, fake
|
||||
self.client = admin.app.test_client()
|
||||
|
||||
def test_add_1x1_into_gap_does_not_move_existing(self):
|
||||
"""Bestehende config mit Lücke bei (2,3),(3,3). Add hello (1x1) soll
|
||||
auf (2,3) gehen (erste scan-line freie Zelle) — alle anderen UNVERÄNDERT."""
|
||||
self.fake.load_config.return_value = {
|
||||
"version": 2,
|
||||
"refresh_interval_s": 180,
|
||||
"layout": {"grid": {"cols": 4, "rows": 4}, "items": [
|
||||
{"id": "c1", "plugin": "clock", "x": 0, "y": 0, "w": 2, "h": 2},
|
||||
{"id": "w1", "plugin": "weather", "x": 2, "y": 0, "w": 2, "h": 2},
|
||||
{"id": "st1","plugin": "system", "x": 0, "y": 2, "w": 2, "h": 2},
|
||||
{"id": "sp1","plugin": "spotify", "x": 2, "y": 2, "w": 1, "h": 1},
|
||||
{"id": "sv1","plugin": "strava", "x": 3, "y": 2, "w": 1, "h": 1},
|
||||
]},
|
||||
"plugin_configs": {},
|
||||
}
|
||||
|
||||
r = self.client.post("/api/layout/add", data={"plugin": "hello"})
|
||||
self.assertEqual(r.status_code, 200, r.get_data(as_text=True))
|
||||
j = r.get_json()
|
||||
self.assertTrue(j["ok"], j)
|
||||
items = j["items"]
|
||||
by_id = {it["id"]: it for it in items}
|
||||
# Existierende UNVERÄNDERT
|
||||
self.assertEqual((by_id["c1"]["x"], by_id["c1"]["y"]), (0, 0), "clock moved!")
|
||||
self.assertEqual((by_id["w1"]["x"], by_id["w1"]["y"]), (2, 0), "weather moved!")
|
||||
self.assertEqual((by_id["st1"]["x"], by_id["st1"]["y"]), (0, 2), "system moved!")
|
||||
self.assertEqual((by_id["sp1"]["x"], by_id["sp1"]["y"]), (2, 2), "spotify moved!")
|
||||
self.assertEqual((by_id["sv1"]["x"], by_id["sv1"]["y"]), (3, 2), "strava moved!")
|
||||
# Neues hello auf (2,3) — erste scan-line freie Zelle
|
||||
new_items = [it for it in items if it["plugin"] == "hello" and it["id"] not in ("c1","w1","st1","sp1","sv1")]
|
||||
self.assertEqual(len(new_items), 1, f"expected exactly 1 new hello, got {new_items}")
|
||||
new = new_items[0]
|
||||
self.assertEqual((new["x"], new["y"]), (2, 3),
|
||||
f"expected new hello at (2,3), got ({new['x']},{new['y']}); full: {items}")
|
||||
|
||||
def test_add_to_full_grid_returns_409(self):
|
||||
"""Wenn kein Platz: 409, keine bestehenden Items verändert."""
|
||||
self.fake.load_config.return_value["layout"]["items"] = [
|
||||
{"id": f"f{i}", "plugin": "x", "x": (i % 4), "y": (i // 4),
|
||||
"w": 1, "h": 1} for i in range(16)
|
||||
]
|
||||
before = [(it["id"], it["x"], it["y"]) for it in
|
||||
self.fake.load_config.return_value["layout"]["items"]]
|
||||
r = self.client.post("/api/layout/add", data={"plugin": "hello"})
|
||||
self.assertEqual(r.status_code, 409, r.get_data(as_text=True))
|
||||
j = r.get_json()
|
||||
self.assertFalse(j["ok"])
|
||||
self.assertIn("Auto-Pack", j.get("error", ""))
|
||||
# In-memory config unverändert
|
||||
after = [(it["id"], it["x"], it["y"]) for it in
|
||||
self.fake.load_config.return_value["layout"]["items"]]
|
||||
self.assertEqual(before, after, "full-grid add still mutated state")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -0,0 +1,58 @@
|
||||
// BUG-06 Test: Auto-Pack Undo via Snapshot
|
||||
//
|
||||
// Erwartung:
|
||||
// 1) packAll() speichert layoutItems als JSON vor dem Pack
|
||||
// 2) Toast wird mit action={label:"Rückgängig", onClick} aufgerufen (statt nur Text)
|
||||
// 3) onClick sendet POST /api/layout mit den snapshot-Items
|
||||
// 4) Modal-Text erwähnt "10 Sekunden lang rückgängig"
|
||||
// 5) CSS: .toast-action Klasse vorhanden
|
||||
// 6) toast()-Funktion unterstützt action-Parameter (4-Args-Signatur)
|
||||
|
||||
const fs = require('fs');
|
||||
const html = fs.readFileSync('templates/index.html', 'utf8');
|
||||
const js = html.match(/<script>\s*\n([\s\S]*?)<\/script>/)[1];
|
||||
|
||||
function check(name, fn) {
|
||||
const r = fn();
|
||||
console.log((r ? '✓' : '✗') + ' ' + name);
|
||||
if (!r) process.exitCode = 1;
|
||||
}
|
||||
|
||||
// 1) Snapshot wird erstellt (JSON.stringify vor pack-Call)
|
||||
check('packAll speichert Snapshot (JSON.stringify(layoutItems))', () =>
|
||||
/async function packAll[\s\S]*?JSON\.stringify\(layoutItems\)/.test(js));
|
||||
|
||||
// 2) Toast mit action-Parameter
|
||||
check('Toast wird mit action-Parameter aufgerufen', () =>
|
||||
/toast\([^,]+,\s*['"]success['"],\s*\d+,\s*\{[\s\S]*?label:\s*['"]Rückgängig['"]/.test(js));
|
||||
|
||||
// 3) Undo-Click ruft POST /api/layout mit snapshot
|
||||
check('Undo ruft POST /api/layout mit snapshot-Items', () =>
|
||||
/fetch\(['"]\/api\/layout['"][\s\S]*?JSON\.parse\(snapshot\)/.test(js));
|
||||
|
||||
// 4) Modal-Body erwähnt Undo-Möglichkeit
|
||||
check('Modal-Body erwähnt "rückgängig"', () =>
|
||||
/rückgängig machen/i.test(js));
|
||||
|
||||
// 5) CSS: .toast-action Klasse
|
||||
check('.toast-action CSS-Klasse vorhanden', () => {
|
||||
const css = html.match(/<style>([\s\S]*?)<\/style>/g).join('\n');
|
||||
return /\.toast-action\s*\{/.test(css);
|
||||
});
|
||||
|
||||
// 6) toast() hat 4-Args Signatur
|
||||
check('toast() unterstützt 4-Args (action-Parameter)', () =>
|
||||
/function toast\(\s*text[^)]*\)\s*\{/.test(js) && /action\s*=/.test(js));
|
||||
|
||||
// 7) Undo-Button ruft bei Click onClick und dismissed Toast
|
||||
check('Action-Button: Click ruft onClick + dismiss', () =>
|
||||
/\.toast-action['"]\)\.addEventListener\(['"]click['"][\s\S]*?action\.onClick\(\)/.test(js));
|
||||
|
||||
// 8) Pointer-events:auto auf Action-Button (sonst klickt Toast-Click-Handler)
|
||||
check('.toast-action hat pointer-events:auto', () => {
|
||||
const css = html.match(/<style>([\s\S]*?)<\/style>/g).join('\n');
|
||||
return /\.toast-action\s*\{[^}]*pointer-events:\s*auto/.test(css);
|
||||
});
|
||||
|
||||
console.log('\n========');
|
||||
process.exit(process.exitCode || 0);
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Tests für layout.first_fit (BUG-04).
|
||||
|
||||
Behauptung: Add-Item darf bestehende Items NICHT verschieben.
|
||||
- Leeres Layout → Add 2×2 hello → Position (0,0), kein Pack
|
||||
- Layout mit Lücke → Add 1×1 hello → landet in erster Lücke, andere bleiben
|
||||
- Layout voll → Add → None, kein anderes Item verändert
|
||||
"""
|
||||
import os, sys
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import unittest
|
||||
from layout import Item, first_fit, GRID_COLS, GRID_ROWS
|
||||
|
||||
|
||||
class TestFirstFit(unittest.TestCase):
|
||||
def test_first_fit_empty_grid(self):
|
||||
"""Auf leerem Grid wird Item bei (0,0) platziert — kein Pack nötig."""
|
||||
new_item = Item("new1", "hello", 0, 0, 1, 1)
|
||||
placed = first_fit(new_item, [])
|
||||
self.assertIsNotNone(placed)
|
||||
self.assertEqual((placed.x, placed.y), (0, 0))
|
||||
|
||||
def test_first_fit_does_not_move_existing(self):
|
||||
"""Items mit Lücke: hello wird in Lücke gesetzt, andere bleiben."""
|
||||
existing = [
|
||||
Item("a", "clock", 0, 0, 2, 2),
|
||||
Item("b", "weather", 2, 0, 2, 2),
|
||||
Item("c", "system", 0, 2, 2, 2),
|
||||
Item("d", "spotify", 2, 2, 2, 1),
|
||||
]
|
||||
before = {(it.id, it.x, it.y) for it in existing}
|
||||
new_item = Item("new", "hello", 0, 0, 1, 1)
|
||||
placed = first_fit(new_item, existing)
|
||||
self.assertIsNotNone(placed)
|
||||
after = {(it.id, it.x, it.y) for it in existing}
|
||||
self.assertEqual(before, after, f"EXISTING MOVED! before={before} after={after}")
|
||||
self.assertEqual((placed.x, placed.y), (2, 3))
|
||||
|
||||
def test_first_fit_full_grid_returns_none(self):
|
||||
"""Wenn kein Platz: None zurück, keine Mutation."""
|
||||
existing = [
|
||||
Item("a", "x", 0, 0, 2, 2),
|
||||
Item("b", "y", 2, 0, 2, 2),
|
||||
Item("c", "z", 0, 2, 2, 2),
|
||||
Item("d", "w", 2, 2, 2, 2),
|
||||
]
|
||||
before = {(it.id, it.x, it.y, it.w, it.h) for it in existing}
|
||||
new_item = Item("new", "hello", 0, 0, 1, 1)
|
||||
placed = first_fit(new_item, existing)
|
||||
self.assertIsNone(placed)
|
||||
after = {(it.id, it.x, it.y, it.w, it.h) for it in existing}
|
||||
self.assertEqual(before, after)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -0,0 +1,74 @@
|
||||
// BUG-05 Test: Speichern-Button entfernt, Save-Indicator hinzugefügt.
|
||||
//
|
||||
// Erwartung:
|
||||
// 1) KEIN <button onclick="saveLayout()"> im Layout-Toolbar mehr
|
||||
// 2) NEUER #saveIndicator im Toolbar mit data-state="idle"
|
||||
// 3) setSaveState() existiert und akzeptiert (state, label?)
|
||||
// 4) setSaveState setzt el.dataset.state und label-Text
|
||||
// 5) debouncedSave setzt Status auf "pending" BEVOR es speichert
|
||||
// 6) saveLayout setzt Status auf "saving" am Anfang, "idle" am Ende (success)
|
||||
// 7) saveLayout setzt Status auf "error" bei j.ok=false
|
||||
// 8) CSS: .save-indicator[data-state="..."] für idle/pending/saving/error
|
||||
|
||||
const fs = require('fs');
|
||||
const html = fs.readFileSync('templates/index.html', 'utf8');
|
||||
const js = html.match(/<script>\s*\n([\s\S]*?)<\/script>/)[1];
|
||||
const css = html.match(/<style>([\s\S]*?)<\/style>/g).join('\n');
|
||||
|
||||
function check(name, fn) {
|
||||
const r = fn();
|
||||
console.log((r ? '✓' : '✗') + ' ' + name);
|
||||
if (!r) process.exitCode = 1;
|
||||
}
|
||||
|
||||
// 1) Kein Speichern-Button mehr
|
||||
check('Speichern-Button entfernt', () => {
|
||||
// Suchen wir den toolbar-Bereich
|
||||
const toolbarMatch = html.match(/<div class="layout-toolbar">([\s\S]*?)<\/div>\s*<div id="gridPreview"/);
|
||||
if (!toolbarMatch) return false; // Struktur geändert?
|
||||
return !/onclick="saveLayout\(\)"/.test(toolbarMatch[1]);
|
||||
});
|
||||
|
||||
// 2) Save-Indicator
|
||||
check('Save-Indicator #saveIndicator vorhanden', () =>
|
||||
/id="saveIndicator"/.test(html) && /data-state="idle"/.test(html));
|
||||
|
||||
// 3) setSaveState existiert
|
||||
check('setSaveState() Funktion definiert', () =>
|
||||
/function setSaveState\(/.test(js));
|
||||
|
||||
// 4) setSaveState setzt state und label
|
||||
check('setSaveState setzt dataset.state', () =>
|
||||
/setSaveState[\s\S]{0,300}dataset\.state/.test(js));
|
||||
|
||||
// 5) debouncedSave setzt pending
|
||||
check('debouncedSave setzt pending-Status', () =>
|
||||
/function debouncedSave\(\)\s*\{[^}]*setSaveState\(['"]pending['"]/.test(js));
|
||||
|
||||
// 6) saveLayout setzt saving→idle
|
||||
check('saveLayout setzt saving am Anfang', () =>
|
||||
/async function saveLayout\(\)[\s\S]{0,300}setSaveState\(['"]saving['"]/.test(js));
|
||||
|
||||
check('saveLayout setzt idle bei Erfolg', () =>
|
||||
/saveLayout[\s\S]{0,800}setSaveState\(['"]idle['"]/.test(js));
|
||||
|
||||
// 7) saveLayout setzt error bei Fehler
|
||||
check('saveLayout setzt error bei j.ok=false', () =>
|
||||
/!j\.ok[\s\S]{0,300}setSaveState\(['"]error['"]/.test(js));
|
||||
|
||||
// 8) CSS für alle States
|
||||
const cssStates = ['idle', 'pending', 'saving', 'error'].map(s =>
|
||||
new RegExp(`\\.save-indicator\\[data-state="${s}"\\]`));
|
||||
check('CSS für alle 4 Indicator-States', () =>
|
||||
cssStates.every(re => re.test(css)));
|
||||
|
||||
// 9) delete success-toast raus (Indicator reicht)
|
||||
check('Kein success-toast in saveLayout mehr', () =>
|
||||
!/function saveLayout[\s\S]{0,1000}toast\([^)]*['"]success['"][^)]*['"]Layout gespeichert['"]/.test(js));
|
||||
|
||||
// 10) margin-left:auto für rechtsbündige Position
|
||||
check('Save-Indicator rechtsbündig (margin-left:auto)', () =>
|
||||
/\.save-indicator\s*\{[^}]*margin-left:\s*auto/.test(css));
|
||||
|
||||
console.log('\n========');
|
||||
process.exit(process.exitCode || 0);
|
||||
@@ -0,0 +1,56 @@
|
||||
// Test für BUG-03: Span-Geometrie im Initial-Render.
|
||||
//
|
||||
// Erwartung nach Fix:
|
||||
// 1) renderGrid setzt grid-column/grid-row am Item direkt (per JS).
|
||||
// 2) Item wird in den Container gehängt (cont.appendChild), nicht in originCell.
|
||||
// 3) Origin-Cell bekommt nur die "occupied"-Klasse.
|
||||
// 4) 2x2-Item hat style.gridColumn === '<x+1> / span 2' und gridRow === '<y+1> / span 2'.
|
||||
|
||||
const fs = require('fs');
|
||||
const html = fs.readFileSync('templates/index.html', 'utf8');
|
||||
const js = html.match(/<script>\s*\n([\s\S]*?)<\/script>/)[1];
|
||||
|
||||
function check(name, fn) {
|
||||
const r = fn();
|
||||
console.log((r ? '✓' : '✗') + ' ' + name);
|
||||
if (!r) process.exitCode = 1;
|
||||
}
|
||||
|
||||
// 1) renderGrid setzt style.gridColumn/gridRow am Item
|
||||
check('gridColumn wird per JS gesetzt', () =>
|
||||
/\.style\.gridColumn\s*=/.test(js));
|
||||
|
||||
check('gridRow wird per JS gesetzt', () =>
|
||||
/\.style\.gridRow\s*=/.test(js));
|
||||
|
||||
// 2) Item wird in cont.appendChild gehängt, NICHT in originCell.appendChild
|
||||
check('Item wird in Container (cont) gehängt', () =>
|
||||
/cont\.appendChild\(div\)/.test(js));
|
||||
|
||||
check('Item wird NICHT mehr in Origin-Cell gehängt', () =>
|
||||
!/originCell\.appendChild\(div\)/.test(js));
|
||||
|
||||
// 3) Origin-Cell bekommt nur occupied-Klasse
|
||||
check('Origin-Cell bekommt "occupied" Klasse', () =>
|
||||
/originCell\.classList\.add\(['"]occupied['"]\)/.test(js));
|
||||
|
||||
// 4) Format: `${it.x + 1} / span ${it.w}` (CSS-Grid-Notation)
|
||||
check('gridColumn Format: <x+1> / span <w>', () =>
|
||||
/it\.x\s*\+\s*1[^`]*\$\{it\.w\}/.test(js) || /\$\{it\.x\s*\+\s*1\}[^`]*span[^`]*\$\{it\.w\}/.test(js));
|
||||
|
||||
// 5) Kein 100%/100% Trick auf Items (das war der Bug, der Span verhindert hat)
|
||||
check('Keine "width: 100%" mehr im Item-CSS-Block', () => {
|
||||
const css = html.match(/<style>([\s\S]*?)<\/style>/g).join('\n');
|
||||
// .grid-item soll nicht width:100% haben
|
||||
const itemCssMatch = /\.grid-item\s*\{([^}]*)\}/.exec(css);
|
||||
if (!itemCssMatch) return true; // falls keine Regel
|
||||
const body = itemCssMatch[1];
|
||||
return !/width:\s*100%/.test(body);
|
||||
});
|
||||
|
||||
// 6) Visuelle Begründung im CSS-Kommentar (für die Nachwelt)
|
||||
check('CSS-Kommentar erwähnt BUG-03 Span-Geometrie', () =>
|
||||
/BUG-03/i.test(html));
|
||||
|
||||
console.log('\n========');
|
||||
process.exit(process.exitCode || 0);
|
||||
@@ -0,0 +1,293 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Netatmo OAuth Setup - ein simpler Web-Endpoint den der User aufruft.
|
||||
|
||||
Ablauf (Standard OAuth2 Authorization Code Flow):
|
||||
1. User öffnet die unten gedruckte URL im Browser.
|
||||
2. Netatmo zeigt Login → User loggt sich ein → User klickt "Authorize".
|
||||
3. Netatmo leitet zurück auf den hier laufenden Callback-Server.
|
||||
4. Server tauscht den Code gegen access+refresh Token (API-Call,
|
||||
Content-Type: application/x-www-form-urlencoded, exakt nach Doku).
|
||||
5. refresh_token wird in config.json gespeichert.
|
||||
6. Eine Bestätigungsseite wird im Browser angezeigt.
|
||||
|
||||
Voraussetzungen (einmalig, vom User gemacht):
|
||||
- Redirect-URI http://<pi-ip>:8765/callback muss in der Netatmo-App
|
||||
auf https://dev.netatmo.com/apps/ registriert sein.
|
||||
|
||||
Verwendung:
|
||||
python3 tools/netatmo_auth.py
|
||||
-> gibt die URL aus, die der User im Browser öffnen soll.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import http.server
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import io
|
||||
from pathlib import Path
|
||||
|
||||
# Exakt nach Netatmo-Doku: https://dev.netatmo.com/apidocumentation/oauth
|
||||
TOKEN_URL = "https://api.netatmo.com/oauth2/token"
|
||||
AUTHORIZE_URL = "https://api.netatmo.com/oauth2/authorize"
|
||||
DEFAULT_SCOPE = "read_station"
|
||||
CONFIG_PATH = Path(__file__).resolve().parent.parent / "config.json"
|
||||
|
||||
|
||||
def detect_lan_ip() -> str:
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
s.connect(("8.8.8.8", 80))
|
||||
return s.getsockname()[0]
|
||||
finally:
|
||||
s.close()
|
||||
except Exception:
|
||||
return "127.0.0.1"
|
||||
|
||||
|
||||
def free_port(port: int = 8765) -> int:
|
||||
"""Findet einen freien Port. Versucht erst den gewünschten, dann
|
||||
die nächsten 20 Ports. Überspringt Ports die im LISTEN-State sind.
|
||||
|
||||
Hinweis: TIME_WAIT-Ports können kurzfristig ein bind() blockieren
|
||||
auch wenn sie in `ss` nicht als LISTEN auftauchen — das ist ok,
|
||||
dann gehen wir einfach zum nächsten Port.
|
||||
"""
|
||||
import subprocess
|
||||
out = subprocess.run(["ss", "-lnt"], capture_output=True, text=True).stdout
|
||||
listening = set()
|
||||
for line in out.splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) >= 4 and parts[0] == "LISTEN":
|
||||
local = parts[3]
|
||||
if ":" in local:
|
||||
try:
|
||||
listening.add(int(local.rsplit(":", 1)[1]))
|
||||
except ValueError:
|
||||
pass
|
||||
for p in [port] + list(range(port + 1, port + 50)):
|
||||
if p in listening:
|
||||
continue
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
try:
|
||||
s.bind(("0.0.0.0", p))
|
||||
return p
|
||||
except OSError:
|
||||
continue
|
||||
raise RuntimeError(f"Kein freier Port zwischen {port} und {port+50} gefunden")
|
||||
|
||||
|
||||
def load_existing_config() -> dict:
|
||||
if not CONFIG_PATH.exists():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(CONFIG_PATH.read_text())
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def save_refresh_token(refresh_token: str, client_id: str, client_secret: str) -> dict:
|
||||
cfg = load_existing_config()
|
||||
cfg.setdefault("plugin_configs", {})
|
||||
netatmo_cfg = cfg["plugin_configs"].setdefault("netatmo", {})
|
||||
netatmo_cfg["client_id"] = client_id
|
||||
netatmo_cfg["client_secret"] = client_secret
|
||||
netatmo_cfg["refresh_token"] = refresh_token
|
||||
CONFIG_PATH.write_text(json.dumps(cfg, indent=2))
|
||||
return cfg
|
||||
|
||||
|
||||
def exchange_code_for_tokens(code: str, client_id: str, client_secret: str,
|
||||
redirect_uri: str) -> dict:
|
||||
"""Authorization Code → Access+Refresh Token via API-Call.
|
||||
|
||||
Exakt nach Netatmo-Doku: POST /oauth2/token mit
|
||||
Content-Type: application/x-www-form-urlencoded;charset=UTF-8
|
||||
|
||||
WICHTIG: redirect_uri muss EXAKT der Wert sein der beim /authorize-Aufruf
|
||||
benutzt wurde. Wenn der eine 10.11.3.144 war, muss der hier auch
|
||||
10.11.3.144 sein, sonst 'invalid_grant'.
|
||||
"""
|
||||
data = urllib.parse.urlencode({
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"code": code,
|
||||
"redirect_uri": redirect_uri,
|
||||
"scope": DEFAULT_SCOPE,
|
||||
}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
TOKEN_URL, data=data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
|
||||
"Accept": "application/json"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as r:
|
||||
return json.loads(r.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode("utf-8", errors="ignore")
|
||||
raise SystemExit(f"Token-Exchange fehlgeschlagen: HTTP {e.code}\n{body}")
|
||||
|
||||
|
||||
def run_callback_server(port: int, expected_state: str, client_id: str,
|
||||
client_secret: str, redirect_uri: str) -> None:
|
||||
"""Startet einen HTTP-Server, der den Callback empfängt und den Token speichert.
|
||||
|
||||
Läuft bis ein Token gespeichert wurde (max 5 Minuten)."""
|
||||
saved = {}
|
||||
|
||||
def make_response(status: int, body: bytes, content_type: str = "text/html; charset=utf-8"):
|
||||
return (status, [("Content-Type", content_type)], body)
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def log_message(self, format, *args):
|
||||
# Logge jeden Request damit wir sehen was passiert
|
||||
print(f" >> {self.command} {self.path} from {self.client_address[0]}")
|
||||
|
||||
def do_GET(self):
|
||||
parsed = urllib.parse.urlparse(self.path)
|
||||
qs = urllib.parse.parse_qs(parsed.query)
|
||||
print(f" qs: {dict(qs)}")
|
||||
if "error" in qs:
|
||||
err = qs.get("error", ["unknown"])[0]
|
||||
status, hdrs, body = make_response(400, (
|
||||
f"<h1>Fehler bei Netatmo-Authentifizierung</h1>"
|
||||
f"<p>Grund: {err}</p>"
|
||||
f"<p>Du kannst dieses Fenster schliessen und es nochmal "
|
||||
f"probieren.</p>").encode())
|
||||
self.send_response(status)
|
||||
for k, v in hdrs: self.send_header(k, v)
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
saved["error"] = err
|
||||
return
|
||||
if "code" not in qs:
|
||||
# Health-Check oder 404
|
||||
self.send_response(204)
|
||||
self.end_headers()
|
||||
return
|
||||
if qs.get("state", [None])[0] != expected_state:
|
||||
status, hdrs, body = make_response(400, b"<h1>State mismatch (CSRF-Schutz)</h1>")
|
||||
self.send_response(status)
|
||||
for k, v in hdrs: self.send_header(k, v)
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
saved["error"] = "state_mismatch"
|
||||
return
|
||||
code = qs["code"][0]
|
||||
print(f" code erhalten, tausche gegen Token...")
|
||||
try:
|
||||
tok = exchange_code_for_tokens(code, client_id, client_secret, redirect_uri)
|
||||
refresh_token = tok["refresh_token"]
|
||||
save_refresh_token(refresh_token, client_id, client_secret)
|
||||
saved["refresh_token"] = refresh_token
|
||||
print(f" refresh_token gespeichert!")
|
||||
html = (
|
||||
"<h1 style='color:green'>OK!</h1>"
|
||||
"<p>Refresh-Token wurde in config.json gespeichert.</p>"
|
||||
"<p>Das Netatmo-Plugin ist jetzt aktiv. Du kannst dieses "
|
||||
"Fenster schliessen.</p>"
|
||||
f"<p style='font-family:monospace;font-size:0.8em;color:#666'>"
|
||||
f"refresh_token: {refresh_token[:24]}...</p>"
|
||||
).encode()
|
||||
status, hdrs, body = make_response(200, html)
|
||||
except SystemExit as e:
|
||||
saved["error"] = str(e)
|
||||
status, hdrs, body = make_response(500, (
|
||||
f"<h1 style='color:red'>Token-Exchange fehlgeschlagen</h1>"
|
||||
f"<pre>{e}</pre>").encode())
|
||||
self.send_response(status)
|
||||
for k, v in hdrs: self.send_header(k, v)
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
server = http.server.HTTPServer(("0.0.0.0", port), Handler)
|
||||
server.timeout = 300 # 5 min
|
||||
print(f"Server listening on 0.0.0.0:{port}, warte auf Callback...",
|
||||
flush=True)
|
||||
# Läuft bis refresh_token gespeichert oder 5 Minuten Timeout
|
||||
end_time = time.time() + 300
|
||||
while time.time() < end_time and "refresh_token" not in saved:
|
||||
server.handle_request()
|
||||
return saved
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Netatmo OAuth Setup")
|
||||
parser.add_argument("--cid", help="Client-ID")
|
||||
parser.add_argument("--csec", help="Client-Secret")
|
||||
parser.add_argument("--redirect", default=None,
|
||||
help="Redirect-URI (default: http://<lan-ip>:8765/callback)")
|
||||
parser.add_argument("--port", type=int, default=8765)
|
||||
parser.add_argument("--scope", default=DEFAULT_SCOPE)
|
||||
args = parser.parse_args()
|
||||
|
||||
existing = load_existing_config().get("plugin_configs", {}).get("netatmo", {})
|
||||
client_id = args.cid or existing.get("client_id") or input("Netatmo Client-ID: ").strip()
|
||||
client_secret = args.csec or existing.get("client_secret") or input("Netatmo Client-Secret: ").strip()
|
||||
if not (client_id and client_secret):
|
||||
raise SystemExit("Client-ID und Client-Secret erforderlich.")
|
||||
|
||||
port = free_port(args.port)
|
||||
lan_ip = detect_lan_ip()
|
||||
if args.redirect:
|
||||
redirect = args.redirect
|
||||
else:
|
||||
redirect = f"http://{lan_ip}:{port}/callback"
|
||||
|
||||
import secrets
|
||||
state = secrets.token_urlsafe(16)
|
||||
|
||||
auth_url = (
|
||||
f"{AUTHORIZE_URL}"
|
||||
f"?client_id={urllib.parse.quote(client_id)}"
|
||||
f"&redirect_uri={urllib.parse.quote(redirect)}"
|
||||
f"&scope={urllib.parse.quote(args.scope)}"
|
||||
f"&state={urllib.parse.quote(state)}"
|
||||
f"&response_type=code"
|
||||
)
|
||||
|
||||
print()
|
||||
print("=" * 70)
|
||||
print(" NETATMO OAUTH SETUP")
|
||||
print("=" * 70)
|
||||
print()
|
||||
print("Voraussetzung: Die folgende Redirect-URI muss EXAKT in deiner")
|
||||
print("Netatmo-App auf https://dev.netatmo.com/apps/ registriert sein:")
|
||||
print()
|
||||
print(f" {redirect}")
|
||||
print()
|
||||
print("=" * 70)
|
||||
print(" BITTE IM BROWSER OEFFNEN:")
|
||||
print("=" * 70)
|
||||
print()
|
||||
print(auth_url)
|
||||
print()
|
||||
print("=" * 70)
|
||||
print(" Nach dem Authorize-Klick landest du auf der Login-Seite,")
|
||||
print(" loggst dich ein, klickst 'Authorize', und wirst zurueckgeleitet.")
|
||||
print(" Der Server holt dann den Token und speichert ihn in config.json.")
|
||||
print("=" * 70)
|
||||
print()
|
||||
|
||||
result = run_callback_server(port, state, client_id, client_secret, redirect)
|
||||
if "refresh_token" in result:
|
||||
print()
|
||||
print("ERFOLG! Refresh-Token gespeichert in", CONFIG_PATH)
|
||||
print()
|
||||
sys.exit(0)
|
||||
elif "error" in result:
|
||||
print()
|
||||
print("FEHLER:", result["error"], file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,63 @@
|
||||
"""Verifizierungs-Script: rendert Netatmo mit dynamischen Mock-Daten.
|
||||
|
||||
Findet und behebt:
|
||||
1. Statische Forecast-Daten (jetzt relativ zu heute)
|
||||
2. Synthetische Outdoor-History mit groesserer Range (damit Chart nicht flach)
|
||||
3. Bessere Indoor Batt-Darstellung
|
||||
"""
|
||||
import sys
|
||||
sys.path.insert(0, '.')
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from plugins.netatmo import Widget
|
||||
from datetime import datetime, timedelta
|
||||
import math
|
||||
import os
|
||||
|
||||
# FIX 1: Mock-Daten mit dynamischen Daten (heute + 2 Tage)
|
||||
now = datetime.now()
|
||||
forecast_days = [(now + timedelta(days=i)).date().isoformat() for i in range(3)]
|
||||
|
||||
# FIX 2: Synthetische Outdoor-History mit groesserem Temperatur-Range
|
||||
# 12h-Verlauf mit sin-voller Swing (12.5 - 22.5 Grad, also 10 Grad Range)
|
||||
outdoor_temps = []
|
||||
for h in range(12):
|
||||
t = 17.5 + 4.5 * math.sin(h / 2.2) + 1.0 * math.cos(h / 1.3)
|
||||
outdoor_temps.append(round(t, 1))
|
||||
temp_history = ';'.join(str(t) for t in outdoor_temps)
|
||||
print(f'History: min={min(outdoor_temps)}, max={max(outdoor_temps)}, n={len(outdoor_temps)}')
|
||||
|
||||
mock = {
|
||||
'station_name': 'Daheim', 'place': {},
|
||||
'main': {'name': 'Daheim', 'data': {'Temperature': 21.5, 'CO2': 620, 'Humidity': 48, 'Pressure': 1014, 'Pressure_trend': 'down'}},
|
||||
'modules': [
|
||||
# Aussentemperatur = letzter Wert der History
|
||||
{'type': 'NAModule1', 'name': 'Aussen', 'data': {'Temperature': outdoor_temps[-1], 'Humidity': 67, 'Temp_history': temp_history}, 'battery_pct': 78},
|
||||
{'type': 'NAModule2', 'name': 'Wind', 'data': {'WindStrength': 12, 'GustStrength': 24, 'WindAngle': 215}, 'battery_pct': 65},
|
||||
{'type': 'NAModule3', 'name': 'Regen', 'data': {'RainRate': 0.0, 'sum_rain_1': 0.4, 'sum_rain_24': 6.2}, 'battery_pct': 82},
|
||||
{'type': 'NAModule4', 'name': 'Kueche', 'data': {'Temperature': 23.5, 'CO2': 980}, 'battery_pct': 88},
|
||||
{'type': 'NAModule4', 'name': 'Schlafen', 'data': {'Temperature': 20.8, 'CO2': 1450}, 'battery_pct': 41},
|
||||
],
|
||||
'forecast': {
|
||||
'daily': {
|
||||
'time': forecast_days, # DYNAMISCH: heute + 2 Tage
|
||||
'temperature_2m_max': [19, 21, 17],
|
||||
'temperature_2m_min': [9, 11, 8],
|
||||
'precipitation_sum': [0.0, 2.4, 8.1],
|
||||
'weather_code': [0, 3, 61],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
w = Widget({})
|
||||
w.fetch = lambda: mock
|
||||
fonts = {}
|
||||
for sz in [8, 9, 10, 11, 12, 14, 16, 18, 20, 22, 24, 28, 32, 36, 40, 48, 60, 72, 84, 96]:
|
||||
fonts[str(sz)] = ImageFont.truetype(os.path.join('C:/Windows/Fonts/arial.ttf'), sz)
|
||||
fonts['default'] = ImageFont.load_default()
|
||||
|
||||
img = Image.new('RGB', (800, 480), (255, 255, 255))
|
||||
draw = ImageDraw.Draw(img)
|
||||
w.set_fonts(fonts)
|
||||
w.render(draw, fonts, 0, 0, 800, 480)
|
||||
img.save('docs/test_netatmo_v10.png')
|
||||
print(f'OK saved v10 ({now.strftime("%H:%M")})')
|
||||
Reference in New Issue
Block a user