From 7cb0bb66d081b3de750cb19be031a041850fe475 Mon Sep 17 00:00:00 2001 From: 3020772 <3020772@stud.hs-mannheim.de> Date: Tue, 20 Oct 2026 10:38:48 +0200 Subject: [PATCH] Wifi Module and frontend --- .gitignore | 3 + backend/README.md | 47 ++++++++++ backend/server.js | 97 ++++++++++++++++++++ backend/simulator.js | 27 ++++++ firmware/dht11_lcd/dht11_lcd.ino | 11 +++ firmware/dht11_lcd/esp_wifi.h | 153 +++++++++++++++++++++++++++++++ frontend/app.js | 113 +++++++++++++++++++++++ frontend/index.html | 44 +++++++++ frontend/style.css | 79 ++++++++++++++++ 9 files changed, 574 insertions(+) create mode 100644 backend/README.md create mode 100644 backend/server.js create mode 100644 backend/simulator.js create mode 100644 firmware/dht11_lcd/esp_wifi.h create mode 100644 frontend/app.js create mode 100644 frontend/index.html create mode 100644 frontend/style.css diff --git a/.gitignore b/.gitignore index 906a29e..d73e868 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ __pycache__/ # Arduino *.hex *.elf + +# WLAN-Zugangsdaten der Firmware +firmware/**/wifi_config.h diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..3f07987 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,47 @@ +# Backend (Empfang + Auslieferung des Frontends) + +Minimaler Server mit Node-Bordmitteln (Node ≥ 18, kein `npm install`). Er nimmt die Messwerte des ESP8266 entgegen, hält die letzten 100 Messungen im Speicher und liefert zusätzlich das Frontend aus. Die Aggregationslogik (Präferenzen, CO2-Hard-Constraint, Sensor-Veto) ist hier noch **nicht** enthalten. + +``` +ESP8266 ──POST /api/messwerte──▶ backend/server.js ◀──GET /api/messwerte── Browser (frontend/) +``` + +## Starten +```bash +node backend/server.js # Port 3000 (oder: node backend/server.js 8080) +node backend/simulator.js # optional: Fake-Sensordaten ohne Hardware +``` +Frontend: http://localhost:3000 + +## Schnittstelle (Datenvertrag) +`POST /api/messwerte`, `Content-Type: application/json`, reines HTTP (kein TLS): + +```json +{"temperatur": 22.5, "luftfeuchte": 45.0, "eco2": 612, "tvoc": 30} +``` + +| Feld | Einheit | Quelle | +|---|---|---| +| `temperatur` | °C | DHT11 | +| `luftfeuchte` | % | DHT11 | +| `eco2` | ppm | CCS811 | +| `tvoc` | ppb | CCS811 | + +Alle Felder sind Zahlen; ein Feld darf fehlen oder `null` sein (z. B. CCS811 noch nicht bereit). Den Zeitstempel setzt der Server beim Empfang. + +`GET /api/messwerte` liefert `{"aktuell": {...} | null, "verlauf": [...]}`. + +Test ohne Hardware: +```bash +curl -X POST localhost:3000/api/messwerte -H 'Content-Type: application/json' \ + -d '{"temperatur":22.5,"luftfeuchte":45,"eco2":1150,"tvoc":30}' +``` + +## ESP8266 erreicht den Server nicht? (WSL2) +Läuft der Server in WSL2, hat er eine eigene IP, die im WLAN nicht erreichbar ist. Der ESP muss die **Windows-IP** des Rechners ansprechen (`ipconfig`), und der Port muss durchgereicht werden (PowerShell als Administrator): + +```powershell +netsh interface portproxy add v4tov4 listenport=3000 listenaddress=0.0.0.0 connectport=3000 connectaddress= +New-NetFirewallRule -DisplayName "Lernklima 3000" -Direction Inbound -Protocol TCP -LocalPort 3000 -Action Allow +``` +Die WSL-IP zeigt `wsl hostname -I`. Alternativ in `%UserProfile%\.wslconfig` den Modus `networkingMode=mirrored` setzen und die Firewall-Regel anlegen. diff --git a/backend/server.js b/backend/server.js new file mode 100644 index 0000000..6b7caf2 --- /dev/null +++ b/backend/server.js @@ -0,0 +1,97 @@ +// Minimaler Empfangs-Server: ESP8266 ➔ POST /api/messwerte ➔ Frontend (GET /api/messwerte) +// Nur Node-Bordmittel, kein npm install nötig. Start: node backend/server.js [Port] +const http = require("http"); +const fs = require("fs"); +const path = require("path"); + +const PORT = Number(process.argv[2] || process.env.PORT || 3000); +const FRONTEND_DIR = path.join(__dirname, "..", "frontend"); +const MAX_VERLAUF = 100; +const MAX_BODY_BYTES = 1024; + +// Erlaubte Felder des Datenvertrags (siehe backend/README.md) +const FELDER = ["temperatur", "luftfeuchte", "eco2", "tvoc"]; + +const verlauf = []; // { zeit: ISO-String, temperatur, luftfeuchte, eco2, tvoc } + +const MIME = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".svg": "image/svg+xml", +}; + +function sendJson(res, status, obj) { + res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }); + res.end(JSON.stringify(obj)); +} + +function leseBody(req, callback) { + let body = ""; + let zuGross = false; + req.on("data", (chunk) => { + body += chunk; + if (body.length > MAX_BODY_BYTES) { + zuGross = true; + req.destroy(); + } + }); + req.on("end", () => callback(zuGross ? null : body)); +} + +function messwertAnnehmen(req, res) { + leseBody(req, (body) => { + if (body === null) return sendJson(res, 413, { fehler: "Anfrage zu gross" }); + let daten; + try { + daten = JSON.parse(body); + } catch { + return sendJson(res, 400, { fehler: "Ungueltiges JSON" }); + } + const eintrag = { zeit: new Date().toISOString() }; + for (const feld of FELDER) { + // Fehlende Sensoren (z. B. CCS811 noch nicht bereit) dürfen fehlen/null sein + const wert = daten[feld]; + if (wert === undefined || wert === null) { + eintrag[feld] = null; + } else if (typeof wert === "number" && Number.isFinite(wert)) { + eintrag[feld] = wert; + } else { + return sendJson(res, 400, { fehler: `Feld '${feld}' muss eine Zahl sein` }); + } + } + verlauf.push(eintrag); + if (verlauf.length > MAX_VERLAUF) verlauf.shift(); + console.log(new Date().toLocaleTimeString("de-DE"), JSON.stringify(eintrag)); + sendJson(res, 200, { ok: true }); + }); +} + +function statischAusliefern(req, res) { + const pfad = new URL(req.url, "http://x").pathname; + const datei = path.normalize(path.join(FRONTEND_DIR, pfad === "/" ? "index.html" : pfad)); + if (!datei.startsWith(FRONTEND_DIR + path.sep)) return sendJson(res, 403, { fehler: "Verboten" }); + fs.readFile(datei, (err, inhalt) => { + if (err) return sendJson(res, 404, { fehler: "Nicht gefunden" }); + res.writeHead(200, { "Content-Type": MIME[path.extname(datei)] || "application/octet-stream" }); + res.end(inhalt); + }); +} + +const server = http.createServer((req, res) => { + const pfad = new URL(req.url, "http://x").pathname; + if (pfad === "/api/messwerte") { + if (req.method === "POST") return messwertAnnehmen(req, res); + if (req.method === "GET") { + return sendJson(res, 200, { aktuell: verlauf[verlauf.length - 1] || null, verlauf }); + } + return sendJson(res, 405, { fehler: "Methode nicht erlaubt" }); + } + if (req.method === "GET") return statischAusliefern(req, res); + sendJson(res, 405, { fehler: "Methode nicht erlaubt" }); +}); + +// 0.0.0.0, damit der ESP8266 im WLAN den Server erreicht +server.listen(PORT, "0.0.0.0", () => { + console.log(`Server laeuft auf http://0.0.0.0:${PORT} (Frontend: http://localhost:${PORT})`); +}); diff --git a/backend/simulator.js b/backend/simulator.js new file mode 100644 index 0000000..33c7718 --- /dev/null +++ b/backend/simulator.js @@ -0,0 +1,27 @@ +// Simuliert den ESP8266: schickt alle 2 s Fantasiewerte an den Server (Entwicklung ohne Hardware). +// Start: node backend/simulator.js [Server-URL] z. B. http://localhost:3000 +const basis = process.argv[2] || "http://localhost:3000"; +let schritt = 0; + +async function sende() { + schritt++; + const messwert = { + temperatur: Math.round((22 + 2 * Math.sin(schritt / 20)) * 10) / 10, + luftfeuchte: Math.round((45 + 5 * Math.sin(schritt / 15)) * 10) / 10, + eco2: Math.round(800 + 400 * Math.sin(schritt / 25)), // überschreitet zeitweise 1000 ppm + tvoc: Math.round(40 + 30 * Math.sin(schritt / 10) + 30), + }; + try { + await fetch(basis + "/api/messwerte", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(messwert), + }); + console.log("gesendet", JSON.stringify(messwert)); + } catch (fehler) { + console.log("Server nicht erreichbar:", fehler.message); + } +} + +setInterval(sende, 2000); +sende(); diff --git a/firmware/dht11_lcd/dht11_lcd.ino b/firmware/dht11_lcd/dht11_lcd.ino index 957e3ba..e6cdc1b 100644 --- a/firmware/dht11_lcd/dht11_lcd.ino +++ b/firmware/dht11_lcd/dht11_lcd.ino @@ -3,6 +3,7 @@ #include #include #include "Adafruit_CCS811.h" +#include "esp_wifi.h" // ESP8266: Messwerte per WLAN an das Backend senden // --- LCD Setup --- // Initialisierung des LCDs mit den Pins aus deinem Schaltplan @@ -88,6 +89,13 @@ void setup() { lcd.setCursor(0, 0); lcd.print("Sensor startet.."); + // WLAN verbinden (kann bis zu ~20 s dauern) + lcd.setCursor(0, 1); + lcd.print("WLAN... "); + bool verbunden = wifiVerbinden(); + lcd.setCursor(0, 1); + lcd.print(verbunden ? "WLAN OK " : "WLAN Fehler "); + // Der DHT11 braucht nach dem Start einen Moment delay(2000); lcd.clear(); @@ -141,6 +149,9 @@ void loop() { Serial.print(ccs.getTVOC()); Serial.println(" ppb"); + // Vollständigen Messwertsatz ans Backend schicken (blockiert ca. 1-2 s) + sendeMesswerte(t, h, ccs.geteCO2(), ccs.getTVOC()); + // Baseline regelmäßig sichern (Vergleich überlaufsicher für millis()) if ((long)(millis() - naechsteBaselineSicherung) >= 0) { baselineSpeichern(); diff --git a/firmware/dht11_lcd/esp_wifi.h b/firmware/dht11_lcd/esp_wifi.h new file mode 100644 index 0000000..b6ea5df --- /dev/null +++ b/firmware/dht11_lcd/esp_wifi.h @@ -0,0 +1,153 @@ +// ESP8266 (ESP-01) per AT-Befehle am Arduino Uno: sendet die Messwerte als HTTP-POST +// an das Backend (POST /api/messwerte, Datenvertrag siehe backend/README.md). +#include +#include "wifi_config.h" + +// --- ESP-01 Verdrahtung --- +// ESP TX -> Uno Pin 3 (SoftwareSerial-RX) +// ESP RX -> Uno Pin 4 (SoftwareSerial-TX), über Spannungsteiler 5 V -> 3,3 V (z. B. 1 kΩ / 2 kΩ) +// ESP VCC und CH_PD -> 3,3 V (Der ESP braucht bis ~300 mA, der 3,3-V-Pin des Uno liefert nur 50 mA!) +// ESP GND -> GND +const uint8_t ESP_RX_PIN = 3; +const uint8_t ESP_TX_PIN = 4; +SoftwareSerial espSerial(ESP_RX_PIN, ESP_TX_PIN); + +bool wifiOk = false; +uint8_t sendeFehler = 0; +const uint8_t MAX_SENDEFEHLER = 3; // danach wird das WLAN neu verbunden + +// Verwirft alles, was der ESP noch geschickt hat +void espLeeren() { + while (espSerial.available()) { + espSerial.read(); + } +} + +// Liest vom ESP, bis `ziel` (max. 11 Zeichen) vorkommt. false bei Timeout. +bool espWarteAuf(const char* ziel, unsigned long timeoutMs) { + char fenster[12]; + const uint8_t n = strlen(ziel); + memset(fenster, 0, sizeof(fenster)); + + unsigned long start = millis(); + while (millis() - start < timeoutMs) { + while (espSerial.available()) { + memmove(fenster, fenster + 1, n - 1); + fenster[n - 1] = espSerial.read(); + if (memcmp(fenster, ziel, n) == 0) { + return true; + } + } + } + return false; +} + +bool espBefehl(const __FlashStringHelper* befehl, const char* ziel = "OK", unsigned long timeoutMs = 2000) { + espLeeren(); + espSerial.println(befehl); + return espWarteAuf(ziel, timeoutMs); +} + +// Findet die Baudrate des Moduls. SoftwareSerial ist bei 115200 unzuverlässig, deshalb +// wird ein Modul mit 115200 (Werkseinstellung) dauerhaft auf 9600 umgestellt. +bool espBaudrateFinden() { + espSerial.begin(9600); + if (espBefehl(F("AT"))) { + return true; + } + + espSerial.begin(115200); + espSerial.println(F("AT+UART_DEF=9600,8,1,0,0")); // neue AT-Firmware + delay(500); + espSerial.println(F("AT+CIOBAUD=9600")); // alte AT-Firmware + delay(500); + + espSerial.begin(9600); + return espBefehl(F("AT")); +} + +bool wifiVerbinden() { + wifiOk = false; + + if (!espBaudrateFinden()) { + Serial.println(F("ESP8266 antwortet nicht (Pins, Versorgung, Baudrate pruefen)")); + return false; + } + espBefehl(F("ATE0")); // Echo aus + if (!espBefehl(F("AT+CWMODE=1"))) { // Station-Modus + Serial.println(F("ESP8266: CWMODE fehlgeschlagen")); + return false; + } + espBefehl(F("AT+CIPMUX=0")); // eine Verbindung gleichzeitig + + Serial.print(F("Verbinde mit WLAN ")); + Serial.println(F(WLAN_SSID)); + espLeeren(); + espSerial.print(F("AT+CWJAP=\"")); + espSerial.print(F(WLAN_SSID)); + espSerial.print(F("\",\"")); + espSerial.print(F(WLAN_PASSWORT)); + espSerial.println(F("\"")); + wifiOk = espWarteAuf("OK", 20000); + + Serial.println(wifiOk ? F("WLAN verbunden") : F("WLAN-Verbindung fehlgeschlagen (SSID/Passwort?)")); + return wifiOk; +} + +// Schickt einen Messwertsatz per HTTP-POST an das Backend. true, wenn der Server mit 200 antwortet. +bool sendeMesswerte(float temperatur, float luftfeuchte, uint16_t eco2, uint16_t tvoc) { + if (!wifiOk && !wifiVerbinden()) { + return false; + } + + char temp[8], feuchte[8]; + dtostrf(temperatur, 1, 1, temp); + dtostrf(luftfeuchte, 1, 1, feuchte); + + char body[72]; + snprintf_P(body, sizeof(body), + PSTR("{\"temperatur\":%s,\"luftfeuchte\":%s,\"eco2\":%u,\"tvoc\":%u}"), + temp, feuchte, eco2, tvoc); + + char anfrage[200]; + int laenge = snprintf_P(anfrage, sizeof(anfrage), + PSTR("POST /api/messwerte HTTP/1.1\r\nHost: %s:%u\r\n" + "Content-Type: application/json\r\nContent-Length: %u\r\n" + "Connection: close\r\n\r\n%s"), + SERVER_IP, (unsigned int)SERVER_PORT, (unsigned int)strlen(body), body); + + espLeeren(); + espSerial.print(F("AT+CIPSTART=\"TCP\",\"")); + espSerial.print(F(SERVER_IP)); + espSerial.print(F("\",")); + espSerial.println(SERVER_PORT); + bool ok = espWarteAuf("OK", 5000); + + if (ok) { + espLeeren(); + espSerial.print(F("AT+CIPSEND=")); + espSerial.println(laenge); + ok = espWarteAuf(">", 2000); + } + if (ok) { + espSerial.print(anfrage); + ok = espWarteAuf("200 OK", 4000); + } + + // Verbindung schließen (der Server hat sie meist schon beendet, dann kommt ERROR) + espSerial.println(F("AT+CIPCLOSE")); + espWarteAuf("OK", 300); + + if (ok) { + sendeFehler = 0; + Serial.println(F("An Server gesendet")); + return true; + } + + Serial.println(F("Senden fehlgeschlagen (Server-IP/Port/Firewall pruefen)")); + if (++sendeFehler >= MAX_SENDEFEHLER) { + sendeFehler = 0; + wifiOk = false; // beim nächsten Senden neu verbinden + } + return false; +} diff --git a/frontend/app.js b/frontend/app.js new file mode 100644 index 0000000..7106fbf --- /dev/null +++ b/frontend/app.js @@ -0,0 +1,113 @@ +// Fragt alle paar Sekunden die Messwerte vom Backend ab und zeigt sie an. +const API_URL = "/api/messwerte"; +const ABFRAGE_INTERVALL_MS = 2000; +const VERALTET_NACH_MS = 10000; +const CO2_GRENZWERT_PPM = 1000; // Hard-Constraint laut Konzept (Standard 1000 ppm) + +const el = (id) => document.getElementById(id); + +function formatiere(wert, stellen) { + return wert === null || wert === undefined ? "–" : wert.toFixed(stellen); +} + +function zeigeStatus(text, klasse) { + const status = el("status"); + status.textContent = text; + status.className = "status " + klasse; +} + +function zeigeWerte(aktuell, veraltet) { + el("wert-temperatur").textContent = formatiere(aktuell && aktuell.temperatur, 1); + el("wert-luftfeuchte").textContent = formatiere(aktuell && aktuell.luftfeuchte, 1); + el("wert-eco2").textContent = formatiere(aktuell && aktuell.eco2, 0); + el("wert-tvoc").textContent = formatiere(aktuell && aktuell.tvoc, 0); + + for (const kachel of document.querySelectorAll(".kachel")) { + kachel.classList.toggle("veraltet", veraltet); + } + + const co2Alarm = !veraltet && aktuell && aktuell.eco2 !== null && aktuell.eco2 >= CO2_GRENZWERT_PPM; + el("kachel-eco2").classList.toggle("alarm", Boolean(co2Alarm)); + el("hinweis-eco2").hidden = !co2Alarm; + el("hinweis-eco2").textContent = `Grenzwert ${CO2_GRENZWERT_PPM} ppm erreicht – bitte lüften`; +} + +function zeichneDiagramm(verlauf) { + const canvas = el("diagramm"); + const ctx = canvas.getContext("2d"); + const stil = getComputedStyle(document.documentElement); + const farbe = (name) => stil.getPropertyValue(name).trim(); + const b = canvas.width; + const h = canvas.height; + ctx.clearRect(0, 0, b, h); + if (verlauf.length < 2) return; + + // Temperatur (links) und CO2 (rechts) haben getrennte Skalen + const reihen = [ + { feld: "temperatur", farbe: farbe("--linie-temp"), einheit: "°C" }, + { feld: "eco2", farbe: farbe("--linie-co2"), einheit: "ppm" }, + ]; + const rand = 8; + const text = farbe("--text-leise"); + ctx.font = "12px system-ui, sans-serif"; + + reihen.forEach((reihe, index) => { + const werte = verlauf.map((e) => e[reihe.feld]); + const vorhanden = werte.filter((w) => w !== null); + if (vorhanden.length === 0) return; + let min = Math.min(...vorhanden); + let max = Math.max(...vorhanden); + if (max - min < 1) { min -= 1; max += 1; } + + ctx.strokeStyle = reihe.farbe; + ctx.lineWidth = 2; + ctx.beginPath(); + let gestartet = false; + werte.forEach((w, i) => { + if (w === null) { gestartet = false; return; } + const x = rand + (i / (werte.length - 1)) * (b - 2 * rand); + const y = h - rand - 16 - ((w - min) / (max - min)) * (h - 2 * rand - 32); + if (gestartet) ctx.lineTo(x, y); else ctx.moveTo(x, y); + gestartet = true; + }); + ctx.stroke(); + + ctx.fillStyle = reihe.farbe; + ctx.textAlign = index === 0 ? "left" : "right"; + ctx.fillText(`${reihe.feld === "eco2" ? "CO₂" : "Temperatur"} ${min.toFixed(0)}–${max.toFixed(0)} ${reihe.einheit}`, + index === 0 ? rand : b - rand, 14); + }); + + ctx.fillStyle = text; + ctx.textAlign = "center"; + ctx.fillText(`${verlauf.length} Messungen`, b / 2, h - 4); +} + +async function aktualisiere() { + try { + const antwort = await fetch(API_URL, { cache: "no-store" }); + if (!antwort.ok) throw new Error("HTTP " + antwort.status); + const { aktuell, verlauf } = await antwort.json(); + + if (!aktuell) { + zeigeWerte(null, true); + zeigeStatus("Noch keine Daten vom Sensor empfangen", "status-warte"); + return; + } + const alter = Date.now() - new Date(aktuell.zeit).getTime(); + const veraltet = alter > VERALTET_NACH_MS; + zeigeWerte(aktuell, veraltet); + zeichneDiagramm(verlauf); + const uhrzeit = new Date(aktuell.zeit).toLocaleTimeString("de-DE"); + zeigeStatus( + veraltet ? `Daten veraltet – letzte Messung um ${uhrzeit}` : `Live – letzte Messung um ${uhrzeit}`, + veraltet ? "status-veraltet" : "status-ok" + ); + } catch (fehler) { + zeigeWerte(null, true); + zeigeStatus("Server nicht erreichbar", "status-veraltet"); + } +} + +aktualisiere(); +setInterval(aktualisiere, ABFRAGE_INTERVALL_MS); diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..6f892c8 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,44 @@ + + + + + + Lernklima – Live-Werte + + + +
+

