Add files via upload

This commit is contained in:
Sinned
2026-04-27 06:01:00 +02:00
committed by GitHub
parent 867742782c
commit f4a2db5896
11 changed files with 1616 additions and 19 deletions

119
Dashboard.py Normal file
View File

@@ -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,
)

289
Notification.py Normal file
View File

@@ -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 += "<tr><td colspan='2' style='padding:5px 0'></td></tr>"
elif ":" in line:
k, _, v = line.partition(":")
rows += (
f"<tr>"
f"<td style='padding:4px 14px 4px 0;color:#888;white-space:nowrap;"
f"font-size:12px'>{k.strip()}</td>"
f"<td style='padding:4px 0;font-family:monospace;font-size:12px'>"
f"{v.strip()}</td></tr>"
)
else:
rows += (
f"<tr><td colspan='2' style='padding:4px 0;color:#ccc;"
f"font-size:13px'>{line}</td></tr>"
)
return f"""<!DOCTYPE html>
<html><head><meta charset="UTF-8"/></head>
<body style="margin:0;padding:0;background:#080b10;font-family:'Segoe UI',sans-serif;color:#c8d6e5">
<table width="100%" cellpadding="0" cellspacing="0"
style="max-width:520px;margin:40px auto">
<tr>
<td style="background:#0f1318;border:1px solid rgba(255,255,255,.08);
border-radius:10px;padding:32px">
<p style="margin:0 0 6px;font-size:10px;letter-spacing:.22em;
text-transform:uppercase;color:#4a5568">Smart Mirror · Arduino Alert</p>
<h1 style="margin:0 0 22px;font-size:17px;color:#e2e8f0;font-weight:600;
line-height:1.3">{subject}</h1>
<table cellpadding="0" cellspacing="0" style="width:100%">{rows}</table>
<p style="margin:24px 0 0;font-size:10px;color:#4a5568">
Automatisch generiert · bitte nicht antworten.
</p>
</td>
</tr>
</table>
</body></html>"""

141
README.md
View File

@@ -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/ usb_monitor_v3/
raspi/Notification.py ├── settings.json ← zentrale Konfiguration
Behandelt E-Mail-Benachrichtigungen und fungiert als SMTP-Client ├── USBRead.py ← serielle Leseschicht + Parser
raspi/Dashboard.py ├── Notification.py ← SMTP- + WhatsApp-Alerting
Stellt das eigentliche Dashboard des MagicMirrors zur Verfügung, basiert im wesentlichen ├── Dashboard.py ← Flask App Factory
auf dem Web Application Framework Flask und der Template Engine Jinja ├── gunicorn.conf.py ← Gunicorn-Konfiguration
raspi/ReadUSB.py ├── requirements.txt
Liest über die entsprechende Grätedatei (z.B. /dev/ACM0) den USB-Input des Arduinos aus und reicht die Informationen ├── templates/
an Dashboard.py zur Anzeige bzw. an Notification.py zur Benachrichtigung weiter ├── base.html ← Jinja2 Basis-Layout
│ └── dashboard.html ← Smart-Mirror-Dashboard
- arduino └── static/
arduino/ReadData.ino ├── css/
Liest Sensordaten von angeschlossenen Senoren aus, bringt sie in ein einheitliches Format (JSON) und überträgt sie per USB an den │ └── mirror.css ← Smart-Mirror-Stylesheet
angeschlossenen Raspberry Pi └── 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)

177
USBRead.py Normal file
View File

@@ -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)

