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}

+ {rows}
+

+ 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 -![logischer aufbau](https://raw.githubusercontent.com/Sinned50/tgbbz-dillingen-magic-mirror/refs/heads/main/Logischer%20Aufbau%20-%20SmartMirror%20(Lucidchart).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 + ══════════════════════════════════ #} +
+ + {{ title }} + +
+
00:00:00
+
+
+ +
+
+ Verbinde … +
+ +
+ + + {# ══════════════════════════════════ + 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 ── #} +
+ +
+
Letzter Wert
+
+
+
+ +
+
Zeilen gesamt
+
0
+
empfangen
+
+ +
+
Fehler
+
0
+
Verbindungsabbrüche
+
+ +
+
Verbindung
+
+
+
+ +
{# /stat-row #} + + + {# ── Chart panel ── #} +
+
Verlauf — Kanal 1 (letzte 80 Messpunkte)
+ +
+ + + {# ── Log + Alerts panel ── #} +
+ +
+
Rohdaten
+
+ Alerts + +
+
+ +
+
+
+ Warte auf Daten … +
+
+
+
+ Keine Alerts. +
+
+
+ +
{# /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 + } + ] +}