Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5de086e1b1 | ||
|
|
5a83826aa5 |
@@ -690,6 +690,124 @@ def api_net_forget():
|
|||||||
return jsonify({"ok": rc == 0, "message": out or err})
|
return jsonify({"ok": rc == 0, "message": out or err})
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Power / DeepSleep — Energie-Tab
|
||||||
|
# ============================================================================
|
||||||
|
@app.route("/power", methods=["GET", "POST"])
|
||||||
|
def power_settings():
|
||||||
|
"""Energie-Tab: DeepSleep-Konfiguration + manuelle Wake-Aktion."""
|
||||||
|
a = require_auth()
|
||||||
|
if a: return a
|
||||||
|
cfg = dashboard_mod.load_config()
|
||||||
|
power = cfg.get("power", {})
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
# Form: deepsleep_enabled, deepsleep_start, deepsleep_end, cpu_throttle
|
||||||
|
power["deepsleep_enabled"] = (request.form.get("deepsleep_enabled") == "on")
|
||||||
|
start = request.form.get("deepsleep_start", "23:30").strip()
|
||||||
|
end = request.form.get("deepsleep_end", "06:30").strip()
|
||||||
|
# Validierung HH:MM
|
||||||
|
import re
|
||||||
|
if re.match(r"^\d{1,2}:\d{2}$", start):
|
||||||
|
power["deepsleep_start"] = start
|
||||||
|
if re.match(r"^\d{1,2}:\d{2}$", end):
|
||||||
|
power["deepsleep_end"] = end
|
||||||
|
power["cpu_throttle"] = (request.form.get("cpu_throttle") == "on")
|
||||||
|
power["wifi_off"] = (request.form.get("wifi_off") == "on")
|
||||||
|
cfg["power"] = power
|
||||||
|
dashboard_mod.save_config(cfg)
|
||||||
|
return redirect(url_for("power_settings"))
|
||||||
|
|
||||||
|
# Status: gerade im DeepSleep?
|
||||||
|
from datetime import datetime
|
||||||
|
now = datetime.now()
|
||||||
|
is_sleep = False
|
||||||
|
next_wake = None
|
||||||
|
if power.get("deepsleep_enabled", False):
|
||||||
|
try:
|
||||||
|
def _to_min(s):
|
||||||
|
h, m = s.split(":")
|
||||||
|
return int(h) * 60 + int(m)
|
||||||
|
now_min = now.hour * 60 + now.minute
|
||||||
|
s = _to_min(power.get("deepsleep_start", "23:00"))
|
||||||
|
e = _to_min(power.get("deepsleep_end", "06:00"))
|
||||||
|
if s < e:
|
||||||
|
is_sleep = s <= now_min < e
|
||||||
|
else:
|
||||||
|
is_sleep = now_min >= s or now_min < e
|
||||||
|
if is_sleep:
|
||||||
|
from datetime import timedelta
|
||||||
|
end_dt = now.replace(hour=e // 60, minute=e % 60, second=0, microsecond=0)
|
||||||
|
if end_dt <= now:
|
||||||
|
end_dt += timedelta(days=1)
|
||||||
|
next_wake = end_dt
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return render_template("power.html",
|
||||||
|
power=power,
|
||||||
|
is_deepsleep=is_sleep,
|
||||||
|
next_wake=next_wake.strftime("%H:%M") if next_wake else None,
|
||||||
|
now=now.strftime("%H:%M"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/power", methods=["GET"])
|
||||||
|
def api_power():
|
||||||
|
"""JSON-Status fuer Live-Anzeige in der UI."""
|
||||||
|
a = require_auth()
|
||||||
|
if a: return a
|
||||||
|
cfg = dashboard_mod.load_config()
|
||||||
|
power = cfg.get("power", {})
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
now = datetime.now()
|
||||||
|
is_sleep = False
|
||||||
|
next_wake = None
|
||||||
|
if power.get("deepsleep_enabled", False):
|
||||||
|
try:
|
||||||
|
def _to_min(s):
|
||||||
|
h, m = s.split(":")
|
||||||
|
return int(h) * 60 + int(m)
|
||||||
|
now_min = now.hour * 60 + now.minute
|
||||||
|
s = _to_min(power.get("deepsleep_start", "23:00"))
|
||||||
|
e = _to_min(power.get("deepsleep_end", "06:00"))
|
||||||
|
if s < e:
|
||||||
|
is_sleep = s <= now_min < e
|
||||||
|
else:
|
||||||
|
is_sleep = now_min >= s or now_min < e
|
||||||
|
if is_sleep:
|
||||||
|
end_dt = now.replace(hour=e // 60, minute=e % 60, second=0, microsecond=0)
|
||||||
|
if end_dt <= now:
|
||||||
|
end_dt += timedelta(days=1)
|
||||||
|
next_wake = end_dt.strftime("%H:%M")
|
||||||
|
secs_left = int((end_dt - now).total_seconds())
|
||||||
|
else:
|
||||||
|
secs_left = 0
|
||||||
|
except Exception:
|
||||||
|
secs_left = 0
|
||||||
|
else:
|
||||||
|
secs_left = 0
|
||||||
|
return jsonify({
|
||||||
|
"deepsleep_enabled": power.get("deepsleep_enabled", False),
|
||||||
|
"deepsleep_start": power.get("deepsleep_start", "23:30"),
|
||||||
|
"deepsleep_end": power.get("deepsleep_end", "06:30"),
|
||||||
|
"cpu_throttle": power.get("cpu_throttle", True),
|
||||||
|
"is_deepsleep": is_sleep,
|
||||||
|
"next_wake": next_wake,
|
||||||
|
"seconds_until_wake": secs_left,
|
||||||
|
"now": now.strftime("%H:%M"),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@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():
|
def main():
|
||||||
# Wenn direkt ausgeführt: eigenen Renderer starten ist Sache des Run-Skripts.
|
# Wenn direkt ausgeführt: eigenen Renderer starten ist Sache des Run-Skripts.
|
||||||
# Wir nehmen einfach Port 8080.
|
# Wir nehmen einfach Port 8080.
|
||||||
|
|||||||
+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
|
Lädt config.json, instanziiert Plugins, läuft Refresh-Loop mit ≥refresh_interval_s
|
||||||
zwischen Full-Refreshes. Reagiert auf Live-Trigger via Unix-Socket.
|
zwischen Full-Refreshes. Reagiert auf Live-Trigger via Unix-Socket.
|
||||||
|
|
||||||
|
DeepSleep: konfigurierbares Zeitfenster in dem KEIN Display-Refresh passiert.
|
||||||
|
Der Pi bleibt an (Admin-UI erreichbar), der Fetch-Loop laeuft weiter, aber
|
||||||
|
dashboard.display() wird uebersprungen. ePaper haelt das letzte Bild ohne Strom.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import os, sys, time, json, math, threading, signal, socket, logging
|
import os, sys, time, json, math, threading, signal, socket, logging
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
HERE = Path(__file__).resolve().parent
|
HERE = Path(__file__).resolve().parent
|
||||||
sys.path.insert(0, str(HERE))
|
sys.path.insert(0, str(HERE))
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
from palette import BG, FG, OK
|
from palette import BG, FG, OK, POWER_DEFAULTS
|
||||||
from plugins.base import all_widget_classes, Widget
|
from plugins.base import all_widget_classes, Widget, draw_deepsleep_indicator
|
||||||
from layout import Item, GRID_COLS, GRID_ROWS, CELL_W, CELL_H, DISPLAY_W as LAYOUT_DISPLAY_W, pack, find_overlaps, find_out_of_bounds
|
from layout import Item, GRID_COLS, GRID_ROWS, CELL_W, CELL_H, DISPLAY_W as LAYOUT_DISPLAY_W, pack, find_overlaps, find_out_of_bounds
|
||||||
|
|
||||||
import importlib, pkgutil
|
import importlib, pkgutil
|
||||||
@@ -103,6 +107,12 @@ def load_config() -> dict:
|
|||||||
cfg["plugin_configs"] = {}
|
cfg["plugin_configs"] = {}
|
||||||
if "refresh_interval_s" not in cfg:
|
if "refresh_interval_s" not in cfg:
|
||||||
cfg["refresh_interval_s"] = 180
|
cfg["refresh_interval_s"] = 180
|
||||||
|
# Power-Defaults (DeepSleep) sicherstellen
|
||||||
|
if "power" not in cfg or not isinstance(cfg["power"], dict):
|
||||||
|
cfg["power"] = dict(POWER_DEFAULTS)
|
||||||
|
else:
|
||||||
|
for k, v in POWER_DEFAULTS.items():
|
||||||
|
cfg["power"].setdefault(k, v)
|
||||||
return cfg
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
@@ -119,6 +129,7 @@ def _empty_v2_config() -> dict:
|
|||||||
"items": [],
|
"items": [],
|
||||||
},
|
},
|
||||||
"plugin_configs": {},
|
"plugin_configs": {},
|
||||||
|
"power": dict(POWER_DEFAULTS),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -198,7 +209,8 @@ def slot_box(idx: int) -> tuple[int, int, int, int]:
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Render
|
# Render
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
def render_full(items: list, widgets: list, fonts: dict) -> Image.Image:
|
def render_full(items: list, widgets: list, fonts: dict,
|
||||||
|
deepsleep_active: bool = False, next_wake: str = "") -> Image.Image:
|
||||||
"""Render alle Items aufs Display.
|
"""Render alle Items aufs Display.
|
||||||
|
|
||||||
items: list[Item] (Layout-Items mit x,y,w,h)
|
items: list[Item] (Layout-Items mit x,y,w,h)
|
||||||
@@ -241,6 +253,9 @@ def render_full(items: list, widgets: list, fonts: dict) -> Image.Image:
|
|||||||
draw.text((px + 8, py + 8), f"{widget.label} (overflow)",
|
draw.text((px + 8, py + 8), f"{widget.label} (overflow)",
|
||||||
font=fonts.get("16", fonts.get("default")), fill=FG)
|
font=fonts.get("16", fonts.get("default")), fill=FG)
|
||||||
continue
|
continue
|
||||||
|
# Fonts an Widget durchreichen VOR render()
|
||||||
|
if hasattr(widget, "set_fonts"):
|
||||||
|
widget.set_fonts(fonts)
|
||||||
try:
|
try:
|
||||||
widget.render(draw, fonts, px, py, pw, ph)
|
widget.render(draw, fonts, px, py, pw, ph)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -249,6 +264,11 @@ def render_full(items: list, widgets: list, fonts: dict) -> Image.Image:
|
|||||||
f"{widget.label}: render error",
|
f"{widget.label}: render error",
|
||||||
font=fonts.get("16", fonts.get("default")), fill=FG)
|
font=fonts.get("16", fonts.get("default")), fill=FG)
|
||||||
|
|
||||||
|
# DeepSleep-Indikator im oberen rechten Bereich
|
||||||
|
if deepsleep_active:
|
||||||
|
ind_w = min(280, DISPLAY_W // 3)
|
||||||
|
draw_deepsleep_indicator(draw, DISPLAY_W - ind_w - 6, 4, ind_w, 18, next_wake)
|
||||||
|
|
||||||
return img
|
return img
|
||||||
|
|
||||||
|
|
||||||
@@ -300,8 +320,69 @@ class Dashboard:
|
|||||||
def trigger_refresh(self):
|
def trigger_refresh(self):
|
||||||
self._trigger.set()
|
self._trigger.set()
|
||||||
|
|
||||||
def render_once(self) -> Image.Image:
|
# ------------------------------------------------------------------
|
||||||
# fetch all widgets
|
# DeepSleep: Zeitschaltung fuer Display-Refresh
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def _parse_hhmm(self, s: str) -> int:
|
||||||
|
"""'HH:MM' → Minuten seit Mitternacht. Fallback: 0."""
|
||||||
|
try:
|
||||||
|
parts = s.split(":")
|
||||||
|
return int(parts[0]) * 60 + int(parts[1])
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def _is_deepsleep(self) -> bool:
|
||||||
|
"""True wenn jetzt in der DeepSleep-Zeit liegt."""
|
||||||
|
cfg = self.config.get("power", {})
|
||||||
|
if not cfg.get("deepsleep_enabled", False):
|
||||||
|
return False
|
||||||
|
now = datetime.now()
|
||||||
|
now_min = now.hour * 60 + now.minute
|
||||||
|
start = self._parse_hhmm(cfg.get("deepsleep_start", "23:00"))
|
||||||
|
end = self._parse_hhmm(cfg.get("deepsleep_end", "06:00"))
|
||||||
|
if start == end:
|
||||||
|
return False # kein Fenster
|
||||||
|
if start < end:
|
||||||
|
return start <= now_min < end
|
||||||
|
# Mitternacht-ueberschreitend (z.B. 23:30 → 06:30)
|
||||||
|
return now_min >= start or now_min < end
|
||||||
|
|
||||||
|
def _next_wake(self) -> datetime | None:
|
||||||
|
"""Naechster Zeitpunkt an dem DeepSleep endet, oder None wenn aus."""
|
||||||
|
if not self._is_deepsleep():
|
||||||
|
return None
|
||||||
|
cfg = self.config.get("power", {})
|
||||||
|
end = self._parse_hhmm(cfg.get("deepsleep_end", "06:00"))
|
||||||
|
now = datetime.now()
|
||||||
|
end_dt = now.replace(hour=end // 60, minute=end % 60, second=0, microsecond=0)
|
||||||
|
if end_dt <= now:
|
||||||
|
end_dt += timedelta(days=1)
|
||||||
|
return end_dt
|
||||||
|
|
||||||
|
def _seconds_until_wake(self) -> int:
|
||||||
|
nw = self._next_wake()
|
||||||
|
if nw is None:
|
||||||
|
return 0
|
||||||
|
return max(5, int((nw - datetime.now()).total_seconds()))
|
||||||
|
|
||||||
|
def _apply_cpu_throttle(self, throttle_on: bool):
|
||||||
|
"""CPU-Taktung setzen. Nur auf Linux/Pi wirksam; sonst noop."""
|
||||||
|
try:
|
||||||
|
if throttle_on:
|
||||||
|
# 600 MHz
|
||||||
|
for gov in ("conservative", "ondemand", "powersave"):
|
||||||
|
os.system(f"echo {gov} > /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor 2>/dev/null")
|
||||||
|
os.system("echo 600000 > /sys/devices/system/cpu/cpu0/cpufreq/scaling_max_freq 2>/dev/null")
|
||||||
|
else:
|
||||||
|
# Default: ondemand, max 1.5 GHz
|
||||||
|
for gov in ("ondemand", "performance"):
|
||||||
|
os.system(f"echo {gov} > /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor 2>/dev/null")
|
||||||
|
os.system("echo 1500000 > /sys/devices/system/cpu/cpu0/cpufreq/scaling_max_freq 2>/dev/null")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def render_once(self, with_deepsleep: bool = True) -> Image.Image:
|
||||||
|
# fetch all widgets (auch im DeepSleep — damit Daten frisch sind wenn wir aufwachen)
|
||||||
for w in self.widgets:
|
for w in self.widgets:
|
||||||
if w is None: continue
|
if w is None: continue
|
||||||
try:
|
try:
|
||||||
@@ -311,7 +392,15 @@ class Dashboard:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
items = list(self.items)
|
items = list(self.items)
|
||||||
widgets = list(self.widgets)
|
widgets = list(self.widgets)
|
||||||
img = render_full(items, widgets, self.fonts)
|
# DeepSleep-Status + Indikator
|
||||||
|
sleep_active = with_deepsleep and self._is_deepsleep()
|
||||||
|
next_wake = ""
|
||||||
|
if sleep_active:
|
||||||
|
nw = self._next_wake()
|
||||||
|
if nw:
|
||||||
|
next_wake = nw.strftime("%H:%M")
|
||||||
|
img = render_full(items, widgets, self.fonts,
|
||||||
|
deepsleep_active=sleep_active, next_wake=next_wake)
|
||||||
return img
|
return img
|
||||||
|
|
||||||
def display(self, img: Image.Image):
|
def display(self, img: Image.Image):
|
||||||
@@ -341,11 +430,43 @@ class Dashboard:
|
|||||||
# start IPC socket listener
|
# start IPC socket listener
|
||||||
threading.Thread(target=self._socket_loop, daemon=True).start()
|
threading.Thread(target=self._socket_loop, daemon=True).start()
|
||||||
|
|
||||||
|
_throttle_applied = False
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
self.maybe_reload_config()
|
self.maybe_reload_config()
|
||||||
interval = int(self.config.get("refresh_interval_s", 180))
|
interval = int(self.config.get("refresh_interval_s", 180))
|
||||||
# sleep until interval elapsed OR trigger set
|
# ----- DeepSleep Check -----
|
||||||
|
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)
|
wait = max(5, interval)
|
||||||
logging.info(f"waiting up to {wait}s (manual trigger interrupts)")
|
logging.info(f"waiting up to {wait}s (manual trigger interrupts)")
|
||||||
if self._trigger.wait(timeout=wait):
|
if self._trigger.wait(timeout=wait):
|
||||||
|
|||||||
+186
@@ -235,3 +235,189 @@ def is_tall(w: int, h: int) -> bool:
|
|||||||
"""True wenn Slot hoch aber schmal ist (1x4, 1x2)."""
|
"""True wenn Slot hoch aber schmal ist (1x4, 1x2)."""
|
||||||
return h >= 280 and w < 280
|
return h >= 280 and w < 280
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Batterie-Farben — Single Source of Truth
|
||||||
|
# ============================================================================
|
||||||
|
BATT_LOW = RED # < 25% → kritisch
|
||||||
|
BATT_MID = ORANGE # < 50% → bald wechseln
|
||||||
|
BATT_OK = GREEN # >= 50% → OK
|
||||||
|
|
||||||
|
# Standard-Batterie-Schwellen (werden von Plugins einheitlich genutzt)
|
||||||
|
BATT_THRESHOLDS = (
|
||||||
|
(25, BATT_LOW),
|
||||||
|
(50, BATT_MID),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def batt_color(pct: int | None) -> tuple:
|
||||||
|
"""Liefert die passende Batterie-Farbe fuer einen Prozent-Wert.
|
||||||
|
|
||||||
|
None → FG (keine Anzeige), sonst BATT_LOW/MID/OK.
|
||||||
|
"""
|
||||||
|
if pct is None:
|
||||||
|
return FG
|
||||||
|
pct = max(0, min(100, pct))
|
||||||
|
for threshold, color in BATT_THRESHOLDS:
|
||||||
|
if pct < threshold:
|
||||||
|
return color
|
||||||
|
return BATT_OK
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Deutsche Formatter — Single Source of Truth (kein Englisches "Monday" mehr)
|
||||||
|
# ============================================================================
|
||||||
|
DE_WEEKDAYS_LONG = [
|
||||||
|
"Montag", "Dienstag", "Mittwoch", "Donnerstag",
|
||||||
|
"Freitag", "Samstag", "Sonntag",
|
||||||
|
]
|
||||||
|
DE_WEEKDAYS_SHORT = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]
|
||||||
|
DE_MONTHS_LONG = [
|
||||||
|
"Januar", "Februar", "Maerz", "April", "Mai", "Juni",
|
||||||
|
"Juli", "August", "September", "Oktober", "November", "Dezember",
|
||||||
|
]
|
||||||
|
DE_MONTHS_SHORT = ["Jan", "Feb", "Mae", "Apr", "Mai", "Jun",
|
||||||
|
"Jul", "Aug", "Sep", "Okt", "Nov", "Dez"]
|
||||||
|
|
||||||
|
|
||||||
|
def de_weekday_long(weekday: int) -> str:
|
||||||
|
"""datetime.weekday() (0=Montag) → 'Montag'/'Dienstag'/..."""
|
||||||
|
return DE_WEEKDAYS_LONG[weekday]
|
||||||
|
|
||||||
|
|
||||||
|
def de_weekday_short(weekday: int) -> str:
|
||||||
|
"""datetime.weekday() (0=Montag) → 'Mo'/'Di'/..."""
|
||||||
|
return DE_WEEKDAYS_SHORT[weekday]
|
||||||
|
|
||||||
|
|
||||||
|
def de_month_short(month: int) -> str:
|
||||||
|
"""datetime.month (1-12) → 'Jan'/'Feb'/..."""
|
||||||
|
return DE_MONTHS_SHORT[month - 1]
|
||||||
|
|
||||||
|
|
||||||
|
def de_date(dt, short: bool = True) -> str:
|
||||||
|
"""Datum als '30. Aug 2026' (default short=True)."""
|
||||||
|
if short:
|
||||||
|
return f"{dt.day:02d}. {de_month_short(dt.month)} {dt.year}"
|
||||||
|
return f"{dt.day:02d}. {DE_MONTHS_LONG[dt.month - 1]} {dt.year}"
|
||||||
|
|
||||||
|
|
||||||
|
def de_weekday_date(dt, short: bool = True) -> str:
|
||||||
|
"""'Sa 30.08' wenn short, sonst 'Samstag 30. August 2026'."""
|
||||||
|
if short:
|
||||||
|
return f"{de_weekday_short(dt.weekday())} {dt.day:02d}.{dt.month:02d}"
|
||||||
|
return f"{de_weekday_long(dt.weekday())} {de_date(dt, short=False)}"
|
||||||
|
|
||||||
|
|
||||||
|
def de_time(dt, show_seconds: bool = False) -> str:
|
||||||
|
"""24h-Zeit 'HH:MM' oder 'HH:MM:SS'."""
|
||||||
|
return dt.strftime("%H:%M:%S" if show_seconds else "%H:%M")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Wind / Temp Helper — einheitliche Formatierung
|
||||||
|
# ============================================================================
|
||||||
|
def format_temp(value: float | None, unit: str = "C", decimals: int = 1) -> str:
|
||||||
|
"""Temperatur formatieren. None → em-dash."""
|
||||||
|
if value is None:
|
||||||
|
return "—"
|
||||||
|
return f"{value:.{decimals}f}°{unit}"
|
||||||
|
|
||||||
|
|
||||||
|
def format_wind(speed: float | None, unit: str = "kmh", decimals: int = 0) -> str:
|
||||||
|
"""Windgeschwindigkeit. 'kmh' → 'km/h', 'ms' → 'm/s'."""
|
||||||
|
if speed is None:
|
||||||
|
return "—"
|
||||||
|
label = "km/h" if unit == "kmh" else "m/s"
|
||||||
|
return f"{speed:.{decimals}f} {label}"
|
||||||
|
|
||||||
|
|
||||||
|
def format_rain(rate_mm: float | None, decimals: int = 1) -> str:
|
||||||
|
"""Regenrate 'X.X mm/h'."""
|
||||||
|
if rate_mm is None:
|
||||||
|
return "—"
|
||||||
|
return f"{rate_mm:.{decimals}f} mm/h"
|
||||||
|
|
||||||
|
|
||||||
|
def temp_color(value: float | None) -> tuple:
|
||||||
|
"""Farbe nach Temperaturwert: kalt→Blau, normal→FG, warm→Orange, heiss→Rot."""
|
||||||
|
if value is None:
|
||||||
|
return FG
|
||||||
|
if value >= 28:
|
||||||
|
return RED
|
||||||
|
if value >= 22:
|
||||||
|
return ORANGE
|
||||||
|
if value <= 5:
|
||||||
|
return BLUE
|
||||||
|
return FG
|
||||||
|
|
||||||
|
|
||||||
|
def co2_color(ppm: int | float | None) -> tuple:
|
||||||
|
"""CO2-Ampel: <600 gruen, <1000 orange, <1500 gelb, >=1500 rot."""
|
||||||
|
if ppm is None:
|
||||||
|
return FG
|
||||||
|
if ppm >= 1500:
|
||||||
|
return RED
|
||||||
|
if ppm >= 1000:
|
||||||
|
return YELLOW
|
||||||
|
if ppm >= 600:
|
||||||
|
return ORANGE
|
||||||
|
return GREEN
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Power-Management — Defaults
|
||||||
|
# ============================================================================
|
||||||
|
POWER_DEFAULTS = {
|
||||||
|
"deepsleep_enabled": False,
|
||||||
|
"deepsleep_start": "23:30",
|
||||||
|
"deepsleep_end": "06:30",
|
||||||
|
"cpu_throttle": True, # waehrend DeepSleep auf 600MHz
|
||||||
|
"wifi_off": False, # WiFi anlassen fuer Admin-Erreichbarkeit
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Weather-Code-Mapping (WMO Standard + Netatmo-Fallback)
|
||||||
|
# ============================================================================
|
||||||
|
# WMO Weather interpretation codes (https://open-meteo.com/en/docs)
|
||||||
|
WMO_ICONS = {
|
||||||
|
0: "sun", # Clear sky
|
||||||
|
1: "partly", # Mainly clear
|
||||||
|
2: "partly", # Partly cloudy
|
||||||
|
3: "cloud", # Overcast
|
||||||
|
45: "fog", # Fog
|
||||||
|
48: "fog", # Depositing rime fog
|
||||||
|
51: "rain", # Drizzle: light
|
||||||
|
53: "rain", # Drizzle: moderate
|
||||||
|
55: "rain", # Drizzle: dense
|
||||||
|
56: "rain", # Freezing Drizzle: light
|
||||||
|
57: "rain", # Freezing Drizzle: dense
|
||||||
|
61: "rain", # Rain: slight
|
||||||
|
63: "rain", # Rain: moderate
|
||||||
|
65: "rain", # Rain: heavy
|
||||||
|
66: "rain", # Freezing Rain: light
|
||||||
|
67: "rain", # Freezing Rain: heavy
|
||||||
|
71: "snow", # Snow fall: slight
|
||||||
|
73: "snow", # Snow fall: moderate
|
||||||
|
75: "snow", # Snow fall: heavy
|
||||||
|
77: "snow", # Snow grains
|
||||||
|
80: "rain", # Rain showers: slight
|
||||||
|
81: "rain", # Rain showers: moderate
|
||||||
|
82: "rain", # Rain showers: violent
|
||||||
|
85: "snow", # Snow showers: slight
|
||||||
|
86: "snow", # Snow showers: heavy
|
||||||
|
95: "storm", # Thunderstorm
|
||||||
|
96: "storm", # Thunderstorm with slight hail
|
||||||
|
99: "storm", # Thunderstorm with heavy hail
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def wmo_to_icon(code: int | str | None) -> str:
|
||||||
|
"""WMO-Code (int) oder Symbol-String → 'sun'/'cloud'/'rain'/'storm'/..."""
|
||||||
|
if code is None:
|
||||||
|
return "cloud"
|
||||||
|
if isinstance(code, str):
|
||||||
|
return code # schon ein symbol
|
||||||
|
return WMO_ICONS.get(code, "cloud")
|
||||||
|
|
||||||
|
|||||||
+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
|
exportiert. Die Klasse wird beim Start dynamisch geladen und in der
|
||||||
Admin-UI zur Auswahl angeboten.
|
Admin-UI zur Auswahl angeboten.
|
||||||
|
|
||||||
|
Dieses Modul enthaelt auch Shared Render-Helpers (Batterie, Compass,
|
||||||
|
Trend-Pfeile, Wetter-Icons, Charts), die von allen Plugins einheitlich
|
||||||
|
genutzt werden — damit sehen Batterie-Icons, Windrosen, etc. auf dem
|
||||||
|
Display konsistent aus.
|
||||||
|
|
||||||
Minimal-Beispiel siehe plugins/hello.py.
|
Minimal-Beispiel siehe plugins/hello.py.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import math
|
||||||
import time
|
import time
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import socket as _socket
|
import socket as _socket
|
||||||
from PIL import ImageDraw
|
from PIL import ImageDraw, ImageFont
|
||||||
|
|
||||||
|
from palette import (
|
||||||
|
FG, BG, BLACK, WHITE, GREEN, BLUE, RED, YELLOW, ORANGE,
|
||||||
|
BATT_LOW, BATT_MID, BATT_OK, batt_color,
|
||||||
|
measure, text_wh,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -153,6 +165,13 @@ class Widget(ABC):
|
|||||||
merged = dict(self.default_config)
|
merged = dict(self.default_config)
|
||||||
merged.update(config or {})
|
merged.update(config or {})
|
||||||
self.config = merged
|
self.config = merged
|
||||||
|
self.fonts: dict | None = None # wird vom Dashboard gesetzt vor render()
|
||||||
|
self.theme: Any = None # Theme-Instanz (von designbase), optional
|
||||||
|
|
||||||
|
def set_fonts(self, fonts: dict):
|
||||||
|
"""Wird vom Dashboard vor render() aufgerufen. Plugins koennen
|
||||||
|
dann self.fonts["24"] etc. nutzen statt selbst Fonts zu laden."""
|
||||||
|
self.fonts = fonts
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def fetch(self) -> dict:
|
def fetch(self) -> dict:
|
||||||
@@ -189,3 +208,337 @@ def all_widget_classes() -> list[type[Widget]]:
|
|||||||
classes.append(cls)
|
classes.append(cls)
|
||||||
classes.sort(key=lambda c: c.label or c.name)
|
classes.sort(key=lambda c: c.label or c.name)
|
||||||
return classes
|
return classes
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Shared Render Helpers — Single Source of Truth fuer Batterie, Compass,
|
||||||
|
# Trend, Wetter-Icons, Charts. Alle Plugins nutzen diese Funktionen damit
|
||||||
|
# das Display konsistent aussieht.
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# Sans-Schriftart aus dem System (lazy-loaded, gecacht)
|
||||||
|
_BASE_SANS_PATHS = [
|
||||||
|
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
|
||||||
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||||
|
"C:/Windows/Fonts/arial.ttf",
|
||||||
|
"C:/Windows/Fonts/seguisb.ttf",
|
||||||
|
]
|
||||||
|
_BASE_SANS = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_base_sans() -> ImageFont.FreeTypeFont | None:
|
||||||
|
"""Lazy-load Sans-Basis-Font (size 14) fuer die Helper."""
|
||||||
|
global _BASE_SANS
|
||||||
|
if _BASE_SANS is not None:
|
||||||
|
return _BASE_SANS
|
||||||
|
import os
|
||||||
|
for p in _BASE_SANS_PATHS:
|
||||||
|
if os.path.exists(p):
|
||||||
|
try:
|
||||||
|
_BASE_SANS = ImageFont.truetype(p, 14)
|
||||||
|
return _BASE_SANS
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
_BASE_SANS = ImageFont.load_default()
|
||||||
|
except Exception:
|
||||||
|
_BASE_SANS = None
|
||||||
|
return _BASE_SANS
|
||||||
|
|
||||||
|
|
||||||
|
def _fit_size(draw, text: str, max_w: int, max_h: int,
|
||||||
|
candidates: list[str | int] | None = None) -> ImageFont.FreeTypeFont:
|
||||||
|
"""Iteriere durch Font-Groessen, waehle die erste die passt.
|
||||||
|
|
||||||
|
Verwendet das gleiche Sans-Family wie die Basis-Schrift.
|
||||||
|
"""
|
||||||
|
if candidates is None:
|
||||||
|
candidates = ["48", "36", "32", "28", "24", "20", "16", "14", "12", "10"]
|
||||||
|
base = _get_base_sans()
|
||||||
|
base_path = getattr(base, "path", None) if base else None
|
||||||
|
for sz in candidates:
|
||||||
|
try:
|
||||||
|
sz_int = int(sz)
|
||||||
|
if base_path:
|
||||||
|
f = ImageFont.truetype(base_path, sz_int)
|
||||||
|
else:
|
||||||
|
f = base
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
tw, th = measure(draw, text, f)
|
||||||
|
if tw <= max_w and th <= max_h:
|
||||||
|
return f
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def text_in_box(draw, text, x, y, w, h, font, color, align: str = "left"):
|
||||||
|
"""Zeichnet Text in reservierten Bereich (x,y,w,h). Garantiert keine
|
||||||
|
Ueberlappung mit anderen Elementen wenn jeder seinen eigenen Bereich hat.
|
||||||
|
|
||||||
|
align: 'left' (default), 'right', 'center'
|
||||||
|
"""
|
||||||
|
tw, th = measure(draw, text, font)
|
||||||
|
if tw > w or th > h:
|
||||||
|
return False # wuerde ueberlaufen
|
||||||
|
if align == "left":
|
||||||
|
draw.text((x, y + (h - th) // 2), text, font=font, fill=color)
|
||||||
|
elif align == "right":
|
||||||
|
draw.text((x + w - tw, y + (h - th) // 2), text, font=font, fill=color)
|
||||||
|
elif align == "center":
|
||||||
|
draw.text((x + (w - tw) // 2, y + (h - th) // 2), text, font=font, fill=color)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Batterie-Icon (Single Source of Truth) ------------------------------
|
||||||
|
def draw_battery(draw, x, y, pct: int | None, w: int = 22, h: int = 10,
|
||||||
|
with_text: bool = True, text_color: tuple | None = None):
|
||||||
|
"""Zeichnet ein konsistentes Batterie-Icon.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pct: Prozent (0-100), None → noop
|
||||||
|
w, h: Icon-Groesse (ohne Tip)
|
||||||
|
with_text: ob "%" daneben gezeichnet werden soll
|
||||||
|
text_color: falls None, automatisch nach Schwellen (LOW/MID/OK)
|
||||||
|
"""
|
||||||
|
if pct is None:
|
||||||
|
return 0
|
||||||
|
pct = max(0, min(100, pct))
|
||||||
|
color = text_color or batt_color(pct)
|
||||||
|
# Body
|
||||||
|
draw.rectangle((x, y, x + w - 3, y + h - 1), outline=FG, width=1)
|
||||||
|
# Tip
|
||||||
|
draw.rectangle((x + w - 2, y + 3, x + w, y + h - 3), fill=FG)
|
||||||
|
# Fill
|
||||||
|
fill_w = max(0, int((w - 4) * pct / 100))
|
||||||
|
if fill_w > 0:
|
||||||
|
draw.rectangle((x + 2, y + 2, x + 2 + fill_w, y + h - 2), fill=color)
|
||||||
|
used = w
|
||||||
|
if with_text:
|
||||||
|
f = _fit_size(draw, f"{pct}%", 30, h, candidates=["10", "9", "8"])
|
||||||
|
text_in_box(draw, f"{pct}%", x + w + 3, y - 1, 30, h + 2, f, color, "left")
|
||||||
|
used += 30
|
||||||
|
return used
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Trend-Pfeil (up / down / stable) ------------------------------------
|
||||||
|
def draw_trend(draw, x, y, trend: str, size: int = 10, color: tuple | None = None):
|
||||||
|
"""Pfeil nach oben/unten, oder waagerechter Strich fuer 'stable'."""
|
||||||
|
color = color or FG
|
||||||
|
cx, cy = x + size // 2, y + size // 2
|
||||||
|
if trend == "up":
|
||||||
|
draw.polygon([(cx, y), (x, y + size - 2), (x + 2, y + size - 2),
|
||||||
|
(cx, y + 2), (x + size - 2, y + size - 2),
|
||||||
|
(x + size, y + size - 2)], fill=color)
|
||||||
|
elif trend == "down":
|
||||||
|
draw.polygon([(cx, y + size), (x, y + 2), (x + 2, y + 2),
|
||||||
|
(cx, y + size - 2), (x + size - 2, y + 2),
|
||||||
|
(x + size, y + 2)], fill=color)
|
||||||
|
else: # stable
|
||||||
|
draw.line((x, cy, x + size, cy), fill=color, width=2)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Windrose / Compass --------------------------------------------------
|
||||||
|
def draw_compass(draw, cx, cy, r: int, deg: float, color: tuple = BLUE,
|
||||||
|
with_labels: bool = False):
|
||||||
|
"""Zeichnet eine Windrose.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
deg: 0=Nord, 90=Ost, 180=Sued, 270=West
|
||||||
|
with_labels: N/O/S/W Beschriftung (nur fuer grosse Varianten sinnvoll)
|
||||||
|
"""
|
||||||
|
if r < 4:
|
||||||
|
return
|
||||||
|
# Aeusserer Kreis
|
||||||
|
draw.ellipse((cx - r, cy - r, cx + r, cy + r), outline=FG, width=1)
|
||||||
|
# Cardinal Ticks
|
||||||
|
for a in [270, 90, 180, 0]: # N/O/S/W in Wetter-Konvention
|
||||||
|
rd = math.radians(a - 90)
|
||||||
|
x1 = cx + (r - 6) * math.cos(rd)
|
||||||
|
y1 = cy + (r - 6) * math.sin(rd)
|
||||||
|
x2 = cx + (r - 2) * math.cos(rd)
|
||||||
|
y2 = cy + (r - 2) * math.sin(rd)
|
||||||
|
draw.line((x1, y1, x2, y2), fill=FG, width=1)
|
||||||
|
# Pfeil
|
||||||
|
rad = math.radians(deg - 90)
|
||||||
|
tx = cx + (r - 4) * math.cos(rad)
|
||||||
|
ty = cy + (r - 4) * math.sin(rad)
|
||||||
|
draw.line((cx, cy, tx, ty), fill=color, width=2 if r < 16 else 3)
|
||||||
|
draw.ellipse((cx - 2, cy - 2, cx + 2, cy + 2), fill=color)
|
||||||
|
# Optionale N/O/S/W Labels
|
||||||
|
if with_labels and r >= 14:
|
||||||
|
for letter, a in [("N", 270), ("O", 0), ("S", 90), ("W", 180)]:
|
||||||
|
rd = math.radians(a - 90)
|
||||||
|
lx = cx + (r + 7) * math.cos(rd) - 4
|
||||||
|
ly = cy + (r + 7) * math.sin(rd) - 6
|
||||||
|
f = _fit_size(draw, letter, 10, 10, candidates=["10", "9", "8"])
|
||||||
|
draw.text((lx, ly), letter, font=f, fill=FG)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Wetter-Icon (7 Wetter-Zustaende) ------------------------------------
|
||||||
|
def draw_weather_icon(draw, x, y, size: int, code: int | str):
|
||||||
|
"""Zeichnet ein Wetter-Icon in (x, y, size, size) Box.
|
||||||
|
|
||||||
|
Akzeptiert WMO-Code (int) oder symbol-string ('sun'/'cloud'/'rain'/'storm'/'snow'/'fog'/'partly').
|
||||||
|
Verwendet nur die 7 ACeP-Farben.
|
||||||
|
"""
|
||||||
|
from palette import wmo_to_icon
|
||||||
|
sym = wmo_to_icon(code) if isinstance(code, int) else code
|
||||||
|
cx, cy = x + size // 2, y + size // 2
|
||||||
|
r = max(3, size // 3)
|
||||||
|
if sym in ("sun", "clear"):
|
||||||
|
draw.ellipse((cx - r, cy - r, cx + r, cy + r), fill=YELLOW)
|
||||||
|
for ang in range(0, 360, 45):
|
||||||
|
rad = math.radians(ang)
|
||||||
|
x1 = cx + int((r + 2) * math.cos(rad))
|
||||||
|
y1 = cy + int((r + 2) * math.sin(rad))
|
||||||
|
x2 = cx + int((r + 5) * math.cos(rad))
|
||||||
|
y2 = cy + int((r + 5) * math.sin(rad))
|
||||||
|
draw.line((x1, y1, x2, y2), fill=FG, width=1)
|
||||||
|
elif sym == "partly":
|
||||||
|
draw.ellipse((cx - r // 2 - 2, cy - r, cx + r // 2, cy + 1), fill=YELLOW)
|
||||||
|
cw = size // 2
|
||||||
|
ch = size // 4
|
||||||
|
draw.ellipse((cx - cw // 2, cy, cx + cw // 2, cy + ch),
|
||||||
|
fill=WHITE, outline=FG, width=1)
|
||||||
|
elif sym == "cloud":
|
||||||
|
cw = int(size * 0.7)
|
||||||
|
ch = size // 3
|
||||||
|
draw.ellipse((cx - cw // 2, cy - ch // 2, cx + cw // 2, cy + ch // 2),
|
||||||
|
fill=WHITE, outline=FG, width=2)
|
||||||
|
draw.ellipse((cx - cw // 3, cy - ch, cx + cw // 3, cy + ch // 2),
|
||||||
|
fill=WHITE, outline=FG, width=2)
|
||||||
|
elif sym == "rain":
|
||||||
|
cw = int(size * 0.7)
|
||||||
|
ch = size // 3
|
||||||
|
draw.ellipse((cx - cw // 2, cy - ch - 1, cx + cw // 2, cy + ch // 2 - 1),
|
||||||
|
fill=WHITE, outline=FG, width=2)
|
||||||
|
for dx in (-4, 0, 4):
|
||||||
|
draw.line((cx + dx, cy + ch, cx + dx, cy + ch + 5), fill=BLUE, width=2)
|
||||||
|
elif sym == "storm":
|
||||||
|
cw = int(size * 0.7)
|
||||||
|
ch = size // 3
|
||||||
|
draw.ellipse((cx - cw // 2, cy - ch - 1, cx + cw // 2, cy + ch // 2 - 1),
|
||||||
|
fill=WHITE, outline=FG, width=2)
|
||||||
|
draw.polygon([(cx - 3, cy + ch - 1), (cx + 1, cy + ch + 3),
|
||||||
|
(cx - 1, cy + ch + 3), (cx + 3, cy + ch + 8)],
|
||||||
|
fill=YELLOW)
|
||||||
|
elif sym == "snow":
|
||||||
|
cw = int(size * 0.7)
|
||||||
|
ch = size // 3
|
||||||
|
draw.ellipse((cx - cw // 2, cy - ch - 1, cx + cw // 2, cy + ch // 2 - 1),
|
||||||
|
fill=WHITE, outline=FG, width=2)
|
||||||
|
for dx in (-4, 0, 4):
|
||||||
|
for dy in (0, 5):
|
||||||
|
draw.line((cx + dx - 2, cy + ch + dy, cx + dx + 2, cy + ch + dy),
|
||||||
|
fill=FG, width=1)
|
||||||
|
draw.line((cx + dx, cy + ch + dy - 2, cx + dx, cy + ch + dy + 2),
|
||||||
|
fill=FG, width=1)
|
||||||
|
elif sym == "fog":
|
||||||
|
cw = int(size * 0.7)
|
||||||
|
for i, dy in enumerate((-2, 1, 4)):
|
||||||
|
y_pos = cy + dy
|
||||||
|
draw.line((cx - cw // 2, y_pos, cx + cw // 2, y_pos), fill=FG, width=1)
|
||||||
|
else:
|
||||||
|
f = _fit_size(draw, "?", size - 4, size - 4, candidates=["20", "16", "12"])
|
||||||
|
text_in_box(draw, "?", x, y, size, size, f, FG, "center")
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Linien-Chart (mit optionalem Y-Grid) ---------------------------------
|
||||||
|
def draw_line_chart(draw, x, y, w, h, data: list, color: tuple = GREEN,
|
||||||
|
y_min: float | None = None, y_max: float | None = None,
|
||||||
|
with_y_grid: bool = True, n_ticks: int = 3):
|
||||||
|
"""Zeichnet einen Linien-Chart in (x,y,w,h) Box.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Liste von Werten
|
||||||
|
color: Linien-Farbe
|
||||||
|
y_min, y_max: Y-Achsen-Bereich. None → auto aus Daten
|
||||||
|
with_y_grid: Y-Tick-Linien mit Werten am linken Rand
|
||||||
|
n_ticks: Anzahl Y-Ticks (default 3)
|
||||||
|
"""
|
||||||
|
if len(data) < 2 or w <= 10 or h <= 10:
|
||||||
|
return
|
||||||
|
# Y-Range bestimmen
|
||||||
|
if y_min is None:
|
||||||
|
y_min = min(data)
|
||||||
|
if y_max is None:
|
||||||
|
y_max = max(data)
|
||||||
|
if y_min == y_max:
|
||||||
|
y_min -= 1
|
||||||
|
y_max += 1
|
||||||
|
# Grid: gestrichelte Linien (ACeP kann keine echten Dashes, daher Segmente)
|
||||||
|
if with_y_grid:
|
||||||
|
# Nur 2 Ticks zeigen: Min und Max (sonst zu viele Labels für kleinen Chart)
|
||||||
|
ticks = []
|
||||||
|
if n_ticks >= 2:
|
||||||
|
ticks = [y_min, y_max]
|
||||||
|
elif n_ticks == 1:
|
||||||
|
ticks = [(y_min + y_max) / 2]
|
||||||
|
for tick in ticks:
|
||||||
|
ty = y + int((y_max - tick) / max(1, y_max - y_min) * (h - 8)) + 4
|
||||||
|
# Y-Label links (NICHT unten — kollidiert mit x-Achsen-Labels)
|
||||||
|
f_lbl = _fit_size(draw, f"{int(tick)}°", 22, 10, candidates=["9", "8"])
|
||||||
|
text_in_box(draw, f"{int(tick)}°", x - 24, ty - 5, 22, 10, f_lbl, FG, "right")
|
||||||
|
# Gestrichelte Linie
|
||||||
|
for seg_x in range(x, x + w, 6):
|
||||||
|
draw.line((seg_x, ty, min(seg_x + 3, x + w), ty), fill=FG, width=1)
|
||||||
|
# Daten-Linie
|
||||||
|
pad_top, pad_bot = 4, 4
|
||||||
|
pts = []
|
||||||
|
for i, v in enumerate(data):
|
||||||
|
px = x + int(i * w / (len(data) - 1))
|
||||||
|
py = y + pad_top + int((1 - (v - y_min) / (y_max - y_min)) * (h - pad_top - pad_bot))
|
||||||
|
pts.append((px, py))
|
||||||
|
for i in range(len(pts) - 1):
|
||||||
|
draw.line((pts[i][0], pts[i][1], pts[i+1][0], pts[i+1][1]),
|
||||||
|
fill=color, width=2)
|
||||||
|
for px, py in pts:
|
||||||
|
draw.ellipse((px - 1, py - 1, px + 1, py + 1), fill=color)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Mini-Bar mit Schwellen-Farbe ----------------------------------------
|
||||||
|
def draw_mini_bar(draw, x, y, w, h, value: float, max_value: float,
|
||||||
|
color: tuple | None = None):
|
||||||
|
"""Horizontale Mini-Bar mit Outline + Fill in gegebener Farbe."""
|
||||||
|
if w < 6 or h < 4:
|
||||||
|
return
|
||||||
|
color = color or FG
|
||||||
|
draw.rectangle((x, y, x + w - 1, y + h - 1), outline=FG, width=1)
|
||||||
|
pct = min(100, max(0, value / max(0.001, max_value) * 100))
|
||||||
|
fill_w = max(0, int((w - 2) * pct / 100))
|
||||||
|
if fill_w > 0:
|
||||||
|
draw.rectangle((x + 1, y + 1, x + 1 + fill_w, y + h - 2), fill=color)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- ZZZ / Sleep-Indikator -----------------------------------------------
|
||||||
|
def draw_zzz_indicator(draw, x, y, size: int = 16):
|
||||||
|
"""Kleines Mond-Icon + 'Zzz' fuer DeepSleep-Anzeige.
|
||||||
|
|
||||||
|
Wird vom Dashboard-Frame im Header-Bereich gezeichnet, wenn Power-Save
|
||||||
|
aktiv ist.
|
||||||
|
"""
|
||||||
|
cx, cy = x + size // 2, y + size // 2
|
||||||
|
r = size // 3
|
||||||
|
# Mond (Yellow circle, halb verdeckt)
|
||||||
|
draw.ellipse((cx - r, cy - r, cx + r, cy + r), fill=YELLOW)
|
||||||
|
# 'Z' Buchstaben daneben
|
||||||
|
f = _fit_size(draw, "Z", size, size, candidates=["14", "12", "10"])
|
||||||
|
text_in_box(draw, "z", x + size + 2, y, 12, size, f, FG, "left")
|
||||||
|
|
||||||
|
|
||||||
|
# ---- DeepSleep-Status-Anzeige fuer Display ------------------------------
|
||||||
|
def draw_deepsleep_indicator(draw, x, y, w, h, next_wake: str = ""):
|
||||||
|
"""Zeichnet einen schmalen 'Schlafend'-Bereich im Header.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
next_wake: z.B. '06:30' → wird als Text daneben gezeichnet
|
||||||
|
"""
|
||||||
|
if w < 60 or h < 12:
|
||||||
|
return
|
||||||
|
# Gelber Mond-Icon
|
||||||
|
f_moon = _fit_size(draw, "Zz", 30, h, candidates=["12", "10"])
|
||||||
|
text_in_box(draw, "Zz", x, y, 24, h, f_moon, ORANGE, "center")
|
||||||
|
# 'Schlafend bis' Text
|
||||||
|
if next_wake:
|
||||||
|
f = _fit_size(draw, f"schlafend bis {next_wake}", w - 26, h, candidates=["10", "9", "8"])
|
||||||
|
text_in_box(draw, f"schlafend bis {next_wake}", x + 26, y, w - 26, h, f, FG, "left")
|
||||||
|
|||||||
+448
-558
File diff suppressed because it is too large
Load Diff
@@ -939,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>
|
<span class="icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
|
||||||
Settings
|
Settings
|
||||||
</button>
|
</button>
|
||||||
|
<a class="sidebar-item" href="/power" style="text-decoration:none">
|
||||||
|
<span class="icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18.36 6.64a9 9 0 1 1-12.73 0"/><line x1="12" y1="2" x2="12" y2="12"/></svg></span>
|
||||||
|
Energie
|
||||||
|
<span class="badge" id="sbPower">—</span>
|
||||||
|
</a>
|
||||||
<button class="sidebar-item" data-tab="network" onclick="switchTab('network')">
|
<button class="sidebar-item" data-tab="network" onclick="switchTab('network')">
|
||||||
<span class="icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/></svg></span>
|
<span class="icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/></svg></span>
|
||||||
Network
|
Network
|
||||||
@@ -2042,6 +2047,32 @@
|
|||||||
};
|
};
|
||||||
document.getElementById('netHint').textContent = hints[s.mode] || '';
|
document.getElementById('netHint').textContent = hints[s.mode] || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Power-Status (DeepSleep) ----
|
||||||
|
async function refreshPowerStatus() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/power');
|
||||||
|
const d = await r.json();
|
||||||
|
const sb = document.getElementById('sbPower');
|
||||||
|
if (sb) {
|
||||||
|
if (d.deepsleep_enabled && d.is_deepsleep) {
|
||||||
|
sb.textContent = 'Zz';
|
||||||
|
sb.style.background = '#fef3c7';
|
||||||
|
sb.style.color = '#92400e';
|
||||||
|
} else if (d.deepsleep_enabled) {
|
||||||
|
sb.textContent = 'an';
|
||||||
|
sb.style.background = '';
|
||||||
|
sb.style.color = '';
|
||||||
|
} else {
|
||||||
|
sb.textContent = 'aus';
|
||||||
|
sb.style.background = '';
|
||||||
|
sb.style.color = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
refreshPowerStatus();
|
||||||
|
setInterval(refreshPowerStatus, 30000);
|
||||||
async function refreshScan() {
|
async function refreshScan() {
|
||||||
const r = await netFetch('/api/network/scan'); if (!r) return;
|
const r = await netFetch('/api/network/scan'); if (!r) return;
|
||||||
const sel = document.getElementById('ssidSelect');
|
const sel = document.getElementById('ssidSelect');
|
||||||
|
|||||||
@@ -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>
|
||||||
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