13
base.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>{% block title %}Smart Mirror{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/mirror.css') }}"/>
{% block extra_head %}{% endblock %}
</head>
<body>
{% block body %}{% endblock %}
</body>
</html>

141
dashboard.html Normal file
View File

@@ -0,0 +1,141 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock %}
{% block body %}
{# Root element carries config as data-attributes for dashboard.js #}
<div id="mirror-root"
data-poll-ms="{{ poll_ms }}"
data-sensors="{{ sensors_json | e }}">
<div class="mirror-layout">
{# ══════════════════════════════════
HEADER — clock + connection badge
══════════════════════════════════ #}
<header class="mirror-header glass">
<span class="mirror-title">{{ title }}</span>
<div class="mirror-clock">
<div class="clock-time" id="clock-time">00:00:00</div>
<div class="clock-date" id="clock-date"></div>
</div>
<div class="conn-badge glass" id="conn-badge">
<div class="conn-dot" id="conn-dot"></div>
<span id="conn-text">Verbinde …</span>
</div>
</header>
{# ══════════════════════════════════
BODY — sensors / chart / log
══════════════════════════════════ #}
<main class="mirror-body">
{# ── Sensor tiles (one per configured sensor) ── #}
{% for s in sensors %}
<div class="sensor-tile glass" id="sc-{{ loop.index0 }}">
<div class="sensor-label">{{ s.name }}</div>
<div class="sensor-value" id="sv-{{ loop.index0 }}"></div>
<div class="sensor-threshold">
{%- if s.threshold_low is not none -%}
↓ {{ s.threshold_low }}{{ s.unit }}
{%- endif -%}
{%- if s.threshold_high is not none -%}
&nbsp; ↑ {{ s.threshold_high }}{{ s.unit }}
{%- endif -%}
</div>
</div>
{% endfor %}
{# ── Stat row ── #}
<div class="stat-row">
<div class="stat-card glass">
<div class="stat-label">Letzter Wert</div>
<div class="stat-value" id="stat-last"></div>
<div class="stat-sub" id="stat-ts"></div>
</div>
<div class="stat-card glass">
<div class="stat-label">Zeilen gesamt</div>
<div class="stat-value" id="stat-total">0</div>
<div class="stat-sub">empfangen</div>
</div>
<div class="stat-card glass">
<div class="stat-label">Fehler</div>
<div class="stat-value red" id="stat-errors">0</div>
<div class="stat-sub">Verbindungsabbrüche</div>
</div>
<div class="stat-card glass">
<div class="stat-label">Verbindung</div>
<div class="stat-value" id="stat-port" style="font-size:1rem;padding-top:.35rem"></div>
<div class="stat-sub" id="stat-baud"></div>
</div>
</div>{# /stat-row #}
{# ── Chart panel ── #}
<div class="chart-panel glass">
<div class="panel-label">Verlauf — Kanal 1 (letzte 80 Messpunkte)</div>
<canvas id="chart"></canvas>
</div>
{# ── Log + Alerts panel ── #}
<div class="log-panel glass">
<div class="tab-bar">
<div class="tab active" data-tab="log" onclick="switchTab('log')">Rohdaten</div>
<div class="tab" data-tab="alerts" onclick="switchTab('alerts')">
Alerts
<span class="alert-badge" id="alert-badge"></span>
</div>
</div>
<div class="tab-body">
<div class="tab-pane active" id="pane-log">
<div class="log-entry" style="color:var(--frost-ghost)">
<span>Warte auf Daten …</span>
</div>
</div>
<div class="tab-pane" id="pane-alerts">
<div class="alert-entry" style="color:var(--frost-ghost)">
<span>Keine Alerts.</span>
</div>
</div>
</div>
</div>{# /log-panel #}
</main>{# /mirror-body #}
{# ══════════════════════════════════
FOOTER
══════════════════════════════════ #}
<footer class="mirror-footer">
<span>Smart Mirror · Arduino USB Monitor</span>
<span id="footer-ts"></span>
</footer>
</div>{# /mirror-layout #}
</div>{# /mirror-root #}
<script src="{{ url_for('static', filename='js/dashboard.js') }}"></script>
{# Keep footer timestamp fresh #}
<script>
setInterval(() => {
document.getElementById('footer-ts').textContent =
new Date().toLocaleString('de-DE');
}, 1000);
</script>
{% endblock %}

273
dashboard.js Normal file
View File

@@ -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 =
`<span class="log-ts">${e.ts.split('T')[1]}</span>` +
`<span class="log-raw">${e.raw}</span>` +
(numStr ? `<span class="log-num">${numStr}</span>` : '');
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 =
`<span class="alert-icon">⚠</span>` +
`<span class="alert-ts">${a.ts.split('T')[1]}</span>` +
`<span class="alert-msg">${a.subject}</span>`;
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();

17
gunicorn.conf.py Normal file
View File

@@ -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"

385
mirror.css Normal file
View File

@@ -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; }
}

5
requirements.txt Normal file
View File

@@ -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

75
settings.json Normal file
View File

@@ -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
}
]
}