Lernklima Vorlesungsraum

+

Warte auf Daten …

+
+ +
+
+
+

Temperatur

+

– °C

+
+
+

Luftfeuchtigkeit

+

– %

+
+
+

CO₂ (eCO₂)

+

– ppm

+ +
+
+

TVOC

+

– ppb

+
+
+ +
+

Verlauf (letzte Messungen)

+ +
+
+ + + + diff --git a/frontend/style.css b/frontend/style.css new file mode 100644 index 0000000..470c699 --- /dev/null +++ b/frontend/style.css @@ -0,0 +1,79 @@ +:root { + --bg: #f4f6f8; + --karte: #ffffff; + --text: #1c2530; + --text-leise: #5b6775; + --rand: #d9dee4; + --ok: #1a7f4b; + --warnung: #b3261e; + --warnung-bg: #fdecea; + --veraltet: #9a6700; + --linie-temp: #c2410c; + --linie-co2: #1d4ed8; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #14181d; + --karte: #1e242b; + --text: #e8ecf0; + --text-leise: #9aa6b2; + --rand: #323a44; + --ok: #4cc38a; + --warnung: #ff8a80; + --warnung-bg: #3b1e1c; + --veraltet: #e3b341; + --linie-temp: #fb923c; + --linie-co2: #60a5fa; + } +} + +* { box-sizing: border-box; } + +body { + margin: 0; + padding: 0 16px 32px; + background: var(--bg); + color: var(--text); + font-family: system-ui, -apple-system, "Segoe UI", sans-serif; +} + +header, main { max-width: 900px; margin: 0 auto; } +h1 { font-size: 1.5rem; margin: 24px 0 8px; } +h2 { font-size: 0.95rem; font-weight: 600; color: var(--text-leise); margin: 0 0 8px; } + +.status { margin: 0 0 20px; font-size: 0.9rem; } +.status-ok { color: var(--ok); } +.status-warte, .status-veraltet { color: var(--veraltet); } + +.kacheln { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 12px; +} + +.kachel { + background: var(--karte); + border: 1px solid var(--rand); + border-radius: 10px; + padding: 16px; +} + +.wert { margin: 0; font-size: 2.2rem; font-weight: 600; font-variant-numeric: tabular-nums; } +.wert small { font-size: 1rem; font-weight: 400; color: var(--text-leise); } + +.hinweis { margin: 8px 0 0; font-size: 0.85rem; font-weight: 600; } + +.kachel.alarm { border-color: var(--warnung); background: var(--warnung-bg); } +.kachel.alarm .wert, .kachel.alarm .hinweis { color: var(--warnung); } +.kachel.veraltet .wert { opacity: 0.45; } + +.verlauf { + margin-top: 24px; + background: var(--karte); + border: 1px solid var(--rand); + border-radius: 10px; + padding: 16px; +} + +canvas { width: 100%; height: auto; display: block; }