FEAT-DEEPSLEEP: Display-Refresh-Timer + CPU-Throttle + Admin Power-Tab
DeepSleep-Modus fuer das ePaper-Display:
- dashboard.py: _is_deepsleep(), _next_wake(), _seconds_until_wake(),
_apply_cpu_throttle() (Pi auf 600MHz im Sleep)
- 60s sleep-chunks in main loop statt festes Intervall
- render_full() mit deepsleep_active + next_wake params
- ZZZ-Indikator + CPU-Throttle-Badge wenn schlafend
- load_config() auto-filled 'power' defaults aus POWER_DEFAULTS
Admin UI (templates/power.html + admin.py):
- /power (GET+POST): DeepSleep-Toggle, Wake-Times, CPU-Throttle-Settings
- /api/power (JSON): live status polling fuer Sidebar-Badge
- /api/wake (POST): manuelles Display-Aufwecken aus dem Admin
- templates/index.html: Energie-Sidebar-Link + Live-Status-Badge
(Zz / wach / an / aus) via refreshPowerStatus()
This commit is contained in:
+136
-15
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user