This commit is contained in:
2026-05-02 20:54:53 +02:00
parent 638f4a7b75
commit 408da56450
14 changed files with 1271 additions and 1104 deletions

84
README.md Normal file → Executable file
View File

@@ -1,42 +1,42 @@
# "SmartMirror" - Projektwochen am TGBBZ Dillingen
## Repo-Strktur
```
arduino/
arduino/arduino.ino
Arduino-Skript, um die Sensordaten auszulesen und sie per USB
an den Raspberry Pi zu übertragen
raspi/
raspi/templates
Jinja basierte HTML-Templates
raspi/static
Ordner mit frontend (CSS, JS) Dateien
raspi/Dashboard.py
Stellt das Flask-basierte Web-Dashboard bereit
raspi/USBRead.py
Liest in regelmäßigen Intervallen die USB-Gerätedatei (/dev/ttyACM0) aus
und stellt die Daten Dashboard.py und Notification.py bereit
raspi/Notification.py
Dient als SMTP-Client, der Benachrichtigungen über E-Mail und Messanger
versendet#
raspi/gunicorn.conf.py
Startup-Datei für den WSGI-Webserver Gunicorn, der das Flask Web-Dashboard
bereitstellt
raspi/requirements.txt
requirements.txt für pip (lieste der benötigten Python-Abhängigkeiten)
raspi/settings.json
Zentrale Konfigurationsdatei zur Konfiguration der Raspi-Skripte
raspi/README.md
docs/
aufbau.png
Übersicht über den logischen Aufbau des Setups
verkabelung.txt
Übersicht über die phyische Verkabelung
```
## Logischer Aufbau
![logischer-aufbau](https://raw.githubusercontent.com/Sinned50/tgbbz-dillingen-smart-mirror/refs/heads/main/docs/aufbau.png?token=GHSAT0AAAAAADZ4ZH3YOFA5URKOHZM7A66G2PPHOCA)
# "SmartMirror" - Projektwochen am TGBBZ Dillingen
## Repo-Strktur
```
arduino/
arduino/arduino.ino
Arduino-Skript, um die Sensordaten auszulesen und sie per USB
an den Raspberry Pi zu übertragen
raspi/
raspi/templates
Jinja basierte HTML-Templates
raspi/static
Ordner mit frontend (CSS, JS) Dateien
raspi/Dashboard.py
Stellt das Flask-basierte Web-Dashboard bereit
raspi/USBRead.py
Liest in regelmäßigen Intervallen die USB-Gerätedatei (/dev/ttyACM0) aus
und stellt die Daten Dashboard.py und Notification.py bereit
raspi/Notification.py
Dient als SMTP-Client, der Benachrichtigungen über E-Mail und Messanger
versendet#
raspi/gunicorn.conf.py
Startup-Datei für den WSGI-Webserver Gunicorn, der das Flask Web-Dashboard
bereitstellt
raspi/requirements.txt
requirements.txt für pip (lieste der benötigten Python-Abhängigkeiten)
raspi/settings.json
Zentrale Konfigurationsdatei zur Konfiguration der Raspi-Skripte
raspi/README.md
docs/
aufbau.png
Übersicht über den logischen Aufbau des Setups
verkabelung.txt
Übersicht über die phyische Verkabelung
```
## Logischer Aufbau
![logischer-aufbau](https://raw.githubusercontent.com/Sinned50/tgbbz-dillingen-smart-mirror/refs/heads/main/docs/aufbau.png?token=GHSAT0AAAAAADZ4ZH3YOFA5URKOHZM7A66G2PPHOCA)

0
arduino/arduino.ino Normal file → Executable file
View File

0
docs/aufbau.png Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 164 KiB

40
docs/verkabelung.txt Normal file → Executable file
View File

@@ -1,21 +1,21 @@
DHT22:
+ Modul <-> 3,3V Breadboard (über vertikale Schiene)
Out Modul <-> D4 Arduino
- Modul <-> GND Breadboard (über vertikale Schiene)
Regenmodul:
AO Modul <-> A1 Arduino
DO Modul <-> D2 Arduino
GND Modul <-> GND Breadboard (über vertikale Schiene)
VCC Modul <-> V3,3 Breadboard (über vertikale Schiene)
BMP22:
VCC Modul <-> 3,3V Breadboard (direkt Phasenschiene)
GND Modul <-> GND Breadboard (direkt an Erdungsschiene)
SCL Modul <-> A5 Arduino (SCL)
SDA Modul <-> A4 Arduino (SDA)
CSB Modul <-> 3,3V Breadboard (direkt an Phasenschiene)
DHT22:
+ Modul <-> 3,3V Breadboard (über vertikale Schiene)
Out Modul <-> D4 Arduino
- Modul <-> GND Breadboard (über vertikale Schiene)
Regenmodul:
AO Modul <-> A1 Arduino
DO Modul <-> D2 Arduino
GND Modul <-> GND Breadboard (über vertikale Schiene)
VCC Modul <-> V3,3 Breadboard (über vertikale Schiene)
BMP22:
VCC Modul <-> 3,3V Breadboard (direkt Phasenschiene)
GND Modul <-> GND Breadboard (direkt an Erdungsschiene)
SCL Modul <-> A5 Arduino (SCL)
SDA Modul <-> A4 Arduino (SDA)
CSB Modul <-> 3,3V Breadboard (direkt an Phasenschiene)
SDD Modul <-> GND Breadboard (direkt an Erdungsschiene)

238
raspi/Dashboard.py Normal file → Executable file
View File

@@ -1,119 +1,119 @@
"""
Dashboard.py
────────────
Flask app factory for the Smart Mirror dashboard.
Routes
------
GET / → Jinja2 rendered dashboard (templates/dashboard.html)
GET /api/data → JSON snapshot from USBRead (status + ring-buffer)
GET /api/alerts → JSON log of dispatched alert events
Start via Gunicorn:
gunicorn -c gunicorn.conf.py "Dashboard:create_app()"
Or for development:
python Dashboard.py
"""
import json
import threading
from collections import deque
from datetime import datetime
from pathlib import Path
from flask import Flask, jsonify, render_template
import Notification
import USBRead
# ── Resolve paths relative to this file ──────────────────────────
BASE_DIR = Path(__file__).parent
SETTINGS_PATH = BASE_DIR / "settings.json"
# ── Settings loader ───────────────────────────────────────────────
def load_settings() -> dict:
with open(SETTINGS_PATH, encoding="utf-8") as fh:
return json.load(fh)
# ── Flask app factory ─────────────────────────────────────────────
def create_app() -> Flask:
settings = load_settings()
dash_cfg = settings["dashboard"]
sensor_cfg = settings.get("sensors", [])
# ── Init subsystems ───────────────────────────────────────────
USBRead.init(settings["usb"])
USBRead.start_reader()
Notification.init(settings)
Notification.attach_to_usb()
# ── In-memory alert log (visible in dashboard) ────────────────
alert_log : deque = deque(maxlen=500)
alert_lock : threading.Lock = threading.Lock()
# Wrap Notification.send_alert so every outbound alert is also
# appended to the dashboard log.
_orig_send = Notification.send_alert
def _logging_send(subject: str, body: str) -> None:
with alert_lock:
alert_log.append({
"ts": datetime.now().isoformat(timespec="milliseconds"),
"subject": subject,
"body": body,
})
_orig_send(subject, body)
Notification.send_alert = _logging_send
# ── Build Flask app ───────────────────────────────────────────
app = Flask(
__name__,
template_folder = str(BASE_DIR / "templates"),
static_folder = str(BASE_DIR / "static"),
)
# Pre-serialise sensor config once (passed to template as JSON
# data-attribute so dashboard.js can read it without an extra request)
sensors_json = json.dumps(sensor_cfg)
# ── Routes ────────────────────────────────────────────────────
@app.route("/")
def index():
return render_template(
"dashboard.html",
title = dash_cfg.get("title", "Smart Mirror"),
sensors = sensor_cfg,
sensors_json = sensors_json,
poll_ms = dash_cfg.get("poll_interval_ms", 800),
)
@app.route("/api/data")
def api_data():
return jsonify(USBRead.get_snapshot())
@app.route("/api/alerts")
def api_alerts():
with alert_lock:
return jsonify(list(alert_log))
return app
# ── Dev entry point ───────────────────────────────────────────────
if __name__ == "__main__":
settings = load_settings()
dash = settings["dashboard"]
app = create_app()
app.run(
host = dash.get("host", "0.0.0.0"),
port = dash.get("port", 80),
debug = False,
)
"""
Dashboard.py
────────────
Flask app factory for the Smart Mirror dashboard.
Routes
------
GET / → Jinja2 rendered dashboard (templates/dashboard.html)
GET /api/data → JSON snapshot from USBRead (status + ring-buffer)
GET /api/alerts → JSON log of dispatched alert events
Start via Gunicorn:
gunicorn -c gunicorn.conf.py "Dashboard:create_app()"
Or for development:
python Dashboard.py
"""
import json
import threading
from collections import deque
from datetime import datetime
from pathlib import Path
from flask import Flask, jsonify, render_template
import Notification
import USBRead
# ── Resolve paths relative to this file ──────────────────────────
BASE_DIR = Path(__file__).parent
SETTINGS_PATH = BASE_DIR / "settings.json"
# ── Settings loader ───────────────────────────────────────────────
def load_settings() -> dict:
with open(SETTINGS_PATH, encoding="utf-8") as fh:
return json.load(fh)
# ── Flask app factory ─────────────────────────────────────────────
def create_app() -> Flask:
settings = load_settings()
dash_cfg = settings["dashboard"]
sensor_cfg = settings.get("sensors", [])
# ── Init subsystems ───────────────────────────────────────────
USBRead.init(settings["usb"])
USBRead.start_reader()
Notification.init(settings)
Notification.attach_to_usb()
# ── In-memory alert log (visible in dashboard) ────────────────
alert_log : deque = deque(maxlen=500)
alert_lock : threading.Lock = threading.Lock()
# Wrap Notification.send_alert so every outbound alert is also
# appended to the dashboard log.
_orig_send = Notification.send_alert
def _logging_send(subject: str, body: str) -> None:
with alert_lock:
alert_log.append({
"ts": datetime.now().isoformat(timespec="milliseconds"),
"subject": subject,
"body": body,
})
_orig_send(subject, body)
Notification.send_alert = _logging_send
# ── Build Flask app ───────────────────────────────────────────
app = Flask(
__name__,
template_folder = str(BASE_DIR / "templates"),
static_folder = str(BASE_DIR / "static"),
)
# Pre-serialise sensor config once (passed to template as JSON
# data-attribute so dashboard.js can read it without an extra request)
sensors_json = json.dumps(sensor_cfg)
# ── Routes ────────────────────────────────────────────────────
@app.route("/")
def index():
return render_template(
"dashboard.html",
title = dash_cfg.get("title", "Smart Mirror"),
sensors = sensor_cfg,
sensors_json = sensors_json,
poll_ms = dash_cfg.get("poll_interval_ms", 800),
)
@app.route("/api/data")
def api_data():
return jsonify(USBRead.get_snapshot())
@app.route("/api/alerts")
def api_alerts():
with alert_lock:
return jsonify(list(alert_log))
return app
# ── Dev entry point ───────────────────────────────────────────────
if __name__ == "__main__":
settings = load_settings()
dash = settings["dashboard"]
app = create_app()
app.run(
host = dash.get("host", "0.0.0.0"),
port = dash.get("port", 80),
debug = False,
)

578
raspi/Notification.py Normal file → Executable file
View File

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

361
raspi/USBRead.py Normal file → Executable file
View File

@@ -1,177 +1,184 @@
"""
USBRead.py
──────────
Reads raw lines from a serial USB device (e.g. Arduino) at the configured
baud rate, parses numeric sensor values, and stores everything in a shared
thread-safe ring-buffer.
Callers import `start_reader()` to launch the background thread, then read
from `get_snapshot()` at any time.
"""
import threading
import time
from collections import deque
from datetime import datetime
from typing import Any
import serial
# ── Populated once by init() ──────────────────────────────────────────────────
_cfg: dict[str, Any] = {}
_lock = threading.Lock()
_buffer: deque = deque()
status: dict[str, Any] = {
"connected": False,
"port": "",
"baud": 0,
"last_received": None,
"total_lines": 0,
"errors": 0,
}
# Callbacks: list of callables(entry) invoked after every parsed entry
_callbacks: list = []
# ── Public API ────────────────────────────────────────────────────────────────
def init(usb_cfg: dict[str, Any]) -> None:
"""Must be called once before start_reader()."""
global _buffer, _cfg
_cfg = usb_cfg
_buffer = deque(maxlen=usb_cfg.get("buffer_size", 200))
status["port"] = usb_cfg["port"]
status["baud"] = usb_cfg["baud_rate"]
def register_callback(fn) -> None:
"""Register a function(entry) that is called for each new parsed line."""
_callbacks.append(fn)
def get_snapshot() -> dict[str, Any]:
"""Return a thread-safe snapshot of the current buffer and status."""
with _lock:
return {
"status": dict(status),
"entries": list(_buffer),
}
def start_reader() -> threading.Thread:
"""Start the background serial-reader thread (daemon)."""
t = threading.Thread(target=_reader_loop, daemon=True, name="usb-reader")
t.start()
return t
# ── Parsing helpers ───────────────────────────────────────────────────────────
def parse_line(raw: str) -> dict[str, Any]:
"""
Parse a raw serial line into a structured entry dict.
Supported Arduino output formats (auto-detected):
• Space / comma / semicolon separated numbers:
"23.5 67.1 4.92" → values: [23.5, 67.1, 4.92]
• Key=value pairs:
"temp=23.5,hum=67.1" → values: [23.5, 67.1],
labels: ["temp", "hum"]
• JSON (if Arduino outputs JSON):
'{"temp":23.5,"hum":67.1}' → values: [23.5, 67.1],
labels: ["temp", "hum"]
"""
ts = datetime.now().isoformat(timespec="milliseconds")
entry: dict[str, Any] = {"ts": ts, "raw": raw, "values": [], "labels": []}
# Try JSON first
if raw.startswith("{"):
try:
import json
obj = json.loads(raw)
for k, v in obj.items():
try:
entry["values"].append(float(v))
entry["labels"].append(str(k))
except (TypeError, ValueError):
pass
return entry
except Exception:
pass
# Try key=value pairs (e.g. "temp=23.5,hum=67")
if "=" in raw:
for token in raw.replace(",", " ").replace(";", " ").split():
if "=" in token:
k, _, v = token.partition("=")
try:
entry["values"].append(float(v))
entry["labels"].append(k.strip())
except ValueError:
pass
if entry["values"]:
return entry
# Fallback: space / comma / semicolon delimited numbers
for token in raw.replace(",", " ").replace(";", " ").split():
try:
entry["values"].append(float(token))
entry["labels"].append(f"ch{len(entry['values'])}")
except ValueError:
pass
return entry
# ── Internal reader loop ──────────────────────────────────────────────────────
def _reader_loop() -> None:
port = _cfg["port"]
baud = _cfg["baud_rate"]
reconnect_delay = _cfg.get("reconnect_delay_s", 3)
while True:
try:
with serial.Serial(port, baud, timeout=1) as ser:
with _lock:
status["connected"] = True
print(f"[USBRead] Connected → {port} @ {baud} baud")
while True:
raw_bytes = ser.readline()
if not raw_bytes:
continue
raw_str = raw_bytes.decode("utf-8", errors="replace").strip()
if not raw_str:
continue
entry = parse_line(raw_str)
with _lock:
_buffer.append(entry)
status["last_received"] = entry["ts"]
status["total_lines"] += 1
# Fire registered callbacks outside the lock
for cb in _callbacks:
try:
cb(entry)
except Exception as exc:
print(f"[USBRead] Callback error: {exc}")
except serial.SerialException as exc:
with _lock:
status["connected"] = False
status["errors"] += 1
print(f"[USBRead] SerialException: {exc} retry in {reconnect_delay}s")
time.sleep(reconnect_delay)
except Exception as exc:
with _lock:
status["connected"] = False
status["errors"] += 1
print(f"[USBRead] Unexpected error: {exc} retry in {reconnect_delay}s")
time.sleep(reconnect_delay)
"""
USBRead.py
──────────
Reads raw lines from a serial USB device (e.g. Arduino) at the configured
baud rate, parses numeric sensor values, and stores everything in a shared
thread-safe ring-buffer.
Callers import `start_reader()` to launch the background thread, then read
from `get_snapshot()` at any time.
"""
import threading
import time
from collections import deque
from datetime import datetime
from typing import Any
import serial
# ── Populated once by init() ──────────────────────────────────────────────────
_cfg: dict[str, Any] = {}
_lock = threading.Lock()
_buffer: deque = deque()
status: dict[str, Any] = {
"connected": False,
"port": "",
"baud": 0,
"last_received": None,
"total_lines": 0,
"errors": 0,
}
# Callbacks: list of callables(entry) invoked after every parsed entry
_callbacks: list = []
# ── Public API ────────────────────────────────────────────────────────────────
def init(usb_cfg: dict[str, Any]) -> None:
"""Must be called once before start_reader()."""
global _buffer, _cfg
_cfg = usb_cfg
_buffer = deque(maxlen=usb_cfg.get("buffer_size", 200))
status["port"] = usb_cfg["port"]
status["baud"] = usb_cfg["baud_rate"]
def register_callback(fn) -> None:
"""Register a function(entry) that is called for each new parsed line."""
_callbacks.append(fn)
def get_snapshot() -> dict[str, Any]:
"""Return a thread-safe snapshot of the current buffer and status."""
with _lock:
return {
"status": dict(status),
"entries": list(_buffer),
}
def start_reader() -> threading.Thread:
"""Start the background serial-reader thread (daemon)."""
t = threading.Thread(target=_reader_loop, daemon=True, name="usb-reader")
t.start()
return t
# ── Parsing helpers ───────────────────────────────────────────────────────────
def parse_line(raw: str) -> dict[str, Any]:
"""
Parse a raw serial line into a structured entry dict.
Supported Arduino output formats (auto-detected):
• Space / comma / semicolon separated numbers:
"23.5 67.1 4.92" → values: [23.5, 67.1, 4.92]
• Key=value pairs:
"temp=23.5,hum=67.1" → values: [23.5, 67.1],
labels: ["temp", "hum"]
• JSON (if Arduino outputs JSON):
'{"temp":23.5,"hum":67.1}' → values: [23.5, 67.1],
labels: ["temp", "hum"]
"""
ts = datetime.now().isoformat(timespec="milliseconds")
entry: dict[str, Any] = {"ts": ts, "raw": raw, "values": [], "labels": []}
# Try JSON first
if raw.startswith("{"):
try:
import json
def _flatten(obj, prefix=""):
for k, v in obj.items():
key = f"{prefix}.{k}" if prefix else k
if isinstance(v, dict):
_flatten(v, key)
else:
try:
entry["values"].append(float(v))
entry["labels"].append(key)
except (TypeError, ValueError):
pass
_flatten(json.loads(raw))
return entry
except Exception:
pass
# Try key=value pairs (e.g. "temp=23.5,hum=67")
if "=" in raw:
for token in raw.replace(",", " ").replace(";", " ").split():
if "=" in token:
k, _, v = token.partition("=")
try:
entry["values"].append(float(v))
entry["labels"].append(k.strip())
except ValueError:
pass
if entry["values"]:
return entry
# Fallback: space / comma / semicolon delimited numbers
for token in raw.replace(",", " ").replace(";", " ").split():
try:
entry["values"].append(float(token))
entry["labels"].append(f"ch{len(entry['values'])}")
except ValueError:
pass
return entry
# ── Internal reader loop ──────────────────────────────────────────────────────
def _reader_loop() -> None:
port = _cfg["port"]
baud = _cfg["baud_rate"]
reconnect_delay = _cfg.get("reconnect_delay_s", 3)
while True:
try:
with serial.Serial(port, baud, timeout=1) as ser:
with _lock:
status["connected"] = True
print(f"[USBRead] Connected → {port} @ {baud} baud")
while True:
raw_bytes = ser.readline()
if not raw_bytes:
continue
raw_str = raw_bytes.decode("utf-8", errors="replace").strip()
if not raw_str:
continue
entry = parse_line(raw_str)
with _lock:
_buffer.append(entry)
status["last_received"] = entry["ts"]
status["total_lines"] += 1
# Fire registered callbacks outside the lock
for cb in _callbacks:
try:
cb(entry)
except Exception as exc:
print(f"[USBRead] Callback error: {exc}")
except serial.SerialException as exc:
with _lock:
status["connected"] = False
status["errors"] += 1
print(f"[USBRead] SerialException: {exc} retry in {reconnect_delay}s")
time.sleep(reconnect_delay)
except Exception as exc:
with _lock:
status["connected"] = False
status["errors"] += 1
print(f"[USBRead] Unexpected error: {exc} retry in {reconnect_delay}s")
time.sleep(reconnect_delay)

34
raspi/gunicorn.conf.py Normal file → Executable file
View File

@@ -1,17 +1,17 @@
# gunicorn.conf.py
# Host/port are read from settings.json — no duplication needed.
import json
from pathlib import Path
_s = json.loads((Path(__file__).parent / "settings.json").read_text())
_d = _s["dashboard"]
bind = f"{_d['host']}:{_d['port']}"
workers = 1 # must be 1 — shared in-process state (USBRead threads)
threads = 4
worker_class = "gthread"
timeout = 30
accesslog = "-"
errorlog = "-"
loglevel = "info"
# gunicorn.conf.py
# Host/port are read from settings.json — no duplication needed.
import json
from pathlib import Path
_s = json.loads((Path(__file__).parent / "settings.json").read_text())
_d = _s["dashboard"]
bind = f"{_d['host']}:{_d['port']}"
workers = 1 # must be 1 — shared in-process state (USBRead threads)
threads = 4
worker_class = "gthread"
timeout = 30
accesslog = "-"
errorlog = "-"
loglevel = "info"

10
raspi/requirements.txt Normal file → Executable file
View File

@@ -1,5 +1,5 @@
flask>=3.0.0
gunicorn>=21.2.0
pyserial>=3.5
# Optional: only needed when whatsapp.provider = "twilio"
# twilio>=9.0.0
flask>=3.0.0
gunicorn>=21.2.0
pyserial>=3.5
# Optional: only needed when whatsapp.provider = "twilio"
# twilio>=9.0.0

31
raspi/settings.json Normal file → Executable file
View File

@@ -7,14 +7,14 @@
},
"dashboard": {
"host": "127.0.0.1",
"host": "0.0.0.0",
"port": 8088,
"poll_interval_ms": 800,
"poll_interval_ms": 2500,
"title": "Smart Mirror"
},
"smtp": {
"enabled": true,
"enabled": false,
"host": "smtp.example.com",
"port": 587,
"use_tls": true,
@@ -29,14 +29,12 @@
"enabled": false,
"provider": "twilio",
"cooldown_s": 300,
"twilio": {
"account_sid": "ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"auth_token": "your_auth_token",
"from_number": "whatsapp:+14155238886",
"to_numbers": ["whatsapp:+49151XXXXXXXX"]
},
"callmebot": {
"api_key": "your_callmebot_apikey",
"to_numbers": ["+49151XXXXXXXX"]
@@ -48,7 +46,7 @@
"name": "Temperatur",
"field_index": 0,
"unit": "°C",
"threshold_high": 80.0,
"threshold_high": 40.0,
"threshold_low": -10.0,
"notify_on_high": true,
"notify_on_low": true
@@ -63,13 +61,22 @@
"notify_on_low": false
},
{
"name": "Spannung",
"name": "Regen (Analog)",
"field_index": 2,
"unit": "V",
"threshold_high": 5.5,
"threshold_low": 3.0,
"notify_on_high": true,
"notify_on_low": true
"unit": "",
"threshold_high": null,
"threshold_low": null,
"notify_on_high": false,
"notify_on_low": false
},
{
"name": "Regen (Digital)",
"field_index": 3,
"unit": "",
"threshold_high": null,
"threshold_low": null,
"notify_on_high": false,
"notify_on_low": false
}
]
}

