From f4a2db5896d2f0ccb1682e91f49181955664f2bd Mon Sep 17 00:00:00 2001
From: Sinned <34993855+Sinned50@users.noreply.github.com>
Date: Mon, 27 Apr 2026 06:01:00 +0200
Subject: [PATCH] Add files via upload
---
Dashboard.py | 119 +++++++++++++++
Notification.py | 289 +++++++++++++++++++++++++++++++++++
README.md | 141 ++++++++++++++---
USBRead.py | 177 ++++++++++++++++++++++
base.html | 13 ++
dashboard.html | 141 +++++++++++++++++
dashboard.js | 273 +++++++++++++++++++++++++++++++++
gunicorn.conf.py | 17 +++
mirror.css | 385 +++++++++++++++++++++++++++++++++++++++++++++++
requirements.txt | 5 +
settings.json | 75 +++++++++
11 files changed, 1616 insertions(+), 19 deletions(-)
create mode 100644 Dashboard.py
create mode 100644 Notification.py
create mode 100644 USBRead.py
create mode 100644 base.html
create mode 100644 dashboard.html
create mode 100644 dashboard.js
create mode 100644 gunicorn.conf.py
create mode 100644 mirror.css
create mode 100644 requirements.txt
create mode 100644 settings.json
diff --git a/Dashboard.py b/Dashboard.py
new file mode 100644
index 0000000..ffff668
--- /dev/null
+++ b/Dashboard.py
@@ -0,0 +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,
+ )
diff --git a/Notification.py b/Notification.py
new file mode 100644
index 0000000..185b2b2
--- /dev/null
+++ b/Notification.py
@@ -0,0 +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}
+
+
+ Automatisch generiert · bitte nicht antworten.
+
+ |
+
+
+"""
diff --git a/README.md b/README.md
index 276c3b1..9a2dafb 100644
--- a/README.md
+++ b/README.md
@@ -1,26 +1,129 @@
-# tgbbz-dillingen-smart-mirror
+# Smart Mirror · Arduino USB Monitor v3
-Eine Sammlung an Python und Arduino-Skripten zur Umsetzung eines kleinen "SmartMirrors", der im Rahmen eines Berufsschulprojektes am TGBBZ Dillingen (saar) entstanden ist.
+## Projektstruktur
-## Repostruktur
```
-- raspi/
- raspi/Notification.py
- Behandelt E-Mail-Benachrichtigungen und fungiert als SMTP-Client
- raspi/Dashboard.py
- Stellt das eigentliche Dashboard des MagicMirrors zur Verfügung, basiert im wesentlichen
- auf dem Web Application Framework Flask und der Template Engine Jinja
- raspi/ReadUSB.py
- Liest über die entsprechende Grätedatei (z.B. /dev/ACM0) den USB-Input des Arduinos aus und reicht die Informationen
- an Dashboard.py zur Anzeige bzw. an Notification.py zur Benachrichtigung weiter
-
-- arduino
- arduino/ReadData.ino
- Liest Sensordaten von angeschlossenen Senoren aus, bringt sie in ein einheitliches Format (JSON) und überträgt sie per USB an den
- angeschlossenen Raspberry Pi
+usb_monitor_v3/
+├── settings.json ← zentrale Konfiguration
+├── USBRead.py ← serielle Leseschicht + Parser
+├── Notification.py ← SMTP- + WhatsApp-Alerting
+├── Dashboard.py ← Flask App Factory
+├── gunicorn.conf.py ← Gunicorn-Konfiguration
+├── requirements.txt
+├── templates/
+│ ├── base.html ← Jinja2 Basis-Layout
+│ └── dashboard.html ← Smart-Mirror-Dashboard
+└── static/
+ ├── css/
+ │ └── mirror.css ← Smart-Mirror-Stylesheet
+ └── js/
+ └── dashboard.js ← Polling, Chart, Uhr, Sensor-Tiles
```
-## Übersicht
-.png?token=GHSAT0AAAAAAD3QF5YYY2GQAOTGUZYORHHK2POACEA)
+---
+## Installation
+```bash
+pip install -r requirements.txt
+
+# Nur bei whatsapp.provider = "twilio":
+pip install twilio
+```
+
+---
+
+## Starten
+
+```bash
+# Gunicorn (Produktion, Port 80 → sudo nötig)
+sudo gunicorn -c gunicorn.conf.py "Dashboard:create_app()"
+
+# Ohne Root (Port in settings.json auf z.B. 8080 setzen)
+gunicorn -c gunicorn.conf.py "Dashboard:create_app()"
+
+# Flask direkt (Entwicklung)
+python Dashboard.py
+```
+
+---
+
+## settings.json — Referenz
+
+### `usb`
+| Schlüssel | Beschreibung |
+|----------------------|--------------------------------------------------|
+| `port` | Gerätepfad, z.B. `/dev/ttyACM0` |
+| `baud_rate` | Baudrate |
+| `reconnect_delay_s` | Sekunden bis Reconnect-Versuch |
+| `buffer_size` | Maximale Einträge im Ring-Buffer |
+
+### `dashboard`
+| Schlüssel | Beschreibung |
+|----------------------|--------------------------------------------------|
+| `host` | Bind-Adresse (`0.0.0.0` = alle Interfaces) |
+| `port` | HTTP-Port |
+| `poll_interval_ms` | Browser-Polling-Intervall in ms |
+| `title` | Titel im Header und Browser-Tab |
+
+### `smtp`
+| Schlüssel | Beschreibung |
+|----------------------|--------------------------------------------------|
+| `enabled` | `true` / `false` — Kanal an/aus |
+| `host` | SMTP-Hostname |
+| `port` | SMTP-Port (587 STARTTLS, 465 SSL) |
+| `use_tls` | `true` → STARTTLS, `false` → direktes SSL |
+| `username` | Login |
+| `password` | Passwort |
+| `from_address` | Absender |
+| `to_addresses` | Empfänger-Array |
+| `cooldown_s` | Mindestabstand zwischen Alerts (pro Sensor) |
+
+### `whatsapp`
+| Schlüssel | Beschreibung |
+|----------------------|--------------------------------------------------|
+| `enabled` | `true` / `false` |
+| `provider` | `"twilio"` oder `"callmebot"` |
+| `cooldown_s` | Mindestabstand WhatsApp-Alerts (pro Sensor) |
+
+**Twilio** (`whatsapp.twilio`):
+- Account SID + Auth Token aus der Twilio Console
+- `from_number`: `"whatsapp:+14155238886"` (Sandbox) oder eigene Nummer
+- `to_numbers`: Array mit `"whatsapp:+49..."`
+- Einmalige Sandbox-Aktivierung: https://www.twilio.com/console/sms/whatsapp/sandbox
+
+**CallMeBot** (`whatsapp.callmebot`) — kostenlos, kein Account:
+- API-Key einmalig aktivieren: https://www.callmebot.com/blog/free-api-whatsapp-messages/
+- `to_numbers`: Array mit Rufnummern im Format `"+49151..."`
+
+### `sensors`
+Jeder Eintrag im Array beschreibt einen Messkanal:
+
+| Schlüssel | Beschreibung |
+|--------------------|-------------------------------------------------------|
+| `name` | Anzeigename |
+| `field_index` | Index im `values`-Array (0-basiert) |
+| `unit` | Einheit (Anzeige), z.B. `"°C"` |
+| `threshold_high` | Oberer Grenzwert (`null` = kein) |
+| `threshold_low` | Unterer Grenzwert (`null` = kein) |
+| `notify_on_high` | E-Mail + WA senden bei Überschreitung |
+| `notify_on_low` | E-Mail + WA senden bei Unterschreitung |
+
+---
+
+## Arduino-Ausgabeformate (automatisch erkannt)
+
+| Format | Beispiel |
+|--------------|--------------------------------|
+| Numerisch | `23.5 67.1 4.92` |
+| Key=Value | `temp=23.5,hum=67.1,volt=4.92` |
+| JSON | `{"temp":23.5,"hum":67.1}` |
+
+---
+
+## Smart Mirror Betrieb
+
+Für den Einsatz als Smart Mirror empfiehlt sich:
+- Chromium im Kiosk-Modus: `chromium-browser --kiosk http://localhost`
+- Bildschirm-Timeout deaktivieren: `xset s off && xset -dpms`
+- Autostart via `/etc/rc.local` oder systemd (siehe README v2)
diff --git a/USBRead.py b/USBRead.py
new file mode 100644
index 0000000..59f2bde
--- /dev/null
+++ b/USBRead.py
@@ -0,0 +1,177 @@
+"""
+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)
diff --git a/base.html b/base.html
new file mode 100644
index 0000000..fe62efd
--- /dev/null
+++ b/base.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+ {% block title %}Smart Mirror{% endblock %}
+
+ {% block extra_head %}{% endblock %}
+
+
+ {% block body %}{% endblock %}
+
+
diff --git a/dashboard.html b/dashboard.html
new file mode 100644
index 0000000..23eb74d
--- /dev/null
+++ b/dashboard.html
@@ -0,0 +1,141 @@
+{% extends "base.html" %}
+
+{% block title %}{{ title }}{% endblock %}
+
+{% block body %}
+{# Root element carries config as data-attributes for dashboard.js #}
+
+
+
+
+ {# ══════════════════════════════════
+ HEADER — clock + connection badge
+ ══════════════════════════════════ #}
+
+
+
+ {# ══════════════════════════════════
+ BODY — sensors / chart / log
+ ══════════════════════════════════ #}
+
+
+ {# ── Sensor tiles (one per configured sensor) ── #}
+ {% for s in sensors %}
+
+
{{ 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 %}
+
+ {# ── Stat row ── #}
+
+
+
+
+
+
Zeilen gesamt
+
0
+
empfangen
+
+
+
+
Fehler
+
0
+
Verbindungsabbrüche
+
+
+
+
+
{# /stat-row #}
+
+
+ {# ── Chart panel ── #}
+
+
Verlauf — Kanal 1 (letzte 80 Messpunkte)
+
+
+
+
+ {# ── Log + Alerts panel ── #}
+
+
+
+
Rohdaten
+
+ Alerts
+
+
+
+
+
+
+
+ Warte auf Daten …
+
+
+
+
+
+
{# /log-panel #}
+
+ {# /mirror-body #}
+
+
+ {# ══════════════════════════════════
+ FOOTER
+ ══════════════════════════════════ #}
+
+
+
{# /mirror-layout #}
+
+
{# /mirror-root #}
+
+
+
+{# Keep footer timestamp fresh #}
+
+{% endblock %}
diff --git a/dashboard.js b/dashboard.js
new file mode 100644
index 0000000..0c1e621
--- /dev/null
+++ b/dashboard.js
@@ -0,0 +1,273 @@
+/* ═══════════════════════════════════════════════════════════════
+ 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 || '[]');
+
+/* ── State ─────────────────────────────────────────────────────── */
+let lastLogCount = 0;
+let alertsSeen = 0;
+let chartHistory = []; // first numeric channel only
+
+/* ══════════════════════════════════════════════════════════════════
+ CLOCK
+══════════════════════════════════════════════════════════════════ */
+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;
+}
+
+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;
+}
+window.addEventListener('resize', () => { resizeCanvas(); drawChart(); });
+resizeCanvas();
+
+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);
+ 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 yPos = v => H - ((v - min) / range) * H * 0.82 - H * 0.09;
+
+ /* grid lines */
+ ctx.strokeStyle = 'rgba(255,255,255,.04)';
+ ctx.lineWidth = 1;
+ ctx.font = `9px 'Share Tech Mono', monospace`;
+ ctx.fillStyle = 'rgba(232,244,255,.2)';
+ for (let i = 0; i <= 4; i++) {
+ const y = H - (i / 4) * H * 0.82 - H * 0.09;
+ ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
+ ctx.fillText((min + (i / 4) * range).toFixed(2), 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)');
+
+ ctx.beginPath();
+ pts.forEach((v, i) => {
+ const x = i * step, y = yPos(v);
+ i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
+ });
+ ctx.lineTo((pts.length - 1) * step, H);
+ ctx.lineTo(0, H);
+ ctx.closePath();
+ ctx.fillStyle = grad;
+ ctx.fill();
+
+ /* line */
+ ctx.beginPath();
+ pts.forEach((v, i) => {
+ const x = i * step, y = yPos(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.stroke();
+ ctx.shadowBlur = 0;
+
+ /* last point dot */
+ const lv = pts[pts.length - 1];
+ const lx = (pts.length - 1) * step;
+ const ly = yPos(lv);
+ ctx.beginPath();
+ ctx.arc(lx, ly, 3.5, 0, Math.PI * 2);
+ ctx.fillStyle = '#a8d8f0';
+ ctx.shadowColor = '#a8d8f0';
+ ctx.shadowBlur = 12;
+ ctx.fill();
+ ctx.shadowBlur = 0;
+}
+
+
+/* ══════════════════════════════════════════════════════════════════
+ SENSOR TILES
+══════════════════════════════════════════════════════════════════ */
+function updateSensorTiles(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;
+
+ valEl.textContent = `${v.toFixed(2)} ${s.unit || ''}`;
+
+ // Clear old badges
+ 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) {
+ tile.classList.add('breach-high');
+ const b = document.createElement('span');
+ b.className = 'sensor-alert-badge';
+ b.textContent = '↑ Grenzwert überschritten';
+ tile.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);
+ }
+ });
+}
+
+
+/* ══════════════════════════════════════════════════════════════════
+ 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}`);
+ });
+ if (name === 'alerts') {
+ const badge = document.getElementById('alert-badge');
+ badge.classList.remove('visible');
+ badge.textContent = '';
+ }
+}
+
+
+/* ══════════════════════════════════════════════════════════════════
+ MAIN POLL
+══════════════════════════════════════════════════════════════════ */
+async function poll() {
+ try {
+ const [dataRes, alertRes] = await Promise.all([
+ fetch('/api/data'),
+ fetch('/api/alerts'),
+ ]);
+ const data = await dataRes.json();
+ const alerts = await alertRes.json();
+
+ /* ── 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 …';
+
+ /* ── 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`;
+
+ if (data.entries.length) {
+ const latest = data.entries[data.entries.length - 1];
+ const disp = latest.values?.length
+ ? latest.values[0].toFixed(3)
+ : 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);
+ }
+
+ /* ── Log (new entries only, prepended) ── */
+ const newEntries = data.entries.slice(-(data.entries.length - lastLogCount));
+ lastLogCount = data.entries.length;
+ const logPane = document.getElementById('pane-log');
+
+ newEntries.forEach(e => {
+ if (e.values?.length) chartHistory.push(e.values[0]);
+
+ const row = document.createElement('div');
+ row.className = 'log-entry';
+ const numStr = e.values?.map(v => v.toFixed(3)).join(' ') || '';
+ row.innerHTML =
+ `${e.ts.split('T')[1]}` +
+ `${e.raw}` +
+ (numStr ? `${numStr}` : '');
+ logPane.prepend(row);
+ });
+
+ /* ── 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';
+ row.innerHTML =
+ `⚠` +
+ `${a.ts.split('T')[1]}` +
+ `${a.subject}`;
+ alertPane.prepend(row);
+ });
+
+ const alertBadge = document.getElementById('alert-badge');
+ if (!document.getElementById('pane-alerts').classList.contains('active')) {
+ alertBadge.textContent = newAlerts.length > 9 ? '9+' : newAlerts.length;
+ alertBadge.classList.add('visible');
+ }
+ }
+
+ drawChart();
+
+ } catch (err) {
+ console.warn('[Mirror] Poll error:', err);
+ }
+
+ setTimeout(poll, POLL_MS);
+}
+
+/* ── Kick off ─────────────────────────────────────────────────── */
+poll();
diff --git a/gunicorn.conf.py b/gunicorn.conf.py
new file mode 100644
index 0000000..543afb9
--- /dev/null
+++ b/gunicorn.conf.py
@@ -0,0 +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"
diff --git a/mirror.css b/mirror.css
new file mode 100644
index 0000000..168271d
--- /dev/null
+++ b/mirror.css
@@ -0,0 +1,385 @@
+/* ═══════════════════════════════════════════════════════════════
+ 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=Outfit:wght@200;300;400;600&family=Share+Tech+Mono&display=swap');
+
+/* ── 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);
+
+ --frost: #e8f4ff; /* primary text */
+ --frost-dim: rgba(232,244,255,.35);
+ --frost-ghost: rgba(232,244,255,.14);
+
+ --ice: #a8d8f0; /* accent — cool blue */
+ --ice-glow: rgba(168,216,240,.22);
+
+ --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);
+
+ --font-ui: 'Outfit', sans-serif;
+ --font-mono: 'Share Tech Mono', monospace;
+
+ --r: 8px; /* border-radius */
+ --gap: 18px;
+}
+
+/* ── Reset & base ──────────────────────────────────────────────── */
+*, *::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;
+ -webkit-font-smoothing: antialiased;
+}
+
+/* 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 */
+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
+ );
+}
+
+/* ── Layout wrapper ────────────────────────────────────────────── */
+.mirror-layout {
+ position: relative; z-index: 2;
+ display: grid;
+ grid-template-rows: auto 1fr auto;
+ min-height: 100vh;
+ padding: var(--gap);
+ gap: var(--gap);
+}
+
+/* ── Glass panel primitive ─────────────────────────────────────── */
+.glass {
+ background: var(--glass);
+ border: 1px solid var(--glass-edge);
+ border-radius: var(--r);
+ backdrop-filter: blur(2px);
+ -webkit-backdrop-filter: blur(2px);
+ transition: border-color .35s, background .35s;
+}
+.glass:hover { background: var(--glass-hover); }
+
+/* ── Header bar ────────────────────────────────────────────────── */
+.mirror-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 14px 22px;
+}
+
+.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-time {
+ font-size: clamp(2.4rem, 6vw, 4.5rem);
+ font-weight: 200;
+ letter-spacing: .06em;
+ color: var(--frost);
+ line-height: 1;
+ text-shadow: 0 0 40px rgba(232,244,255,.18);
+}
+.clock-date {
+ font-size: .7rem;
+ font-weight: 300;
+ letter-spacing: .25em;
+ text-transform: uppercase;
+ color: var(--frost-dim);
+ margin-top: 5px;
+}
+
+/* Connection indicator */
+.conn-badge {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ font-family: var(--font-mono);
+ font-size: .68rem;
+ color: var(--frost-ghost);
+ transition: color .4s;
+}
+.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;
+ flex-shrink: 0;
+}
+.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); }
+
+/* ── Main content grid ─────────────────────────────────────────── */
+.mirror-body {
+ display: grid;
+ grid-template-columns: 1fr 1fr 1fr;
+ grid-template-rows: auto auto auto;
+ gap: var(--gap);
+}
+
+/* ── Sensor tiles ──────────────────────────────────────────────── */
+.sensor-tile {
+ padding: 20px 22px 18px;
+ position: relative;
+ overflow: hidden;
+}
+.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.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); }
+
+.sensor-label {
+ font-size: .58rem;
+ font-weight: 400;
+ letter-spacing: .22em;
+ 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);
+}
+
+/* ── Stat row (small cards) ────────────────────────────────────── */
+.stat-row {
+ grid-column: 1 / -1;
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: var(--gap);
+}
+.stat-card {
+ padding: 16px 18px;
+}
+.stat-label {
+ font-size: .55rem;
+ letter-spacing: .22em;
+ text-transform: uppercase;
+ color: var(--frost-ghost);
+ margin-bottom: 8px;
+}
+.stat-value {
+ font-family: var(--font-mono);
+ font-size: 1.55rem;
+ color: var(--frost);
+ line-height: 1;
+}
+.stat-value.red { color: var(--danger); }
+.stat-sub {
+ font-size: .58rem;
+ color: var(--frost-ghost);
+ margin-top: 5px;
+ font-family: var(--font-mono);
+}
+
+/* ── Chart panel ───────────────────────────────────────────────── */
+.chart-panel {
+ grid-column: 1 / 3;
+ padding: 18px 20px;
+}
+.panel-label {
+ font-size: .56rem;
+ letter-spacing: .22em;
+ text-transform: uppercase;
+ color: var(--frost-ghost);
+ margin-bottom: 12px;
+}
+#chart { display: block; width: 100%; }
+
+/* ── Log / Alert panel ─────────────────────────────────────────── */
+.log-panel {
+ grid-column: 3 / 4;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.tab-bar {
+ display: flex;
+ border-bottom: 1px solid var(--glass-edge);
+ flex-shrink: 0;
+}
+.tab {
+ flex: 1;
+ padding: 11px 14px;
+ font-size: .56rem;
+ letter-spacing: .2em;
+ text-transform: uppercase;
+ color: var(--frost-ghost);
+ cursor: pointer;
+ text-align: center;
+ transition: color .2s, background .2s;
+ border-bottom: 2px solid transparent;
+ margin-bottom: -1px;
+}
+.tab:hover { color: var(--frost-dim); }
+.tab.active { color: var(--ice); border-bottom-color: var(--ice); }
+
+.tab-body {
+ flex: 1;
+ overflow-y: auto;
+ padding: 10px 14px;
+ font-family: var(--font-mono);
+ font-size: .62rem;
+}
+.tab-body::-webkit-scrollbar { width: 3px; }
+.tab-body::-webkit-scrollbar-thumb { background: var(--glass-edge); 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;
+ gap: 8px;
+ padding: 5px 0;
+ border-bottom: 1px solid rgba(255,255,255,.04);
+ align-items: flex-start;
+ animation: slideIn .2s ease;
+}
+.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; }
+
+.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;
+ margin-left: 5px;
+ vertical-align: middle;
+ display: none;
+}
+.alert-badge.visible { display: inline-flex; }
+
+/* ── 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);
+}
+
+/* ── Responsive ────────────────────────────────────────────────── */
+@media (max-width: 900px) {
+ .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; }
+}
+@media (max-width: 560px) {
+ .mirror-body { grid-template-columns: 1fr; }
+ .stat-row { grid-template-columns: 1fr 1fr; }
+ .clock-time { font-size: 2.2rem; }
+}
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..9382737
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,5 @@
+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/settings.json b/settings.json
new file mode 100644
index 0000000..7797dfb
--- /dev/null
+++ b/settings.json
@@ -0,0 +1,75 @@
+{
+ "usb": {
+ "port": "/dev/ttyACM0",
+ "baud_rate": 9600,
+ "reconnect_delay_s": 3,
+ "buffer_size": 200
+ },
+
+ "dashboard": {
+ "host": "127.0.0.1",
+ "port": 8088,
+ "poll_interval_ms": 800,
+ "title": "Smart Mirror"
+ },
+
+ "smtp": {
+ "enabled": true,
+ "host": "smtp.example.com",
+ "port": 587,
+ "use_tls": true,
+ "username": "alerts@example.com",
+ "password": "secret",
+ "from_address": "alerts@example.com",
+ "to_addresses": ["admin@example.com"],
+ "cooldown_s": 300
+ },
+
+ "whatsapp": {
+ "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"]
+ }
+ },
+
+ "sensors": [
+ {
+ "name": "Temperatur",
+ "field_index": 0,
+ "unit": "°C",
+ "threshold_high": 80.0,
+ "threshold_low": -10.0,
+ "notify_on_high": true,
+ "notify_on_low": true
+ },
+ {
+ "name": "Luftfeuchtigkeit",
+ "field_index": 1,
+ "unit": "%",
+ "threshold_high": 95.0,
+ "threshold_low": null,
+ "notify_on_high": true,
+ "notify_on_low": false
+ },
+ {
+ "name": "Spannung",
+ "field_index": 2,
+ "unit": "V",
+ "threshold_high": 5.5,
+ "threshold_low": 3.0,
+ "notify_on_high": true,
+ "notify_on_low": true
+ }
+ ]
+}