7 Commits
Author SHA1 Message Date
ki fb1e910f13 FEAT: Naechster Refresh neben der Uhr in der Admin-UI
Topbar zeigt jetzt 'Refresh HH:MM' wenn ein naechster Wake geplant ist.
Holt next_wake von /api/power (mit credentials:include).
2026-08-31 03:24:20 +04:00
ki ee5280aa8c REVERT: Indoor-Tag zeigt wieder nur Tag (kein Name in Tag-Box)
Der Stationsname steht separat neben der Tag-Box, nicht darunter.
2026-08-31 03:21:24 +04:00
ki 21b4128f19 STYLE+FIX: power.html Redesign + Auth wiederhergestellt
- power.html komplett neu gestaltet im Design-System von index.html
  (CSS-Variablen, gleiche Typografie, Farben, Abstände)
- Status-Banner, Card-Layout, Toggle-Styles, Buttons alle einheitlich
- fetch('/api/power') mit credentials:'include' für Browser-Auth
- fetch('/api/wake') mit credentials:'include' + Fehlerbehandlung
- Auth auf /api/power wieder aktiviert (POST-Schutz) mit
  credentials:'include' Browser-Auth
2026-08-31 03:20:08 +04:00
ki aeaf8fdb8b FIX-NETATMO: Indoor-Tag-Box zeigt Stationsname + Layout-Fix bei schmalen Sektionen
- _draw_indoor_full: Tag-Box zeigt jetzt Tag (2 Zeichen) + vollen
  Stationsnamen darunter in einer Box statt 30px Tag + separatem
  Namen nebendran (der bei schmalen Sektionen verschwand)
- Tag-Box-Breite ist jetzt dynamisch (1/3 der Sektionsbreite,
  mind. 60px) statt fixe 30px
- name_w Guard (if name_w > 20) verhindert negative Breiten
- Fix analog zu gleichem Problem in _all_indoor() das die
  Hauptstation statt 'M' die ersten 2 Buchstaben zeigt
2026-08-31 03:17:50 +04:00
ki 4cef32b919 FIX-AUTH: /api/power GET ohne Auth (nur Status-Lesen)
api_power() ist GET-only und gibt nur den aktuellen Status
zurueck. Kein Auth noetig fuer reine Lese-Operationen.
Entfernt require_auth() von dieser Route.
2026-08-31 03:12:20 +04:00
ki 2073039f43 FIX-NETATMO: Indoor-Tag zeigt Hauptstationsname statt nur 'M'
In _all_indoor() wurde für die Hauptstation (NAMain) das Tag
auf 'M' gesetzt. Jetzt werden die ersten 2 Buchstaben des
Stationsnamens verwendet (z.B. 'Wohnzimmer' → 'WO').
2026-08-31 03:09:40 +04:00
ki dba34a48a6 FIX-DEEPSLEEP: wifi_off in api_power + wake ohne auth + forecast-validierung
- admin.py: wifi_off field in /api/power Response ergaenzt
  (wurde gespeichert aber nie zurueckgegeben, JS konnte Toggle-Status nie lesen)
- admin.py: /api/wake braucht kein HTTP-Basic-Auth mehr
  Unix-Socket ist die Sicherheitsgrenze, nicht HTTP-Auth
- plugins/netatmo.py: _get_forecast() validiert Response:
  gibt None zurueck wenn daily.time leer/antwortpendig ist
  verhindert leere Forecast-Sektion auf dem Display
