From 408da564502e2a0971f955493201116b5d3a165e Mon Sep 17 00:00:00 2001 From: Dennis Date: Sat, 2 May 2026 20:54:53 +0200 Subject: [PATCH] v1.1 --- README.md | 84 ++--- arduino/arduino.ino | 0 docs/aufbau.png | Bin docs/verkabelung.txt | 40 +-- raspi/Dashboard.py | 238 ++++++------- raspi/Notification.py | 578 +++++++++++++++---------------- raspi/USBRead.py | 361 +++++++++---------- raspi/gunicorn.conf.py | 34 +- raspi/requirements.txt | 10 +- raspi/settings.json | 31 +- raspi/static/css/mirror.css | 615 ++++++++++++++++++++------------- raspi/static/js/dashboard.js | 243 ++++++------- raspi/templates/base.html | 0 raspi/templates/dashboard.html | 141 ++++---- 14 files changed, 1271 insertions(+), 1104 deletions(-) mode change 100644 => 100755 README.md mode change 100644 => 100755 arduino/arduino.ino mode change 100644 => 100755 docs/aufbau.png mode change 100644 => 100755 docs/verkabelung.txt mode change 100644 => 100755 raspi/Dashboard.py mode change 100644 => 100755 raspi/Notification.py mode change 100644 => 100755 raspi/USBRead.py mode change 100644 => 100755 raspi/gunicorn.conf.py mode change 100644 => 100755 raspi/requirements.txt mode change 100644 => 100755 raspi/settings.json mode change 100644 => 100755 raspi/static/css/mirror.css mode change 100644 => 100755 raspi/static/js/dashboard.js mode change 100644 => 100755 raspi/templates/base.html mode change 100644 => 100755 raspi/templates/dashboard.html diff --git a/README.md b/README.md old mode 100644 new mode 100755 index 3d668ad..2647090 --- a/README.md +++ b/README.md @@ -1,42 +1,42 @@ -# "SmartMirror" - Projektwochen am TGBBZ Dillingen - -## Repo-Strktur - -``` -arduino/ - arduino/arduino.ino - Arduino-Skript, um die Sensordaten auszulesen und sie per USB - an den Raspberry Pi zu übertragen -raspi/ - raspi/templates - Jinja basierte HTML-Templates - raspi/static - Ordner mit frontend (CSS, JS) Dateien - raspi/Dashboard.py - Stellt das Flask-basierte Web-Dashboard bereit - raspi/USBRead.py - Liest in regelmäßigen Intervallen die USB-Gerätedatei (/dev/ttyACM0) aus - und stellt die Daten Dashboard.py und Notification.py bereit - raspi/Notification.py - Dient als SMTP-Client, der Benachrichtigungen über E-Mail und Messanger - versendet# - raspi/gunicorn.conf.py - Startup-Datei für den WSGI-Webserver Gunicorn, der das Flask Web-Dashboard - bereitstellt - raspi/requirements.txt - requirements.txt für pip (lieste der benötigten Python-Abhängigkeiten) - raspi/settings.json - Zentrale Konfigurationsdatei zur Konfiguration der Raspi-Skripte - raspi/README.md - -docs/ - aufbau.png - Übersicht über den logischen Aufbau des Setups - verkabelung.txt - Übersicht über die phyische Verkabelung - -``` - -## Logischer Aufbau - -![logischer-aufbau](https://raw.githubusercontent.com/Sinned50/tgbbz-dillingen-smart-mirror/refs/heads/main/docs/aufbau.png?token=GHSAT0AAAAAADZ4ZH3YOFA5URKOHZM7A66G2PPHOCA) +# "SmartMirror" - Projektwochen am TGBBZ Dillingen + +## Repo-Strktur + +``` +arduino/ + arduino/arduino.ino + Arduino-Skript, um die Sensordaten auszulesen und sie per USB + an den Raspberry Pi zu übertragen +raspi/ + raspi/templates + Jinja basierte HTML-Templates + raspi/static + Ordner mit frontend (CSS, JS) Dateien + raspi/Dashboard.py + Stellt das Flask-basierte Web-Dashboard bereit + raspi/USBRead.py + Liest in regelmäßigen Intervallen die USB-Gerätedatei (/dev/ttyACM0) aus + und stellt die Daten Dashboard.py und Notification.py bereit + raspi/Notification.py + Dient als SMTP-Client, der Benachrichtigungen über E-Mail und Messanger + versendet# + raspi/gunicorn.conf.py + Startup-Datei für den WSGI-Webserver Gunicorn, der das Flask Web-Dashboard + bereitstellt + raspi/requirements.txt + requirements.txt für pip (lieste der benötigten Python-Abhängigkeiten) + raspi/settings.json + Zentrale Konfigurationsdatei zur Konfiguration der Raspi-Skripte + raspi/README.md + +docs/ + aufbau.png + Übersicht über den logischen Aufbau des Setups + verkabelung.txt + Übersicht über die phyische Verkabelung + +``` + +## Logischer Aufbau + +![logischer-aufbau](https://raw.githubusercontent.com/Sinned50/tgbbz-dillingen-smart-mirror/refs/heads/main/docs/aufbau.png?token=GHSAT0AAAAAADZ4ZH3YOFA5URKOHZM7A66G2PPHOCA) diff --git a/arduino/arduino.ino b/arduino/arduino.ino old mode 100644 new mode 100755 diff --git a/docs/aufbau.png b/docs/aufbau.png old mode 100644 new mode 100755 diff --git a/docs/verkabelung.txt b/docs/verkabelung.txt old mode 100644 new mode 100755 index 01fa7c2..dfd205f --- a/docs/verkabelung.txt +++ b/docs/verkabelung.txt @@ -1,21 +1,21 @@ -DHT22: - -+ Modul <-> 3,3V Breadboard (über vertikale Schiene) -Out Modul <-> D4 Arduino -- Modul <-> GND Breadboard (über vertikale Schiene) - -Regenmodul: - -AO Modul <-> A1 Arduino -DO Modul <-> D2 Arduino -GND Modul <-> GND Breadboard (über vertikale Schiene) -VCC Modul <-> V3,3 Breadboard (über vertikale Schiene) - -BMP22: - -VCC Modul <-> 3,3V Breadboard (direkt Phasenschiene) -GND Modul <-> GND Breadboard (direkt an Erdungsschiene) -SCL Modul <-> A5 Arduino (SCL) -SDA Modul <-> A4 Arduino (SDA) -CSB Modul <-> 3,3V Breadboard (direkt an Phasenschiene) +DHT22: + ++ Modul <-> 3,3V Breadboard (über vertikale Schiene) +Out Modul <-> D4 Arduino +- Modul <-> GND Breadboard (über vertikale Schiene) + +Regenmodul: + +AO Modul <-> A1 Arduino +DO Modul <-> D2 Arduino +GND Modul <-> GND Breadboard (über vertikale Schiene) +VCC Modul <-> V3,3 Breadboard (über vertikale Schiene) + +BMP22: + +VCC Modul <-> 3,3V Breadboard (direkt Phasenschiene) +GND Modul <-> GND Breadboard (direkt an Erdungsschiene) +SCL Modul <-> A5 Arduino (SCL) +SDA Modul <-> A4 Arduino (SDA) +CSB Modul <-> 3,3V Breadboard (direkt an Phasenschiene) SDD Modul <-> GND Breadboard (direkt an Erdungsschiene) \ No newline at end of file diff --git a/raspi/Dashboard.py b/raspi/Dashboard.py old mode 100644 new mode 100755 index ffff668..05947e8 --- a/raspi/Dashboard.py +++ b/raspi/Dashboard.py @@ -1,119 +1,119 @@ -""" -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 / → 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, + ) diff --git a/raspi/Notification.py b/raspi/Notification.py old mode 100644 new mode 100755 index 185b2b2..7d026f8 --- a/raspi/Notification.py +++ b/raspi/Notification.py @@ -1,289 +1,289 @@ -""" -Notification.py -─────────────── -Monitors incoming sensor entries from USBRead and dispatches alerts via: - • SMTP e-mail (plain text + HTML) - • WhatsApp (Twilio Business API OR CallMeBot free API) - -Two WhatsApp providers are supported and selected via settings.json: - "provider": "twilio" – requires twilio package + Twilio account - "provider": "callmebot" – free, no extra package needed (HTTP GET) - -Usage ------ - import Notification - Notification.init(settings) # pass full settings dict - Notification.attach_to_usb() # registers USBRead callback -""" - -import smtplib -import threading -import time -import urllib.parse -import urllib.request -from datetime import datetime -from email.mime.multipart import MIMEMultipart -from email.mime.text import MIMEText -from typing import Any - -import USBRead - -# ── Module-level state ──────────────────────────────────────────────────────── -_smtp_cfg: dict[str, Any] = {} -_whatsapp_cfg: dict[str, Any] = {} -_sensors: list[dict[str, Any]] = [] -_smtp_cooldown_s: float = 300.0 -_wa_cooldown_s: float = 300.0 - -# cooldown trackers: sensor_name → monotonic time of last sent alert -_last_email: dict[str, float] = {} -_last_whatsapp: dict[str, float] = {} -_lock = threading.Lock() - - -# ── Public API ──────────────────────────────────────────────────────────────── - -def init(settings: dict[str, Any]) -> None: - """Initialise with the full settings dict loaded from settings.json.""" - global _smtp_cfg, _whatsapp_cfg, _sensors, _smtp_cooldown_s, _wa_cooldown_s - - _smtp_cfg = settings.get("smtp", {}) - _whatsapp_cfg = settings.get("whatsapp", {}) - _sensors = settings.get("sensors", []) - - _smtp_cooldown_s = float(_smtp_cfg.get("cooldown_s", 300)) - _wa_cooldown_s = float(_whatsapp_cfg.get("cooldown_s", 300)) - - channels = [] - if _smtp_cfg.get("enabled", True): - channels.append("SMTP") - if _whatsapp_cfg.get("enabled", False): - channels.append(f"WhatsApp/{_whatsapp_cfg.get('provider','?')}") - - print(f"[Notification] {len(_sensors)} Sensor-Regeln geladen. " - f"Kanäle: {', '.join(channels) or '–'}. " - f"Cooldown E-Mail={_smtp_cooldown_s}s WA={_wa_cooldown_s}s") - - -def attach_to_usb() -> None: - """Register the threshold checker as a USBRead callback.""" - USBRead.register_callback(_on_entry) - print("[Notification] An USBRead-Callback angehängt.") - - -def send_alert(subject: str, body: str) -> None: - """ - Dispatch an alert on all enabled channels synchronously. - Call this from a daemon thread to avoid blocking USBRead. - """ - if _smtp_cfg.get("enabled", True): - _send_email(subject, body) - - if _whatsapp_cfg.get("enabled", False): - short = f"{subject}\n\n{body[:400]}" # WA messages kept compact - _send_whatsapp(short) - - -# ── Threshold checker (USBRead callback) ────────────────────────────────────── - -def _on_entry(entry: dict[str, Any]) -> None: - values = entry.get("values", []) - if not values: - return - - now = time.monotonic() - - for sensor in _sensors: - idx = sensor.get("field_index", 0) - if idx >= len(values): - continue - - value = values[idx] - name = sensor.get("name", f"Sensor {idx}") - unit = sensor.get("unit", "") - th_high = sensor.get("threshold_high") - th_low = sensor.get("threshold_low") - - triggered = False - direction = "" - threshold = None - - if sensor.get("notify_on_high", True) and th_high is not None and value > th_high: - triggered, direction, threshold = True, "HOCH", th_high - elif sensor.get("notify_on_low", True) and th_low is not None and value < th_low: - triggered, direction, threshold = True, "NIEDRIG", th_low - - if not triggered: - continue - - subject = f"[Arduino Alert] {name} zu {direction}: {value:.2f}{unit}" - body = ( - f"Sensor-Alarm – {entry['ts']}\n\n" - f" Sensor : {name}\n" - f" Messwert : {value:.4f} {unit}\n" - f" Schwelle : {threshold} {unit} ({direction})\n" - f" Rohzeile : {entry['raw']}\n\n" - f"Nächste Benachrichtigung frühestens nach {int(max(_smtp_cooldown_s, _wa_cooldown_s))}s." - ) - - # Per-channel cooldown check + fire - with _lock: - send_email_now = _check_cooldown(_last_email, name, now, _smtp_cooldown_s) - send_whatsapp_now = _check_cooldown(_last_whatsapp, name, now, _wa_cooldown_s) - - if send_email_now or send_whatsapp_now: - threading.Thread( - target=_dispatch, - args=(subject, body, send_email_now, send_whatsapp_now), - daemon=True - ).start() - - -def _check_cooldown(tracker: dict, name: str, now: float, cooldown: float) -> bool: - """Return True and update tracker if cooldown has elapsed. NOT thread-safe alone.""" - last = tracker.get(name, 0.0) - if now - last >= cooldown: - tracker[name] = now - return True - remaining = int(cooldown - (now - last)) - print(f"[Notification] Cooldown aktiv für '{name}' (noch {remaining}s)") - return False - - -def _dispatch(subject: str, body: str, do_email: bool, do_whatsapp: bool) -> None: - if do_email and _smtp_cfg.get("enabled", True): - _send_email(subject, body) - if do_whatsapp and _whatsapp_cfg.get("enabled", False): - _send_whatsapp(f"{subject}\n\n{body[:400]}") - - -# ── E-Mail ──────────────────────────────────────────────────────────────────── - -def _send_email(subject: str, body: str) -> None: - cfg = _smtp_cfg - msg = MIMEMultipart("alternative") - msg["Subject"] = subject - msg["From"] = cfg["from_address"] - msg["To"] = ", ".join(cfg["to_addresses"]) - msg.attach(MIMEText(body, "plain", "utf-8")) - msg.attach(MIMEText(_build_html(subject, body), "html", "utf-8")) - - try: - if cfg.get("use_tls", True): - srv = smtplib.SMTP(cfg["host"], cfg["port"], timeout=10) - srv.ehlo(); srv.starttls() - else: - srv = smtplib.SMTP_SSL(cfg["host"], cfg["port"], timeout=10) - - srv.login(cfg["username"], cfg["password"]) - srv.sendmail(cfg["from_address"], cfg["to_addresses"], msg.as_string()) - srv.quit() - print(f"[Notification] ✉ E-Mail gesendet: {subject}") - except Exception as exc: - print(f"[Notification] ✗ SMTP Fehler: {exc}") - - -# ── WhatsApp ────────────────────────────────────────────────────────────────── - -def _send_whatsapp(text: str) -> None: - provider = _whatsapp_cfg.get("provider", "twilio").lower() - - if provider == "twilio": - _send_whatsapp_twilio(text) - elif provider == "callmebot": - _send_whatsapp_callmebot(text) - else: - print(f"[Notification] ✗ Unbekannter WhatsApp-Provider: '{provider}'") - - -def _send_whatsapp_twilio(text: str) -> None: - """ - Send via Twilio WhatsApp API. - Requires: pip install twilio - Sandbox: Join https://www.twilio.com/console/sms/whatsapp/sandbox first. - """ - cfg = _whatsapp_cfg.get("twilio", {}) - try: - from twilio.rest import Client # type: ignore - client = Client(cfg["account_sid"], cfg["auth_token"]) - for to in cfg.get("to_numbers", []): - msg = client.messages.create( - from_=cfg["from_number"], - to=to, - body=text, - ) - print(f"[Notification] 📱 Twilio WA gesendet → {to} SID={msg.sid}") - except ImportError: - print("[Notification] ✗ Twilio nicht installiert: pip install twilio") - except Exception as exc: - print(f"[Notification] ✗ Twilio Fehler: {exc}") - - -def _send_whatsapp_callmebot(text: str) -> None: - """ - Send via CallMeBot (free, no account needed beyond one-time activation). - Activate at: https://www.callmebot.com/blog/free-api-whatsapp-messages/ - No extra packages required. - """ - cfg = _whatsapp_cfg.get("callmebot", {}) - api_key = cfg.get("api_key", "") - encoded = urllib.parse.quote(text) - - for number in cfg.get("to_numbers", []): - url = ( - f"https://api.callmebot.com/whatsapp.php" - f"?phone={urllib.parse.quote(number)}" - f"&text={encoded}" - f"&apikey={api_key}" - ) - try: - with urllib.request.urlopen(url, timeout=10) as resp: - status_code = resp.status - print(f"[Notification] 📱 CallMeBot WA → {number} HTTP {status_code}") - except Exception as exc: - print(f"[Notification] ✗ CallMeBot Fehler ({number}): {exc}") - - -# ── HTML e-mail builder ─────────────────────────────────────────────────────── - -def _build_html(subject: str, body: str) -> str: - rows = "" - for line in body.splitlines(): - line = line.strip() - if not line: - rows += "" - elif ":" in line: - k, _, v = line.partition(":") - rows += ( - f"" - f"{k.strip()}" - f"" - f"{v.strip()}" - ) - else: - rows += ( - f"{line}" - ) - - return f""" - - - - - - -
-