615
raspi/static/css/mirror.css Normal file → Executable file
View File

@@ -1,385 +1,526 @@
/* ═══════════════════════════════════════════════════════════════
Smart Mirror — Dashboard Stylesheet
Aesthetic: deep void black, razor-thin glass panels, cold white
typography, neon frost accents. Designed to read on a mirror.
═══════════════════════════════════════════════════════════════ */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=JetBrains+Mono:wght@400;500&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@200;300;400;600&family=Share+Tech+Mono&display=swap');
/* ── Design tokens ─────────────────────────────────────────────── */
/* ── Design Tokens ── */
:root {
--void: #000000;
--glass: rgba(255,255,255,.032);
--glass-edge: rgba(255,255,255,.07);
--glass-hover: rgba(255,255,255,.055);
--bg: #111318;
--bg-card: #1c1f2b;
--bg-card2: #21253a;
--border: rgba(255,255,255,.07);
--border-hover:rgba(255,255,255,.13);
--frost: #e8f4ff; /* primary text */
--frost-dim: rgba(232,244,255,.35);
--frost-ghost: rgba(232,244,255,.14);
--txt: #e4e9f5;
--txt-dim: rgba(228,233,245,.55);
--txt-ghost: rgba(228,233,245,.28);
--ice: #a8d8f0; /* accent — cool blue */
--ice-glow: rgba(168,216,240,.22);
--blue: #3b82f6;
--blue-glow: rgba(59,130,246,.25);
--blue-soft: rgba(59,130,246,.12);
--danger: #ff6b6b;
--danger-glow: rgba(255,107,107,.2);
--warn: #ffd166;
--warn-glow: rgba(255,209,102,.18);
--ok: #6bffc4;
--ok-glow: rgba(107,255,196,.18);
--green: #22c55e;
--green-soft: rgba(34,197,94,.12);
--green-glow: rgba(34,197,94,.3);
--font-ui: 'Outfit', sans-serif;
--font-mono: 'Share Tech Mono', monospace;
--yellow: #f59e0b;
--yellow-soft: rgba(245,158,11,.12);
--r: 8px; /* border-radius */
--gap: 18px;
--red: #ef4444;
--red-soft: rgba(239,68,68,.12);
--red-glow: rgba(239,68,68,.3);
--teal: #14b8a6;
--teal-soft: rgba(20,184,166,.12);
--purple: #a78bfa;
--purple-soft: rgba(167,139,250,.12);
--r: 12px;
--r-sm: 8px;
--gap: 14px;
--font: 'Inter', system-ui, sans-serif;
--mono: 'JetBrains Mono', monospace;
}
/* ── Reset & base ──────────────────────────────────────────────── */
/* ── Reset ── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
width: 100%; min-height: 100vh;
background: var(--void);
color: var(--frost);
font-family: var(--font-ui);
font-weight: 300;
overflow-x: hidden;
background: var(--bg);
color: var(--txt);
font-family: var(--font);
font-size: 14px;
font-weight: 400;
-webkit-font-smoothing: antialiased;
overflow-x: hidden;
}
/* Subtle vignette to simulate mirror depth */
body::after {
content: '';
position: fixed; inset: 0; pointer-events: none; z-index: 0;
background: radial-gradient(ellipse 140% 100% at 50% 0%,
transparent 40%,
rgba(0,0,0,.55) 100%);
}
/* Very faint horizontal scanlines — mirror CRT ghost */
/* Subtle grid background */
body::before {
content: '';
position: fixed; inset: 0; pointer-events: none; z-index: 1;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 3px,
rgba(255,255,255,.008) 3px,
rgba(255,255,255,.008) 4px
);
position: fixed; inset: 0; z-index: 0; pointer-events: none;
background-image:
linear-gradient(rgba(59,130,246,.025) 1px, transparent 1px),
linear-gradient(90deg, rgba(59,130,246,.025) 1px, transparent 1px);
background-size: 40px 40px;
}
/* ── Layout wrapper ────────────────────────────────────────────── */
/* ── Layout ── */
.mirror-layout {
position: relative; z-index: 2;
position: relative; z-index: 1;
display: grid;
grid-template-rows: auto 1fr auto;
min-height: 100vh;
padding: var(--gap);
gap: var(--gap);
max-width: 1400px;
margin: 0 auto;
}
/* ── Glass panel primitive ─────────────────────────────────────── */
.glass {
background: var(--glass);
border: 1px solid var(--glass-edge);
/* ── Card primitive ── */
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--r);
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
transition: border-color .35s, background .35s;
transition: border-color .25s, background .25s;
}
.glass:hover { background: var(--glass-hover); }
.card:hover { border-color: var(--border-hover); }
/* ── Header bar ────────────────────────────────────────────────── */
/* ── Header ── */
.mirror-header {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 20px;
padding: 16px 20px;
}
.header-brand {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 22px;
gap: 10px;
}
.brand-icon {
width: 32px; height: 32px;
border-radius: 8px;
background: var(--blue-soft);
border: 1px solid var(--blue);
display: flex; align-items: center; justify-content: center;
font-size: 16px;
}
.brand-name {
font-size: 13px;
font-weight: 600;
letter-spacing: .04em;
color: var(--txt);
}
.brand-sub {
font-size: 11px;
color: var(--txt-ghost);
margin-top: 1px;
}
.mirror-title {
font-size: .65rem;
font-weight: 400;
letter-spacing: .35em;
text-transform: uppercase;
color: var(--frost-dim);
}
/* Clock widget — large, centred */
.mirror-clock {
text-align: center;
flex: 1;
}
/* Clock — centred */
.mirror-clock { text-align: center; }
.clock-time {
font-size: clamp(2.4rem, 6vw, 4.5rem);
font-weight: 200;
letter-spacing: .06em;
color: var(--frost);
font-family: var(--mono);
font-size: clamp(2rem, 5vw, 3.6rem);
font-weight: 500;
letter-spacing: .08em;
color: var(--txt);
line-height: 1;
text-shadow: 0 0 40px rgba(232,244,255,.18);
}
.clock-date {
font-size: .7rem;
font-weight: 300;
letter-spacing: .25em;
font-size: 11px;
font-weight: 400;
letter-spacing: .18em;
text-transform: uppercase;
color: var(--frost-dim);
margin-top: 5px;
color: var(--txt-ghost);
margin-top: 6px;
}
/* Connection indicator */
/* Connection badge */
.conn-badge {
display: flex;
align-items: center;
gap: 7px;
font-family: var(--font-mono);
font-size: .68rem;
color: var(--frost-ghost);
transition: color .4s;
gap: 8px;
padding: 8px 14px;
border-radius: var(--r-sm);
font-family: var(--mono);
font-size: 11px;
color: var(--txt-ghost);
transition: all .3s;
}
.conn-badge.ok {
background: var(--green-soft);
border-color: rgba(34,197,94,.25);
color: var(--green);
}
.conn-badge.err {
background: var(--red-soft);
border-color: rgba(239,68,68,.25);
color: var(--red);
}
.conn-badge.ok { color: var(--ok); }
.conn-badge.err { color: var(--danger); }
.conn-dot {
width: 7px; height: 7px;
border-radius: 50%;
background: var(--frost-ghost);
transition: background .4s, box-shadow .4s;
background: currentColor;
flex-shrink: 0;
transition: box-shadow .3s;
}
.conn-badge.ok .conn-dot { background: var(--ok); box-shadow: 0 0 8px var(--ok); }
.conn-badge.err .conn-dot { background: var(--danger); box-shadow: 0 0 8px var(--danger); }
.conn-badge.ok .conn-dot { box-shadow: 0 0 6px var(--green); animation: pulse-green 2s infinite; }
.conn-badge.err .conn-dot { box-shadow: 0 0 6px var(--red); }
/* ── Main content grid ─────────────────────────────────────────── */
@keyframes pulse-green {
0%,100% { opacity: 1; }
50% { opacity: .5; }
}
/* ── Body grid ── */
.mirror-body {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
grid-template-columns: repeat(4, 1fr);
grid-template-rows: auto auto auto;
gap: var(--gap);
}
/* ── Sensor tiles ──────────────────────────────────────────────── */
/* ── Sensor tiles ── */
.sensor-tile {
padding: 20px 22px 18px;
padding: 18px 18px 14px;
position: relative;
overflow: hidden;
cursor: default;
transition: transform .2s, box-shadow .2s, border-color .25s;
}
.sensor-tile::before {
content: '';
position: absolute; top: 0; left: 0; right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, var(--ice), transparent);
opacity: .25;
transition: opacity .4s;
.sensor-tile:hover {
transform: translateY(-1px);
box-shadow: 0 8px 24px rgba(0,0,0,.35);
}
.sensor-tile.breach-high::before { background: linear-gradient(90deg,transparent,var(--danger),transparent); opacity:.7; }
.sensor-tile.breach-low::before { background: linear-gradient(90deg,transparent,var(--warn), transparent); opacity:.7; }
.sensor-tile.breach-high { border-color: rgba(255,107,107,.25); }
.sensor-tile.breach-low { border-color: rgba(255,209,102,.22); }
/* Coloured left stripe */
.sensor-tile::after {
content: '';
position: absolute; top: 0; left: 0; bottom: 0;
width: 3px;
border-radius: var(--r) 0 0 var(--r);
background: var(--blue);
transition: background .3s;
}
.sensor-tile.breach-high::after { background: var(--red); }
.sensor-tile.breach-low::after { background: var(--yellow); }
.sensor-tile-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
}
.sensor-icon {
width: 30px; height: 30px;
border-radius: 7px;
background: var(--blue-soft);
border: 1px solid rgba(59,130,246,.2);
display: flex; align-items: center; justify-content: center;
font-size: 14px;
transition: background .3s, border-color .3s;
}
.sensor-tile.breach-high .sensor-icon {
background: var(--red-soft);
border-color: rgba(239,68,68,.3);
}
.sensor-tile.breach-low .sensor-icon {
background: var(--yellow-soft);
border-color: rgba(245,158,11,.3);
}
.sensor-state-dot {
width: 6px; height: 6px;
border-radius: 50%;
background: var(--blue);
opacity: .5;
transition: background .3s, opacity .3s, box-shadow .3s;
}
.sensor-tile.breach-high .sensor-state-dot {
background: var(--red); opacity: 1;
box-shadow: 0 0 6px var(--red);
animation: blink .8s infinite;
}
.sensor-tile.breach-low .sensor-state-dot {
background: var(--yellow); opacity: 1;
box-shadow: 0 0 6px var(--yellow);
animation: blink .8s infinite;
}
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:.3} }
.sensor-label {
font-size: .58rem;
font-weight: 400;
letter-spacing: .22em;
font-size: 11px;
font-weight: 500;
letter-spacing: .06em;
text-transform: uppercase;
color: var(--frost-ghost);
margin-bottom: 10px;
}
.sensor-value {
font-family: var(--font-mono);
font-size: clamp(1.6rem, 3vw, 2.6rem);
font-weight: 400;
color: var(--frost);
line-height: 1;
transition: color .4s, text-shadow .4s;
}
.sensor-tile.breach-high .sensor-value {
color: var(--danger);
text-shadow: 0 0 20px var(--danger-glow);
}
.sensor-tile.breach-low .sensor-value {
color: var(--warn);
text-shadow: 0 0 20px var(--warn-glow);
}
.sensor-threshold {
font-family: var(--font-mono);
font-size: .6rem;
color: var(--frost-ghost);
margin-top: 8px;
}
.sensor-alert-badge {
display: inline-block;
margin-top: 8px;
font-size: .52rem;
letter-spacing: .12em;
text-transform: uppercase;
padding: 2px 8px;
border-radius: 3px;
background: rgba(255,107,107,.12);
color: var(--danger);
border: 1px solid rgba(255,107,107,.2);
}
.sensor-alert-badge.low {
background: rgba(255,209,102,.1);
color: var(--warn);
border-color: rgba(255,209,102,.2);
color: var(--txt-ghost);
}
/* ── Stat row (small cards) ────────────────────────────────────── */
.sensor-value {
font-family: var(--mono);
font-size: clamp(1.5rem, 2.5vw, 2.1rem);
font-weight: 500;
color: var(--txt);
line-height: 1;
transition: color .3s;
}
.sensor-tile.breach-high .sensor-value { color: var(--red); }
.sensor-tile.breach-low .sensor-value { color: var(--yellow); }
.sensor-meta {
margin-top: 10px;
display: flex;
align-items: center;
justify-content: space-between;
}
.sensor-threshold {
font-family: var(--mono);
font-size: 10px;
color: var(--txt-ghost);
}
.sensor-alert-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 10px;
font-weight: 500;
letter-spacing: .05em;
text-transform: uppercase;
padding: 2px 7px;
border-radius: 4px;
background: var(--red-soft);
color: var(--red);
border: 1px solid rgba(239,68,68,.25);
}
.sensor-alert-badge.low {
background: var(--yellow-soft);
color: var(--yellow);
border-color: rgba(245,158,11,.25);
}
/* ── Stat row ── */
.stat-row {
grid-column: 1 / -1;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--gap);
}
.stat-card {
padding: 16px 18px;
padding: 14px 16px;
display: flex;
align-items: center;
gap: 12px;
}
.stat-card-icon {
width: 36px; height: 36px;
border-radius: 9px;
display: flex; align-items: center; justify-content: center;
font-size: 16px;
flex-shrink: 0;
}
.stat-card-icon.blue { background: var(--blue-soft); }
.stat-card-icon.green { background: var(--green-soft); }
.stat-card-icon.red { background: var(--red-soft); }
.stat-card-icon.teal { background: var(--teal-soft); }
.stat-card-icon.purple { background: var(--purple-soft); }
.stat-info { min-width: 0; }
.stat-label {
font-size: .55rem;
letter-spacing: .22em;
font-size: 11px;
font-weight: 500;
letter-spacing: .06em;
text-transform: uppercase;
color: var(--frost-ghost);
margin-bottom: 8px;
color: var(--txt-ghost);
margin-bottom: 3px;
}
.stat-value {
font-family: var(--font-mono);
font-size: 1.55rem;
color: var(--frost);
line-height: 1;
font-family: var(--mono);
font-size: 1.15rem;
font-weight: 500;
color: var(--txt);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.stat-value.red { color: var(--danger); }
.stat-value.red { color: var(--red); }
.stat-sub {
font-size: .58rem;
color: var(--frost-ghost);
margin-top: 5px;
font-family: var(--font-mono);
font-size: 10px;
color: var(--txt-ghost);
margin-top: 2px;
font-family: var(--mono);
}
/* ── Chart panel ───────────────────────────────────────────────── */
/* ── Chart panel ── */
.chart-panel {
grid-column: 1 / 3;
padding: 18px 20px;
padding: 16px 18px 14px;
}
.panel-label {
font-size: .56rem;
letter-spacing: .22em;
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
}
.panel-title {
font-size: 11px;
font-weight: 600;
letter-spacing: .08em;
text-transform: uppercase;
color: var(--frost-ghost);
margin-bottom: 12px;
color: var(--txt-dim);
}
.panel-badge {
font-family: var(--mono);
font-size: 10px;
color: var(--txt-ghost);
background: rgba(255,255,255,.04);
border: 1px solid var(--border);
border-radius: 4px;
padding: 2px 7px;
}
#chart { display: block; width: 100%; }
/* ── Log / Alert panel ─────────────────────────────────────────── */
/* ── Log panel ── */
.log-panel {
grid-column: 3 / 4;
grid-column: 3 / -1;
padding: 0;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 220px;
}
.tab-bar {
display: flex;
border-bottom: 1px solid var(--glass-edge);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
padding: 0 6px;
gap: 2px;
}
.tab {
flex: 1;
padding: 11px 14px;
font-size: .56rem;
letter-spacing: .2em;
padding: 10px 12px;
font-size: 11px;
font-weight: 500;
letter-spacing: .06em;
text-transform: uppercase;
color: var(--frost-ghost);
color: var(--txt-ghost);
cursor: pointer;
text-align: center;
transition: color .2s, background .2s;
transition: color .2s;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
position: relative;
}
.tab:hover { color: var(--frost-dim); }
.tab.active { color: var(--ice); border-bottom-color: var(--ice); }
.tab:hover { color: var(--txt-dim); }
.tab.active { color: var(--blue); border-bottom-color: var(--blue); }
.tab-body {
flex: 1;
overflow-y: auto;
padding: 10px 14px;
font-family: var(--font-mono);
font-size: .62rem;
padding: 8px 12px;
font-family: var(--mono);
font-size: 11px;
}
.tab-body::-webkit-scrollbar { width: 3px; }
.tab-body::-webkit-scrollbar-thumb { background: var(--glass-edge); border-radius: 2px; }
.tab-body::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
.tab-pane { display: none; }
.tab-pane.active { display: block; }
.log-entry {
display: flex;
gap: 8px;
padding: 4px 0;
border-bottom: 1px solid rgba(255,255,255,.04);
animation: slideIn .2s ease;
}
@keyframes slideIn {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: none; }
}
.log-ts { color: var(--frost-ghost); flex-shrink: 0; }
.log-raw { color: var(--frost-dim); flex: 1; word-break: break-all; }
.log-num { color: var(--ice); flex-shrink: 0; }
.alert-entry {
display: flex;
display: grid;
grid-template-columns: 68px 1fr auto;
gap: 8px;
padding: 5px 0;
border-bottom: 1px solid rgba(255,255,255,.04);
align-items: flex-start;
animation: slideIn .2s ease;
animation: fadeSlide .15s ease;
align-items: center;
}
.alert-icon { color: var(--danger); flex-shrink: 0; }
.alert-ts { color: var(--frost-ghost); flex-shrink: 0; }
.alert-msg { color: var(--frost-dim); flex: 1; }
@keyframes fadeSlide {
from { opacity: 0; transform: translateY(-3px); }
to { opacity: 1; transform: none; }
}
.log-ts { color: var(--txt-ghost); font-size: 10px; }
.log-raw { color: var(--txt-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.log-num { color: var(--blue); text-align: right; white-space: nowrap; }
.alert-entry {
display: grid;
grid-template-columns: 16px 68px 1fr;
gap: 8px;
padding: 6px 0;
border-bottom: 1px solid rgba(255,255,255,.04);
align-items: center;
animation: fadeSlide .15s ease;
}
.alert-icon { color: var(--red); font-size: 12px; }
.alert-ts { color: var(--txt-ghost); font-size: 10px; }
.alert-msg { color: var(--txt-dim); }
.alert-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px; height: 14px;
border-radius: 50%;
background: var(--danger);
color: #000;
font-size: .5rem;
min-width: 16px; height: 16px;
border-radius: 8px;
background: var(--red);
color: #fff;
font-size: 9px;
font-weight: 600;
margin-left: 5px;
vertical-align: middle;
padding: 0 3px;
display: none;
}
.alert-badge.visible { display: inline-flex; }
/* ── Footer ────────────────────────────────────────────────────── */
/* ── Rain tile — special ── */
.rain-tile {
display: flex;
flex-direction: column;
gap: 8px;
}
.rain-bar-wrap {
width: 100%;
height: 6px;
background: rgba(255,255,255,.06);
border-radius: 3px;
overflow: hidden;
margin-top: 8px;
}
.rain-bar {
height: 100%;
background: linear-gradient(90deg, var(--blue), var(--teal));
border-radius: 3px;
transition: width .5s ease;
}
/* ── Footer ── */
.mirror-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 22px;
font-size: .55rem;
letter-spacing: .18em;
text-transform: uppercase;
color: var(--frost-ghost);
padding: 10px 20px;
font-size: 11px;
font-family: var(--mono);
color: var(--txt-ghost);
}
/* ── Responsive ────────────────────────────────────────────────── */
@media (max-width: 900px) {
.mirror-body {
grid-template-columns: 1fr 1fr;
}
.stat-row { grid-template-columns: 1fr 1fr; }
/* ── Responsive ── */
@media (max-width: 1100px) {
.mirror-body { grid-template-columns: 1fr 1fr; }
.stat-row { grid-template-columns: 1fr 1fr; }
.chart-panel { grid-column: 1 / -1; }
.log-panel { grid-column: 1 / -1; min-height: 220px; }
.log-panel { grid-column: 1 / -1; min-height: 200px; }
}
@media (max-width: 560px) {
.mirror-body { grid-template-columns: 1fr; }
@media (max-width: 640px) {
.mirror-body { grid-template-columns: 1fr 1fr; }
.stat-row { grid-template-columns: 1fr 1fr; }
.clock-time { font-size: 2.2rem; }
.mirror-header { grid-template-columns: 1fr; }
.header-brand, .conn-badge { display: none; }
}

243
raspi/static/js/dashboard.js Normal file → Executable file
View File

@@ -1,51 +1,43 @@
/* ═══════════════════════════════════════════════════════════════
Smart Mirror — Dashboard JS
Handles: clock, polling, chart, sensor cards, log, alerts
═══════════════════════════════════════════════════════════════ */
'use strict';
/* ── Config injected by Flask via data-attribute ───────────────── */
const ROOT = document.getElementById('mirror-root');
const POLL_MS = parseInt(ROOT.dataset.pollMs || '800', 10);
const SENSORS = JSON.parse(ROOT.dataset.sensors || '[]');
/* ── Config from Flask ── */
const ROOT = document.getElementById('mirror-root');
const POLL_MS = parseInt(ROOT.dataset.pollMs || '2500', 10);
const SENSORS = JSON.parse(ROOT.dataset.sensors || '[]');
/* ── State ─────────────────────────────────────────────────────── */
let lastLogCount = 0;
let alertsSeen = 0;
let chartHistory = []; // first numeric channel only
/* ── State ── */
let lastLogCount = 0;
let alertsSeen = 0;
let chartData = []; // {ts, v} for temp channel (field_index 0)
let firstPoll = true;
/* ══════════════════════════════════════════════════════════════════
/* ══════════════════════════════
CLOCK
══════════════════════════════════════════════════════════════════ */
══════════════════════════════ */
const DAYS = ['Sonntag','Montag','Dienstag','Mittwoch','Donnerstag','Freitag','Samstag'];
const MONTHS = ['Januar','Februar','März','April','Mai','Juni',
'Juli','August','September','Oktober','November','Dezember'];
function tickClock() {
const now = new Date();
const hh = String(now.getHours()).padStart(2,'0');
const mm = String(now.getMinutes()).padStart(2,'0');
const ss = String(now.getSeconds()).padStart(2,'0');
const days = ['Sonntag','Montag','Dienstag','Mittwoch','Donnerstag','Freitag','Samstag'];
const months = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez'];
const dateStr = `${days[now.getDay()]}, ${now.getDate()}. ${months[now.getMonth()]} ${now.getFullYear()}`;
document.getElementById('clock-time').textContent = `${hh}:${mm}:${ss}`;
document.getElementById('clock-date').textContent = dateStr;
const n = new Date();
const pad = x => String(x).padStart(2, '0');
document.getElementById('clock-time').textContent =
`${pad(n.getHours())}:${pad(n.getMinutes())}:${pad(n.getSeconds())}`;
document.getElementById('clock-date').textContent =
`${DAYS[n.getDay()]}, ${n.getDate()}. ${MONTHS[n.getMonth()]} ${n.getFullYear()}`;
}
setInterval(tickClock, 1000);
tickClock();
/* ══════════════════════════════════════════════════════════════════
/* ══════════════════════════════
CANVAS CHART
══════════════════════════════════════════════════════════════════ */
══════════════════════════════ */
const canvas = document.getElementById('chart');
const ctx = canvas.getContext('2d');
function resizeCanvas() {
const wrap = canvas.parentElement;
canvas.width = wrap.clientWidth - 40;
canvas.height = 140;
canvas.width = canvas.parentElement.clientWidth - 36;
canvas.height = 130;
}
window.addEventListener('resize', () => { resizeCanvas(); drawChart(); });
resizeCanvas();
@@ -54,81 +46,80 @@ function drawChart() {
const W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H);
if (chartHistory.length < 2) {
ctx.fillStyle = 'rgba(232,244,255,.08)';
ctx.font = `11px 'Share Tech Mono', monospace`;
ctx.fillText('Warte auf Daten …', 8, H / 2 + 4);
const pts = chartData.slice(-80).map(d => d.v);
if (pts.length < 2) {
ctx.fillStyle = 'rgba(228,233,245,.15)';
ctx.font = `11px 'JetBrains Mono', monospace`;
ctx.fillText('Warte auf Messdaten …', 10, H / 2 + 4);
return;
}
const pts = chartHistory.slice(-80);
const min = Math.min(...pts);
const max = Math.max(...pts);
const range = max - min || 1;
const step = W / (pts.length - 1);
const mn = Math.min(...pts);
const mx = Math.max(...pts);
const rng = (mx - mn) || 1;
const sx = W / (pts.length - 1);
const yp = v => H - ((v - mn) / rng) * H * 0.80 - H * 0.10;
const yPos = v => H - ((v - min) / range) * H * 0.82 - H * 0.09;
/* grid lines */
/* grid */
ctx.strokeStyle = 'rgba(255,255,255,.04)';
ctx.lineWidth = 1;
ctx.font = `9px 'Share Tech Mono', monospace`;
ctx.fillStyle = 'rgba(232,244,255,.2)';
ctx.font = `9px 'JetBrains Mono', monospace`;
for (let i = 0; i <= 4; i++) {
const y = H - (i / 4) * H * 0.82 - H * 0.09;
const y = H - (i / 4) * H * 0.80 - H * 0.10;
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
ctx.fillText((min + (i / 4) * range).toFixed(2), 4, y - 3);
ctx.fillStyle = 'rgba(228,233,245,.18)';
ctx.fillText((mn + (i / 4) * rng).toFixed(1) + '°', 4, y - 3);
}
/* gradient fill */
const grad = ctx.createLinearGradient(0, 0, 0, H);
grad.addColorStop(0, 'rgba(168,216,240,.18)');
grad.addColorStop(1, 'rgba(168,216,240,.00)');
const grd = ctx.createLinearGradient(0, 0, 0, H);
grd.addColorStop(0, 'rgba(59,130,246,.2)');
grd.addColorStop(1, 'rgba(59,130,246,.0)');
ctx.beginPath();
pts.forEach((v, i) => {
const x = i * step, y = yPos(v);
const x = i * sx, y = yp(v);
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.lineTo((pts.length - 1) * step, H);
ctx.lineTo((pts.length - 1) * sx, H);
ctx.lineTo(0, H);
ctx.closePath();
ctx.fillStyle = grad;
ctx.fillStyle = grd;
ctx.fill();
/* line */
ctx.beginPath();
pts.forEach((v, i) => {
const x = i * step, y = yPos(v);
const x = i * sx, y = yp(v);
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.strokeStyle = 'rgba(168,216,240,.85)';
ctx.lineWidth = 1.5;
ctx.shadowColor = 'rgba(168,216,240,.4)';
ctx.shadowBlur = 8;
ctx.strokeStyle = '#3b82f6';
ctx.lineWidth = 2;
ctx.shadowColor = 'rgba(59,130,246,.5)';
ctx.shadowBlur = 10;
ctx.stroke();
ctx.shadowBlur = 0;
/* last point dot */
/* last dot */
const lv = pts[pts.length - 1];
const lx = (pts.length - 1) * step;
const ly = yPos(lv);
const lx = (pts.length - 1) * sx;
const ly = yp(lv);
ctx.beginPath();
ctx.arc(lx, ly, 3.5, 0, Math.PI * 2);
ctx.fillStyle = '#a8d8f0';
ctx.shadowColor = '#a8d8f0';
ctx.shadowBlur = 12;
ctx.arc(lx, ly, 4, 0, Math.PI * 2);
ctx.fillStyle = '#3b82f6';
ctx.shadowColor = '#3b82f6';
ctx.shadowBlur = 14;
ctx.fill();
ctx.shadowBlur = 0;
}
/* ══════════════════════════════════════════════════════════════════
/* ══════════════════════════════
SENSOR TILES
══════════════════════════════════════════════════════════════════ */
══════════════════════════════ */
function updateSensorTiles(values) {
SENSORS.forEach((s, i) => {
const idx = s.field_index;
const idx = s.field_index;
if (idx >= values.length) return;
const v = values[idx];
@@ -136,9 +127,9 @@ function updateSensorTiles(values) {
const valEl= document.getElementById(`sv-${i}`);
if (!tile || !valEl) return;
valEl.textContent = `${v.toFixed(2)} ${s.unit || ''}`;
const fmt = Number.isInteger(v) ? String(v) : v.toFixed(1);
valEl.textContent = `${fmt}${s.unit ? ' ' + s.unit : ''}`;
// Clear old badges
tile.querySelectorAll('.sensor-alert-badge').forEach(b => b.remove());
tile.classList.remove('breach-high', 'breach-low');
@@ -149,40 +140,36 @@ function updateSensorTiles(values) {
tile.classList.add('breach-high');
const b = document.createElement('span');
b.className = 'sensor-alert-badge';
b.textContent = '↑ Grenzwert überschritten';
tile.appendChild(b);
b.textContent = '↑ Überschritten';
tile.querySelector('.sensor-meta').appendChild(b);
} else if (isLow) {
tile.classList.add('breach-low');
const b = document.createElement('span');
b.className = 'sensor-alert-badge low';
b.textContent = '↓ Grenzwert unterschritten';
tile.appendChild(b);
b.textContent = '↓ Unterschritten';
tile.querySelector('.sensor-meta').appendChild(b);
}
});
}
/* ══════════════════════════════════════════════════════════════════
/* ══════════════════════════════
TABS
══════════════════════════════════════════════════════════════════ */
══════════════════════════════ */
function switchTab(name) {
document.querySelectorAll('.tab').forEach(t => {
t.classList.toggle('active', t.dataset.tab === name);
});
document.querySelectorAll('.tab-pane').forEach(p => {
p.classList.toggle('active', p.id === `pane-${name}`);
});
document.querySelectorAll('.tab').forEach(t =>
t.classList.toggle('active', t.dataset.tab === name));
document.querySelectorAll('.tab-pane').forEach(p =>
p.classList.toggle('active', p.id === `pane-${name}`));
if (name === 'alerts') {
const badge = document.getElementById('alert-badge');
badge.classList.remove('visible');
badge.textContent = '';
const b = document.getElementById('alert-badge');
b.classList.remove('visible');
b.textContent = '';
}
}
/* ══════════════════════════════════════════════════════════════════
MAIN POLL
══════════════════════════════════════════════════════════════════ */
/* ══════════════════════════════
POLL
══════════════════════════════ */
async function poll() {
try {
const [dataRes, alertRes] = await Promise.all([
@@ -192,53 +179,68 @@ async function poll() {
const data = await dataRes.json();
const alerts = await alertRes.json();
/* ── Connection badge ── */
/* Connection badge */
const badge = document.getElementById('conn-badge');
badge.className = `conn-badge glass ${data.status.connected ? 'ok' : 'err'}`;
document.getElementById('conn-dot').className = 'conn-dot';
document.getElementById('conn-text').textContent =
data.status.connected
? `${data.status.port} · ${data.status.baud} Bd`
: 'Getrennt — warte …';
const ok = data.status.connected;
badge.className = `conn-badge card ${ok ? 'ok' : 'err'}`;
document.getElementById('conn-text').textContent = ok
? `${data.status.port} · ${data.status.baud} Bd`
: 'Getrennt — warte …';
/* ── Stat cards ── */
/* Stat cards */
document.getElementById('stat-total').textContent = data.status.total_lines;
document.getElementById('stat-errors').textContent = data.status.errors;
document.getElementById('stat-port').textContent = data.status.port;
document.getElementById('stat-baud').textContent = `${data.status.baud} baud`;
document.getElementById('stat-port').textContent = data.status.port || '—';
document.getElementById('stat-baud').textContent = data.status.baud
? `${data.status.baud} baud` : '—';
if (data.entries.length) {
const latest = data.entries[data.entries.length - 1];
const disp = latest.values?.length
? latest.values[0].toFixed(3)
? latest.values[0].toFixed(2)
: latest.raw.slice(0, 14);
document.getElementById('stat-last').textContent = disp;
document.getElementById('stat-ts').textContent = latest.ts.split('T')[1];
if (latest.values?.length) updateSensorTiles(latest.values);
document.getElementById('stat-ts').textContent = latest.ts.split('T')[1].slice(0,8);
if (latest.values?.length) {
updateSensorTiles(latest.values);
chartData.push({ ts: latest.ts, v: latest.values[0] });
if (chartData.length > 200) chartData = chartData.slice(-200);
}
}
/* ── Log (new entries only, prepended) ── */
const newEntries = data.entries.slice(-(data.entries.length - lastLogCount));
/* Log — only new entries */
const newEntries = data.entries.slice(lastLogCount);
lastLogCount = data.entries.length;
const logPane = document.getElementById('pane-log');
newEntries.forEach(e => {
if (e.values?.length) chartHistory.push(e.values[0]);
if (firstPoll && newEntries.length === 0) {
/* keep placeholder */
} else if (firstPoll) {
logPane.innerHTML = '';
firstPoll = false;
}
const row = document.createElement('div');
newEntries.forEach(e => {
const row = document.createElement('div');
row.className = 'log-entry';
const numStr = e.values?.map(v => v.toFixed(3)).join(' ') || '';
const numStr = e.values?.map(v => v.toFixed(2)).join(' ') || '';
row.innerHTML =
`<span class="log-ts">${e.ts.split('T')[1]}</span>` +
`<span class="log-ts">${e.ts.split('T')[1].slice(0,8)}</span>` +
`<span class="log-raw">${e.raw}</span>` +
(numStr ? `<span class="log-num">${numStr}</span>` : '');
(numStr ? `<span class="log-num">${numStr}</span>` : '<span></span>');
logPane.prepend(row);
/* trim log DOM to 100 rows */
while (logPane.children.length > 100) {
logPane.removeChild(logPane.lastChild);
}
});
/* ── Alerts ── */
/* Alerts */
if (alerts.length > alertsSeen) {
const newAlerts = alerts.slice(alertsSeen);
alertsSeen = alerts.length;
alertsSeen = alerts.length;
const alertPane = document.getElementById('pane-alerts');
if (alertsSeen === newAlerts.length) alertPane.innerHTML = '';
@@ -248,15 +250,15 @@ async function poll() {
row.className = 'alert-entry';
row.innerHTML =
`<span class="alert-icon">⚠</span>` +
`<span class="alert-ts">${a.ts.split('T')[1]}</span>` +
`<span class="alert-ts">${a.ts.split('T')[1].slice(0,8)}</span>` +
`<span class="alert-msg">${a.subject}</span>`;
alertPane.prepend(row);
});
const alertBadge = document.getElementById('alert-badge');
const ab = document.getElementById('alert-badge');
if (!document.getElementById('pane-alerts').classList.contains('active')) {
alertBadge.textContent = newAlerts.length > 9 ? '9+' : newAlerts.length;
alertBadge.classList.add('visible');
ab.textContent = newAlerts.length > 9 ? '9+' : String(newAlerts.length);
ab.classList.add('visible');
}
}
@@ -269,5 +271,4 @@ async function poll() {
setTimeout(poll, POLL_MS);
}
/* ── Kick off ─────────────────────────────────────────────────── */
poll();

0
raspi/templates/base.html Normal file → Executable file
View File

141
raspi/templates/dashboard.html Normal file → Executable file
View File

@@ -1,28 +1,30 @@
{% 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">
<!-- ══ HEADER ══ -->
<header class="mirror-header card">
<span class="mirror-title">{{ title }}</span>
<div class="header-brand">
<div class="brand-icon">🪞</div>
<div>
<div class="brand-name">{{ title }}</div>
<div class="brand-sub">Arduino Wetterstation</div>
</div>
</div>
<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-badge card" id="conn-badge">
<div class="conn-dot" id="conn-dot"></div>
<span id="conn-text">Verbinde …</span>
</div>
@@ -30,108 +32,117 @@
</header>
{# ══════════════════════════════════
BODY — sensors / chart / log
══════════════════════════════════ #}
<!-- ══ BODY ══ -->
<main class="mirror-body">
{# ── Sensor tiles (one per configured sensor) ── #}
<!-- Sensor tiles -->
{% set icons = ['🌡️','💧','🌧️','📡','📊','⚡'] %}
{% for s in sensors %}
<div class="sensor-tile glass" id="sc-{{ loop.index0 }}">
<div class="sensor-tile card" id="sc-{{ loop.index0 }}">
<div class="sensor-tile-header">
<div class="sensor-icon">{{ icons[loop.index0 % icons|length] }}</div>
<div class="sensor-state-dot" id="sd-{{ loop.index0 }}"></div>
</div>
<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 class="sensor-value" id="sv-{{ loop.index0 }}"></div>
<div class="sensor-meta">
<div class="sensor-threshold" id="st-{{ loop.index0 }}">
{%- if s.threshold_low is not none -%}↓ {{ s.threshold_low }}{{ s.unit }}{%- endif -%}
{%- if s.threshold_high is not none and s.threshold_low is not none -%}&nbsp;&nbsp;{%- endif -%}
{%- if s.threshold_high is not none -%}↑ {{ s.threshold_high }}{{ s.unit }}{%- endif -%}
</div>
</div>
</div>
{% endfor %}
{# ── Stat row ── #}
<!-- 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 class="stat-card card">
<div class="stat-card-icon blue">📥</div>
<div class="stat-info">
<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>
<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 class="stat-card card">
<div class="stat-card-icon green">📊</div>
<div class="stat-info">
<div class="stat-label">Zeilen gesamt</div>
<div class="stat-value" id="stat-total">0</div>
<div class="stat-sub">empfangen</div>
</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 class="stat-card card">
<div class="stat-card-icon red">⚠️</div>
<div class="stat-info">
<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>
<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 class="stat-card card">
<div class="stat-card-icon teal">🔌</div>
<div class="stat-info">
<div class="stat-label">Verbindung</div>
<div class="stat-value" id="stat-port" style="font-size:.9rem"></div>
<div class="stat-sub" id="stat-baud"></div>
</div>
</div>
</div>{# /stat-row #}
</div><!-- /stat-row -->
{# ── Chart panel ── #}
<div class="chart-panel glass">
<div class="panel-label">Verlauf — Kanal 1 (letzte 80 Messpunkte)</div>
<!-- Chart -->
<div class="chart-panel card">
<div class="panel-header">
<div class="panel-title">Temperaturverlauf</div>
<span class="panel-badge" id="chart-label">letzte 80 Werte</span>
</div>
<canvas id="chart"></canvas>
</div>
{# ── Log + Alerts panel ── #}
<div class="log-panel glass">
<!-- Log + Alerts -->
<div class="log-panel card">
<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>
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 class="log-entry" style="color:var(--txt-ghost)">
<span></span><span>Warte auf Daten …</span><span></span>
</div>
</div>
<div class="tab-pane" id="pane-alerts">
<div class="alert-entry" style="color:var(--frost-ghost)">
<span>Keine Alerts.</span>
<div class="alert-entry" style="color:var(--txt-ghost)">
<span></span><span></span><span>Keine Alerts.</span>
</div>
</div>
</div>
</div>
</div>{# /log-panel #}
</main>{# /mirror-body #}
</main><!-- /mirror-body -->
{# ══════════════════════════════════
FOOTER
══════════════════════════════════ #}
<!-- ══ FOOTER ══ -->
<footer class="mirror-footer">
<span>Smart Mirror · Arduino USB Monitor</span>
<span id="footer-ts"></span>
<span>Smart Mirror · TGBBz Dillingen · Arduino USB Monitor</span>
<span id="footer-ts"></span>
</footer>
</div>{# /mirror-layout #}
</div>{# /mirror-root #}
</div>
</div>
<script src="{{ url_for('static', filename='js/dashboard.js') }}"></script>
{# Keep footer timestamp fresh #}
<script>
setInterval(() => {
document.getElementById('footer-ts').textContent =