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 # "SmartMirror" - Projektwochen am TGBBZ Dillingen
## Repo-Strktur ## Repo-Strktur
``` ```
arduino/ arduino/
arduino/arduino.ino arduino/arduino.ino
Arduino-Skript, um die Sensordaten auszulesen und sie per USB Arduino-Skript, um die Sensordaten auszulesen und sie per USB
an den Raspberry Pi zu übertragen an den Raspberry Pi zu übertragen
raspi/ raspi/
raspi/templates raspi/templates
Jinja basierte HTML-Templates Jinja basierte HTML-Templates
raspi/static raspi/static
Ordner mit frontend (CSS, JS) Dateien Ordner mit frontend (CSS, JS) Dateien
raspi/Dashboard.py raspi/Dashboard.py
Stellt das Flask-basierte Web-Dashboard bereit Stellt das Flask-basierte Web-Dashboard bereit
raspi/USBRead.py raspi/USBRead.py
Liest in regelmäßigen Intervallen die USB-Gerätedatei (/dev/ttyACM0) aus Liest in regelmäßigen Intervallen die USB-Gerätedatei (/dev/ttyACM0) aus
und stellt die Daten Dashboard.py und Notification.py bereit und stellt die Daten Dashboard.py und Notification.py bereit
raspi/Notification.py raspi/Notification.py
Dient als SMTP-Client, der Benachrichtigungen über E-Mail und Messanger Dient als SMTP-Client, der Benachrichtigungen über E-Mail und Messanger
versendet# versendet#
raspi/gunicorn.conf.py raspi/gunicorn.conf.py
Startup-Datei für den WSGI-Webserver Gunicorn, der das Flask Web-Dashboard Startup-Datei für den WSGI-Webserver Gunicorn, der das Flask Web-Dashboard
bereitstellt bereitstellt
raspi/requirements.txt raspi/requirements.txt
requirements.txt für pip (lieste der benötigten Python-Abhängigkeiten) requirements.txt für pip (lieste der benötigten Python-Abhängigkeiten)
raspi/settings.json raspi/settings.json
Zentrale Konfigurationsdatei zur Konfiguration der Raspi-Skripte Zentrale Konfigurationsdatei zur Konfiguration der Raspi-Skripte
raspi/README.md raspi/README.md
docs/ docs/
aufbau.png aufbau.png
Übersicht über den logischen Aufbau des Setups Übersicht über den logischen Aufbau des Setups
verkabelung.txt verkabelung.txt
Übersicht über die phyische Verkabelung Übersicht über die phyische Verkabelung
``` ```
## Logischer Aufbau ## Logischer Aufbau
![logischer-aufbau](https://raw.githubusercontent.com/Sinned50/tgbbz-dillingen-smart-mirror/refs/heads/main/docs/aufbau.png?token=GHSAT0AAAAAADZ4ZH3YOFA5URKOHZM7A66G2PPHOCA) ![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: DHT22:
+ Modul <-> 3,3V Breadboard (über vertikale Schiene) + Modul <-> 3,3V Breadboard (über vertikale Schiene)
Out Modul <-> D4 Arduino Out Modul <-> D4 Arduino
- Modul <-> GND Breadboard (über vertikale Schiene) - Modul <-> GND Breadboard (über vertikale Schiene)
Regenmodul: Regenmodul:
AO Modul <-> A1 Arduino AO Modul <-> A1 Arduino
DO Modul <-> D2 Arduino DO Modul <-> D2 Arduino
GND Modul <-> GND Breadboard (über vertikale Schiene) GND Modul <-> GND Breadboard (über vertikale Schiene)
VCC Modul <-> V3,3 Breadboard (über vertikale Schiene) VCC Modul <-> V3,3 Breadboard (über vertikale Schiene)
BMP22: BMP22:
VCC Modul <-> 3,3V Breadboard (direkt Phasenschiene) VCC Modul <-> 3,3V Breadboard (direkt Phasenschiene)
GND Modul <-> GND Breadboard (direkt an Erdungsschiene) GND Modul <-> GND Breadboard (direkt an Erdungsschiene)
SCL Modul <-> A5 Arduino (SCL) SCL Modul <-> A5 Arduino (SCL)
SDA Modul <-> A4 Arduino (SDA) SDA Modul <-> A4 Arduino (SDA)
CSB Modul <-> 3,3V Breadboard (direkt an Phasenschiene) CSB Modul <-> 3,3V Breadboard (direkt an Phasenschiene)
SDD Modul <-> GND Breadboard (direkt an Erdungsschiene) SDD Modul <-> GND Breadboard (direkt an Erdungsschiene)

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

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

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

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

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

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

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

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

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

@@ -7,14 +7,14 @@
}, },
"dashboard": { "dashboard": {
"host": "127.0.0.1", "host": "0.0.0.0",
"port": 8088, "port": 8088,
"poll_interval_ms": 800, "poll_interval_ms": 2500,
"title": "Smart Mirror" "title": "Smart Mirror"
}, },
"smtp": { "smtp": {
"enabled": true, "enabled": false,
"host": "smtp.example.com", "host": "smtp.example.com",
"port": 587, "port": 587,
"use_tls": true, "use_tls": true,
@@ -29,14 +29,12 @@
"enabled": false, "enabled": false,
"provider": "twilio", "provider": "twilio",
"cooldown_s": 300, "cooldown_s": 300,
"twilio": { "twilio": {
"account_sid": "ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "account_sid": "ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"auth_token": "your_auth_token", "auth_token": "your_auth_token",
"from_number": "whatsapp:+14155238886", "from_number": "whatsapp:+14155238886",
"to_numbers": ["whatsapp:+49151XXXXXXXX"] "to_numbers": ["whatsapp:+49151XXXXXXXX"]
}, },
"callmebot": { "callmebot": {
"api_key": "your_callmebot_apikey", "api_key": "your_callmebot_apikey",
"to_numbers": ["+49151XXXXXXXX"] "to_numbers": ["+49151XXXXXXXX"]
@@ -48,7 +46,7 @@
"name": "Temperatur", "name": "Temperatur",
"field_index": 0, "field_index": 0,
"unit": "°C", "unit": "°C",
"threshold_high": 80.0, "threshold_high": 40.0,
"threshold_low": -10.0, "threshold_low": -10.0,
"notify_on_high": true, "notify_on_high": true,
"notify_on_low": true "notify_on_low": true
@@ -63,13 +61,22 @@
"notify_on_low": false "notify_on_low": false
}, },
{ {
"name": "Spannung", "name": "Regen (Analog)",
"field_index": 2, "field_index": 2,
"unit": "V", "unit": "",
"threshold_high": 5.5, "threshold_high": null,
"threshold_low": 3.0, "threshold_low": null,
"notify_on_high": true, "notify_on_high": false,
"notify_on_low": true "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 @@
/* ═══════════════════════════════════════════════════════════════ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=JetBrains+Mono:wght@400;500&display=swap');
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 ── */
/* ── Design tokens ─────────────────────────────────────────────── */
:root { :root {
--void: #000000; --bg: #111318;
--glass: rgba(255,255,255,.032); --bg-card: #1c1f2b;
--glass-edge: rgba(255,255,255,.07); --bg-card2: #21253a;
--glass-hover: rgba(255,255,255,.055); --border: rgba(255,255,255,.07);
--border-hover:rgba(255,255,255,.13);
--frost: #e8f4ff; /* primary text */ --txt: #e4e9f5;
--frost-dim: rgba(232,244,255,.35); --txt-dim: rgba(228,233,245,.55);
--frost-ghost: rgba(232,244,255,.14); --txt-ghost: rgba(228,233,245,.28);
--ice: #a8d8f0; /* accent — cool blue */ --blue: #3b82f6;
--ice-glow: rgba(168,216,240,.22); --blue-glow: rgba(59,130,246,.25);
--blue-soft: rgba(59,130,246,.12);
--danger: #ff6b6b; --green: #22c55e;
--danger-glow: rgba(255,107,107,.2); --green-soft: rgba(34,197,94,.12);
--warn: #ffd166; --green-glow: rgba(34,197,94,.3);
--warn-glow: rgba(255,209,102,.18);
--ok: #6bffc4;
--ok-glow: rgba(107,255,196,.18);
--font-ui: 'Outfit', sans-serif; --yellow: #f59e0b;
--font-mono: 'Share Tech Mono', monospace; --yellow-soft: rgba(245,158,11,.12);
--r: 8px; /* border-radius */ --red: #ef4444;
--gap: 18px; --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; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { html, body {
width: 100%; min-height: 100vh; width: 100%; min-height: 100vh;
background: var(--void); background: var(--bg);
color: var(--frost); color: var(--txt);
font-family: var(--font-ui); font-family: var(--font);
font-weight: 300; font-size: 14px;
overflow-x: hidden; font-weight: 400;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
overflow-x: hidden;
} }
/* Subtle vignette to simulate mirror depth */ /* Subtle grid background */
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 { body::before {
content: ''; content: '';
position: fixed; inset: 0; pointer-events: none; z-index: 1; position: fixed; inset: 0; z-index: 0; pointer-events: none;
background: repeating-linear-gradient( background-image:
0deg, linear-gradient(rgba(59,130,246,.025) 1px, transparent 1px),
transparent, linear-gradient(90deg, rgba(59,130,246,.025) 1px, transparent 1px);
transparent 3px, background-size: 40px 40px;
rgba(255,255,255,.008) 3px,
rgba(255,255,255,.008) 4px
);
} }
/* ── Layout wrapper ────────────────────────────────────────────── */ /* ── Layout ── */
.mirror-layout { .mirror-layout {
position: relative; z-index: 2; position: relative; z-index: 1;
display: grid; display: grid;
grid-template-rows: auto 1fr auto; grid-template-rows: auto 1fr auto;
min-height: 100vh; min-height: 100vh;
padding: var(--gap); padding: var(--gap);
gap: var(--gap); gap: var(--gap);
max-width: 1400px;
margin: 0 auto;
} }
/* ── Glass panel primitive ─────────────────────────────────────── */ /* ── Card primitive ── */
.glass { .card {
background: var(--glass); background: var(--bg-card);
border: 1px solid var(--glass-edge); border: 1px solid var(--border);
border-radius: var(--r); border-radius: var(--r);
backdrop-filter: blur(2px); transition: border-color .25s, background .25s;
-webkit-backdrop-filter: blur(2px);
transition: border-color .35s, background .35s;
} }
.glass:hover { background: var(--glass-hover); } .card:hover { border-color: var(--border-hover); }
/* ── Header bar ────────────────────────────────────────────────── */ /* ── Header ── */
.mirror-header { .mirror-header {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 20px;
padding: 16px 20px;
}
.header-brand {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; gap: 10px;
padding: 14px 22px; }
.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 { /* Clock — centred */
font-size: .65rem; .mirror-clock { text-align: center; }
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 { .clock-time {
font-size: clamp(2.4rem, 6vw, 4.5rem); font-family: var(--mono);
font-weight: 200; font-size: clamp(2rem, 5vw, 3.6rem);
letter-spacing: .06em; font-weight: 500;
color: var(--frost); letter-spacing: .08em;
color: var(--txt);
line-height: 1; line-height: 1;
text-shadow: 0 0 40px rgba(232,244,255,.18);
} }
.clock-date { .clock-date {
font-size: .7rem; font-size: 11px;
font-weight: 300; font-weight: 400;
letter-spacing: .25em; letter-spacing: .18em;
text-transform: uppercase; text-transform: uppercase;
color: var(--frost-dim); color: var(--txt-ghost);
margin-top: 5px; margin-top: 6px;
} }
/* Connection indicator */ /* Connection badge */
.conn-badge { .conn-badge {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 7px; gap: 8px;
font-family: var(--font-mono); padding: 8px 14px;
font-size: .68rem; border-radius: var(--r-sm);
color: var(--frost-ghost); font-family: var(--mono);
transition: color .4s; 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 { .conn-dot {
width: 7px; height: 7px; width: 7px; height: 7px;
border-radius: 50%; border-radius: 50%;
background: var(--frost-ghost); background: currentColor;
transition: background .4s, box-shadow .4s;
flex-shrink: 0; flex-shrink: 0;
transition: box-shadow .3s;
} }
.conn-badge.ok .conn-dot { background: var(--ok); box-shadow: 0 0 8px var(--ok); } .conn-badge.ok .conn-dot { box-shadow: 0 0 6px var(--green); animation: pulse-green 2s infinite; }
.conn-badge.err .conn-dot { background: var(--danger); box-shadow: 0 0 8px var(--danger); } .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 { .mirror-body {
display: grid; display: grid;
grid-template-columns: 1fr 1fr 1fr; grid-template-columns: repeat(4, 1fr);
grid-template-rows: auto auto auto; grid-template-rows: auto auto auto;
gap: var(--gap); gap: var(--gap);
} }
/* ── Sensor tiles ──────────────────────────────────────────────── */ /* ── Sensor tiles ── */
.sensor-tile { .sensor-tile {
padding: 20px 22px 18px; padding: 18px 18px 14px;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
cursor: default;
transition: transform .2s, box-shadow .2s, border-color .25s;
} }
.sensor-tile::before { .sensor-tile:hover {
content: ''; transform: translateY(-1px);
position: absolute; top: 0; left: 0; right: 0; box-shadow: 0 8px 24px rgba(0,0,0,.35);
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); } /* Coloured left stripe */
.sensor-tile.breach-low { border-color: rgba(255,209,102,.22); } .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 { .sensor-label {
font-size: .58rem; font-size: 11px;
font-weight: 400; font-weight: 500;
letter-spacing: .22em; letter-spacing: .06em;
text-transform: uppercase; text-transform: uppercase;
color: var(--frost-ghost); color: var(--txt-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) ────────────────────────────────────── */ .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 { .stat-row {
grid-column: 1 / -1; grid-column: 1 / -1;
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, 1fr);
gap: var(--gap); gap: var(--gap);
} }
.stat-card { .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 { .stat-label {
font-size: .55rem; font-size: 11px;
letter-spacing: .22em; font-weight: 500;
letter-spacing: .06em;
text-transform: uppercase; text-transform: uppercase;
color: var(--frost-ghost); color: var(--txt-ghost);
margin-bottom: 8px; margin-bottom: 3px;
} }
.stat-value { .stat-value {
font-family: var(--font-mono); font-family: var(--mono);
font-size: 1.55rem; font-size: 1.15rem;
color: var(--frost); font-weight: 500;
line-height: 1; 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 { .stat-sub {
font-size: .58rem; font-size: 10px;
color: var(--frost-ghost); color: var(--txt-ghost);
margin-top: 5px; margin-top: 2px;
font-family: var(--font-mono); font-family: var(--mono);
} }
/* ── Chart panel ───────────────────────────────────────────────── */ /* ── Chart panel ── */
.chart-panel { .chart-panel {
grid-column: 1 / 3; grid-column: 1 / 3;
padding: 18px 20px; padding: 16px 18px 14px;
} }
.panel-label { .panel-header {
font-size: .56rem; display: flex;
letter-spacing: .22em; align-items: center;
justify-content: space-between;
margin-bottom: 14px;
}
.panel-title {
font-size: 11px;
font-weight: 600;
letter-spacing: .08em;
text-transform: uppercase; text-transform: uppercase;
color: var(--frost-ghost); color: var(--txt-dim);
margin-bottom: 12px; }
.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%; } #chart { display: block; width: 100%; }
/* ── Log / Alert panel ─────────────────────────────────────────── */ /* ── Log panel ── */
.log-panel { .log-panel {
grid-column: 3 / 4; grid-column: 3 / -1;
padding: 0; padding: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
min-height: 220px;
} }
.tab-bar { .tab-bar {
display: flex; display: flex;
border-bottom: 1px solid var(--glass-edge); border-bottom: 1px solid var(--border);
flex-shrink: 0; flex-shrink: 0;
padding: 0 6px;
gap: 2px;
} }
.tab { .tab {
flex: 1; flex: 1;
padding: 11px 14px; padding: 10px 12px;
font-size: .56rem; font-size: 11px;
letter-spacing: .2em; font-weight: 500;
letter-spacing: .06em;
text-transform: uppercase; text-transform: uppercase;
color: var(--frost-ghost); color: var(--txt-ghost);
cursor: pointer; cursor: pointer;
text-align: center; text-align: center;
transition: color .2s, background .2s; transition: color .2s;
border-bottom: 2px solid transparent; border-bottom: 2px solid transparent;
margin-bottom: -1px; margin-bottom: -1px;
position: relative;
} }
.tab:hover { color: var(--frost-dim); } .tab:hover { color: var(--txt-dim); }
.tab.active { color: var(--ice); border-bottom-color: var(--ice); } .tab.active { color: var(--blue); border-bottom-color: var(--blue); }
.tab-body { .tab-body {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 10px 14px; padding: 8px 12px;
font-family: var(--font-mono); font-family: var(--mono);
font-size: .62rem; font-size: 11px;
} }
.tab-body::-webkit-scrollbar { width: 3px; } .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 { display: none; }
.tab-pane.active { display: block; } .tab-pane.active { display: block; }
.log-entry { .log-entry {
display: flex; display: grid;
gap: 8px; grid-template-columns: 68px 1fr auto;
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; gap: 8px;
padding: 5px 0; padding: 5px 0;
border-bottom: 1px solid rgba(255,255,255,.04); border-bottom: 1px solid rgba(255,255,255,.04);
align-items: flex-start; animation: fadeSlide .15s ease;
animation: slideIn .2s ease; align-items: center;
} }
.alert-icon { color: var(--danger); flex-shrink: 0; } @keyframes fadeSlide {
.alert-ts { color: var(--frost-ghost); flex-shrink: 0; } from { opacity: 0; transform: translateY(-3px); }
.alert-msg { color: var(--frost-dim); flex: 1; } 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 { .alert-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 14px; height: 14px; min-width: 16px; height: 16px;
border-radius: 50%; border-radius: 8px;
background: var(--danger); background: var(--red);
color: #000; color: #fff;
font-size: .5rem; font-size: 9px;
font-weight: 600;
margin-left: 5px; margin-left: 5px;
vertical-align: middle; vertical-align: middle;
padding: 0 3px;
display: none; display: none;
} }
.alert-badge.visible { display: inline-flex; } .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 { .mirror-footer {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 10px 22px; padding: 10px 20px;
font-size: .55rem; font-size: 11px;
letter-spacing: .18em; font-family: var(--mono);
text-transform: uppercase; color: var(--txt-ghost);
color: var(--frost-ghost);
} }
/* ── Responsive ────────────────────────────────────────────────── */ /* ── Responsive ── */
@media (max-width: 900px) { @media (max-width: 1100px) {
.mirror-body { .mirror-body { grid-template-columns: 1fr 1fr; }
grid-template-columns: 1fr 1fr; .stat-row { grid-template-columns: 1fr 1fr; }
}
.stat-row { grid-template-columns: 1fr 1fr; }
.chart-panel { grid-column: 1 / -1; } .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) { @media (max-width: 640px) {
.mirror-body { grid-template-columns: 1fr; } .mirror-body { grid-template-columns: 1fr 1fr; }
.stat-row { 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'; 'use strict';
/* ── Config injected by Flask via data-attribute ───────────────── */ /* ── Config from Flask ── */
const ROOT = document.getElementById('mirror-root'); const ROOT = document.getElementById('mirror-root');
const POLL_MS = parseInt(ROOT.dataset.pollMs || '800', 10); const POLL_MS = parseInt(ROOT.dataset.pollMs || '2500', 10);
const SENSORS = JSON.parse(ROOT.dataset.sensors || '[]'); const SENSORS = JSON.parse(ROOT.dataset.sensors || '[]');
/* ── State ─────────────────────────────────────────────────────── */ /* ── State ── */
let lastLogCount = 0; let lastLogCount = 0;
let alertsSeen = 0; let alertsSeen = 0;
let chartHistory = []; // first numeric channel only let chartData = []; // {ts, v} for temp channel (field_index 0)
let firstPoll = true;
/* ══════════════════════════════════════════════════════════════════ /* ══════════════════════════════
CLOCK 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() { function tickClock() {
const now = new Date(); const n = new Date();
const hh = String(now.getHours()).padStart(2,'0'); const pad = x => String(x).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2,'0'); document.getElementById('clock-time').textContent =
const ss = String(now.getSeconds()).padStart(2,'0'); `${pad(n.getHours())}:${pad(n.getMinutes())}:${pad(n.getSeconds())}`;
document.getElementById('clock-date').textContent =
const days = ['Sonntag','Montag','Dienstag','Mittwoch','Donnerstag','Freitag','Samstag']; `${DAYS[n.getDay()]}, ${n.getDate()}. ${MONTHS[n.getMonth()]} ${n.getFullYear()}`;
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); setInterval(tickClock, 1000);
tickClock(); tickClock();
/* ══════════════════════════════
/* ══════════════════════════════════════════════════════════════════
CANVAS CHART CANVAS CHART
══════════════════════════════════════════════════════════════════ */ ══════════════════════════════ */
const canvas = document.getElementById('chart'); const canvas = document.getElementById('chart');
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
function resizeCanvas() { function resizeCanvas() {
const wrap = canvas.parentElement; canvas.width = canvas.parentElement.clientWidth - 36;
canvas.width = wrap.clientWidth - 40; canvas.height = 130;
canvas.height = 140;
} }
window.addEventListener('resize', () => { resizeCanvas(); drawChart(); }); window.addEventListener('resize', () => { resizeCanvas(); drawChart(); });
resizeCanvas(); resizeCanvas();
@@ -54,81 +46,80 @@ function drawChart() {
const W = canvas.width, H = canvas.height; const W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H); ctx.clearRect(0, 0, W, H);
if (chartHistory.length < 2) { const pts = chartData.slice(-80).map(d => d.v);
ctx.fillStyle = 'rgba(232,244,255,.08)';
ctx.font = `11px 'Share Tech Mono', monospace`; if (pts.length < 2) {
ctx.fillText('Warte auf Daten …', 8, H / 2 + 4); ctx.fillStyle = 'rgba(228,233,245,.15)';
ctx.font = `11px 'JetBrains Mono', monospace`;
ctx.fillText('Warte auf Messdaten …', 10, H / 2 + 4);
return; return;
} }
const pts = chartHistory.slice(-80); const mn = Math.min(...pts);
const min = Math.min(...pts); const mx = Math.max(...pts);
const max = Math.max(...pts); const rng = (mx - mn) || 1;
const range = max - min || 1; const sx = W / (pts.length - 1);
const step = 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 */
/* grid lines */
ctx.strokeStyle = 'rgba(255,255,255,.04)'; ctx.strokeStyle = 'rgba(255,255,255,.04)';
ctx.lineWidth = 1; ctx.lineWidth = 1;
ctx.font = `9px 'Share Tech Mono', monospace`; ctx.font = `9px 'JetBrains Mono', monospace`;
ctx.fillStyle = 'rgba(232,244,255,.2)';
for (let i = 0; i <= 4; i++) { 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.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 */ /* gradient fill */
const grad = ctx.createLinearGradient(0, 0, 0, H); const grd = ctx.createLinearGradient(0, 0, 0, H);
grad.addColorStop(0, 'rgba(168,216,240,.18)'); grd.addColorStop(0, 'rgba(59,130,246,.2)');
grad.addColorStop(1, 'rgba(168,216,240,.00)'); grd.addColorStop(1, 'rgba(59,130,246,.0)');
ctx.beginPath(); ctx.beginPath();
pts.forEach((v, i) => { 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); 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.lineTo(0, H);
ctx.closePath(); ctx.closePath();
ctx.fillStyle = grad; ctx.fillStyle = grd;
ctx.fill(); ctx.fill();
/* line */ /* line */
ctx.beginPath(); ctx.beginPath();
pts.forEach((v, i) => { 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); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}); });
ctx.strokeStyle = 'rgba(168,216,240,.85)'; ctx.strokeStyle = '#3b82f6';
ctx.lineWidth = 1.5; ctx.lineWidth = 2;
ctx.shadowColor = 'rgba(168,216,240,.4)'; ctx.shadowColor = 'rgba(59,130,246,.5)';
ctx.shadowBlur = 8; ctx.shadowBlur = 10;
ctx.stroke(); ctx.stroke();
ctx.shadowBlur = 0; ctx.shadowBlur = 0;
/* last point dot */ /* last dot */
const lv = pts[pts.length - 1]; const lv = pts[pts.length - 1];
const lx = (pts.length - 1) * step; const lx = (pts.length - 1) * sx;
const ly = yPos(lv); const ly = yp(lv);
ctx.beginPath(); ctx.beginPath();
ctx.arc(lx, ly, 3.5, 0, Math.PI * 2); ctx.arc(lx, ly, 4, 0, Math.PI * 2);
ctx.fillStyle = '#a8d8f0'; ctx.fillStyle = '#3b82f6';
ctx.shadowColor = '#a8d8f0'; ctx.shadowColor = '#3b82f6';
ctx.shadowBlur = 12; ctx.shadowBlur = 14;
ctx.fill(); ctx.fill();
ctx.shadowBlur = 0; ctx.shadowBlur = 0;
} }
/* ══════════════════════════════
/* ══════════════════════════════════════════════════════════════════
SENSOR TILES SENSOR TILES
══════════════════════════════════════════════════════════════════ */ ══════════════════════════════ */
function updateSensorTiles(values) { function updateSensorTiles(values) {
SENSORS.forEach((s, i) => { SENSORS.forEach((s, i) => {
const idx = s.field_index; const idx = s.field_index;
if (idx >= values.length) return; if (idx >= values.length) return;
const v = values[idx]; const v = values[idx];
@@ -136,9 +127,9 @@ function updateSensorTiles(values) {
const valEl= document.getElementById(`sv-${i}`); const valEl= document.getElementById(`sv-${i}`);
if (!tile || !valEl) return; 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.querySelectorAll('.sensor-alert-badge').forEach(b => b.remove());
tile.classList.remove('breach-high', 'breach-low'); tile.classList.remove('breach-high', 'breach-low');
@@ -149,40 +140,36 @@ function updateSensorTiles(values) {
tile.classList.add('breach-high'); tile.classList.add('breach-high');
const b = document.createElement('span'); const b = document.createElement('span');
b.className = 'sensor-alert-badge'; b.className = 'sensor-alert-badge';
b.textContent = '↑ Grenzwert überschritten'; b.textContent = '↑ Überschritten';
tile.appendChild(b); tile.querySelector('.sensor-meta').appendChild(b);
} else if (isLow) { } else if (isLow) {
tile.classList.add('breach-low'); tile.classList.add('breach-low');
const b = document.createElement('span'); const b = document.createElement('span');
b.className = 'sensor-alert-badge low'; b.className = 'sensor-alert-badge low';
b.textContent = '↓ Grenzwert unterschritten'; b.textContent = '↓ Unterschritten';
tile.appendChild(b); tile.querySelector('.sensor-meta').appendChild(b);
} }
}); });
} }
/* ══════════════════════════════
/* ══════════════════════════════════════════════════════════════════
TABS TABS
══════════════════════════════════════════════════════════════════ */ ══════════════════════════════ */
function switchTab(name) { function switchTab(name) {
document.querySelectorAll('.tab').forEach(t => { document.querySelectorAll('.tab').forEach(t =>
t.classList.toggle('active', t.dataset.tab === name); t.classList.toggle('active', t.dataset.tab === name));
}); document.querySelectorAll('.tab-pane').forEach(p =>
document.querySelectorAll('.tab-pane').forEach(p => { p.classList.toggle('active', p.id === `pane-${name}`));
p.classList.toggle('active', p.id === `pane-${name}`);
});
if (name === 'alerts') { if (name === 'alerts') {
const badge = document.getElementById('alert-badge'); const b = document.getElementById('alert-badge');
badge.classList.remove('visible'); b.classList.remove('visible');
badge.textContent = ''; b.textContent = '';
} }
} }
/* ══════════════════════════════
/* ══════════════════════════════════════════════════════════════════ POLL
MAIN POLL ══════════════════════════════ */
══════════════════════════════════════════════════════════════════ */
async function poll() { async function poll() {
try { try {
const [dataRes, alertRes] = await Promise.all([ const [dataRes, alertRes] = await Promise.all([
@@ -192,53 +179,68 @@ async function poll() {
const data = await dataRes.json(); const data = await dataRes.json();
const alerts = await alertRes.json(); const alerts = await alertRes.json();
/* ── Connection badge ── */ /* Connection badge */
const badge = document.getElementById('conn-badge'); const badge = document.getElementById('conn-badge');
badge.className = `conn-badge glass ${data.status.connected ? 'ok' : 'err'}`; const ok = data.status.connected;
document.getElementById('conn-dot').className = 'conn-dot'; badge.className = `conn-badge card ${ok ? 'ok' : 'err'}`;
document.getElementById('conn-text').textContent = document.getElementById('conn-text').textContent = ok
data.status.connected ? `${data.status.port} · ${data.status.baud} Bd`
? `${data.status.port} · ${data.status.baud} Bd` : 'Getrennt — warte …';
: 'Getrennt — warte …';
/* ── Stat cards ── */ /* Stat cards */
document.getElementById('stat-total').textContent = data.status.total_lines; document.getElementById('stat-total').textContent = data.status.total_lines;
document.getElementById('stat-errors').textContent = data.status.errors; document.getElementById('stat-errors').textContent = data.status.errors;
document.getElementById('stat-port').textContent = data.status.port; document.getElementById('stat-port').textContent = data.status.port || '—';
document.getElementById('stat-baud').textContent = `${data.status.baud} baud`; document.getElementById('stat-baud').textContent = data.status.baud
? `${data.status.baud} baud` : '—';
if (data.entries.length) { if (data.entries.length) {
const latest = data.entries[data.entries.length - 1]; const latest = data.entries[data.entries.length - 1];
const disp = latest.values?.length const disp = latest.values?.length
? latest.values[0].toFixed(3) ? latest.values[0].toFixed(2)
: latest.raw.slice(0, 14); : latest.raw.slice(0, 14);
document.getElementById('stat-last').textContent = disp; document.getElementById('stat-last').textContent = disp;
document.getElementById('stat-ts').textContent = latest.ts.split('T')[1]; document.getElementById('stat-ts').textContent = latest.ts.split('T')[1].slice(0,8);
if (latest.values?.length) updateSensorTiles(latest.values);
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) ── */ /* Log — only new entries */
const newEntries = data.entries.slice(-(data.entries.length - lastLogCount)); const newEntries = data.entries.slice(lastLogCount);
lastLogCount = data.entries.length; lastLogCount = data.entries.length;
const logPane = document.getElementById('pane-log'); const logPane = document.getElementById('pane-log');
newEntries.forEach(e => { if (firstPoll && newEntries.length === 0) {
if (e.values?.length) chartHistory.push(e.values[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'; 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 = 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>` + `<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); logPane.prepend(row);
/* trim log DOM to 100 rows */
while (logPane.children.length > 100) {
logPane.removeChild(logPane.lastChild);
}
}); });
/* ── Alerts ── */ /* Alerts */
if (alerts.length > alertsSeen) { if (alerts.length > alertsSeen) {
const newAlerts = alerts.slice(alertsSeen); const newAlerts = alerts.slice(alertsSeen);
alertsSeen = alerts.length; alertsSeen = alerts.length;
const alertPane = document.getElementById('pane-alerts'); const alertPane = document.getElementById('pane-alerts');
if (alertsSeen === newAlerts.length) alertPane.innerHTML = ''; if (alertsSeen === newAlerts.length) alertPane.innerHTML = '';
@@ -248,15 +250,15 @@ async function poll() {
row.className = 'alert-entry'; row.className = 'alert-entry';
row.innerHTML = row.innerHTML =
`<span class="alert-icon">⚠</span>` + `<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>`; `<span class="alert-msg">${a.subject}</span>`;
alertPane.prepend(row); alertPane.prepend(row);
}); });
const alertBadge = document.getElementById('alert-badge'); const ab = document.getElementById('alert-badge');
if (!document.getElementById('pane-alerts').classList.contains('active')) { if (!document.getElementById('pane-alerts').classList.contains('active')) {
alertBadge.textContent = newAlerts.length > 9 ? '9+' : newAlerts.length; ab.textContent = newAlerts.length > 9 ? '9+' : String(newAlerts.length);
alertBadge.classList.add('visible'); ab.classList.add('visible');
} }
} }
@@ -269,5 +271,4 @@ async function poll() {
setTimeout(poll, POLL_MS); setTimeout(poll, POLL_MS);
} }
/* ── Kick off ─────────────────────────────────────────────────── */
poll(); 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" %} {% extends "base.html" %}
{% block title %}{{ title }}{% endblock %} {% block title %}{{ title }}{% endblock %}
{% block body %} {% block body %}
{# Root element carries config as data-attributes for dashboard.js #}
<div id="mirror-root" <div id="mirror-root"
data-poll-ms="{{ poll_ms }}" data-poll-ms="{{ poll_ms }}"
data-sensors="{{ sensors_json | e }}"> data-sensors="{{ sensors_json | e }}">
<div class="mirror-layout"> <div class="mirror-layout">
{# ══════════════════════════════════ <!-- ══ HEADER ══ -->
HEADER — clock + connection badge <header class="mirror-header card">
══════════════════════════════════ #}
<header class="mirror-header glass">
<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="mirror-clock">
<div class="clock-time" id="clock-time">00:00:00</div> <div class="clock-time" id="clock-time">00:00:00</div>
<div class="clock-date" id="clock-date"></div> <div class="clock-date" id="clock-date"></div>
</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> <div class="conn-dot" id="conn-dot"></div>
<span id="conn-text">Verbinde …</span> <span id="conn-text">Verbinde …</span>
</div> </div>
@@ -30,108 +32,117 @@
</header> </header>
{# ══════════════════════════════════ <!-- ══ BODY ══ -->
BODY — sensors / chart / log
══════════════════════════════════ #}
<main class="mirror-body"> <main class="mirror-body">
{# ── Sensor tiles (one per configured sensor) ── #} <!-- Sensor tiles -->
{% set icons = ['🌡️','💧','🌧️','📡','📊','⚡'] %}
{% for s in sensors %} {% 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-label">{{ s.name }}</div>
<div class="sensor-value" id="sv-{{ loop.index0 }}"></div> <div class="sensor-value" id="sv-{{ loop.index0 }}"></div>
<div class="sensor-threshold"> <div class="sensor-meta">
{%- if s.threshold_low is not none -%} <div class="sensor-threshold" id="st-{{ loop.index0 }}">
↓ {{ s.threshold_low }}{{ s.unit }} {%- if s.threshold_low is not none -%}↓ {{ s.threshold_low }}{{ s.unit }}{%- endif -%}
{%- 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 -%} {%- if s.threshold_high is not none -%}↑ {{ s.threshold_high }}{{ s.unit }}{%- endif -%}
&nbsp; ↑ {{ s.threshold_high }}{{ s.unit }} </div>
{%- endif -%}
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
{# ── Stat row ── #}
<!-- Stat row -->
<div class="stat-row"> <div class="stat-row">
<div class="stat-card glass"> <div class="stat-card card">
<div class="stat-label">Letzter Wert</div> <div class="stat-card-icon blue">📥</div>
<div class="stat-value" id="stat-last"></div> <div class="stat-info">
<div class="stat-sub" id="stat-ts"></div> <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>
<div class="stat-card glass"> <div class="stat-card card">
<div class="stat-label">Zeilen gesamt</div> <div class="stat-card-icon green">📊</div>
<div class="stat-value" id="stat-total">0</div> <div class="stat-info">
<div class="stat-sub">empfangen</div> <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>
<div class="stat-card glass"> <div class="stat-card card">
<div class="stat-label">Fehler</div> <div class="stat-card-icon red">⚠️</div>
<div class="stat-value red" id="stat-errors">0</div> <div class="stat-info">
<div class="stat-sub">Verbindungsabbrüche</div> <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>
<div class="stat-card glass"> <div class="stat-card card">
<div class="stat-label">Verbindung</div> <div class="stat-card-icon teal">🔌</div>
<div class="stat-value" id="stat-port" style="font-size:1rem;padding-top:.35rem"></div> <div class="stat-info">
<div class="stat-sub" id="stat-baud"></div> <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>
</div>{# /stat-row #} </div><!-- /stat-row -->
{# ── Chart panel ── #} <!-- Chart -->
<div class="chart-panel glass"> <div class="chart-panel card">
<div class="panel-label">Verlauf — Kanal 1 (letzte 80 Messpunkte)</div> <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> <canvas id="chart"></canvas>
</div> </div>
{# ── Log + Alerts panel ── #} <!-- Log + Alerts -->
<div class="log-panel glass"> <div class="log-panel card">
<div class="tab-bar"> <div class="tab-bar">
<div class="tab active" data-tab="log" onclick="switchTab('log')">Rohdaten</div> <div class="tab active" data-tab="log" onclick="switchTab('log')">Rohdaten</div>
<div class="tab" data-tab="alerts" onclick="switchTab('alerts')"> <div class="tab" data-tab="alerts" onclick="switchTab('alerts')">
Alerts Alerts <span class="alert-badge" id="alert-badge"></span>
<span class="alert-badge" id="alert-badge"></span>
</div> </div>
</div> </div>
<div class="tab-body"> <div class="tab-body">
<div class="tab-pane active" id="pane-log"> <div class="tab-pane active" id="pane-log">
<div class="log-entry" style="color:var(--frost-ghost)"> <div class="log-entry" style="color:var(--txt-ghost)">
<span>Warte auf Daten …</span> <span></span><span>Warte auf Daten …</span><span></span>
</div> </div>
</div> </div>
<div class="tab-pane" id="pane-alerts"> <div class="tab-pane" id="pane-alerts">
<div class="alert-entry" style="color:var(--frost-ghost)"> <div class="alert-entry" style="color:var(--txt-ghost)">
<span>Keine Alerts.</span> <span></span><span></span><span>Keine Alerts.</span>
</div> </div>
</div> </div>
</div> </div>
</div>
</div>{# /log-panel #} </main><!-- /mirror-body -->
</main>{# /mirror-body #}
{# ══════════════════════════════════ <!-- ══ FOOTER ══ -->
FOOTER
══════════════════════════════════ #}
<footer class="mirror-footer"> <footer class="mirror-footer">
<span>Smart Mirror · Arduino USB Monitor</span> <span>Smart Mirror · TGBBz Dillingen · Arduino USB Monitor</span>
<span id="footer-ts"></span> <span id="footer-ts"></span>
</footer> </footer>
</div>{# /mirror-layout #} </div>
</div>
</div>{# /mirror-root #}
<script src="{{ url_for('static', filename='js/dashboard.js') }}"></script> <script src="{{ url_for('static', filename='js/dashboard.js') }}"></script>
{# Keep footer timestamp fresh #}
<script> <script>
setInterval(() => { setInterval(() => {
document.getElementById('footer-ts').textContent = document.getElementById('footer-ts').textContent =