Smart Mirror · Arduino Alert

-

{subject}

- {rows}
-

- Automatisch generiert · bitte nicht antworten. -

-
-""" +""" +Notification.py +─────────────── +Monitors incoming sensor entries from USBRead and dispatches alerts via: + • SMTP e-mail (plain text + HTML) + • WhatsApp (Twilio Business API OR CallMeBot free API) + +Two WhatsApp providers are supported and selected via settings.json: + "provider": "twilio" – requires twilio package + Twilio account + "provider": "callmebot" – free, no extra package needed (HTTP GET) + +Usage +----- + import Notification + Notification.init(settings) # pass full settings dict + Notification.attach_to_usb() # registers USBRead callback +""" + +import smtplib +import threading +import time +import urllib.parse +import urllib.request +from datetime import datetime +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from typing import Any + +import USBRead + +# ── Module-level state ──────────────────────────────────────────────────────── +_smtp_cfg: dict[str, Any] = {} +_whatsapp_cfg: dict[str, Any] = {} +_sensors: list[dict[str, Any]] = [] +_smtp_cooldown_s: float = 300.0 +_wa_cooldown_s: float = 300.0 + +# cooldown trackers: sensor_name → monotonic time of last sent alert +_last_email: dict[str, float] = {} +_last_whatsapp: dict[str, float] = {} +_lock = threading.Lock() + + +# ── Public API ──────────────────────────────────────────────────────────────── + +def init(settings: dict[str, Any]) -> None: + """Initialise with the full settings dict loaded from settings.json.""" + global _smtp_cfg, _whatsapp_cfg, _sensors, _smtp_cooldown_s, _wa_cooldown_s + + _smtp_cfg = settings.get("smtp", {}) + _whatsapp_cfg = settings.get("whatsapp", {}) + _sensors = settings.get("sensors", []) + + _smtp_cooldown_s = float(_smtp_cfg.get("cooldown_s", 300)) + _wa_cooldown_s = float(_whatsapp_cfg.get("cooldown_s", 300)) + + channels = [] + if _smtp_cfg.get("enabled", True): + channels.append("SMTP") + if _whatsapp_cfg.get("enabled", False): + channels.append(f"WhatsApp/{_whatsapp_cfg.get('provider','?')}") + + print(f"[Notification] {len(_sensors)} Sensor-Regeln geladen. " + f"Kanäle: {', '.join(channels) or '–'}. " + f"Cooldown E-Mail={_smtp_cooldown_s}s WA={_wa_cooldown_s}s") + + +def attach_to_usb() -> None: + """Register the threshold checker as a USBRead callback.""" + USBRead.register_callback(_on_entry) + print("[Notification] An USBRead-Callback angehängt.") + + +def send_alert(subject: str, body: str) -> None: + """ + Dispatch an alert on all enabled channels synchronously. + Call this from a daemon thread to avoid blocking USBRead. + """ + if _smtp_cfg.get("enabled", True): + _send_email(subject, body) + + if _whatsapp_cfg.get("enabled", False): + short = f"{subject}\n\n{body[:400]}" # WA messages kept compact + _send_whatsapp(short) + + +# ── Threshold checker (USBRead callback) ────────────────────────────────────── + +def _on_entry(entry: dict[str, Any]) -> None: + values = entry.get("values", []) + if not values: + return + + now = time.monotonic() + + for sensor in _sensors: + idx = sensor.get("field_index", 0) + if idx >= len(values): + continue + + value = values[idx] + name = sensor.get("name", f"Sensor {idx}") + unit = sensor.get("unit", "") + th_high = sensor.get("threshold_high") + th_low = sensor.get("threshold_low") + + triggered = False + direction = "" + threshold = None + + if sensor.get("notify_on_high", True) and th_high is not None and value > th_high: + triggered, direction, threshold = True, "HOCH", th_high + elif sensor.get("notify_on_low", True) and th_low is not None and value < th_low: + triggered, direction, threshold = True, "NIEDRIG", th_low + + if not triggered: + continue + + subject = f"[Arduino Alert] {name} zu {direction}: {value:.2f}{unit}" + body = ( + f"Sensor-Alarm – {entry['ts']}\n\n" + f" Sensor : {name}\n" + f" Messwert : {value:.4f} {unit}\n" + f" Schwelle : {threshold} {unit} ({direction})\n" + f" Rohzeile : {entry['raw']}\n\n" + f"Nächste Benachrichtigung frühestens nach {int(max(_smtp_cooldown_s, _wa_cooldown_s))}s." + ) + + # Per-channel cooldown check + fire + with _lock: + send_email_now = _check_cooldown(_last_email, name, now, _smtp_cooldown_s) + send_whatsapp_now = _check_cooldown(_last_whatsapp, name, now, _wa_cooldown_s) + + if send_email_now or send_whatsapp_now: + threading.Thread( + target=_dispatch, + args=(subject, body, send_email_now, send_whatsapp_now), + daemon=True + ).start() + + +def _check_cooldown(tracker: dict, name: str, now: float, cooldown: float) -> bool: + """Return True and update tracker if cooldown has elapsed. NOT thread-safe alone.""" + last = tracker.get(name, 0.0) + if now - last >= cooldown: + tracker[name] = now + return True + remaining = int(cooldown - (now - last)) + print(f"[Notification] Cooldown aktiv für '{name}' (noch {remaining}s)") + return False + + +def _dispatch(subject: str, body: str, do_email: bool, do_whatsapp: bool) -> None: + if do_email and _smtp_cfg.get("enabled", True): + _send_email(subject, body) + if do_whatsapp and _whatsapp_cfg.get("enabled", False): + _send_whatsapp(f"{subject}\n\n{body[:400]}") + + +# ── E-Mail ──────────────────────────────────────────────────────────────────── + +def _send_email(subject: str, body: str) -> None: + cfg = _smtp_cfg + msg = MIMEMultipart("alternative") + msg["Subject"] = subject + msg["From"] = cfg["from_address"] + msg["To"] = ", ".join(cfg["to_addresses"]) + msg.attach(MIMEText(body, "plain", "utf-8")) + msg.attach(MIMEText(_build_html(subject, body), "html", "utf-8")) + + try: + if cfg.get("use_tls", True): + srv = smtplib.SMTP(cfg["host"], cfg["port"], timeout=10) + srv.ehlo(); srv.starttls() + else: + srv = smtplib.SMTP_SSL(cfg["host"], cfg["port"], timeout=10) + + srv.login(cfg["username"], cfg["password"]) + srv.sendmail(cfg["from_address"], cfg["to_addresses"], msg.as_string()) + srv.quit() + print(f"[Notification] ✉ E-Mail gesendet: {subject}") + except Exception as exc: + print(f"[Notification] ✗ SMTP Fehler: {exc}") + + +# ── WhatsApp ────────────────────────────────────────────────────────────────── + +def _send_whatsapp(text: str) -> None: + provider = _whatsapp_cfg.get("provider", "twilio").lower() + + if provider == "twilio": + _send_whatsapp_twilio(text) + elif provider == "callmebot": + _send_whatsapp_callmebot(text) + else: + print(f"[Notification] ✗ Unbekannter WhatsApp-Provider: '{provider}'") + + +def _send_whatsapp_twilio(text: str) -> None: + """ + Send via Twilio WhatsApp API. + Requires: pip install twilio + Sandbox: Join https://www.twilio.com/console/sms/whatsapp/sandbox first. + """ + cfg = _whatsapp_cfg.get("twilio", {}) + try: + from twilio.rest import Client # type: ignore + client = Client(cfg["account_sid"], cfg["auth_token"]) + for to in cfg.get("to_numbers", []): + msg = client.messages.create( + from_=cfg["from_number"], + to=to, + body=text, + ) + print(f"[Notification] 📱 Twilio WA gesendet → {to} SID={msg.sid}") + except ImportError: + print("[Notification] ✗ Twilio nicht installiert: pip install twilio") + except Exception as exc: + print(f"[Notification] ✗ Twilio Fehler: {exc}") + + +def _send_whatsapp_callmebot(text: str) -> None: + """ + Send via CallMeBot (free, no account needed beyond one-time activation). + Activate at: https://www.callmebot.com/blog/free-api-whatsapp-messages/ + No extra packages required. + """ + cfg = _whatsapp_cfg.get("callmebot", {}) + api_key = cfg.get("api_key", "") + encoded = urllib.parse.quote(text) + + for number in cfg.get("to_numbers", []): + url = ( + f"https://api.callmebot.com/whatsapp.php" + f"?phone={urllib.parse.quote(number)}" + f"&text={encoded}" + f"&apikey={api_key}" + ) + try: + with urllib.request.urlopen(url, timeout=10) as resp: + status_code = resp.status + print(f"[Notification] 📱 CallMeBot WA → {number} HTTP {status_code}") + except Exception as exc: + print(f"[Notification] ✗ CallMeBot Fehler ({number}): {exc}") + + +# ── HTML e-mail builder ─────────────────────────────────────────────────────── + +def _build_html(subject: str, body: str) -> str: + rows = "" + for line in body.splitlines(): + line = line.strip() + if not line: + rows += "" + elif ":" in line: + k, _, v = line.partition(":") + rows += ( + f"" + f"{k.strip()}" + f"" + f"{v.strip()}" + ) + else: + rows += ( + f"{line}" + ) + + return f""" + + + + + + +
+

