Wifi Module and frontend
parent
a698448879
commit
7cb0bb66d0
|
|
@ -21,3 +21,6 @@ __pycache__/
|
||||||
# Arduino
|
# Arduino
|
||||||
*.hex
|
*.hex
|
||||||
*.elf
|
*.elf
|
||||||
|
|
||||||
|
# WLAN-Zugangsdaten der Firmware
|
||||||
|
firmware/**/wifi_config.h
|
||||||
|
|
|
||||||
|
|
@ -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=<WSL-IP>
|
||||||
|
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.
|
||||||
|
|
@ -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})`);
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
#include <Wire.h>
|
#include <Wire.h>
|
||||||
#include <EEPROM.h>
|
#include <EEPROM.h>
|
||||||
#include "Adafruit_CCS811.h"
|
#include "Adafruit_CCS811.h"
|
||||||
|
#include "esp_wifi.h" // ESP8266: Messwerte per WLAN an das Backend senden
|
||||||
|
|
||||||
// --- LCD Setup ---
|
// --- LCD Setup ---
|
||||||
// Initialisierung des LCDs mit den Pins aus deinem Schaltplan
|
// Initialisierung des LCDs mit den Pins aus deinem Schaltplan
|
||||||
|
|
@ -88,6 +89,13 @@ void setup() {
|
||||||
lcd.setCursor(0, 0);
|
lcd.setCursor(0, 0);
|
||||||
lcd.print("Sensor startet..");
|
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
|
// Der DHT11 braucht nach dem Start einen Moment
|
||||||
delay(2000);
|
delay(2000);
|
||||||
lcd.clear();
|
lcd.clear();
|
||||||
|
|
@ -141,6 +149,9 @@ void loop() {
|
||||||
Serial.print(ccs.getTVOC());
|
Serial.print(ccs.getTVOC());
|
||||||
Serial.println(" ppb");
|
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())
|
// Baseline regelmäßig sichern (Vergleich überlaufsicher für millis())
|
||||||
if ((long)(millis() - naechsteBaselineSicherung) >= 0) {
|
if ((long)(millis() - naechsteBaselineSicherung) >= 0) {
|
||||||
baselineSpeichern();
|
baselineSpeichern();
|
||||||
|
|
|
||||||
|
|
@ -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 <SoftwareSerial.h>
|
||||||
|
#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;
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Lernklima – Live-Werte</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>Lernklima Vorlesungsraum</h1>
|
||||||
|
<p id="status" class="status status-warte">Warte auf Daten …</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section class="kacheln">
|
||||||
|
<article class="kachel" id="kachel-temperatur">
|
||||||
|
<h2>Temperatur</h2>
|
||||||
|
<p class="wert"><span id="wert-temperatur">–</span> <small>°C</small></p>
|
||||||
|
</article>
|
||||||
|
<article class="kachel" id="kachel-luftfeuchte">
|
||||||
|
<h2>Luftfeuchtigkeit</h2>
|
||||||
|
<p class="wert"><span id="wert-luftfeuchte">–</span> <small>%</small></p>
|
||||||
|
</article>
|
||||||
|
<article class="kachel" id="kachel-eco2">
|
||||||
|
<h2>CO₂ (eCO₂)</h2>
|
||||||
|
<p class="wert"><span id="wert-eco2">–</span> <small>ppm</small></p>
|
||||||
|
<p class="hinweis" id="hinweis-eco2" hidden></p>
|
||||||
|
</article>
|
||||||
|
<article class="kachel" id="kachel-tvoc">
|
||||||
|
<h2>TVOC</h2>
|
||||||
|
<p class="wert"><span id="wert-tvoc">–</span> <small>ppb</small></p>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="verlauf">
|
||||||
|
<h2>Verlauf (letzte Messungen)</h2>
|
||||||
|
<canvas id="diagramm" width="800" height="240" aria-label="Verlauf von Temperatur und CO₂"></canvas>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -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; }
|
||||||
Loading…
Reference in New Issue