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 + + + Energie + — + + + +
+

DeepSleep

+
+
+ + + + + +
+
+ + + + bis + + +
+
+ + + + + +
+
+ + + + + +
+
+ +
+
+
+ +
+

Wie es funktioniert

+ +
+ + + + + +