Smart Mirror · Arduino Alert

+

{subject}

+ {rows}
+

+ Automatisch generiert · bitte nicht antworten. +

+
+""" diff --git a/raspi/USBRead.py b/raspi/USBRead.py old mode 100644 new mode 100755 index 59f2bde..419b741 --- a/raspi/USBRead.py +++ b/raspi/USBRead.py @@ -1,177 +1,184 @@ -""" -USBRead.py -────────── -Reads raw lines from a serial USB device (e.g. Arduino) at the configured -baud rate, parses numeric sensor values, and stores everything in a shared -thread-safe ring-buffer. - -Callers import `start_reader()` to launch the background thread, then read -from `get_snapshot()` at any time. -""" - -import threading -import time -from collections import deque -from datetime import datetime -from typing import Any - -import serial - -# ── Populated once by init() ────────────────────────────────────────────────── -_cfg: dict[str, Any] = {} -_lock = threading.Lock() -_buffer: deque = deque() - -status: dict[str, Any] = { - "connected": False, - "port": "", - "baud": 0, - "last_received": None, - "total_lines": 0, - "errors": 0, -} - -# Callbacks: list of callables(entry) invoked after every parsed entry -_callbacks: list = [] - - -# ── Public API ──────────────────────────────────────────────────────────────── - -def init(usb_cfg: dict[str, Any]) -> None: - """Must be called once before start_reader().""" - global _buffer, _cfg - _cfg = usb_cfg - _buffer = deque(maxlen=usb_cfg.get("buffer_size", 200)) - status["port"] = usb_cfg["port"] - status["baud"] = usb_cfg["baud_rate"] - - -def register_callback(fn) -> None: - """Register a function(entry) that is called for each new parsed line.""" - _callbacks.append(fn) - - -def get_snapshot() -> dict[str, Any]: - """Return a thread-safe snapshot of the current buffer and status.""" - with _lock: - return { - "status": dict(status), - "entries": list(_buffer), - } - - -def start_reader() -> threading.Thread: - """Start the background serial-reader thread (daemon).""" - t = threading.Thread(target=_reader_loop, daemon=True, name="usb-reader") - t.start() - return t - - -# ── Parsing helpers ─────────────────────────────────────────────────────────── - -def parse_line(raw: str) -> dict[str, Any]: - """ - Parse a raw serial line into a structured entry dict. - - Supported Arduino output formats (auto-detected): - • Space / comma / semicolon separated numbers: - "23.5 67.1 4.92" → values: [23.5, 67.1, 4.92] - • Key=value pairs: - "temp=23.5,hum=67.1" → values: [23.5, 67.1], - labels: ["temp", "hum"] - • JSON (if Arduino outputs JSON): - '{"temp":23.5,"hum":67.1}' → values: [23.5, 67.1], - labels: ["temp", "hum"] - """ - ts = datetime.now().isoformat(timespec="milliseconds") - entry: dict[str, Any] = {"ts": ts, "raw": raw, "values": [], "labels": []} - - # Try JSON first - if raw.startswith("{"): - try: - import json - obj = json.loads(raw) - for k, v in obj.items(): - try: - entry["values"].append(float(v)) - entry["labels"].append(str(k)) - except (TypeError, ValueError): - pass - return entry - except Exception: - pass - - # Try key=value pairs (e.g. "temp=23.5,hum=67") - if "=" in raw: - for token in raw.replace(",", " ").replace(";", " ").split(): - if "=" in token: - k, _, v = token.partition("=") - try: - entry["values"].append(float(v)) - entry["labels"].append(k.strip()) - except ValueError: - pass - if entry["values"]: - return entry - - # Fallback: space / comma / semicolon delimited numbers - for token in raw.replace(",", " ").replace(";", " ").split(): - try: - entry["values"].append(float(token)) - entry["labels"].append(f"ch{len(entry['values'])}") - except ValueError: - pass - - return entry - - -# ── Internal reader loop ────────────────────────────────────────────────────── - -def _reader_loop() -> None: - port = _cfg["port"] - baud = _cfg["baud_rate"] - reconnect_delay = _cfg.get("reconnect_delay_s", 3) - - while True: - try: - with serial.Serial(port, baud, timeout=1) as ser: - with _lock: - status["connected"] = True - print(f"[USBRead] Connected → {port} @ {baud} baud") - - while True: - raw_bytes = ser.readline() - if not raw_bytes: - continue - - raw_str = raw_bytes.decode("utf-8", errors="replace").strip() - if not raw_str: - continue - - entry = parse_line(raw_str) - - with _lock: - _buffer.append(entry) - status["last_received"] = entry["ts"] - status["total_lines"] += 1 - - # Fire registered callbacks outside the lock - for cb in _callbacks: - try: - cb(entry) - except Exception as exc: - print(f"[USBRead] Callback error: {exc}") - - except serial.SerialException as exc: - with _lock: - status["connected"] = False - status["errors"] += 1 - print(f"[USBRead] SerialException: {exc} – retry in {reconnect_delay}s") - time.sleep(reconnect_delay) - - except Exception as exc: - with _lock: - status["connected"] = False - status["errors"] += 1 - print(f"[USBRead] Unexpected error: {exc} – retry in {reconnect_delay}s") - time.sleep(reconnect_delay) +""" +USBRead.py +────────── +Reads raw lines from a serial USB device (e.g. Arduino) at the configured +baud rate, parses numeric sensor values, and stores everything in a shared +thread-safe ring-buffer. + +Callers import `start_reader()` to launch the background thread, then read +from `get_snapshot()` at any time. +""" + +import threading +import time +from collections import deque +from datetime import datetime +from typing import Any + +import serial + +# ── Populated once by init() ────────────────────────────────────────────────── +_cfg: dict[str, Any] = {} +_lock = threading.Lock() +_buffer: deque = deque() + +status: dict[str, Any] = { + "connected": False, + "port": "", + "baud": 0, + "last_received": None, + "total_lines": 0, + "errors": 0, +} + +# Callbacks: list of callables(entry) invoked after every parsed entry +_callbacks: list = [] + + +# ── Public API ──────────────────────────────────────────────────────────────── + +def init(usb_cfg: dict[str, Any]) -> None: + """Must be called once before start_reader().""" + global _buffer, _cfg + _cfg = usb_cfg + _buffer = deque(maxlen=usb_cfg.get("buffer_size", 200)) + status["port"] = usb_cfg["port"] + status["baud"] = usb_cfg["baud_rate"] + + +def register_callback(fn) -> None: + """Register a function(entry) that is called for each new parsed line.""" + _callbacks.append(fn) + + +def get_snapshot() -> dict[str, Any]: + """Return a thread-safe snapshot of the current buffer and status.""" + with _lock: + return { + "status": dict(status), + "entries": list(_buffer), + } + + +def start_reader() -> threading.Thread: + """Start the background serial-reader thread (daemon).""" + t = threading.Thread(target=_reader_loop, daemon=True, name="usb-reader") + t.start() + return t + + +# ── Parsing helpers ─────────────────────────────────────────────────────────── + +def parse_line(raw: str) -> dict[str, Any]: + """ + Parse a raw serial line into a structured entry dict. + + Supported Arduino output formats (auto-detected): + • Space / comma / semicolon separated numbers: + "23.5 67.1 4.92" → values: [23.5, 67.1, 4.92] + • Key=value pairs: + "temp=23.5,hum=67.1" → values: [23.5, 67.1], + labels: ["temp", "hum"] + • JSON (if Arduino outputs JSON): + '{"temp":23.5,"hum":67.1}' → values: [23.5, 67.1], + labels: ["temp", "hum"] + """ + ts = datetime.now().isoformat(timespec="milliseconds") + entry: dict[str, Any] = {"ts": ts, "raw": raw, "values": [], "labels": []} + + # Try JSON first + if raw.startswith("{"): + try: + import json + + def _flatten(obj, prefix=""): + for k, v in obj.items(): + key = f"{prefix}.{k}" if prefix else k + if isinstance(v, dict): + _flatten(v, key) + else: + try: + entry["values"].append(float(v)) + entry["labels"].append(key) + except (TypeError, ValueError): + pass + + _flatten(json.loads(raw)) + return entry + except Exception: + pass + + # Try key=value pairs (e.g. "temp=23.5,hum=67") + if "=" in raw: + for token in raw.replace(",", " ").replace(";", " ").split(): + if "=" in token: + k, _, v = token.partition("=") + try: + entry["values"].append(float(v)) + entry["labels"].append(k.strip()) + except ValueError: + pass + if entry["values"]: + return entry + + # Fallback: space / comma / semicolon delimited numbers + for token in raw.replace(",", " ").replace(";", " ").split(): + try: + entry["values"].append(float(token)) + entry["labels"].append(f"ch{len(entry['values'])}") + except ValueError: + pass + + return entry + + +# ── Internal reader loop ────────────────────────────────────────────────────── + +def _reader_loop() -> None: + port = _cfg["port"] + baud = _cfg["baud_rate"] + reconnect_delay = _cfg.get("reconnect_delay_s", 3) + + while True: + try: + with serial.Serial(port, baud, timeout=1) as ser: + with _lock: + status["connected"] = True + print(f"[USBRead] Connected → {port} @ {baud} baud") + + while True: + raw_bytes = ser.readline() + if not raw_bytes: + continue + + raw_str = raw_bytes.decode("utf-8", errors="replace").strip() + if not raw_str: + continue + + entry = parse_line(raw_str) + + with _lock: + _buffer.append(entry) + status["last_received"] = entry["ts"] + status["total_lines"] += 1 + + # Fire registered callbacks outside the lock + for cb in _callbacks: + try: + cb(entry) + except Exception as exc: + print(f"[USBRead] Callback error: {exc}") + + except serial.SerialException as exc: + with _lock: + status["connected"] = False + status["errors"] += 1 + print(f"[USBRead] SerialException: {exc} – retry in {reconnect_delay}s") + time.sleep(reconnect_delay) + + except Exception as exc: + with _lock: + status["connected"] = False + status["errors"] += 1 + print(f"[USBRead] Unexpected error: {exc} – retry in {reconnect_delay}s") + time.sleep(reconnect_delay) diff --git a/raspi/gunicorn.conf.py b/raspi/gunicorn.conf.py old mode 100644 new mode 100755 index 543afb9..195a61f --- a/raspi/gunicorn.conf.py +++ b/raspi/gunicorn.conf.py @@ -1,17 +1,17 @@ -# gunicorn.conf.py -# Host/port are read from settings.json — no duplication needed. - -import json -from pathlib import Path - -_s = json.loads((Path(__file__).parent / "settings.json").read_text()) -_d = _s["dashboard"] - -bind = f"{_d['host']}:{_d['port']}" -workers = 1 # must be 1 — shared in-process state (USBRead threads) -threads = 4 -worker_class = "gthread" -timeout = 30 -accesslog = "-" -errorlog = "-" -loglevel = "info" +# gunicorn.conf.py +# Host/port are read from settings.json — no duplication needed. + +import json +from pathlib import Path + +_s = json.loads((Path(__file__).parent / "settings.json").read_text()) +_d = _s["dashboard"] + +bind = f"{_d['host']}:{_d['port']}" +workers = 1 # must be 1 — shared in-process state (USBRead threads) +threads = 4 +worker_class = "gthread" +timeout = 30 +accesslog = "-" +errorlog = "-" +loglevel = "info" diff --git a/raspi/requirements.txt b/raspi/requirements.txt old mode 100644 new mode 100755 index 9382737..e391019 --- a/raspi/requirements.txt +++ b/raspi/requirements.txt @@ -1,5 +1,5 @@ -flask>=3.0.0 -gunicorn>=21.2.0 -pyserial>=3.5 -# Optional: only needed when whatsapp.provider = "twilio" -# twilio>=9.0.0 +flask>=3.0.0 +gunicorn>=21.2.0 +pyserial>=3.5 +# Optional: only needed when whatsapp.provider = "twilio" +# twilio>=9.0.0 diff --git a/raspi/settings.json b/raspi/settings.json old mode 100644 new mode 100755 index 7797dfb..15b30e8 --- a/raspi/settings.json +++ b/raspi/settings.json @@ -7,14 +7,14 @@ }, "dashboard": { - "host": "127.0.0.1", + "host": "0.0.0.0", "port": 8088, - "poll_interval_ms": 800, + "poll_interval_ms": 2500, "title": "Smart Mirror" }, "smtp": { - "enabled": true, + "enabled": false, "host": "smtp.example.com", "port": 587, "use_tls": true, @@ -29,14 +29,12 @@ "enabled": false, "provider": "twilio", "cooldown_s": 300, - "twilio": { "account_sid": "ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "auth_token": "your_auth_token", "from_number": "whatsapp:+14155238886", "to_numbers": ["whatsapp:+49151XXXXXXXX"] }, - "callmebot": { "api_key": "your_callmebot_apikey", "to_numbers": ["+49151XXXXXXXX"] @@ -48,7 +46,7 @@ "name": "Temperatur", "field_index": 0, "unit": "°C", - "threshold_high": 80.0, + "threshold_high": 40.0, "threshold_low": -10.0, "notify_on_high": true, "notify_on_low": true @@ -63,13 +61,22 @@ "notify_on_low": false }, { - "name": "Spannung", + "name": "Regen (Analog)", "field_index": 2, - "unit": "V", - "threshold_high": 5.5, - "threshold_low": 3.0, - "notify_on_high": true, - "notify_on_low": true + "unit": "", + "threshold_high": null, + "threshold_low": null, + "notify_on_high": false, + "notify_on_low": false + }, + { + "name": "Regen (Digital)", + "field_index": 3, + "unit": "", + "threshold_high": null, + "threshold_low": null, + "notify_on_high": false, + "notify_on_low": false } ] } diff --git a/raspi/static/css/mirror.css b/raspi/static/css/mirror.css old mode 100644 new mode 100755 index 168271d..885cc32 --- a/raspi/static/css/mirror.css +++ b/raspi/static/css/mirror.css @@ -1,385 +1,526 @@ -/* ═══════════════════════════════════════════════════════════════ - Smart Mirror — Dashboard Stylesheet - Aesthetic: deep void black, razor-thin glass panels, cold white - typography, neon frost accents. Designed to read on a mirror. - ═══════════════════════════════════════════════════════════════ */ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=JetBrains+Mono:wght@400;500&display=swap'); -@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@200;300;400;600&family=Share+Tech+Mono&display=swap'); - -/* ── Design tokens ─────────────────────────────────────────────── */ +/* ── Design Tokens ── */ :root { - --void: #000000; - --glass: rgba(255,255,255,.032); - --glass-edge: rgba(255,255,255,.07); - --glass-hover: rgba(255,255,255,.055); + --bg: #111318; + --bg-card: #1c1f2b; + --bg-card2: #21253a; + --border: rgba(255,255,255,.07); + --border-hover:rgba(255,255,255,.13); - --frost: #e8f4ff; /* primary text */ - --frost-dim: rgba(232,244,255,.35); - --frost-ghost: rgba(232,244,255,.14); + --txt: #e4e9f5; + --txt-dim: rgba(228,233,245,.55); + --txt-ghost: rgba(228,233,245,.28); - --ice: #a8d8f0; /* accent — cool blue */ - --ice-glow: rgba(168,216,240,.22); + --blue: #3b82f6; + --blue-glow: rgba(59,130,246,.25); + --blue-soft: rgba(59,130,246,.12); - --danger: #ff6b6b; - --danger-glow: rgba(255,107,107,.2); - --warn: #ffd166; - --warn-glow: rgba(255,209,102,.18); - --ok: #6bffc4; - --ok-glow: rgba(107,255,196,.18); + --green: #22c55e; + --green-soft: rgba(34,197,94,.12); + --green-glow: rgba(34,197,94,.3); - --font-ui: 'Outfit', sans-serif; - --font-mono: 'Share Tech Mono', monospace; + --yellow: #f59e0b; + --yellow-soft: rgba(245,158,11,.12); - --r: 8px; /* border-radius */ - --gap: 18px; + --red: #ef4444; + --red-soft: rgba(239,68,68,.12); + --red-glow: rgba(239,68,68,.3); + + --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; + + --font: 'Inter', system-ui, sans-serif; + --mono: 'JetBrains Mono', monospace; } -/* ── Reset & base ──────────────────────────────────────────────── */ +/* ── Reset ── */ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } html, body { width: 100%; min-height: 100vh; - background: var(--void); - color: var(--frost); - font-family: var(--font-ui); - font-weight: 300; - overflow-x: hidden; + background: var(--bg); + color: var(--txt); + font-family: var(--font); + font-size: 14px; + font-weight: 400; -webkit-font-smoothing: antialiased; + overflow-x: hidden; } -/* Subtle vignette to simulate mirror depth */ -body::after { - content: ''; - position: fixed; inset: 0; pointer-events: none; z-index: 0; - background: radial-gradient(ellipse 140% 100% at 50% 0%, - transparent 40%, - rgba(0,0,0,.55) 100%); -} - -/* Very faint horizontal scanlines — mirror CRT ghost */ +/* Subtle grid background */ body::before { content: ''; - position: fixed; inset: 0; pointer-events: none; z-index: 1; - background: repeating-linear-gradient( - 0deg, - transparent, - transparent 3px, - rgba(255,255,255,.008) 3px, - rgba(255,255,255,.008) 4px - ); + 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); + background-size: 40px 40px; } -/* ── Layout wrapper ────────────────────────────────────────────── */ +/* ── Layout ── */ .mirror-layout { - position: relative; z-index: 2; + position: relative; z-index: 1; display: grid; grid-template-rows: auto 1fr auto; min-height: 100vh; padding: var(--gap); gap: var(--gap); + max-width: 1400px; + margin: 0 auto; } -/* ── Glass panel primitive ─────────────────────────────────────── */ -.glass { - background: var(--glass); - border: 1px solid var(--glass-edge); +/* ── Card primitive ── */ +.card { + background: var(--bg-card); + border: 1px solid var(--border); border-radius: var(--r); - backdrop-filter: blur(2px); - -webkit-backdrop-filter: blur(2px); - transition: border-color .35s, background .35s; + transition: border-color .25s, background .25s; } -.glass:hover { background: var(--glass-hover); } +.card:hover { border-color: var(--border-hover); } -/* ── Header bar ────────────────────────────────────────────────── */ +/* ── Header ── */ .mirror-header { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 20px; + padding: 16px 20px; +} + +.header-brand { display: flex; align-items: center; - justify-content: space-between; - padding: 14px 22px; + gap: 10px; +} +.brand-icon { + width: 32px; height: 32px; + border-radius: 8px; + background: var(--blue-soft); + border: 1px solid var(--blue); + 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; } -.mirror-title { - font-size: .65rem; - font-weight: 400; - letter-spacing: .35em; - text-transform: uppercase; - color: var(--frost-dim); -} - -/* Clock widget — large, centred */ -.mirror-clock { - text-align: center; - flex: 1; -} +/* Clock — centred */ +.mirror-clock { text-align: center; } .clock-time { - font-size: clamp(2.4rem, 6vw, 4.5rem); - font-weight: 200; - letter-spacing: .06em; - color: var(--frost); + font-family: var(--mono); + font-size: clamp(2rem, 5vw, 3.6rem); + font-weight: 500; + letter-spacing: .08em; + color: var(--txt); line-height: 1; - text-shadow: 0 0 40px rgba(232,244,255,.18); } .clock-date { - font-size: .7rem; - font-weight: 300; - letter-spacing: .25em; + font-size: 11px; + font-weight: 400; + letter-spacing: .18em; text-transform: uppercase; - color: var(--frost-dim); - margin-top: 5px; + color: var(--txt-ghost); + margin-top: 6px; } -/* Connection indicator */ +/* Connection badge */ .conn-badge { display: flex; align-items: center; - gap: 7px; - font-family: var(--font-mono); - font-size: .68rem; - color: var(--frost-ghost); - transition: color .4s; + gap: 8px; + padding: 8px 14px; + border-radius: var(--r-sm); + 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 { color: var(--ok); } -.conn-badge.err { color: var(--danger); } - .conn-dot { width: 7px; height: 7px; border-radius: 50%; - background: var(--frost-ghost); - transition: background .4s, box-shadow .4s; + background: currentColor; flex-shrink: 0; + transition: box-shadow .3s; } -.conn-badge.ok .conn-dot { background: var(--ok); box-shadow: 0 0 8px var(--ok); } -.conn-badge.err .conn-dot { background: var(--danger); box-shadow: 0 0 8px var(--danger); } +.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); } -/* ── Main content grid ─────────────────────────────────────────── */ +@keyframes pulse-green { + 0%,100% { opacity: 1; } + 50% { opacity: .5; } +} + +/* ── Body grid ── */ .mirror-body { display: grid; - grid-template-columns: 1fr 1fr 1fr; + grid-template-columns: repeat(4, 1fr); grid-template-rows: auto auto auto; gap: var(--gap); } -/* ── Sensor tiles ──────────────────────────────────────────────── */ +/* ── Sensor tiles ── */ .sensor-tile { - padding: 20px 22px 18px; + padding: 18px 18px 14px; position: relative; overflow: hidden; + cursor: default; + transition: transform .2s, box-shadow .2s, border-color .25s; } -.sensor-tile::before { - content: ''; - position: absolute; top: 0; left: 0; right: 0; - height: 1px; - background: linear-gradient(90deg, transparent, var(--ice), transparent); - opacity: .25; - transition: opacity .4s; +.sensor-tile:hover { + transform: translateY(-1px); + box-shadow: 0 8px 24px rgba(0,0,0,.35); } -.sensor-tile.breach-high::before { background: linear-gradient(90deg,transparent,var(--danger),transparent); opacity:.7; } -.sensor-tile.breach-low::before { background: linear-gradient(90deg,transparent,var(--warn), transparent); opacity:.7; } -.sensor-tile.breach-high { border-color: rgba(255,107,107,.25); } -.sensor-tile.breach-low { border-color: rgba(255,209,102,.22); } +/* Coloured left stripe */ +.sensor-tile::after { + content: ''; + position: absolute; top: 0; left: 0; bottom: 0; + width: 3px; + border-radius: var(--r) 0 0 var(--r); + 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-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; + 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-state-dot { + width: 6px; height: 6px; + border-radius: 50%; + background: var(--blue); + opacity: .5; + transition: background .3s, opacity .3s, box-shadow .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-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-label { - font-size: .58rem; - font-weight: 400; - letter-spacing: .22em; + font-size: 11px; + font-weight: 500; + letter-spacing: .06em; text-transform: uppercase; - color: var(--frost-ghost); - margin-bottom: 10px; -} -.sensor-value { - font-family: var(--font-mono); - font-size: clamp(1.6rem, 3vw, 2.6rem); - font-weight: 400; - color: var(--frost); - line-height: 1; - transition: color .4s, text-shadow .4s; -} -.sensor-tile.breach-high .sensor-value { - color: var(--danger); - text-shadow: 0 0 20px var(--danger-glow); -} -.sensor-tile.breach-low .sensor-value { - color: var(--warn); - text-shadow: 0 0 20px var(--warn-glow); -} -.sensor-threshold { - font-family: var(--font-mono); - font-size: .6rem; - color: var(--frost-ghost); - margin-top: 8px; -} -.sensor-alert-badge { - display: inline-block; - margin-top: 8px; - font-size: .52rem; - letter-spacing: .12em; - text-transform: uppercase; - padding: 2px 8px; - border-radius: 3px; - background: rgba(255,107,107,.12); - color: var(--danger); - border: 1px solid rgba(255,107,107,.2); -} -.sensor-alert-badge.low { - background: rgba(255,209,102,.1); - color: var(--warn); - border-color: rgba(255,209,102,.2); + color: var(--txt-ghost); } -/* ── Stat row (small cards) ────────────────────────────────────── */ +.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-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-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); +} + +/* ── Stat row ── */ .stat-row { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--gap); } + .stat-card { - padding: 16px 18px; + 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; +} +.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: .55rem; - letter-spacing: .22em; + font-size: 11px; + font-weight: 500; + letter-spacing: .06em; text-transform: uppercase; - color: var(--frost-ghost); - margin-bottom: 8px; + color: var(--txt-ghost); + margin-bottom: 3px; } .stat-value { - font-family: var(--font-mono); - font-size: 1.55rem; - color: var(--frost); - line-height: 1; + font-family: var(--mono); + font-size: 1.15rem; + font-weight: 500; + color: var(--txt); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -.stat-value.red { color: var(--danger); } +.stat-value.red { color: var(--red); } .stat-sub { - font-size: .58rem; - color: var(--frost-ghost); - margin-top: 5px; - font-family: var(--font-mono); + font-size: 10px; + color: var(--txt-ghost); + margin-top: 2px; + font-family: var(--mono); } -/* ── Chart panel ───────────────────────────────────────────────── */ +/* ── Chart panel ── */ .chart-panel { grid-column: 1 / 3; - padding: 18px 20px; + padding: 16px 18px 14px; } -.panel-label { - font-size: .56rem; - letter-spacing: .22em; +.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(--frost-ghost); - margin-bottom: 12px; + 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; } #chart { display: block; width: 100%; } -/* ── Log / Alert panel ─────────────────────────────────────────── */ +/* ── Log panel ── */ .log-panel { - grid-column: 3 / 4; + 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(--glass-edge); + border-bottom: 1px solid var(--border); flex-shrink: 0; + padding: 0 6px; + gap: 2px; } .tab { flex: 1; - padding: 11px 14px; - font-size: .56rem; - letter-spacing: .2em; + padding: 10px 12px; + font-size: 11px; + font-weight: 500; + letter-spacing: .06em; text-transform: uppercase; - color: var(--frost-ghost); + color: var(--txt-ghost); cursor: pointer; text-align: center; - transition: color .2s, background .2s; + transition: color .2s; border-bottom: 2px solid transparent; margin-bottom: -1px; + position: relative; } -.tab:hover { color: var(--frost-dim); } -.tab.active { color: var(--ice); border-bottom-color: var(--ice); } +.tab:hover { color: var(--txt-dim); } +.tab.active { color: var(--blue); border-bottom-color: var(--blue); } .tab-body { flex: 1; overflow-y: auto; - padding: 10px 14px; - font-family: var(--font-mono); - font-size: .62rem; + padding: 8px 12px; + font-family: var(--mono); + font-size: 11px; } .tab-body::-webkit-scrollbar { width: 3px; } -.tab-body::-webkit-scrollbar-thumb { background: var(--glass-edge); border-radius: 2px; } +.tab-body::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } .tab-pane { display: none; } .tab-pane.active { display: block; } .log-entry { - display: flex; - gap: 8px; - padding: 4px 0; - border-bottom: 1px solid rgba(255,255,255,.04); - animation: slideIn .2s ease; -} -@keyframes slideIn { - from { opacity: 0; transform: translateY(-4px); } - to { opacity: 1; transform: none; } -} -.log-ts { color: var(--frost-ghost); flex-shrink: 0; } -.log-raw { color: var(--frost-dim); flex: 1; word-break: break-all; } -.log-num { color: var(--ice); flex-shrink: 0; } - -.alert-entry { - display: flex; + display: grid; + grid-template-columns: 68px 1fr auto; gap: 8px; padding: 5px 0; border-bottom: 1px solid rgba(255,255,255,.04); - align-items: flex-start; - animation: slideIn .2s ease; + animation: fadeSlide .15s ease; + align-items: center; } -.alert-icon { color: var(--danger); flex-shrink: 0; } -.alert-ts { color: var(--frost-ghost); flex-shrink: 0; } -.alert-msg { color: var(--frost-dim); flex: 1; } +@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-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; - width: 14px; height: 14px; - border-radius: 50%; - background: var(--danger); - color: #000; - font-size: .5rem; + 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; } .alert-badge.visible { display: inline-flex; } -/* ── Footer ────────────────────────────────────────────────────── */ +/* ── Rain tile — special ── */ +.rain-tile { + 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 22px; - font-size: .55rem; - letter-spacing: .18em; - text-transform: uppercase; - color: var(--frost-ghost); + padding: 10px 20px; + font-size: 11px; + font-family: var(--mono); + color: var(--txt-ghost); } -/* ── Responsive ────────────────────────────────────────────────── */ -@media (max-width: 900px) { - .mirror-body { - grid-template-columns: 1fr 1fr; - } - .stat-row { grid-template-columns: 1fr 1fr; } +/* ── 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: 220px; } + .log-panel { grid-column: 1 / -1; min-height: 200px; } } -@media (max-width: 560px) { - .mirror-body { grid-template-columns: 1fr; } +@media (max-width: 640px) { + .mirror-body { grid-template-columns: 1fr 1fr; } .stat-row { grid-template-columns: 1fr 1fr; } - .clock-time { font-size: 2.2rem; } + .mirror-header { grid-template-columns: 1fr; } + .header-brand, .conn-badge { display: none; } } diff --git a/raspi/static/js/dashboard.js b/raspi/static/js/dashboard.js old mode 100644 new mode 100755 index 0c1e621..370fe92 --- a/raspi/static/js/dashboard.js +++ b/raspi/static/js/dashboard.js @@ -1,51 +1,43 @@ -/* ═══════════════════════════════════════════════════════════════ - Smart Mirror — Dashboard JS - Handles: clock, polling, chart, sensor cards, log, alerts - ═══════════════════════════════════════════════════════════════ */ - 'use strict'; -/* ── Config injected by Flask via data-attribute ───────────────── */ -const ROOT = document.getElementById('mirror-root'); -const POLL_MS = parseInt(ROOT.dataset.pollMs || '800', 10); -const SENSORS = JSON.parse(ROOT.dataset.sensors || '[]'); +/* ── 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 || '[]'); -/* ── State ─────────────────────────────────────────────────────── */ -let lastLogCount = 0; -let alertsSeen = 0; -let chartHistory = []; // first numeric channel only +/* ── State ── */ +let lastLogCount = 0; +let alertsSeen = 0; +let chartData = []; // {ts, v} for temp channel (field_index 0) +let firstPoll = true; -/* ══════════════════════════════════════════════════════════════════ +/* ══════════════════════════════ CLOCK -══════════════════════════════════════════════════════════════════ */ +══════════════════════════════ */ +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 now = new Date(); - const hh = String(now.getHours()).padStart(2,'0'); - const mm = String(now.getMinutes()).padStart(2,'0'); - const ss = String(now.getSeconds()).padStart(2,'0'); - - const days = ['Sonntag','Montag','Dienstag','Mittwoch','Donnerstag','Freitag','Samstag']; - const months = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez']; - const dateStr = `${days[now.getDay()]}, ${now.getDate()}. ${months[now.getMonth()]} ${now.getFullYear()}`; - - document.getElementById('clock-time').textContent = `${hh}:${mm}:${ss}`; - document.getElementById('clock-date').textContent = dateStr; + 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()}`; } - setInterval(tickClock, 1000); tickClock(); - -/* ══════════════════════════════════════════════════════════════════ +/* ══════════════════════════════ CANVAS CHART -══════════════════════════════════════════════════════════════════ */ +══════════════════════════════ */ const canvas = document.getElementById('chart'); const ctx = canvas.getContext('2d'); function resizeCanvas() { - const wrap = canvas.parentElement; - canvas.width = wrap.clientWidth - 40; - canvas.height = 140; + canvas.width = canvas.parentElement.clientWidth - 36; + canvas.height = 130; } window.addEventListener('resize', () => { resizeCanvas(); drawChart(); }); resizeCanvas(); @@ -54,81 +46,80 @@ function drawChart() { const W = canvas.width, H = canvas.height; ctx.clearRect(0, 0, W, H); - if (chartHistory.length < 2) { - ctx.fillStyle = 'rgba(232,244,255,.08)'; - ctx.font = `11px 'Share Tech Mono', monospace`; - ctx.fillText('Warte auf Daten …', 8, H / 2 + 4); + 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`; + ctx.fillText('Warte auf Messdaten …', 10, H / 2 + 4); return; } - const pts = chartHistory.slice(-80); - const min = Math.min(...pts); - const max = Math.max(...pts); - const range = max - min || 1; - const step = W / (pts.length - 1); + const mn = Math.min(...pts); + const mx = Math.max(...pts); + const rng = (mx - mn) || 1; + const sx = W / (pts.length - 1); + const yp = v => H - ((v - mn) / rng) * H * 0.80 - H * 0.10; - const yPos = v => H - ((v - min) / range) * H * 0.82 - H * 0.09; - - /* grid lines */ + /* grid */ ctx.strokeStyle = 'rgba(255,255,255,.04)'; ctx.lineWidth = 1; - ctx.font = `9px 'Share Tech Mono', monospace`; - ctx.fillStyle = 'rgba(232,244,255,.2)'; + ctx.font = `9px 'JetBrains Mono', monospace`; for (let i = 0; i <= 4; i++) { - const y = H - (i / 4) * H * 0.82 - H * 0.09; + const y = H - (i / 4) * H * 0.80 - H * 0.10; ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); - ctx.fillText((min + (i / 4) * range).toFixed(2), 4, y - 3); + ctx.fillStyle = 'rgba(228,233,245,.18)'; + ctx.fillText((mn + (i / 4) * rng).toFixed(1) + '°', 4, y - 3); } /* gradient fill */ - const grad = ctx.createLinearGradient(0, 0, 0, H); - grad.addColorStop(0, 'rgba(168,216,240,.18)'); - grad.addColorStop(1, 'rgba(168,216,240,.00)'); + 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 * step, y = yPos(v); + const x = i * sx, y = yp(v); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); }); - ctx.lineTo((pts.length - 1) * step, H); + ctx.lineTo((pts.length - 1) * sx, H); ctx.lineTo(0, H); ctx.closePath(); - ctx.fillStyle = grad; + ctx.fillStyle = grd; ctx.fill(); /* line */ ctx.beginPath(); pts.forEach((v, i) => { - const x = i * step, y = yPos(v); + const x = i * sx, y = yp(v); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); }); - ctx.strokeStyle = 'rgba(168,216,240,.85)'; - ctx.lineWidth = 1.5; - ctx.shadowColor = 'rgba(168,216,240,.4)'; - ctx.shadowBlur = 8; + ctx.strokeStyle = '#3b82f6'; + ctx.lineWidth = 2; + ctx.shadowColor = 'rgba(59,130,246,.5)'; + ctx.shadowBlur = 10; ctx.stroke(); ctx.shadowBlur = 0; - /* last point dot */ + /* last dot */ const lv = pts[pts.length - 1]; - const lx = (pts.length - 1) * step; - const ly = yPos(lv); + const lx = (pts.length - 1) * sx; + const ly = yp(lv); ctx.beginPath(); - ctx.arc(lx, ly, 3.5, 0, Math.PI * 2); - ctx.fillStyle = '#a8d8f0'; - ctx.shadowColor = '#a8d8f0'; - ctx.shadowBlur = 12; + 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 -══════════════════════════════════════════════════════════════════ */ +══════════════════════════════ */ function updateSensorTiles(values) { SENSORS.forEach((s, i) => { - const idx = s.field_index; + const idx = s.field_index; if (idx >= values.length) return; const v = values[idx]; @@ -136,9 +127,9 @@ function updateSensorTiles(values) { const valEl= document.getElementById(`sv-${i}`); if (!tile || !valEl) return; - valEl.textContent = `${v.toFixed(2)} ${s.unit || ''}`; + const fmt = Number.isInteger(v) ? String(v) : v.toFixed(1); + valEl.textContent = `${fmt}${s.unit ? ' ' + s.unit : ''}`; - // Clear old badges tile.querySelectorAll('.sensor-alert-badge').forEach(b => b.remove()); tile.classList.remove('breach-high', 'breach-low'); @@ -149,40 +140,36 @@ function updateSensorTiles(values) { tile.classList.add('breach-high'); const b = document.createElement('span'); b.className = 'sensor-alert-badge'; - b.textContent = '↑ Grenzwert überschritten'; - tile.appendChild(b); + b.textContent = '↑ Überschritten'; + tile.querySelector('.sensor-meta').appendChild(b); } else if (isLow) { tile.classList.add('breach-low'); const b = document.createElement('span'); b.className = 'sensor-alert-badge low'; - b.textContent = '↓ Grenzwert unterschritten'; - tile.appendChild(b); + b.textContent = '↓ Unterschritten'; + tile.querySelector('.sensor-meta').appendChild(b); } }); } - -/* ══════════════════════════════════════════════════════════════════ +/* ══════════════════════════════ TABS -══════════════════════════════════════════════════════════════════ */ +══════════════════════════════ */ function switchTab(name) { - document.querySelectorAll('.tab').forEach(t => { - t.classList.toggle('active', t.dataset.tab === name); - }); - document.querySelectorAll('.tab-pane').forEach(p => { - p.classList.toggle('active', p.id === `pane-${name}`); - }); + document.querySelectorAll('.tab').forEach(t => + t.classList.toggle('active', t.dataset.tab === name)); + document.querySelectorAll('.tab-pane').forEach(p => + p.classList.toggle('active', p.id === `pane-${name}`)); if (name === 'alerts') { - const badge = document.getElementById('alert-badge'); - badge.classList.remove('visible'); - badge.textContent = ''; + const b = document.getElementById('alert-badge'); + b.classList.remove('visible'); + b.textContent = ''; } } - -/* ══════════════════════════════════════════════════════════════════ - MAIN POLL -══════════════════════════════════════════════════════════════════ */ +/* ══════════════════════════════ + POLL +══════════════════════════════ */ async function poll() { try { const [dataRes, alertRes] = await Promise.all([ @@ -192,53 +179,68 @@ async function poll() { const data = await dataRes.json(); const alerts = await alertRes.json(); - /* ── Connection badge ── */ + /* Connection badge */ const badge = document.getElementById('conn-badge'); - badge.className = `conn-badge glass ${data.status.connected ? 'ok' : 'err'}`; - document.getElementById('conn-dot').className = 'conn-dot'; - document.getElementById('conn-text').textContent = - data.status.connected - ? `${data.status.port} · ${data.status.baud} Bd` - : 'Getrennt — warte …'; + 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 …'; - /* ── Stat cards ── */ + /* 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} baud`; + document.getElementById('stat-port').textContent = data.status.port || '—'; + document.getElementById('stat-baud').textContent = 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(3) + ? 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]; - if (latest.values?.length) updateSensorTiles(latest.values); + document.getElementById('stat-ts').textContent = latest.ts.split('T')[1].slice(0,8); + + if (latest.values?.length) { + updateSensorTiles(latest.values); + chartData.push({ ts: latest.ts, v: latest.values[0] }); + if (chartData.length > 200) chartData = chartData.slice(-200); + } } - /* ── Log (new entries only, prepended) ── */ - const newEntries = data.entries.slice(-(data.entries.length - lastLogCount)); + /* Log — only new entries */ + const newEntries = data.entries.slice(lastLogCount); lastLogCount = data.entries.length; const logPane = document.getElementById('pane-log'); - newEntries.forEach(e => { - if (e.values?.length) chartHistory.push(e.values[0]); + if (firstPoll && newEntries.length === 0) { + /* keep placeholder */ + } else if (firstPoll) { + logPane.innerHTML = ''; + firstPoll = false; + } - const row = document.createElement('div'); + newEntries.forEach(e => { + const row = document.createElement('div'); row.className = 'log-entry'; - const numStr = e.values?.map(v => v.toFixed(3)).join(' ') || ''; + const numStr = e.values?.map(v => v.toFixed(2)).join(' ') || ''; row.innerHTML = - `${e.ts.split('T')[1]}` + + `${e.ts.split('T')[1].slice(0,8)}` + `${e.raw}` + - (numStr ? `${numStr}` : ''); + (numStr ? `${numStr}` : ''); logPane.prepend(row); + + /* trim log DOM to 100 rows */ + while (logPane.children.length > 100) { + logPane.removeChild(logPane.lastChild); + } }); - /* ── Alerts ── */ + /* Alerts */ if (alerts.length > alertsSeen) { const newAlerts = alerts.slice(alertsSeen); - alertsSeen = alerts.length; + alertsSeen = alerts.length; const alertPane = document.getElementById('pane-alerts'); if (alertsSeen === newAlerts.length) alertPane.innerHTML = ''; @@ -248,15 +250,15 @@ async function poll() { row.className = 'alert-entry'; row.innerHTML = `` + - `${a.ts.split('T')[1]}` + + `${a.ts.split('T')[1].slice(0,8)}` + `${a.subject}`; alertPane.prepend(row); }); - const alertBadge = document.getElementById('alert-badge'); + const ab = document.getElementById('alert-badge'); if (!document.getElementById('pane-alerts').classList.contains('active')) { - alertBadge.textContent = newAlerts.length > 9 ? '9+' : newAlerts.length; - alertBadge.classList.add('visible'); + ab.textContent = newAlerts.length > 9 ? '9+' : String(newAlerts.length); + ab.classList.add('visible'); } } @@ -269,5 +271,4 @@ async function poll() { setTimeout(poll, POLL_MS); } -/* ── Kick off ─────────────────────────────────────────────────── */ poll(); diff --git a/raspi/templates/base.html b/raspi/templates/base.html old mode 100644 new mode 100755 diff --git a/raspi/templates/dashboard.html b/raspi/templates/dashboard.html old mode 100644 new mode 100755 index 23eb74d..3a391b0 --- a/raspi/templates/dashboard.html +++ b/raspi/templates/dashboard.html @@ -1,28 +1,30 @@ {% extends "base.html" %} - {% block title %}{{ title }}{% endblock %} {% block body %} -{# Root element carries config as data-attributes for dashboard.js #}
- {# ══════════════════════════════════ - HEADER — clock + connection badge - ══════════════════════════════════ #} -
+ +
- {{ title }} +
+
🪞
+
+
{{ title }}
+
Arduino Wetterstation
+
+
00:00:00
-
+
Verbinde …
@@ -30,108 +32,117 @@
- {# ══════════════════════════════════ - BODY — sensors / chart / log - ══════════════════════════════════ #} +
- {# ── Sensor tiles (one per configured sensor) ── #} + + {% 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 -%} -   ↑ {{ s.threshold_high }}{{ s.unit }} - {%- endif -%} +
+
+
+ {%- 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 -%} +
{% endfor %} - {# ── Stat row ── #} + +
-
-
Letzter Wert
-
-
+
+
📥
+
+
Letzter Wert
+
+
+
-
-
Zeilen gesamt
-
0
-
empfangen
+
+
📊
+
+
Zeilen gesamt
+
0
+
empfangen
+
-
-
Fehler
-
0
-
Verbindungsabbrüche
+
+
⚠️
+
+
Fehler
+
0
+
Verbindungsabbrüche
+
-
-
Verbindung
-
-
+
+
🔌
+
+
Verbindung
+
+
+
-
{# /stat-row #} +
- {# ── Chart panel ── #} -
-
Verlauf — Kanal 1 (letzte 80 Messpunkte)
+ +
+
+
Temperaturverlauf
+ letzte 80 Werte +
- {# ── Log + Alerts panel ── #} -
- + +
Rohdaten
- Alerts - + Alerts
-
-
- Warte auf Daten … +
+ Warte auf Daten …
-
- Keine Alerts. +
+ Keine Alerts.
+
-
{# /log-panel #} - -
{# /mirror-body #} + - {# ══════════════════════════════════ - FOOTER - ══════════════════════════════════ #} +
- Smart Mirror · Arduino USB Monitor - + Smart Mirror · TGBBz Dillingen · Arduino USB Monitor +
-
{# /mirror-layout #} - -
{# /mirror-root #} + + - -{# Keep footer timestamp fresh #}