diff --git a/raspi/Dashboard.py b/raspi/Dashboard.py index 05947e8..fc30048 100755 --- a/raspi/Dashboard.py +++ b/raspi/Dashboard.py @@ -1,119 +1,111 @@ -""" -Dashboard.py -──────────── -Flask app factory for the Smart Mirror dashboard. - -Routes ------- - GET / → Jinja2 rendered dashboard (templates/dashboard.html) - GET /api/data → JSON snapshot from USBRead (status + ring-buffer) - GET /api/alerts → JSON log of dispatched alert events - -Start via Gunicorn: - gunicorn -c gunicorn.conf.py "Dashboard:create_app()" - -Or for development: - python Dashboard.py -""" - -import json -import threading -from collections import deque -from datetime import datetime -from pathlib import Path - -from flask import Flask, jsonify, render_template - -import Notification -import USBRead - -# ── Resolve paths relative to this file ────────────────────────── -BASE_DIR = Path(__file__).parent -SETTINGS_PATH = BASE_DIR / "settings.json" - - -# ── Settings loader ─────────────────────────────────────────────── - -def load_settings() -> dict: - with open(SETTINGS_PATH, encoding="utf-8") as fh: - return json.load(fh) - - -# ── Flask app factory ───────────────────────────────────────────── - -def create_app() -> Flask: - settings = load_settings() - dash_cfg = settings["dashboard"] - sensor_cfg = settings.get("sensors", []) - - # ── Init subsystems ─────────────────────────────────────────── - USBRead.init(settings["usb"]) - USBRead.start_reader() - - Notification.init(settings) - Notification.attach_to_usb() - - # ── In-memory alert log (visible in dashboard) ──────────────── - alert_log : deque = deque(maxlen=500) - alert_lock : threading.Lock = threading.Lock() - - # Wrap Notification.send_alert so every outbound alert is also - # appended to the dashboard log. - _orig_send = Notification.send_alert - - def _logging_send(subject: str, body: str) -> None: - with alert_lock: - alert_log.append({ - "ts": datetime.now().isoformat(timespec="milliseconds"), - "subject": subject, - "body": body, - }) - _orig_send(subject, body) - - Notification.send_alert = _logging_send - - # ── Build Flask app ─────────────────────────────────────────── - app = Flask( - __name__, - template_folder = str(BASE_DIR / "templates"), - static_folder = str(BASE_DIR / "static"), - ) - - # Pre-serialise sensor config once (passed to template as JSON - # data-attribute so dashboard.js can read it without an extra request) - sensors_json = json.dumps(sensor_cfg) - - # ── Routes ──────────────────────────────────────────────────── - - @app.route("/") - def index(): - return render_template( - "dashboard.html", - title = dash_cfg.get("title", "Smart Mirror"), - sensors = sensor_cfg, - sensors_json = sensors_json, - poll_ms = dash_cfg.get("poll_interval_ms", 800), - ) - - @app.route("/api/data") - def api_data(): - return jsonify(USBRead.get_snapshot()) - - @app.route("/api/alerts") - def api_alerts(): - with alert_lock: - return jsonify(list(alert_log)) - - return app - - -# ── Dev entry point ─────────────────────────────────────────────── -if __name__ == "__main__": - settings = load_settings() - dash = settings["dashboard"] - app = create_app() - app.run( - host = dash.get("host", "0.0.0.0"), - port = dash.get("port", 80), - debug = False, - ) +""" +Dashboard.py +──────────── +Flask app factory for the Smart Mirror dashboard. + +Routes +------ + GET / → dashboard (templates/dashboard.html) + GET /api/data → JSON snapshot from USBRead + GET /api/alerts → JSON log of alerts + GET /api/weather → JSON weather from Open-Meteo (cached) +""" + +import json +import threading +from collections import deque +from datetime import datetime +from pathlib import Path + +from flask import Flask, jsonify, render_template + +import Notification +import USBRead +import Weather + +BASE_DIR = Path(__file__).parent +SETTINGS_PATH = BASE_DIR / "settings.json" + + +def load_settings() -> dict: + with open(SETTINGS_PATH, encoding="utf-8") as fh: + return json.load(fh) + + +def create_app() -> Flask: + settings = load_settings() + dash_cfg = settings["dashboard"] + sensor_cfg = settings.get("sensors", []) + loc_cfg = settings.get("location", {}) + + USBRead.init(settings["usb"]) + USBRead.start_reader() + + Notification.init(settings) + Notification.attach_to_usb() + + if loc_cfg: + Weather.init(loc_cfg) + Weather.start_fetcher() + + alert_log : deque = deque(maxlen=500) + alert_lock : threading.Lock = threading.Lock() + + _orig_send = Notification.send_alert + + def _logging_send(subject: str, body: str) -> None: + with alert_lock: + alert_log.append({ + "ts": datetime.now().isoformat(timespec="milliseconds"), + "subject": subject, + "body": body, + }) + _orig_send(subject, body) + + Notification.send_alert = _logging_send + + app = Flask( + __name__, + template_folder = str(BASE_DIR / "templates"), + static_folder = str(BASE_DIR / "static"), + ) + + sensors_json = json.dumps(sensor_cfg) + location_json = json.dumps(loc_cfg) + + @app.route("/") + def index(): + return render_template( + "dashboard.html", + title = dash_cfg.get("title", "Smart Mirror"), + sensors = sensor_cfg, + sensors_json = sensors_json, + location_json = location_json, + poll_ms = dash_cfg.get("poll_interval_ms", 2500), + ) + + @app.route("/api/data") + def api_data(): + return jsonify(USBRead.get_snapshot()) + + @app.route("/api/alerts") + def api_alerts(): + with alert_lock: + return jsonify(list(alert_log)) + + @app.route("/api/weather") + def api_weather(): + return jsonify(Weather.get_weather()) + + return app + + +if __name__ == "__main__": + settings = load_settings() + dash = settings["dashboard"] + app = create_app() + app.run( + host = dash.get("host", "0.0.0.0"), + port = dash.get("port", 8088), + debug = False, + ) diff --git a/raspi/Weather.py b/raspi/Weather.py new file mode 100644 index 0000000..9d13b94 --- /dev/null +++ b/raspi/Weather.py @@ -0,0 +1,117 @@ +""" +Weather.py +────────── +Fetches hourly weather from Open-Meteo (free, no API key). +Runs in a background thread and caches the last result. +""" + +import threading +import time +import urllib.request +import json +from datetime import datetime + +_lock = threading.Lock() +_cache = {} +_cfg = {} + +WMO_CODES = { + 0: ("Klar", "☀️"), + 1: ("Überwiegend klar", "🌤️"), + 2: ("Teilweise bewölkt", "⛅"), + 3: ("Bedeckt", "☁️"), + 45: ("Nebel", "🌫️"), + 48: ("Reifnebel", "🌫️"), + 51: ("Leichter Nieselregen","🌦️"), + 53: ("Nieselregen", "🌦️"), + 55: ("Dichter Nieselregen", "🌦️"), + 61: ("Leichter Regen", "🌧️"), + 63: ("Regen", "🌧️"), + 65: ("Starker Regen", "🌧️"), + 71: ("Leichter Schnee", "🌨️"), + 73: ("Schnee", "🌨️"), + 75: ("Starker Schnee", "❄️"), + 80: ("Regenschauer", "🌦️"), + 81: ("Regenschauer", "🌦️"), + 82: ("Starke Schauer", "⛈️"), + 95: ("Gewitter", "⛈️"), + 96: ("Gewitter mit Hagel", "⛈️"), + 99: ("Heftiges Gewitter", "⛈️"), +} + + +def init(location_cfg: dict) -> None: + global _cfg + _cfg = location_cfg + + +def get_weather() -> dict: + with _lock: + return dict(_cache) + + +def start_fetcher() -> threading.Thread: + t = threading.Thread(target=_fetch_loop, daemon=True, name="weather-fetch") + t.start() + return t + + +def _fetch_loop() -> None: + interval = _cfg.get("weather_refresh_s", 600) + while True: + try: + _do_fetch() + except Exception as exc: + print(f"[Weather] fetch error: {exc}") + time.sleep(interval) + + +def _do_fetch() -> None: + lat = _cfg["latitude"] + lon = _cfg["longitude"] + url = ( + f"https://api.open-meteo.com/v1/forecast" + f"?latitude={lat}&longitude={lon}" + f"&hourly=precipitation_probability" + f"¤t=temperature_2m,relative_humidity_2m," + f"apparent_temperature,weather_code,wind_speed_10m" + f"&wind_speed_unit=kmh" + f"&timezone=Europe%2FBerlin" + f"&forecast_days=1" + ) + with urllib.request.urlopen(url, timeout=10) as resp: + raw = json.loads(resp.read()) + + cur = raw.get("current", {}) + hrly = raw.get("hourly", {}) + + # Pick precipitation probability for current hour + now_h = datetime.now().hour + prec_prob = 0 + times = hrly.get("time", []) + precs = hrly.get("precipitation_probability", []) + for i, t in enumerate(times): + if f"T{now_h:02d}:" in t and i < len(precs): + prec_prob = precs[i] + break + + code = cur.get("weather_code", 0) + desc, icon = WMO_CODES.get(code, ("Unbekannt", "❓")) + + result = { + "location": _cfg.get("name", ""), + "temp": cur.get("temperature_2m"), + "feels_like": cur.get("apparent_temperature"), + "humidity": cur.get("relative_humidity_2m"), + "wind_kmh": cur.get("wind_speed_10m"), + "weather_code": code, + "description": desc, + "icon": icon, + "rain_prob": prec_prob, + "fetched_at": datetime.now().isoformat(timespec="seconds"), + } + + with _lock: + _cache.update(result) + + print(f"[Weather] {desc} {cur.get('temperature_2m')}°C, Regen {prec_prob}%") diff --git a/raspi/settings.json b/raspi/settings.json index 15b30e8..4b76532 100755 --- a/raspi/settings.json +++ b/raspi/settings.json @@ -13,6 +13,13 @@ "title": "Smart Mirror" }, + "location": { + "name": "Dillingen / Saar", + "latitude": 49.357, + "longitude": 6.731, + "weather_refresh_s": 600 + }, + "smtp": { "enabled": false, "host": "smtp.example.com", diff --git a/raspi/static/css/mirror.css b/raspi/static/css/mirror.css index 885cc32..9f4dbf3 100755 --- a/raspi/static/css/mirror.css +++ b/raspi/static/css/mirror.css @@ -1,47 +1,54 @@ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=JetBrains+Mono:wght@400;500&display=swap'); -/* ── Design Tokens ── */ +/* ══════════════════════════════════════════ + DESIGN TOKENS +══════════════════════════════════════════ */ :root { - --bg: #111318; - --bg-card: #1c1f2b; - --bg-card2: #21253a; - --border: rgba(255,255,255,.07); - --border-hover:rgba(255,255,255,.13); + --bg: #111318; + --bg-card: #1c1f2b; + --bg-card2: #21253a; + --border: rgba(255,255,255,.07); + --border-h: rgba(255,255,255,.13); - --txt: #e4e9f5; - --txt-dim: rgba(228,233,245,.55); - --txt-ghost: rgba(228,233,245,.28); + --txt: #e4e9f5; + --txt-dim: rgba(228,233,245,.55); + --txt-ghost: rgba(228,233,245,.28); - --blue: #3b82f6; - --blue-glow: rgba(59,130,246,.25); - --blue-soft: rgba(59,130,246,.12); + --blue: #3b82f6; + --blue-soft: rgba(59,130,246,.12); + --blue-glow: rgba(59,130,246,.35); - --green: #22c55e; - --green-soft: rgba(34,197,94,.12); - --green-glow: rgba(34,197,94,.3); + --green: #22c55e; + --green-soft: rgba(34,197,94,.12); - --yellow: #f59e0b; - --yellow-soft: rgba(245,158,11,.12); + --yellow: #f59e0b; + --yellow-soft: rgba(245,158,11,.12); - --red: #ef4444; - --red-soft: rgba(239,68,68,.12); - --red-glow: rgba(239,68,68,.3); + --red: #ef4444; + --red-soft: rgba(239,68,68,.12); - --teal: #14b8a6; - --teal-soft: rgba(20,184,166,.12); + --teal: #14b8a6; + --teal-soft: rgba(20,184,166,.12); - --purple: #a78bfa; - --purple-soft: rgba(167,139,250,.12); + --r: 12px; + --r-sm: 8px; + --gap: 14px; - --r: 12px; - --r-sm: 8px; - --gap: 14px; + --font: 'Inter', system-ui, sans-serif; + --mono: 'JetBrains Mono', monospace; - --font: 'Inter', system-ui, sans-serif; - --mono: 'JetBrains Mono', monospace; + /* Consumer view palette */ + --c-bg: #090b12; + --c-txt: #dde8ff; + --c-dim: rgba(221,232,255,.45); + --c-ghost: rgba(221,232,255,.2); + --c-accent: #60a5fa; + --c-accent2: #34d399; } -/* ── Reset ── */ +/* ══════════════════════════════════════════ + RESET & BASE +══════════════════════════════════════════ */ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } html, body { @@ -52,20 +59,86 @@ html, body { font-size: 14px; font-weight: 400; -webkit-font-smoothing: antialiased; - overflow-x: hidden; + overflow: hidden; /* both views are full-viewport */ } -/* Subtle grid background */ +/* grid overlay */ body::before { content: ''; position: fixed; inset: 0; z-index: 0; pointer-events: none; background-image: - linear-gradient(rgba(59,130,246,.025) 1px, transparent 1px), - linear-gradient(90deg, rgba(59,130,246,.025) 1px, transparent 1px); + linear-gradient(rgba(59,130,246,.018) 1px, transparent 1px), + linear-gradient(90deg, rgba(59,130,246,.018) 1px, transparent 1px); background-size: 40px 40px; + transition: opacity .6s; +} +body.consumer-mode::before { opacity: 0; } + +/* ══════════════════════════════════════════ + VIEW SWITCHER (hidden toggle button) +══════════════════════════════════════════ */ +#view-toggle { + position: fixed; + bottom: 18px; right: 18px; + z-index: 9999; + width: 28px; height: 28px; + border-radius: 50%; + background: rgba(255,255,255,.04); + border: 1px solid rgba(255,255,255,.08); + cursor: pointer; + display: flex; align-items: center; justify-content: center; + opacity: 0; + transition: opacity .3s, background .2s, transform .2s; + user-select: none; +} +#view-toggle:hover { + opacity: 1 !important; + background: rgba(255,255,255,.10); + transform: scale(1.15); +} +#view-toggle:active { transform: scale(.95); } +#view-toggle svg { width: 12px; height: 12px; fill: rgba(255,255,255,.4); } + +/* Show faintly on long hover of bottom-right corner */ +body:hover #view-toggle { opacity: .15; } + +/* ══════════════════════════════════════════ + VIEWS — TRANSITION CONTAINER +══════════════════════════════════════════ */ +#view-dev, #view-consumer { + position: fixed; inset: 0; + overflow-y: auto; + transition: opacity .55s cubic-bezier(.4,0,.2,1), + transform .55s cubic-bezier(.4,0,.2,1); } -/* ── Layout ── */ +/* dev default: visible */ +#view-dev { + opacity: 1; transform: none; + z-index: 1; + pointer-events: all; +} +/* consumer: hidden right */ +#view-consumer { + opacity: 0; transform: translateX(60px); + z-index: 2; + pointer-events: none; + background: var(--c-bg); +} + +/* active states */ +body.consumer-mode #view-dev { + opacity: 0; transform: translateX(-60px); + pointer-events: none; +} +body.consumer-mode #view-consumer { + opacity: 1; transform: none; + pointer-events: all; +} + +/* ══════════════════════════════════════════ + ── DEV VIEW ── +══════════════════════════════════════════ */ .mirror-layout { position: relative; z-index: 1; display: grid; @@ -77,16 +150,15 @@ body::before { margin: 0 auto; } -/* ── Card primitive ── */ .card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--r); transition: border-color .25s, background .25s; } -.card:hover { border-color: var(--border-hover); } +.card:hover { border-color: var(--border-h); } -/* ── Header ── */ +/* Header */ .mirror-header { display: grid; grid-template-columns: auto 1fr auto; @@ -94,89 +166,50 @@ body::before { gap: 20px; padding: 16px 20px; } - -.header-brand { - display: flex; - align-items: center; - gap: 10px; -} +.header-brand { display: flex; align-items: center; gap: 10px; } .brand-icon { width: 32px; height: 32px; border-radius: 8px; background: var(--blue-soft); - border: 1px solid var(--blue); + border: 1px solid rgba(59,130,246,.3); display: flex; align-items: center; justify-content: center; font-size: 16px; } -.brand-name { - font-size: 13px; - font-weight: 600; - letter-spacing: .04em; - color: var(--txt); -} -.brand-sub { - font-size: 11px; - color: var(--txt-ghost); - margin-top: 1px; -} +.brand-name { font-size: 13px; font-weight: 600; letter-spacing: .04em; } +.brand-sub { font-size: 11px; color: var(--txt-ghost); margin-top: 1px; } -/* Clock — centred */ .mirror-clock { text-align: center; } .clock-time { font-family: var(--mono); - font-size: clamp(2rem, 5vw, 3.6rem); + font-size: clamp(2rem, 5vw, 3.4rem); font-weight: 500; letter-spacing: .08em; - color: var(--txt); line-height: 1; } .clock-date { - font-size: 11px; - font-weight: 400; - letter-spacing: .18em; - text-transform: uppercase; - color: var(--txt-ghost); - margin-top: 6px; + font-size: 11px; font-weight: 400; + letter-spacing: .18em; text-transform: uppercase; + color: var(--txt-ghost); margin-top: 6px; } -/* Connection badge */ .conn-badge { - display: flex; - align-items: center; - gap: 8px; + display: flex; align-items: center; gap: 8px; padding: 8px 14px; border-radius: var(--r-sm); - font-family: var(--mono); - font-size: 11px; + font-family: var(--mono); font-size: 11px; color: var(--txt-ghost); transition: all .3s; } -.conn-badge.ok { - background: var(--green-soft); - border-color: rgba(34,197,94,.25); - color: var(--green); -} -.conn-badge.err { - background: var(--red-soft); - border-color: rgba(239,68,68,.25); - color: var(--red); -} +.conn-badge.ok { background: var(--green-soft); border-color: rgba(34,197,94,.25); color: var(--green); } +.conn-badge.err { background: var(--red-soft); border-color: rgba(239,68,68,.25); color: var(--red); } .conn-dot { - width: 7px; height: 7px; - border-radius: 50%; - background: currentColor; - flex-shrink: 0; - transition: box-shadow .3s; + width: 7px; height: 7px; border-radius: 50%; + background: currentColor; flex-shrink: 0; } -.conn-badge.ok .conn-dot { box-shadow: 0 0 6px var(--green); animation: pulse-green 2s infinite; } -.conn-badge.err .conn-dot { box-shadow: 0 0 6px var(--red); } +.conn-badge.ok .conn-dot { animation: pulse-ok 2s infinite; box-shadow: 0 0 6px var(--green); } +@keyframes pulse-ok { 0%,100%{opacity:1} 50%{opacity:.4} } -@keyframes pulse-green { - 0%,100% { opacity: 1; } - 50% { opacity: .5; } -} - -/* ── Body grid ── */ +/* Body grid */ .mirror-body { display: grid; grid-template-columns: repeat(4, 1fr); @@ -184,343 +217,302 @@ body::before { gap: var(--gap); } -/* ── Sensor tiles ── */ +/* Sensor tiles */ .sensor-tile { padding: 18px 18px 14px; - position: relative; - overflow: hidden; - cursor: default; + position: relative; overflow: hidden; transition: transform .2s, box-shadow .2s, border-color .25s; } -.sensor-tile:hover { - transform: translateY(-1px); - box-shadow: 0 8px 24px rgba(0,0,0,.35); -} - -/* Coloured left stripe */ +.sensor-tile:hover { transform: translateY(-1px); box-shadow: 0 8px 24px rgba(0,0,0,.35); } .sensor-tile::after { content: ''; - position: absolute; top: 0; left: 0; bottom: 0; - width: 3px; + position: absolute; top: 0; left: 0; bottom: 0; width: 3px; border-radius: var(--r) 0 0 var(--r); - background: var(--blue); - transition: background .3s; + background: var(--blue); transition: background .3s; } .sensor-tile.breach-high::after { background: var(--red); } .sensor-tile.breach-low::after { background: var(--yellow); } -.sensor-tile-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 14px; -} +.sensor-tile-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; } .sensor-icon { - width: 30px; height: 30px; - border-radius: 7px; - background: var(--blue-soft); - border: 1px solid rgba(59,130,246,.2); - display: flex; align-items: center; justify-content: center; - font-size: 14px; + width: 30px; height: 30px; border-radius: 7px; + background: var(--blue-soft); border: 1px solid rgba(59,130,246,.2); + display: flex; align-items: center; justify-content: center; font-size: 14px; transition: background .3s, border-color .3s; } -.sensor-tile.breach-high .sensor-icon { - background: var(--red-soft); - border-color: rgba(239,68,68,.3); -} -.sensor-tile.breach-low .sensor-icon { - background: var(--yellow-soft); - border-color: rgba(245,158,11,.3); -} - +.sensor-tile.breach-high .sensor-icon { background: var(--red-soft); border-color: rgba(239,68,68,.3); } +.sensor-tile.breach-low .sensor-icon { background: var(--yellow-soft); border-color: rgba(245,158,11,.3); } .sensor-state-dot { - width: 6px; height: 6px; - border-radius: 50%; - background: var(--blue); - opacity: .5; - transition: background .3s, opacity .3s, box-shadow .3s; + width: 6px; height: 6px; border-radius: 50%; + background: var(--blue); opacity: .4; transition: all .3s; } -.sensor-tile.breach-high .sensor-state-dot { - background: var(--red); opacity: 1; - box-shadow: 0 0 6px var(--red); - animation: blink .8s infinite; +.sensor-tile.breach-high .sensor-state-dot, +.sensor-tile.breach-low .sensor-state-dot { + opacity: 1; animation: blink .8s infinite; } -.sensor-tile.breach-low .sensor-state-dot { - background: var(--yellow); opacity: 1; - box-shadow: 0 0 6px var(--yellow); - animation: blink .8s infinite; -} -@keyframes blink { 0%,100%{opacity:1} 50%{opacity:.3} } +.sensor-tile.breach-high .sensor-state-dot { background: var(--red); box-shadow: 0 0 6px var(--red); } +.sensor-tile.breach-low .sensor-state-dot { background: var(--yellow); box-shadow: 0 0 6px var(--yellow); } +@keyframes blink { 0%,100%{opacity:1} 50%{opacity:.2} } -.sensor-label { - font-size: 11px; - font-weight: 500; - letter-spacing: .06em; - text-transform: uppercase; - color: var(--txt-ghost); -} - -.sensor-value { - font-family: var(--mono); - font-size: clamp(1.5rem, 2.5vw, 2.1rem); - font-weight: 500; - color: var(--txt); - line-height: 1; - transition: color .3s; -} +.sensor-label { font-size: 11px; font-weight: 500; letter-spacing: .06em; text-transform: uppercase; color: var(--txt-ghost); } +.sensor-value { font-family: var(--mono); font-size: clamp(1.5rem,2.5vw,2.1rem); font-weight: 500; line-height: 1; transition: color .3s; } .sensor-tile.breach-high .sensor-value { color: var(--red); } .sensor-tile.breach-low .sensor-value { color: var(--yellow); } - -.sensor-meta { - margin-top: 10px; - display: flex; - align-items: center; - justify-content: space-between; -} -.sensor-threshold { - font-family: var(--mono); - font-size: 10px; - color: var(--txt-ghost); -} +.sensor-meta { margin-top: 10px; display: flex; align-items: center; justify-content: space-between; } +.sensor-threshold { font-family: var(--mono); font-size: 10px; color: var(--txt-ghost); } .sensor-alert-badge { - display: inline-flex; - align-items: center; - gap: 4px; - font-size: 10px; - font-weight: 500; - letter-spacing: .05em; - text-transform: uppercase; - padding: 2px 7px; - border-radius: 4px; - background: var(--red-soft); - color: var(--red); - border: 1px solid rgba(239,68,68,.25); -} -.sensor-alert-badge.low { - background: var(--yellow-soft); - color: var(--yellow); - border-color: rgba(245,158,11,.25); + font-size: 10px; font-weight: 500; letter-spacing: .05em; text-transform: uppercase; + padding: 2px 7px; border-radius: 4px; + background: var(--red-soft); color: var(--red); border: 1px solid rgba(239,68,68,.25); } +.sensor-alert-badge.low { background: var(--yellow-soft); color: var(--yellow); border-color: rgba(245,158,11,.25); } -/* ── Stat row ── */ -.stat-row { - grid-column: 1 / -1; - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: var(--gap); -} - -.stat-card { - padding: 14px 16px; - display: flex; - align-items: center; - gap: 12px; -} +/* Stat row */ +.stat-row { grid-column: 1/-1; display: grid; grid-template-columns: repeat(4,1fr); gap: var(--gap); } +.stat-card { padding: 14px 16px; display: flex; align-items: center; gap: 12px; } .stat-card-icon { - width: 36px; height: 36px; - border-radius: 9px; - display: flex; align-items: center; justify-content: center; - font-size: 16px; - flex-shrink: 0; + width: 36px; height: 36px; border-radius: 9px; + display: flex; align-items: center; justify-content: center; font-size: 16px; flex-shrink: 0; } .stat-card-icon.blue { background: var(--blue-soft); } .stat-card-icon.green { background: var(--green-soft); } .stat-card-icon.red { background: var(--red-soft); } .stat-card-icon.teal { background: var(--teal-soft); } -.stat-card-icon.purple { background: var(--purple-soft); } - .stat-info { min-width: 0; } -.stat-label { - font-size: 11px; - font-weight: 500; - letter-spacing: .06em; - text-transform: uppercase; - color: var(--txt-ghost); - margin-bottom: 3px; -} -.stat-value { - font-family: var(--mono); - font-size: 1.15rem; - font-weight: 500; - color: var(--txt); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} +.stat-label { font-size: 11px; font-weight: 500; letter-spacing: .06em; text-transform: uppercase; color: var(--txt-ghost); margin-bottom: 3px; } +.stat-value { font-family: var(--mono); font-size: 1.15rem; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .stat-value.red { color: var(--red); } -.stat-sub { - font-size: 10px; - color: var(--txt-ghost); - margin-top: 2px; - font-family: var(--mono); -} +.stat-sub { font-size: 10px; color: var(--txt-ghost); margin-top: 2px; font-family: var(--mono); } -/* ── Chart panel ── */ -.chart-panel { - grid-column: 1 / 3; - padding: 16px 18px 14px; -} -.panel-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 14px; -} -.panel-title { - font-size: 11px; - font-weight: 600; - letter-spacing: .08em; - text-transform: uppercase; - color: var(--txt-dim); -} +/* Chart */ +.chart-panel { grid-column: 1/3; padding: 16px 18px 14px; } +.panel-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; } +.panel-title { font-size: 11px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; color: var(--txt-dim); } .panel-badge { - font-family: var(--mono); - font-size: 10px; - color: var(--txt-ghost); - background: rgba(255,255,255,.04); - border: 1px solid var(--border); - border-radius: 4px; - padding: 2px 7px; + font-family: var(--mono); font-size: 10px; color: var(--txt-ghost); + background: rgba(255,255,255,.04); border: 1px solid var(--border); border-radius: 4px; padding: 2px 7px; } #chart { display: block; width: 100%; } -/* ── Log panel ── */ -.log-panel { - grid-column: 3 / -1; - padding: 0; - display: flex; - flex-direction: column; - overflow: hidden; - min-height: 220px; -} - -.tab-bar { - display: flex; - border-bottom: 1px solid var(--border); - flex-shrink: 0; - padding: 0 6px; - gap: 2px; -} +/* Log panel */ +.log-panel { grid-column: 3/-1; padding: 0; display: flex; flex-direction: column; overflow: hidden; min-height: 220px; } +.tab-bar { display: flex; border-bottom: 1px solid var(--border); flex-shrink: 0; padding: 0 6px; gap: 2px; } .tab { - flex: 1; - padding: 10px 12px; - font-size: 11px; - font-weight: 500; - letter-spacing: .06em; - text-transform: uppercase; - color: var(--txt-ghost); - cursor: pointer; - text-align: center; - transition: color .2s; - border-bottom: 2px solid transparent; - margin-bottom: -1px; - position: relative; + flex: 1; padding: 10px 12px; + font-size: 11px; font-weight: 500; letter-spacing: .06em; text-transform: uppercase; + color: var(--txt-ghost); cursor: pointer; text-align: center; + transition: color .2s; border-bottom: 2px solid transparent; margin-bottom: -1px; } .tab:hover { color: var(--txt-dim); } .tab.active { color: var(--blue); border-bottom-color: var(--blue); } - -.tab-body { - flex: 1; - overflow-y: auto; - padding: 8px 12px; - font-family: var(--mono); - font-size: 11px; -} +.tab-body { flex: 1; overflow-y: auto; padding: 8px 12px; font-family: var(--mono); font-size: 11px; } .tab-body::-webkit-scrollbar { width: 3px; } .tab-body::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } - .tab-pane { display: none; } .tab-pane.active { display: block; } .log-entry { - display: grid; - grid-template-columns: 68px 1fr auto; - gap: 8px; - padding: 5px 0; + display: grid; grid-template-columns: 68px 1fr auto; + gap: 8px; padding: 5px 0; border-bottom: 1px solid rgba(255,255,255,.04); - animation: fadeSlide .15s ease; - align-items: center; -} -@keyframes fadeSlide { - from { opacity: 0; transform: translateY(-3px); } - to { opacity: 1; transform: none; } + animation: fadeSlide .15s ease; align-items: center; } +@keyframes fadeSlide { from{opacity:0;transform:translateY(-3px)} to{opacity:1;transform:none} } .log-ts { color: var(--txt-ghost); font-size: 10px; } .log-raw { color: var(--txt-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .log-num { color: var(--blue); text-align: right; white-space: nowrap; } -.alert-entry { - display: grid; - grid-template-columns: 16px 68px 1fr; - gap: 8px; - padding: 6px 0; - border-bottom: 1px solid rgba(255,255,255,.04); - align-items: center; - animation: fadeSlide .15s ease; -} +.alert-entry { display: grid; grid-template-columns: 16px 68px 1fr; gap: 8px; padding: 6px 0; border-bottom: 1px solid rgba(255,255,255,.04); align-items: center; animation: fadeSlide .15s ease; } .alert-icon { color: var(--red); font-size: 12px; } .alert-ts { color: var(--txt-ghost); font-size: 10px; } .alert-msg { color: var(--txt-dim); } - .alert-badge { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 16px; height: 16px; - border-radius: 8px; - background: var(--red); - color: #fff; - font-size: 9px; - font-weight: 600; - margin-left: 5px; - vertical-align: middle; - padding: 0 3px; - display: none; + display: none; align-items: center; justify-content: center; + min-width: 16px; height: 16px; border-radius: 8px; + background: var(--red); color: #fff; + font-size: 9px; font-weight: 600; margin-left: 5px; padding: 0 3px; } .alert-badge.visible { display: inline-flex; } -/* ── Rain tile — special ── */ -.rain-tile { +/* Footer */ +.mirror-footer { display: flex; justify-content: space-between; align-items: center; padding: 10px 20px; font-size: 11px; font-family: var(--mono); color: var(--txt-ghost); } + +/* ══════════════════════════════════════════ + ── CONSUMER VIEW ── +══════════════════════════════════════════ */ +.consumer-wrap { display: flex; flex-direction: column; - gap: 8px; -} -.rain-bar-wrap { - width: 100%; - height: 6px; - background: rgba(255,255,255,.06); - border-radius: 3px; - overflow: hidden; - margin-top: 8px; -} -.rain-bar { - height: 100%; - background: linear-gradient(90deg, var(--blue), var(--teal)); - border-radius: 3px; - transition: width .5s ease; -} - -/* ── Footer ── */ -.mirror-footer { - display: flex; - justify-content: space-between; align-items: center; - padding: 10px 20px; - font-size: 11px; - font-family: var(--mono); - color: var(--txt-ghost); + justify-content: center; + min-height: 100vh; + padding: 40px 24px; + gap: 0; + position: relative; + overflow: hidden; } -/* ── Responsive ── */ +/* Atmospheric background gradient */ +.consumer-wrap::before { + content: ''; + position: absolute; inset: 0; + background: + radial-gradient(ellipse 80% 60% at 50% 10%, rgba(59,130,246,.08) 0%, transparent 65%), + radial-gradient(ellipse 60% 40% at 20% 80%, rgba(20,184,166,.05) 0%, transparent 60%); + pointer-events: none; +} + +/* ── Big Clock ── */ +.c-clock { + text-align: center; + margin-bottom: 8px; +} +.c-time { + font-family: var(--mono); + font-size: clamp(4rem, 14vw, 10rem); + font-weight: 300; + letter-spacing: .04em; + color: var(--c-txt); + line-height: 1; + text-shadow: 0 0 80px rgba(96,165,250,.15); +} +.c-date { + font-size: clamp(.8rem, 1.5vw, 1.1rem); + font-weight: 400; + letter-spacing: .22em; + text-transform: uppercase; + color: var(--c-dim); + margin-top: 10px; +} + +/* Divider line */ +.c-divider { + width: 60px; height: 1px; + background: linear-gradient(90deg, transparent, rgba(96,165,250,.35), transparent); + margin: 32px auto; +} + +/* ── Weather block ── */ +.c-weather { + text-align: center; + margin-bottom: 40px; +} +.c-location { + font-size: .7rem; + font-weight: 500; + letter-spacing: .22em; + text-transform: uppercase; + color: var(--c-ghost); + margin-bottom: 18px; +} +.c-weather-main { + display: flex; + align-items: center; + justify-content: center; + gap: 20px; + margin-bottom: 10px; +} +.c-weather-icon { + font-size: clamp(3rem, 8vw, 5.5rem); + line-height: 1; + filter: drop-shadow(0 0 20px rgba(96,165,250,.3)); +} +.c-temp-outside { + font-family: var(--mono); + font-size: clamp(2.8rem, 8vw, 5.5rem); + font-weight: 300; + letter-spacing: .04em; + color: var(--c-txt); + line-height: 1; +} +.c-weather-desc { + font-size: clamp(.9rem, 1.5vw, 1.1rem); + color: var(--c-dim); + letter-spacing: .06em; + margin-top: 6px; +} + +/* ── Sensor + weather stat pills ── */ +.c-stats { + display: flex; + gap: 16px; + justify-content: center; + flex-wrap: wrap; + margin-top: 36px; +} +.c-stat { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + padding: 18px 28px; + border-radius: 16px; + background: rgba(255,255,255,.03); + border: 1px solid rgba(255,255,255,.06); + min-width: 120px; + backdrop-filter: blur(10px); + transition: border-color .3s, background .3s; +} +.c-stat:hover { + border-color: rgba(96,165,250,.18); + background: rgba(96,165,250,.04); +} +.c-stat-icon { font-size: 1.4rem; line-height: 1; } +.c-stat-val { + font-family: var(--mono); + font-size: clamp(1.4rem, 2.5vw, 2rem); + font-weight: 400; + color: var(--c-txt); + line-height: 1; +} +.c-stat-label { + font-size: .65rem; + font-weight: 500; + letter-spacing: .15em; + text-transform: uppercase; + color: var(--c-ghost); +} + +/* Rain probability bar */ +.c-rain-bar-wrap { + width: 100%; + height: 3px; + background: rgba(255,255,255,.08); + border-radius: 2px; + overflow: hidden; + margin-top: 4px; +} +.c-rain-bar { + height: 100%; + background: linear-gradient(90deg, var(--c-accent), var(--c-accent2)); + border-radius: 2px; + transition: width 1s ease; +} + +/* Loading / no-data state */ +.c-stat-val.loading { opacity: .3; } + +/* Weather error notice */ +.c-weather-error { + font-size: .7rem; + color: var(--c-ghost); + margin-top: 16px; + font-family: var(--mono); +} + +/* ══════════════════════════════════════════ + RESPONSIVE +══════════════════════════════════════════ */ @media (max-width: 1100px) { .mirror-body { grid-template-columns: 1fr 1fr; } - .stat-row { grid-template-columns: 1fr 1fr; } - .chart-panel { grid-column: 1 / -1; } - .log-panel { grid-column: 1 / -1; min-height: 200px; } + .stat-row { grid-template-columns: 1fr 1fr; } + .chart-panel { grid-column: 1/-1; } + .log-panel { grid-column: 1/-1; min-height: 200px; } } @media (max-width: 640px) { - .mirror-body { grid-template-columns: 1fr 1fr; } - .stat-row { grid-template-columns: 1fr 1fr; } + .mirror-body { grid-template-columns: 1fr 1fr; } .mirror-header { grid-template-columns: 1fr; } .header-brand, .conn-badge { display: none; } + .c-stats { gap: 10px; } + .c-stat { min-width: 100px; padding: 14px 18px; } } diff --git a/raspi/static/js/dashboard.js b/raspi/static/js/dashboard.js index 370fe92..402b8e3 100755 --- a/raspi/static/js/dashboard.js +++ b/raspi/static/js/dashboard.js @@ -1,53 +1,95 @@ 'use strict'; -/* ── Config from Flask ── */ -const ROOT = document.getElementById('mirror-root'); -const POLL_MS = parseInt(ROOT.dataset.pollMs || '2500', 10); -const SENSORS = JSON.parse(ROOT.dataset.sensors || '[]'); +/* ── Config ── */ +const ROOT = document.getElementById('mirror-root'); +const POLL_MS = parseInt(ROOT.dataset.pollMs || '2500', 10); +const SENSORS = JSON.parse(ROOT.dataset.sensors || '[]'); + +const WEATHER_POLL_MS = 10 * 60 * 1000; // 10 min — matches server cache /* ── State ── */ -let lastLogCount = 0; -let alertsSeen = 0; -let chartData = []; // {ts, v} for temp channel (field_index 0) -let firstPoll = true; +let lastLogCount = 0; +let alertsSeen = 0; +let chartData = []; +let firstPoll = true; +let consumerMode = false; +let latestValues = []; -/* ══════════════════════════════ - CLOCK -══════════════════════════════ */ +/* ══════════════════════════════════ + VIEW TOGGLE +══════════════════════════════════ */ +const STORAGE_KEY = 'sm_view'; + +function toggleView() { + consumerMode = !consumerMode; + document.body.classList.toggle('consumer-mode', consumerMode); + try { localStorage.setItem(STORAGE_KEY, consumerMode ? '1' : '0'); } catch(_) {} +} + +// Restore last view +try { + if (localStorage.getItem(STORAGE_KEY) === '1') toggleView(); +} catch(_) {} + +// Keyboard shortcut: Alt+D (developer) / Alt+C (consumer) +document.addEventListener('keydown', e => { + if (e.altKey && e.key === 'd') { consumerMode = true; toggleView(); } + if (e.altKey && e.key === 'c') { consumerMode = false; toggleView(); } + if (e.key === 'F2') toggleView(); +}); + +/* ══════════════════════════════════ + CLOCK — ticks every second +══════════════════════════════════ */ const DAYS = ['Sonntag','Montag','Dienstag','Mittwoch','Donnerstag','Freitag','Samstag']; const MONTHS = ['Januar','Februar','März','April','Mai','Juni', 'Juli','August','September','Oktober','November','Dezember']; function tickClock() { - const n = new Date(); + const n = new Date(); const pad = x => String(x).padStart(2, '0'); - document.getElementById('clock-time').textContent = - `${pad(n.getHours())}:${pad(n.getMinutes())}:${pad(n.getSeconds())}`; - document.getElementById('clock-date').textContent = - `${DAYS[n.getDay()]}, ${n.getDate()}. ${MONTHS[n.getMonth()]} ${n.getFullYear()}`; + const hms = `${pad(n.getHours())}:${pad(n.getMinutes())}:${pad(n.getSeconds())}`; + const hm = `${pad(n.getHours())}:${pad(n.getMinutes())}`; + const date = `${DAYS[n.getDay()]}, ${n.getDate()}. ${MONTHS[n.getMonth()]} ${n.getFullYear()}`; + + // Dev clock + const dt = document.getElementById('clock-time'); + const dd = document.getElementById('clock-date'); + if (dt) dt.textContent = hms; + if (dd) dd.textContent = date; + + // Consumer clock + const ct = document.getElementById('c-time'); + const cd = document.getElementById('c-date'); + if (ct) ct.textContent = hm; + if (cd) cd.textContent = date; } + setInterval(tickClock, 1000); tickClock(); -/* ══════════════════════════════ +/* ══════════════════════════════════ CANVAS CHART -══════════════════════════════ */ +══════════════════════════════════ */ const canvas = document.getElementById('chart'); -const ctx = canvas.getContext('2d'); +const ctx = canvas ? canvas.getContext('2d') : null; function resizeCanvas() { + if (!canvas) return; canvas.width = canvas.parentElement.clientWidth - 36; canvas.height = 130; } -window.addEventListener('resize', () => { resizeCanvas(); drawChart(); }); -resizeCanvas(); +if (canvas) { + window.addEventListener('resize', () => { resizeCanvas(); drawChart(); }); + resizeCanvas(); +} function drawChart() { + if (!ctx) return; const W = canvas.width, H = canvas.height; ctx.clearRect(0, 0, W, H); const pts = chartData.slice(-80).map(d => d.v); - if (pts.length < 2) { ctx.fillStyle = 'rgba(228,233,245,.15)'; ctx.font = `11px 'JetBrains Mono', monospace`; @@ -61,7 +103,6 @@ function drawChart() { const sx = W / (pts.length - 1); const yp = v => H - ((v - mn) / rng) * H * 0.80 - H * 0.10; - /* grid */ ctx.strokeStyle = 'rgba(255,255,255,.04)'; ctx.lineWidth = 1; ctx.font = `9px 'JetBrains Mono', monospace`; @@ -72,89 +113,82 @@ function drawChart() { ctx.fillText((mn + (i / 4) * rng).toFixed(1) + '°', 4, y - 3); } - /* gradient fill */ const grd = ctx.createLinearGradient(0, 0, 0, H); grd.addColorStop(0, 'rgba(59,130,246,.2)'); grd.addColorStop(1, 'rgba(59,130,246,.0)'); ctx.beginPath(); - pts.forEach((v, i) => { - const x = i * sx, y = yp(v); - i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); - }); - ctx.lineTo((pts.length - 1) * sx, H); - ctx.lineTo(0, H); - ctx.closePath(); - ctx.fillStyle = grd; - ctx.fill(); + pts.forEach((v, i) => { i === 0 ? ctx.moveTo(i*sx, yp(v)) : ctx.lineTo(i*sx, yp(v)); }); + ctx.lineTo((pts.length-1)*sx, H); ctx.lineTo(0, H); ctx.closePath(); + ctx.fillStyle = grd; ctx.fill(); - /* line */ ctx.beginPath(); - pts.forEach((v, i) => { - const x = i * sx, y = yp(v); - i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); - }); - ctx.strokeStyle = '#3b82f6'; - ctx.lineWidth = 2; - ctx.shadowColor = 'rgba(59,130,246,.5)'; - ctx.shadowBlur = 10; - ctx.stroke(); - ctx.shadowBlur = 0; + pts.forEach((v, i) => { i === 0 ? ctx.moveTo(i*sx, yp(v)) : ctx.lineTo(i*sx, yp(v)); }); + ctx.strokeStyle = '#3b82f6'; ctx.lineWidth = 2; + ctx.shadowColor = 'rgba(59,130,246,.5)'; ctx.shadowBlur = 10; + ctx.stroke(); ctx.shadowBlur = 0; - /* last dot */ - const lv = pts[pts.length - 1]; - const lx = (pts.length - 1) * sx; - const ly = yp(lv); - ctx.beginPath(); - ctx.arc(lx, ly, 4, 0, Math.PI * 2); - ctx.fillStyle = '#3b82f6'; - ctx.shadowColor = '#3b82f6'; - ctx.shadowBlur = 14; - ctx.fill(); - ctx.shadowBlur = 0; + const lv = pts[pts.length-1], lx = (pts.length-1)*sx, ly = yp(lv); + ctx.beginPath(); ctx.arc(lx, ly, 4, 0, Math.PI*2); + ctx.fillStyle = '#3b82f6'; + ctx.shadowColor = '#3b82f6'; ctx.shadowBlur = 14; + ctx.fill(); ctx.shadowBlur = 0; } -/* ══════════════════════════════ - SENSOR TILES -══════════════════════════════ */ +/* ══════════════════════════════════ + SENSOR TILES (dev) +══════════════════════════════════ */ function updateSensorTiles(values) { + latestValues = values; SENSORS.forEach((s, i) => { const idx = s.field_index; if (idx >= values.length) return; - const v = values[idx]; const tile = document.getElementById(`sc-${i}`); const valEl= document.getElementById(`sv-${i}`); if (!tile || !valEl) return; const fmt = Number.isInteger(v) ? String(v) : v.toFixed(1); - valEl.textContent = `${fmt}${s.unit ? ' ' + s.unit : ''}`; + valEl.textContent = `${fmt}${s.unit ? ' '+s.unit : ''}`; tile.querySelectorAll('.sensor-alert-badge').forEach(b => b.remove()); tile.classList.remove('breach-high', 'breach-low'); - const isHigh = s.notify_on_high && s.threshold_high != null && v > s.threshold_high; - const isLow = s.notify_on_low && s.threshold_low != null && v < s.threshold_low; - - if (isHigh) { + if (s.notify_on_high && s.threshold_high != null && v > s.threshold_high) { tile.classList.add('breach-high'); - const b = document.createElement('span'); - b.className = 'sensor-alert-badge'; - b.textContent = '↑ Überschritten'; + const b = Object.assign(document.createElement('span'), { className:'sensor-alert-badge', textContent:'↑ Überschritten'}); tile.querySelector('.sensor-meta').appendChild(b); - } else if (isLow) { + } else if (s.notify_on_low && s.threshold_low != null && v < s.threshold_low) { tile.classList.add('breach-low'); - const b = document.createElement('span'); - b.className = 'sensor-alert-badge low'; - b.textContent = '↓ Unterschritten'; + const b = Object.assign(document.createElement('span'), { className:'sensor-alert-badge low', textContent:'↓ Unterschritten'}); tile.querySelector('.sensor-meta').appendChild(b); } }); + + // Mirror sensor values to consumer view + updateConsumerSensors(values); } -/* ══════════════════════════════ +function updateConsumerSensors(values) { + // field_index 0 = temp, 1 = humidity + const tempSensor = SENSORS.find(s => s.field_index === 0); + const humSensor = SENSORS.find(s => s.field_index === 1); + + if (tempSensor) { + const v = values[0]; + const el = document.getElementById('c-sensor-temp'); + if (el) { el.textContent = `${v.toFixed(1)}°C`; el.classList.remove('loading'); } + } + if (humSensor) { + const v = values[1]; + const el = document.getElementById('c-sensor-hum'); + if (el) { el.textContent = `${v.toFixed(0)} %`; el.classList.remove('loading'); } + } +} + +/* ══════════════════════════════════ TABS -══════════════════════════════ */ +══════════════════════════════════ */ function switchTab(name) { document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === name)); @@ -162,14 +196,59 @@ function switchTab(name) { p.classList.toggle('active', p.id === `pane-${name}`)); if (name === 'alerts') { const b = document.getElementById('alert-badge'); - b.classList.remove('visible'); - b.textContent = ''; + b.classList.remove('visible'); b.textContent = ''; } } -/* ══════════════════════════════ - POLL -══════════════════════════════ */ +/* ══════════════════════════════════ + WEATHER FETCH +══════════════════════════════════ */ +async function fetchWeather() { + try { + const res = await fetch('/api/weather'); + const data = await res.json(); + if (!data.temp) return; + + // Location + const loc = document.getElementById('c-location'); + if (loc) loc.textContent = data.location || ''; + + // Icon + temp + const icon = document.getElementById('c-icon'); + const temp = document.getElementById('c-temp-out'); + const desc = document.getElementById('c-desc'); + if (icon) icon.textContent = data.icon || '—'; + if (temp) { temp.textContent = `${Math.round(data.temp)}°`; temp.classList.remove('loading'); } + if (desc) desc.textContent = data.description || ''; + + // Stats + setConsumerStat('c-humidity', data.humidity != null ? `${data.humidity} %` : null); + setConsumerStat('c-rain', data.rain_prob != null ? `${data.rain_prob} %` : null); + setConsumerStat('c-feels', data.feels_like != null ? `${Math.round(data.feels_like)}°` : null); + setConsumerStat('c-wind', data.wind_kmh != null ? `${Math.round(data.wind_kmh)} km/h` : null); + + // Rain bar + const bar = document.getElementById('c-rain-bar'); + if (bar && data.rain_prob != null) bar.style.width = `${data.rain_prob}%`; + + } catch(e) { + console.warn('[Mirror] Weather error:', e); + } +} + +function setConsumerStat(id, val) { + const el = document.getElementById(id); + if (!el) return; + if (val != null) { el.textContent = val; el.classList.remove('loading'); } +} + +// Fetch immediately then every 10 min +fetchWeather(); +setInterval(fetchWeather, WEATHER_POLL_MS); + +/* ══════════════════════════════════ + SENSOR POLL +══════════════════════════════════ */ async function poll() { try { const [dataRes, alertRes] = await Promise.all([ @@ -181,27 +260,27 @@ async function poll() { /* Connection badge */ const badge = document.getElementById('conn-badge'); - const ok = data.status.connected; - badge.className = `conn-badge card ${ok ? 'ok' : 'err'}`; - document.getElementById('conn-text').textContent = ok - ? `${data.status.port} · ${data.status.baud} Bd` - : 'Getrennt — warte …'; + if (badge) { + const ok = data.status.connected; + badge.className = `conn-badge card ${ok ? 'ok' : 'err'}`; + const ct = document.getElementById('conn-text'); + if (ct) ct.textContent = ok + ? `${data.status.port} · ${data.status.baud} Bd` + : 'Getrennt — warte …'; + } - /* Stat cards */ - document.getElementById('stat-total').textContent = data.status.total_lines; - document.getElementById('stat-errors').textContent = data.status.errors; - document.getElementById('stat-port').textContent = data.status.port || '—'; - document.getElementById('stat-baud').textContent = data.status.baud - ? `${data.status.baud} baud` : '—'; + /* Stats */ + const set = (id, v) => { const e = document.getElementById(id); if(e) e.textContent = v; }; + set('stat-total', data.status.total_lines); + set('stat-errors', data.status.errors); + set('stat-port', data.status.port || '—'); + set('stat-baud', data.status.baud ? `${data.status.baud} baud` : '—'); if (data.entries.length) { const latest = data.entries[data.entries.length - 1]; - const disp = latest.values?.length - ? latest.values[0].toFixed(2) - : latest.raw.slice(0, 14); - document.getElementById('stat-last').textContent = disp; - document.getElementById('stat-ts').textContent = latest.ts.split('T')[1].slice(0,8); - + const disp = latest.values?.length ? latest.values[0].toFixed(2) : latest.raw.slice(0, 14); + set('stat-last', disp); + set('stat-ts', latest.ts.split('T')[1].slice(0, 8)); if (latest.values?.length) { updateSensorTiles(latest.values); chartData.push({ ts: latest.ts, v: latest.values[0] }); @@ -209,42 +288,36 @@ async function poll() { } } - /* Log — only new entries */ + /* Log */ const newEntries = data.entries.slice(lastLogCount); lastLogCount = data.entries.length; const logPane = document.getElementById('pane-log'); - if (firstPoll && newEntries.length === 0) { - /* keep placeholder */ - } else if (firstPoll) { + if (firstPoll && newEntries.length > 0) { logPane.innerHTML = ''; firstPoll = false; + } else if (firstPoll) { + // keep placeholder } newEntries.forEach(e => { - const row = document.createElement('div'); + const row = document.createElement('div'); row.className = 'log-entry'; - const numStr = e.values?.map(v => v.toFixed(2)).join(' ') || ''; + const num = e.values?.map(v => v.toFixed(2)).join(' ') || ''; row.innerHTML = `${e.ts.split('T')[1].slice(0,8)}` + `${e.raw}` + - (numStr ? `${numStr}` : ''); + (num ? `${num}` : ''); logPane.prepend(row); - - /* trim log DOM to 100 rows */ - while (logPane.children.length > 100) { - logPane.removeChild(logPane.lastChild); - } + while (logPane.children.length > 100) logPane.removeChild(logPane.lastChild); }); /* Alerts */ if (alerts.length > alertsSeen) { const newAlerts = alerts.slice(alertsSeen); alertsSeen = alerts.length; - const alertPane = document.getElementById('pane-alerts'); if (alertsSeen === newAlerts.length) alertPane.innerHTML = ''; - newAlerts.forEach(a => { const row = document.createElement('div'); row.className = 'alert-entry'; @@ -254,9 +327,8 @@ async function poll() { `${a.subject}`; alertPane.prepend(row); }); - const ab = document.getElementById('alert-badge'); - if (!document.getElementById('pane-alerts').classList.contains('active')) { + if (ab && !document.getElementById('pane-alerts').classList.contains('active')) { ab.textContent = newAlerts.length > 9 ? '9+' : String(newAlerts.length); ab.classList.add('visible'); } @@ -264,7 +336,7 @@ async function poll() { drawChart(); - } catch (err) { + } catch(err) { console.warn('[Mirror] Poll error:', err); } diff --git a/raspi/templates/dashboard.html b/raspi/templates/dashboard.html index 3a391b0..970ddf8 100755 --- a/raspi/templates/dashboard.html +++ b/raspi/templates/dashboard.html @@ -4,143 +4,214 @@ {% block body %}
+ data-sensors="{{ sensors_json | e }}" + data-location="{{ location_json | e }}"> -
+ + - -
+ +
+
-
-
🪞
-
-
{{ title }}
-
Arduino Wetterstation
-
-
- -
-
00:00:00
-
-
- -
-
- Verbinde … -
- -
- - - -
- - - {% set icons = ['🌡️','💧','🌧️','📡','📊','⚡'] %} - {% for s in sensors %} -
-
-
{{ icons[loop.index0 % icons|length] }}
-
-
-
{{ s.name }}
-
-
-
- {%- if s.threshold_low is not none -%}↓ {{ s.threshold_low }}{{ s.unit }}{%- endif -%} - {%- if s.threshold_high is not none and s.threshold_low is not none -%}  {%- endif -%} - {%- if s.threshold_high is not none -%}↑ {{ s.threshold_high }}{{ s.unit }}{%- endif -%} +
+
+
🪞
+
+
{{ title }}
+
Arduino Wetterstation
-
- {% endfor %} - - - -
- -
-
📥
-
-
Letzter Wert
-
-
-
+
+
00:00:00
+
- -
-
📊
-
-
Zeilen gesamt
-
0
-
empfangen
-
+
+
+ Verbinde …
+ -
-
⚠️
-
-
Fehler
-
0
-
Verbindungsabbrüche
+
+ + {% set icons = ['🌡️','💧','🌧️','📡','📊','⚡'] %} + {% for s in sensors %} +
+
+
{{ icons[loop.index0 % icons|length] }}
+
-
- -
-
🔌
-
-
Verbindung
-
-
-
-
- -
- - - -
-
-
Temperaturverlauf
- letzte 80 Werte -
- -
- - - -
-
-
Rohdaten
-
- Alerts -
-
-
-
-
- Warte auf Daten … -
-
-
-
- Keine Alerts. +
{{ s.name }}
+
+
+
+ {%- if s.threshold_low is not none -%}↓ {{ s.threshold_low }}{{ s.unit }}  {%- endif -%} + {%- if s.threshold_high is not none -%}↑ {{ s.threshold_high }}{{ s.unit }}{%- endif -%}
+ {% endfor %} + +
+
+
📥
+
+
Letzter Wert
+
+
+
+
+
+
📊
+
+
Zeilen gesamt
+
0
+
empfangen
+
+
+
+
⚠️
+
+
Fehler
+
0
+
Verbindungsabbrüche
+
+
+
+
🔌
+
+
Verbindung
+
+
+
+
+
+ +
+
+
Temperaturverlauf (Sensor)
+ letzte 80 Werte +
+ +
+ +
+
+
Rohdaten
+
+ Alerts +
+
+
+
+
+ Warte auf Daten … +
+
+
+
+ Keine Alerts. +
+
+
+
+ +
+ + +
+
+ + + +
+
+ + +
+
00:00
+
- +
+ +
+
+
+
+
—°
+
+
Wetterdaten werden geladen …
+
- -
- Smart Mirror · TGBBz Dillingen · Arduino USB Monitor - -
+ +
-
-
+ +
+
🌡️
+
+
Innen Temp.
+
+ + +
+
💧
+
+
Luftfeucht.
+
+ + +
+
🌬️
+
+
Außen Feuchte
+
+ + +
+
🌧️
+
+
Regenwahrsch.
+
+
+
+
+ + +
+
🧥
+
+
Gefühlt
+
+ + +
+
💨
+
+
Wind
+
+ +
+ + + + +