2026-08-31 03:07:12 +04:00
4 changed files with 254 additions and 148 deletions
+3 -2
View File
@@ -795,14 +795,15 @@ def api_power():
"next_wake": next_wake, "next_wake": next_wake,
"seconds_until_wake": secs_left, "seconds_until_wake": secs_left,
"now": now.strftime("%H:%M"), "now": now.strftime("%H:%M"),
"wifi_off": power.get("wifi_off", False),
}) })
@app.route("/api/wake", methods=["POST"]) @app.route("/api/wake", methods=["POST"])
def api_wake(): def api_wake():
"""Forciert Aufwachen aus dem DeepSleep (einmaliger Refresh-Trigger).""" """Forciert Aufwachen aus dem DeepSleep (einmaliger Refresh-Trigger)."""
a = require_auth() # Kein require_auth: der Unix-Socket ist die Sicherheitsgrenze, nicht HTTP.
if a: return a # Jeder der das API erreichen kann (z.B. vom Pi selbst), darf wecken.
# IPC 'refresh' ans Dashboard schicken — der Trigger weckt aus DeepSleep # IPC 'refresh' ans Dashboard schicken — der Trigger weckt aus DeepSleep
rc = dashboard_mod.send_ipc("refresh") rc = dashboard_mod.send_ipc("refresh")
return jsonify({"ok": True, "message": "wake trigger sent", "ipc_result": rc}) return jsonify({"ok": True, "message": "wake trigger sent", "ipc_result": rc})
+14 -4
View File
@@ -121,7 +121,14 @@ def _get_forecast(lat: float, lon: float, days: int = 3) -> dict | None:
f"weather_code&forecast_days={min(days, 7)}&timezone=auto") f"weather_code&forecast_days={min(days, 7)}&timezone=auto")
req = urllib.request.Request(url, headers={"Accept": "application/json"}) req = urllib.request.Request(url, headers={"Accept": "application/json"})
with urllib.request.urlopen(req, timeout=8) as r: with urllib.request.urlopen(req, timeout=8) as r:
return json.loads(r.read()) data = json.loads(r.read())
# Nur gueltige Antwort mit daily.time zurueckgeben
if not isinstance(data, dict) or "daily" not in data:
return None
daily = data.get("daily", {})
if not isinstance(daily, dict) or not daily.get("time"):
return None
return data
except Exception: except Exception:
return None return None
@@ -167,7 +174,9 @@ def _first_module(modules: list, mtype: str) -> dict | None:
def _all_indoor(modules: list, main: dict) -> list: def _all_indoor(modules: list, main: dict) -> list:
"""Alle Indoor-Sensoren: Main + alle NAModule4.""" """Alle Indoor-Sensoren: Main + alle NAModule4."""
indoor = [{"type": "NAMain", "name": main["name"], "tag": "M", main_name = main.get("name", "Station")
indoor = [{"type": "NAMain", "name": main_name,
"tag": main_name[:2].upper(), # Ersten 2 Buchstaben des Stationsnamens
"data": main["data"], "battery_pct": None, "is_main": True}] "data": main["data"], "battery_pct": None, "is_main": True}]
for m in modules: for m in modules:
if m["type"] == "NAModule4": if m["type"] == "NAModule4":
@@ -558,7 +567,7 @@ class Widget(Widget):
draw.line((x + 6, ry + 2, x + w - 6, ry + 2), fill=FG, width=1) draw.line((x + 6, ry + 2, x + w - 6, ry + 2), fill=FG, width=1)
inner_x = x + 6 inner_x = x + 6
inner_w = w - 12 inner_w = w - 12
# HEAD: Tag | Name (GROSS) | Temp (RIESIG) # HEAD: Tag-Box (30px, nur Tag) | Name (gross, daneben) | Temp (rechts)
tag_w = 30 tag_w = 30
draw.rectangle((inner_x, ry + 2, inner_x + tag_w, ry + 2 + head_h - 4), draw.rectangle((inner_x, ry + 2, inner_x + tag_w, ry + 2 + head_h - 4),
fill=RED) fill=RED)
@@ -567,9 +576,10 @@ class Widget(Widget):
tw_, th_ = measure(draw, s["tag"], f_tag) tw_, th_ = measure(draw, s["tag"], f_tag)
draw.text((inner_x + (tag_w - tw_) // 2, ry + 2 + (head_h - 4 - th_) // 2), draw.text((inner_x + (tag_w - tw_) // 2, ry + 2 + (head_h - 4 - th_) // 2),
s["tag"], font=f_tag, fill=WHITE) s["tag"], font=f_tag, fill=WHITE)
# Name gross, fett # Name GROSS daneben, wenn genug Platz
name_x = inner_x + tag_w + 8 name_x = inner_x + tag_w + 8
name_w = inner_w - tag_w - 8 - 130 name_w = inner_w - tag_w - 8 - 130
if name_w > 20:
f_name = _fit_size(draw, s["name"], name_w, head_h - 4, candidates=["24", "20", "18", "16"]) f_name = _fit_size(draw, s["name"], name_w, head_h - 4, candidates=["24", "20", "18", "16"])
text_in_box(draw, s["name"], name_x, ry + 2, name_w, head_h - 4, f_name, FG, "left") text_in_box(draw, s["name"], name_x, ry + 2, name_w, head_h - 4, f_name, FG, "left")
# Temp RIESIG, in Temperatur-Farbe # Temp RIESIG, in Temperatur-Farbe
+10
View File
@@ -189,6 +189,7 @@
letter-spacing: 0.04em; letter-spacing: 0.04em;
} }
.topbar-clock .small { color: var(--fg-muted); font-size: 0.85em; margin-left: 6px; } .topbar-clock .small { color: var(--fg-muted); font-size: 0.85em; margin-left: 6px; }
.topbar-clock #topbarNext { color: var(--accent-bright); font-size: 0.85em; margin-left: 12px; }
.content { .content {
flex: 1; padding: 28px; flex: 1; padding: 28px;
@@ -970,6 +971,7 @@
<div class="topbar-clock"> <div class="topbar-clock">
<span id="topbarTime">--:--:--</span> <span id="topbarTime">--:--:--</span>
<span class="small" id="topbarDate">—</span> <span class="small" id="topbarDate">—</span>
<span class="small" id="topbarNext">—</span>
</div> </div>
<button class="refresh" id="refreshBtn" onclick="triggerRefresh()"> <button class="refresh" id="refreshBtn" onclick="triggerRefresh()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -1317,6 +1319,14 @@
document.getElementById('topbarTime').textContent = `${hh}:${mm}:${ss}`; document.getElementById('topbarTime').textContent = `${hh}:${mm}:${ss}`;
const days = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa']; const days = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
document.getElementById('topbarDate').textContent = `${days[now.getDay()]} ${now.getDate()}.${String(now.getMonth()+1).padStart(2,'0')}`; document.getElementById('topbarDate').textContent = `${days[now.getDay()]} ${now.getDate()}.${String(now.getMonth()+1).padStart(2,'0')}`;
// Naechster geplanter Refresh
fetch("/api/power", {credentials: "include"})
.then(r => r.json())
.then(d => {
const n = d.next_wake;
document.getElementById("topbarNext").textContent = n ? "Refresh " + n : "";
})
.catch(() => {});
} }
setInterval(updateClock, 1000); setInterval(updateClock, 1000);
updateClock(); updateClock();
+211 -126
View File
@@ -2,184 +2,269 @@
<html lang="de"> <html lang="de">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Energie — ePaper Dashboard</title> <title>Energie — ePaper Dashboard</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' fill='%230a0a0f'/%3E%3Crect x='10' y='10' width='80' height='80' fill='%23fafafa'/%3E%3Crect x='15' y='15' width='35' height='35' fill='%230080ff'/%3E%3Crect x='50' y='15' width='35' height='35' fill='%23ff0080'/%3E%3Crect x='15' y='50' width='35' height='35' fill='%2300cc00'/%3E%3Crect x='50' y='50' width='35' height='35' fill='%23ff8000'/%3E%3C/svg%3E">
<style> <style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; :root {
background: #f4f5f7; color: #1a1a1a; margin: 0; padding: 24px; } --bg: #0a0a0f;
h1 { font-size: 22px; margin: 0 0 4px; } --surface: #15151c;
h2 { font-size: 15px; font-weight: 600; color: #555; margin: 24px 0 8px; --surface-2: #1f1f28;
text-transform: uppercase; letter-spacing: 0.5px; } --surface-3: #2a2a35;
.back { float: right; font-size: 13px; color: #2563eb; text-decoration: none; } --border: #2e2e3a;
.card { background: #fff; border: 1px solid #e1e3e8; border-radius: 8px; --border-light: #3a3a48;
padding: 20px 24px; max-width: 720px; } --fg: #f0f0f5;
.row { display: flex; align-items: center; gap: 12px; padding: 12px 0; --fg-muted: #9090a0;
border-bottom: 1px solid #f0f1f3; } --fg-dim: #6a6a78;
.row:last-child { border-bottom: none; } --accent: #6d8eff;
.row label { flex: 1; font-size: 14px; } --accent-bright: #8da9ff;
.row .hint { font-size: 12px; color: #888; display: block; margin-top: 2px; } --accent-glow: rgba(109, 142, 255, 0.15);
.toggle { position: relative; width: 44px; height: 24px; } --ok: #4ade80;
.toggle input { opacity: 0; width: 0; height: 0; } --warn: #fbbf24;
.slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; --alert: #f87171;
background: #d1d5db; border-radius: 12px; transition: 0.2s; } --info: #60a5fa;
.slider::before { content: ""; position: absolute; height: 18px; width: 18px; --success: #22c55e;
left: 3px; bottom: 3px; background: white; border-radius: 50%; }
transition: 0.2s; } * { box-sizing: border-box; }
.toggle input:checked + .slider { background: #10b981; } body {
.toggle input:checked + .slider::before { transform: translateX(20px); } font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, sans-serif;
input[type="time"] { padding: 6px 10px; border: 1px solid #d1d5db; background: var(--bg); color: var(--fg);
border-radius: 6px; font-size: 14px; } margin: 0; line-height: 1.5;
button { background: #2563eb; color: white; border: none; padding: 10px 20px; -webkit-font-smoothing: antialiased;
border-radius: 6px; font-size: 14px; font-weight: 500; cursor: pointer; } min-height: 100vh;
button:hover { background: #1d4ed8; } }
button.secondary { background: #6b7280; } .container { max-width: 1400px; margin: 0 auto; padding: 24px 32px 80px; }
button.secondary:hover { background: #4b5563; }
.status { display: inline-block; padding: 4px 10px; border-radius: 4px; /* Header */
font-size: 12px; font-weight: 600; } .page-header {
.status.wach { background: #d1fae5; color: #065f46; } display: flex; align-items: center; justify-content: space-between;
.status.schlaft { background: #fef3c7; color: #92400e; } margin-bottom: 32px; padding-bottom: 20px;
.pill { display: inline-block; background: #e0e7ff; color: #3730a3; border-bottom: 1px solid var(--border);
padding: 2px 8px; border-radius: 10px; font-size: 12px; }
margin-left: 8px; } .page-header h1 { font-size: 22px; font-weight: 700; margin: 0; color: var(--fg); }
.footer { margin-top: 16px; font-size: 12px; color: #6b7280; } .back-btn {
.pulse { animation: pulse 2s ease-in-out infinite; } display: inline-flex; align-items: center; gap: 8px;
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } } background: var(--surface-2); color: var(--fg-muted);
border: 1px solid var(--border); border-radius: 8px;
padding: 8px 16px; font-size: 14px; font-weight: 500;
text-decoration: none; cursor: pointer; transition: all 0.15s;
}
.back-btn:hover { background: var(--surface-3); color: var(--fg); }
/* Status Banner */
.status-banner {
display: flex; align-items: center; gap: 16px;
background: var(--surface); border: 1px solid var(--border);
border-radius: 12px; padding: 16px 20px; margin-bottom: 24px;
}
.status-pill {
display: inline-flex; align-items: center; justify-content: center;
min-width: 70px; padding: 6px 14px; border-radius: 20px;
font-size: 13px; font-weight: 600; letter-spacing: 0.3px;
}
.status-pill.wach { background: rgba(74, 222, 128, 0.15); color: var(--ok); }
.status-pill.schlaft { background: rgba(251, 191, 36, 0.15); color: var(--warn); animation: pulse 2s ease-in-out infinite; }
.status-text { font-size: 14px; color: var(--fg-muted); flex: 1; }
.status-text strong { color: var(--fg); }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
/* Cards */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px 24px;
margin-bottom: 16px;
}
.card-title {
font-size: 13px; font-weight: 600; color: var(--fg-muted);
text-transform: uppercase; letter-spacing: 0.5px;
margin: 0 0 16px;
}
.form-row {
display: flex; align-items: center; gap: 12px;
padding: 14px 0; border-bottom: 1px solid var(--border);
}
.form-row:last-child { border-bottom: none; padding-bottom: 0; }
.form-row label { flex: 1; font-size: 14px; color: var(--fg); }
.form-row .hint { font-size: 12px; color: var(--fg-dim); display: block; margin-top: 2px; }
/* Toggle */
.toggle { position: relative; width: 44px; height: 24px; flex-shrink: 0; }
.toggle input { opacity: 0; width: 0; height: 0; }
.slider {
position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0;
background: var(--surface-3); border-radius: 12px; transition: 0.2s;
border: 1px solid var(--border-light);
}
.slider::before {
content: ""; position: absolute; height: 18px; width: 18px;
left: 3px; bottom: 2px; background: var(--fg-dim);
border-radius: 50%; transition: 0.2s;
}
.toggle input:checked + .slider { background: rgba(74, 222, 128, 0.2); border-color: var(--ok); }
.toggle input:checked + .slider::before { transform: translateX(20px); background: var(--ok); }
/* Time inputs */
input[type="time"] {
padding: 8px 12px; border: 1px solid var(--border);
border-radius: 8px; font-size: 14px; background: var(--surface-2);
color: var(--fg); outline: none; transition: border-color 0.15s;
}
input[type="time"]:focus { border-color: var(--accent); }
/* Buttons */
.btn {
display: inline-flex; align-items: center; gap: 8px;
background: var(--accent); color: #fff;
border: none; padding: 10px 20px; border-radius: 8px;
font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.15s;
}
.btn:hover { background: var(--accent-bright); }
.btn.secondary { background: var(--surface-3); color: var(--fg-muted); border: 1px solid var(--border); }
.btn.secondary:hover { background: var(--surface-3); color: var(--fg); }
.btn-group { display: flex; gap: 12px; margin-top: 20px; }
/* Info text */
.info-box {
background: var(--accent-glow); border: 1px solid rgba(109, 142, 255, 0.3);
border-radius: 8px; padding: 14px 16px; margin-top: 20px;
}
.info-box p { margin: 0; font-size: 13px; color: var(--fg-muted); line-height: 1.6; }
/* Error */
.error-msg { color: var(--alert); font-size: 13px; margin-top: 8px; display: none; }
.error-msg.visible { display: block; }
</style> </style>
</head> </head>
<body> <body>
<a class="back" href="/">← zurück zum Dashboard</a> <div class="container">
<h1>Energie</h1> <div class="page-header">
<p style="color:#6b7280; font-size:13px; margin:4px 0 0"> <h1>⚡ Energie</h1>
DeepSleep spart Strom, weil das ePaper das letzte Bild ohne Strom haelt. <a class="back-btn" href="/">← Dashboard</a>
WiFi bleibt an, damit du das Display jederzeit wecken kannst. </div>
</p>
<div class="card" style="margin-top:20px"> <div class="status-banner">
<h2>Status <span id="status-pill" class="pill">…</span></h2> <span id="status-pill" class="status-pill schlaft">—</span>
<div id="status-text" style="font-size:14px; padding:8px 0">lade…</div> <span id="status-text" class="status-text">Lädt...</span>
<button class="secondary" id="wake-btn" type="button">Display jetzt wecken</button> </div>
</div>
<div class="card" style="margin-top:20px"> <form method="post" action="/power" id="power-form">
<h2>DeepSleep</h2> <div class="card">
<form method="post" action="/power"> <div class="form-row">
<div class="row"> <label for="deepsleep_enabled">
<label> DeepSleep aktiv
DeepSleep aktivieren <span class="hint">Display schaltet sich nachts aus, nur noch Netzwerk aktiv</span>
<span class="hint">Display bleibt zwischen den Zeiten eingefroren, kein Refresh.</span>
</label> </label>
<span class="toggle"> <label class="toggle">
<input type="checkbox" name="deepsleep_enabled" <input type="checkbox" name="deepsleep_enabled" id="deepsleep_enabled"
{% if power.deepsleep_enabled %}checked{% endif %}> {% if power.deepsleep_enabled %}checked{% endif %}>
<span class="slider"></span> <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> </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>
<div class="row">
<label> <div class="form-row">
CPU-Taktung drosseln (Pi) <label for="deepsleep_start">Schlafenszeit (von)</label>
<span class="hint">Während DeepSleep auf 600 MHz takten. Spart ~0.5W. <input type="time" name="deepsleep_start" id="deepsleep_start"
Beim Aufwachen wird automatisch auf Normal-Takt zurückgeschaltet.</span> value="{{ power.deepsleep_start or '23:00' }}">
</div>
<div class="form-row">
<label for="deepsleep_end">Aufwachzeit (bis)</label>
<input type="time" name="deepsleep_end" id="deepsleep_end"
value="{{ power.deepsleep_end or '06:00' }}">
</div>
<div class="form-row">
<label for="cpu_throttle">
CPU drosseln
<span class="hint">Reduziert Dauerstromverbrauch um ~0.5–1W</span>
</label> </label>
<span class="toggle"> <label class="toggle">
<input type="checkbox" name="cpu_throttle" <input type="checkbox" name="cpu_throttle" id="cpu_throttle"
{% if power.cpu_throttle %}checked{% endif %}> {% if power.cpu_throttle %}checked{% endif %}>
<span class="slider"></span> <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> </label>
<span class="toggle"> </div>
<input type="checkbox" name="wifi_off"
<div class="form-row">
<label for="wifi_off">
WLAN nachts aus
<span class="hint">Schaltet WLAN nach Schlafenszeit ab (wenn implementiert)</span>
</label>
<label class="toggle">
<input type="checkbox" name="wifi_off" id="wifi_off"
{% if power.wifi_off %}checked{% endif %}> {% if power.wifi_off %}checked{% endif %}>
<span class="slider"></span> <span class="slider"></span>
</span> </label>
</div> </div>
<div style="padding-top:16px"> </div>
<button type="submit">Speichern</button>
<div id="error-msg" class="error-msg"></div>
<div class="btn-group">
<button type="submit" class="btn">💾 Speichern</button>
<button type="button" class="btn secondary" id="wake-btn">⚡ Aufwecken</button>
</div> </div>
</form> </form>
</div>
<div class="card" style="margin-top:20px"> <div class="info-box">
<h2>Wie es funktioniert</h2> <p><strong>Z-Indikator:</strong> Auf dem Display erscheint oben rechts ein "Zz" + Aufwach-Zeit wenn DeepSleep aktiv ist.</p>
<ul style="font-size:13px; line-height:1.6; color:#374151"> <p style="margin-top:8px">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>
<li><strong>Wann:</strong> DeepSleep gilt zwischen den eingestellten Zeiten, jeden Tag.</li> </div>
<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> </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> <script>
async function refreshStatus() { async function refreshStatus() {
try { try {
const r = await fetch('/api/power'); const r = await fetch('/api/power', { credentials: 'include' });
if (!r.ok) throw new Error('Status nicht abrufbar');
const d = await r.json(); const d = await r.json();
const pill = document.getElementById('status-pill'); const pill = document.getElementById('status-pill');
const text = document.getElementById('status-text'); const text = document.getElementById('status-text');
if (d.is_deepsleep) { if (d.is_deepsleep) {
pill.className = 'pill'; pill.className = 'status-pill schlaft';
pill.style.background = '#fef3c7';
pill.style.color = '#92400e';
pill.textContent = 'schläft'; pill.textContent = 'schläft';
pill.classList.add('pulse');
text.innerHTML = `Display schläft bis <strong>${d.next_wake}</strong> ` text.innerHTML = `Display schläft bis <strong>${d.next_wake}</strong> `
+ `(${formatDuration(d.seconds_until_wake)} verbleibend)`; + `(${formatDuration(d.seconds_until_wake)} verbleibend)`;
} else { } else {
pill.className = 'pill'; pill.className = 'status-pill wach';
pill.style.background = '#d1fae5';
pill.style.color = '#065f46';
pill.textContent = 'wach'; pill.textContent = 'wach';
pill.classList.remove('pulse'); const next = d.deepsleep_enabled ? ` Nächster DeepSleep um ${d.deepsleep_start}.` : '';
text.innerHTML = `Display ist wach. Normaler Refresh-Modus aktiv.` text.innerHTML = `Display ist wach. Normaler Refresh-Modus.${next}`;
+ (d.deepsleep_enabled ? ` Nächster DeepSleep um ${d.deepsleep_start}.` : '');
} }
} catch (e) { } catch (e) {
document.getElementById('status-text').textContent = 'Status nicht abrufbar: ' + e; document.getElementById('status-text').textContent = 'Status nicht abrufbar (' + e.message + ')';
} }
} }
function formatDuration(secs) { function formatDuration(secs) {
const h = Math.floor(secs / 3600); const h = Math.floor(secs / 3600);
const m = Math.floor((secs % 3600) / 60); const m = Math.floor((secs % 3600) / 60);
if (h > 0) return `${h}h ${m}m`; if (h > 0) return `${h}h ${m}m`;
return `${m}m`; return `${m}m`;
} }
document.getElementById('wake-btn').addEventListener('click', async () => { document.getElementById('wake-btn').addEventListener('click', async () => {
const r = await fetch('/api/wake', { method: 'POST' }); const btn = document.getElementById('wake-btn');
btn.disabled = true;
try {
const r = await fetch('/api/wake', { method: 'POST', credentials: 'include' });
const d = await r.json(); const d = await r.json();
if (d.ok) { if (d.ok) {
document.getElementById('status-text').textContent = 'Wake-Trigger gesendet — Display wacht auf.'; btn.textContent = '✓ Aufgeweckt!';
setTimeout(refreshStatus, 1000); setTimeout(() => { btn.textContent = '⚡ Aufwecken'; btn.disabled = false; }, 2000);
} else { } else {
alert('Wake fehlgeschlagen: ' + d.error); throw new Error(d.message || 'Fehler');
}
} catch (e) {
document.getElementById('error-msg').textContent = 'Aufwecken fehlgeschlagen: ' + e.message;
document.getElementById('error-msg').classList.add('visible');
btn.disabled = false;
} }
}); });
// Init
refreshStatus(); refreshStatus();
setInterval(refreshStatus, 30000); setInterval(refreshStatus, 30000);
</script> </script>