diff --git a/admin.py b/admin.py
index 5f10643..449291d 100644
--- a/admin.py
+++ b/admin.py
@@ -690,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.
diff --git a/dashboard.py b/dashboard.py
index fdd2903..59f7c00 100644
--- a/dashboard.py
+++ b/dashboard.py
@@ -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,18 +430,50 @@ 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
- wait = max(5, interval)
- logging.info(f"waiting up to {wait}s (manual trigger interrupts)")
- if self._trigger.wait(timeout=wait):
- self._trigger.clear()
- logging.info("manual trigger received")
+ # ----- 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:
- logging.info("interval elapsed, refreshing")
+ # Wach: Throttle zurücksetzen falls aktiv
+ if _throttle_applied:
+ self._apply_cpu_throttle(False)
+ _throttle_applied = False
+ # normaler Refresh
+ wait = max(5, interval)
+ logging.info(f"waiting up to {wait}s (manual trigger interrupts)")
+ if self._trigger.wait(timeout=wait):
+ self._trigger.clear()
+ logging.info("manual trigger received")
+ else:
+ logging.info("interval elapsed, refreshing")
t0 = time.time()
img = self.render_once()
diff --git a/templates/index.html b/templates/index.html
index 86d3042..2e33861 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -939,6 +939,11 @@
Settings
+