diff --git a/README.md b/README.md index 042fa94..c006522 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,11 @@ Das vollständige Konzept steht in [docs/aggregationskonzept.md](docs/aggregatio ## Datenfluss Sensoren (Arduino UNO) ➔ WiFi/Bluetooth ➔ Backend (Aggregation & Regeln) ➔ Frontend (Webinterface + 3D-Zwilling) +Aktuell umgesetzt: +``` +DHT11 + CCS811 ─▶ Arduino Uno ─Serial (Pin 0/1)─▶ ESP-01 ─WLAN (Handy-Hotspot)─▶ Laptop: backend/server.py :3000 ◀── Browser +``` + ## Repo-Struktur | Ordner | Inhalt | |---|---| @@ -16,4 +21,47 @@ Sensoren (Arduino UNO) ➔ WiFi/Bluetooth ➔ Backend (Aggregation & Regeln) ➔ | `frontend/` | Responsive Webinterface (Live-Werte, Präferenz-Slider) und 3D-Raum | ## Status -Repo-Grundgerüst; noch kein Code. Tech-Stack offen (siehe Offene Entscheidungen in [docs/offene-punkte.md](docs/offene-punkte.md)). +Live-Messwerte laufen Ende-zu-Ende: Der Arduino misst Temperatur, Luftfeuchte, eCO₂ und TVOC, zeigt sie auf dem LCD und sendet sie per ESP-01 an `backend/server.py`; die Website (`frontend/`) zeigt sie live mit Verlauf und CO₂-Alarm. Aggregation/Präferenzen und 3D-Zwilling fehlen noch (siehe [docs/offene-punkte.md](docs/offene-punkte.md)). + +## Hardware +| Bauteil | Anschluss am Uno | +|---|---| +| DHT11 | Daten → Pin 2 | +| LCD 16×2 | RS 7, E 8, D4–D7 → 9–12, RW → GND, V0 → Poti | +| CCS811 (CJMCU-811) | VCC → 3,3 V, GND, SDA → A4, SCL → A5, WAK → GND, ADD → GND (Adresse 0x5A) | +| ESP-01 | **TX → Pin 0, RX → Pin 1 (gekreuzt!)**, VCC + EN → 3,3 V, GND; RST, GPIO0, GPIO2 frei | + +Der 3,3-V-Pin des Uno liefert nur ca. 150 mA, der ESP-01 braucht beim Start kurz bis zu 300 mA. Wir versorgen den ESP deshalb aus einer eigenen 3,3-V-Quelle (zweiter Uno); deren **GND muss mit dem GND des Haupt-Uno verbunden** sein. + +## Starten +1. `firmware/dht11_lcd/wifi_config.example.h` als `wifi_config.h` kopieren und Hotspot-Name, Passwort und Laptop-IP eintragen (die Datei ist in `.gitignore`). +2. iPhone-Hotspot mit **„Maximale Kompatibilität“** einschalten (ESP-01 kann nur 2,4 GHz), Laptop verbinden. +3. Einmalig `backend/firewall_freigeben.bat` als Administrator ausführen. +4. `backend/start.bat` starten – muss „OK: SERVER_IP … passt“ melden, sonst die angezeigte IP in `wifi_config.h` eintragen. +5. ESP-Datenkabel von Pin 0/1 abziehen, `firmware/dht11_lcd` hochladen, Kabel wieder anstecken, Reset. +6. LCD zeigt „WLAN OK“, Website: http://localhost:3000 + +## Fehlerbehebung: „WLAN Fehler E1“ (ESP antwortet nicht) + +**Symptom:** Das LCD zeigte nach dem Start immer „WLAN Fehler E1“, egal wie TX/RX gesteckt waren; `esp_test` zeigte bei jeder Baudrate „keine Antwort“. Zeitweise meldete außerdem der CCS811 „CCS811 Fehler!“. + +**Ursachen (es waren mehrere gleichzeitig):** +1. **TX/RX nach Instructables-Anleitung verkabelt.** Die Anleitung „Add WiFi to Arduino UNO“ benutzt den Uno nur als USB-Adapter (leerer Sketch, der Laptop spricht mit dem ESP). Dafür wird *gerade* verbunden (ESP TX → Pin 1, ESP RX → Pin 0), weil der USB-Chip des Uno intern schon kreuzt. In unserem Projekt spricht aber der **Uno selbst** mit dem ESP – dann muss **gekreuzt** werden: **ESP TX → Pin 0 (RX), ESP RX → Pin 1 (TX)**. +2. **Keine gemeinsame Masse.** Der ESP hing an der 3,3 V eines zweiten Uno, dessen GND nicht mit dem Haupt-Uno verbunden war. Ohne gemeinsame Masse kommen auf TX/RX keine sauberen Signale an (der ESP läuft trotzdem, die blaue LED blinkt beim Start). +3. **Zu schwache 3,3-V-Versorgung.** Hingen ESP und CCS811 gemeinsam am 3,3-V-Pin des Uno, brach die Spannung beim Start des ESP ein: Der ESP startete unzuverlässig, und der CCS811 antwortete beim Initialisieren nicht („CCS811 Fehler!“). Der Sketch startet den CCS811 deshalb jetzt erst nach dem WLAN und versucht es bei Fehlern erneut. + +**So haben wir es eingegrenzt (Durchreiche-Test, ohne eigenen Sketch):** +1. Leeren Sketch (Datei → Beispiele → 01.Basics → BareMinimum) auf den Uno laden. +2. ESP **gerade** anschließen: TX → Pin 1, RX → Pin 0, gemeinsame Masse. +3. Serial Monitor auf **74880 Baud**, ESP neu einstecken → lesbare Bootmeldung (`SDK ver: …`) heißt: ESP lebt, TX-Leitung ok. +4. Serial Monitor auf **115200 Baud** und „Sowohl NL als auch CR“, `AT` senden → `OK` heißt: ESP voll funktionsfähig. +5. `AT+CWMODE=1` und `AT+CWJAP="",""` → `WIFI CONNECTED`, `WIFI GOT IP`, `OK` heißt: Hotspot ok. +6. Optional `AT+UART_DEF=9600,8,1,0,0`: ESP dauerhaft auf 9600 Baud (vom Uno zuverlässiger empfangen, der Sketch erkennt 9600 automatisch). +7. Danach für `dht11_lcd` wieder **gekreuzt** anschließen. + +| LCD | Bedeutung | +|---|---| +| WLAN Fehler E1 | ESP antwortet nicht: TX/RX nicht gekreuzt, GND fehlt, Strom zu schwach | +| WLAN Fehler E2 | ESP antwortet, Station-Modus nicht setzbar | +| WLAN Fehler E3 | Hotspot nicht erreichbar: SSID/Passwort, „Maximale Kompatibilität“ aus, Hotspot aus | +| CCS811 Fehler! | Sensor antwortet nicht am I2C: Verkabelung prüfen, `firmware/ccs811_diagnose` zeigt den genauen Schritt | diff --git a/backend/README.md b/backend/README.md index 3f07987..2b4f5d0 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,17 +1,23 @@ # 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. +Minimaler Server mit Python-Bordmitteln (Python ≥ 3.8, kein `pip install`, läuft direkt unter Windows). 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/) +ESP8266 ──POST /api/messwerte──▶ backend/server.py ◀──GET /api/messwerte── Browser (frontend/) ``` ## Starten +Doppelklick auf `start.bat` oder: ```bash -node backend/server.js # Port 3000 (oder: node backend/server.js 8080) -node backend/simulator.js # optional: Fake-Sensordaten ohne Hardware +python backend/server.py # Port 3000 (oder: python backend/server.py 8080) +python backend/simulator.py # optional: Fake-Sensordaten ohne Hardware ``` -Frontend: http://localhost:3000 +Frontend: http://localhost:3000, im Hotspot von anderen Geräten: `http://:3000` + +Beim Start zeigt der Server die IP-Adressen des Laptops und prüft, ob `SERVER_IP` in `firmware/dht11_lcd/wifi_config.h` dazu passt („OK“ bzw. „ACHTUNG“). Jeder empfangene Messwert erscheint mit Absender-IP im Fenster (`127.0.0.1` = Simulator, `172.20.10.x` = ESP im Hotspot). + +## Firewall (einmalig) +Windows stuft den Handy-Hotspot meist als „öffentliches Netz“ ein und blockiert eingehende Verbindungen. `firewall_freigeben.bat` per Rechtsklick → „Als Administrator ausführen“ gibt Port 3000 frei. ## Schnittstelle (Datenvertrag) `POST /api/messwerte`, `Content-Type: application/json`, reines HTTP (kein TLS): @@ -31,17 +37,8 @@ Alle Felder sind Zahlen; ein Feld darf fehlen oder `null` sein (z. B. CCS811 noc `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): - +Test ohne Hardware (PowerShell): ```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 +Invoke-RestMethod -Method Post -Uri http://localhost:3000/api/messwerte -ContentType "application/json" ` + -Body '{"temperatur":22.5,"luftfeuchte":45,"eco2":1150,"tvoc":30}' ``` -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/firewall_freigeben.bat b/backend/firewall_freigeben.bat new file mode 100644 index 0000000..82a3df7 --- /dev/null +++ b/backend/firewall_freigeben.bat @@ -0,0 +1,5 @@ +@echo off +REM Einmalig als Administrator ausfuehren (Rechtsklick > Als Administrator ausfuehren): +REM erlaubt dem ESP8266 im Hotspot (Windows stuft ihn meist als "oeffentliches Netz" ein) den Zugriff auf Port 3000. +netsh advfirewall firewall add rule name="Arduino Messwerte 3000" dir=in action=allow protocol=TCP localport=3000 profile=any +pause diff --git a/backend/server.js b/backend/server.js deleted file mode 100644 index 6b7caf2..0000000 --- a/backend/server.js +++ /dev/null @@ -1,97 +0,0 @@ -// 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/server.py b/backend/server.py new file mode 100644 index 0000000..1781935 --- /dev/null +++ b/backend/server.py @@ -0,0 +1,135 @@ +# Minimaler Empfangs-Server: ESP8266 -> POST /api/messwerte -> Website (GET /api/messwerte) +# Nur Python-Bordmittel, kein pip install noetig. Start: python server.py [Port] (oder start.bat) +import json +import math +import re +import socket +import sys +from collections import deque +from datetime import datetime, timezone +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 3000 +HIER = Path(__file__).resolve().parent +FRONTEND_DIR = HIER.parent / "frontend" +WIFI_CONFIG = HIER.parent / "firmware" / "dht11_lcd" / "wifi_config.h" +MAX_VERLAUF = 100 +MAX_BODY_BYTES = 1024 + +# Erlaubte Felder des Datenvertrags (so sendet esp_wifi.h) +FELDER = ["temperatur", "luftfeuchte", "eco2", "tvoc"] + +verlauf = deque(maxlen=MAX_VERLAUF) # { zeit: ISO-String, temperatur, luftfeuchte, eco2, tvoc } + +MIME = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".svg": "image/svg+xml", +} + + +class Handler(BaseHTTPRequestHandler): + def sende_json(self, status, obj): + daten = json.dumps(obj).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Length", str(len(daten))) + self.end_headers() + self.wfile.write(daten) + + def pfad(self): + return self.path.split("?", 1)[0] + + def do_POST(self): + if self.pfad() != "/api/messwerte": + return self.sende_json(405, {"fehler": "Methode nicht erlaubt"}) + + laenge = int(self.headers.get("Content-Length") or 0) + if laenge > MAX_BODY_BYTES: + return self.sende_json(413, {"fehler": "Anfrage zu gross"}) + try: + daten = json.loads(self.rfile.read(laenge)) + except (ValueError, UnicodeDecodeError): + return self.sende_json(400, {"fehler": "Ungueltiges JSON"}) + if not isinstance(daten, dict): + return self.sende_json(400, {"fehler": "JSON-Objekt erwartet"}) + + eintrag = {"zeit": datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")} + for feld in FELDER: + # Fehlende Sensoren (z. B. CCS811 noch nicht bereit) duerfen fehlen/null sein + wert = daten.get(feld) + if wert is None: + eintrag[feld] = None + elif isinstance(wert, (int, float)) and not isinstance(wert, bool) and math.isfinite(wert): + eintrag[feld] = wert + else: + return self.sende_json(400, {"fehler": f"Feld '{feld}' muss eine Zahl sein"}) + + verlauf.append(eintrag) + print(datetime.now().strftime("%H:%M:%S"), self.client_address[0], json.dumps(eintrag), flush=True) + self.sende_json(200, {"ok": True}) + + def do_GET(self): + pfad = self.pfad() + if pfad == "/api/messwerte": + return self.sende_json(200, {"aktuell": verlauf[-1] if verlauf else None, "verlauf": list(verlauf)}) + + datei = (FRONTEND_DIR / ("index.html" if pfad == "/" else pfad.lstrip("/"))).resolve() + if FRONTEND_DIR not in datei.parents: + return self.sende_json(403, {"fehler": "Verboten"}) + if not datei.is_file(): + return self.sende_json(404, {"fehler": "Nicht gefunden"}) + inhalt = datei.read_bytes() + self.send_response(200) + self.send_header("Content-Type", MIME.get(datei.suffix, "application/octet-stream")) + self.send_header("Content-Length", str(len(inhalt))) + self.end_headers() + self.wfile.write(inhalt) + + def log_message(self, *args): + pass # keine Zeile pro Seitenabruf, nur empfangene Messwerte werden ausgegeben + + +def eigene_ips(): + """IPv4-Adressen dieses PCs (ohne 127.x), z. B. 172.20.10.x im iPhone-Hotspot.""" + try: + infos = socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET) + except socket.gaierror: + return [] + return sorted({info[4][0] for info in infos if not info[4][0].startswith("127.")}) + + +def konfigurierte_server_ip(): + try: + treffer = re.search(r'#define\s+SERVER_IP\s+"([^"]+)"', WIFI_CONFIG.read_text(encoding="utf-8")) + except OSError: + return None + return treffer.group(1) if treffer else None + + +def ip_hinweis(): + ips = eigene_ips() + soll = konfigurierte_server_ip() + print("IP-Adressen dieses PCs:", ", ".join(ips) or "keine (nicht verbunden?)") + if soll is None: + return + if soll in ips: + print(f"OK: SERVER_IP in wifi_config.h ({soll}) passt zu diesem PC.") + else: + print(f"ACHTUNG: wifi_config.h sendet an {soll}, diese IP hat der PC gerade nicht!") + print(" -> Mit dem Hotspot verbinden bzw. SERVER_IP auf die Hotspot-IP oben aendern und neu flashen.") + + +if __name__ == "__main__": + # 0.0.0.0, damit der ESP8266 im WLAN/Hotspot den Server erreicht + server = ThreadingHTTPServer(("0.0.0.0", PORT), Handler) + print(f"Server laeuft auf Port {PORT} (Website: http://localhost:{PORT})") + ip_hinweis() + print("Beenden mit Strg+C\n", flush=True) + try: + server.serve_forever() + except KeyboardInterrupt: + pass diff --git a/backend/simulator.js b/backend/simulator.js deleted file mode 100644 index 33c7718..0000000 --- a/backend/simulator.js +++ /dev/null @@ -1,27 +0,0 @@ -// 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/backend/simulator.py b/backend/simulator.py new file mode 100644 index 0000000..cd6cf33 --- /dev/null +++ b/backend/simulator.py @@ -0,0 +1,27 @@ +# Simuliert den ESP8266: schickt alle 2 s Fantasiewerte an den Server (Entwicklung ohne Hardware). +# Start: python simulator.py [Server-URL] z. B. http://localhost:3000 +import json +import math +import sys +import time +import urllib.request + +basis = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:3000" +schritt = 0 + +while True: + schritt += 1 + messwert = { + "temperatur": round(22 + 2 * math.sin(schritt / 20), 1), + "luftfeuchte": round(45 + 5 * math.sin(schritt / 15), 1), + "eco2": round(800 + 400 * math.sin(schritt / 25)), # ueberschreitet zeitweise 1000 ppm + "tvoc": round(70 + 30 * math.sin(schritt / 10)), + } + anfrage = urllib.request.Request(basis + "/api/messwerte", data=json.dumps(messwert).encode(), + headers={"Content-Type": "application/json"}) + try: + urllib.request.urlopen(anfrage, timeout=3).read() + print("gesendet", json.dumps(messwert)) + except OSError as fehler: + print("Server nicht erreichbar:", fehler) + time.sleep(2) diff --git a/backend/start.bat b/backend/start.bat new file mode 100644 index 0000000..e768c20 --- /dev/null +++ b/backend/start.bat @@ -0,0 +1,5 @@ +@echo off +REM Startet den Webserver. Website: http://localhost:3000 +cd /d "%~dp0" +python server.py %* +pause diff --git a/docs/offene-punkte.md b/docs/offene-punkte.md index 454b233..116ae85 100644 --- a/docs/offene-punkte.md +++ b/docs/offene-punkte.md @@ -1,7 +1,7 @@ # Offene Entscheidungen - [ ] Backend-Sprache/Framework (z. B. Node.js, Python/FastAPI) -- [ ] Transport Sensoren ➔ Backend (HTTP, MQTT, WebSocket; WiFi vs. Bluetooth). Hinweis: Arduino UNO hat kein WiFi – ggf. ESP-Modul/UNO R4 WiFi. +- [x] Transport Sensoren ➔ Backend: HTTP-POST über ESP-01 (AT-Befehle) im WLAN/Handy-Hotspot - [ ] Live-Updates im Frontend (WebSocket / SSE) - [ ] Frontend-Framework und 3D-Bibliothek (z. B. three.js) - [ ] Wetter-API (Außentemperatur, Windvorhersage, Feinstaub) @@ -9,4 +9,4 @@ - [ ] Schwellwerte: CO2-Limit (1000 ppm), „extremer Lärm", „extremer Wind" - [ ] Gewichtung: Dozent vs. Studierende beim Durchschnitt? Zeitfenster/Verfall alter Eingaben? - [ ] Authentifizierung/Anonymität der Nutzer (Mehrfachabgaben verhindern) -- [ ] Simulation von Sensordaten für Entwicklung ohne Hardware +- [x] Simulation von Sensordaten für Entwicklung ohne Hardware (`backend/simulator.py`) diff --git a/firmware/ccs811_diagnose/ccs811_diagnose.ino b/firmware/ccs811_diagnose/ccs811_diagnose.ino new file mode 100644 index 0000000..2e92393 --- /dev/null +++ b/firmware/ccs811_diagnose/ccs811_diagnose.ino @@ -0,0 +1,211 @@ +// Schritt-fuer-Schritt-Diagnose fuer den CCS811 (CJMCU-811), ohne Adafruit-Bibliothek, direkt ueber I2C. +// Ausgabe im Serial Monitor (9600 Baud). ESP nicht angeschlossen. +// Verdrahtung: VCC -> 3,3 V, GND -> GND, SDA -> A4, SCL -> A5, WAK -> GND, ADD -> GND (Adresse 0x5A) +#include + +// CCS811-Register (Datenblatt) +const uint8_t REG_STATUS = 0x00; +const uint8_t REG_MEAS_MODE = 0x01; +const uint8_t REG_ALG_RESULT = 0x02; +const uint8_t REG_HW_ID = 0x20; +const uint8_t REG_HW_VERSION = 0x21; +const uint8_t REG_FW_BOOT_VERSION = 0x23; +const uint8_t REG_FW_APP_VERSION = 0x24; +const uint8_t REG_ERROR_ID = 0xE0; +const uint8_t REG_APP_START = 0xF4; +const uint8_t REG_SW_RESET = 0xFF; + +uint8_t adresse = 0; +bool bereit = false; + +bool schreibe(uint8_t reg, const uint8_t* daten, uint8_t n) { + Wire.beginTransmission(adresse); + Wire.write(reg); + Wire.write(daten, n); + return Wire.endTransmission() == 0; +} + +bool lese(uint8_t reg, uint8_t* daten, uint8_t n) { + Wire.beginTransmission(adresse); + Wire.write(reg); + if (Wire.endTransmission() != 0) return false; + if (Wire.requestFrom(adresse, n) != n) return false; + for (uint8_t i = 0; i < n; i++) daten[i] = Wire.read(); + return true; +} + +void hex(uint8_t wert) { + Serial.print(F("0x")); + if (wert < 0x10) Serial.print('0'); + Serial.print(wert, HEX); +} + +void zeigeStatus(uint8_t s) { + Serial.print(F(" STATUS = ")); + hex(s); + Serial.print(F(" ERROR=")); + Serial.print(s & 0x01); + Serial.print(F(" DATA_READY=")); + Serial.print((s >> 3) & 1); + Serial.print(F(" APP_VALID=")); + Serial.print((s >> 4) & 1); + Serial.print(F(" FW_MODE=")); + Serial.println((s >> 7) & 1); +} + +void zeigeFehler() { + uint8_t e; + if (!lese(REG_ERROR_ID, &e, 1)) { + Serial.println(F(" ERROR_ID nicht lesbar")); + return; + } + Serial.print(F(" ERROR_ID = ")); + hex(e); + Serial.println(); + if (e & 0x01) Serial.println(F(" - WRITE_REG_INVALID")); + if (e & 0x02) Serial.println(F(" - READ_REG_INVALID")); + if (e & 0x04) Serial.println(F(" - MEASMODE_INVALID")); + if (e & 0x08) Serial.println(F(" - MAX_RESISTANCE: Sensorelement defekt oder extremer Wert")); + if (e & 0x10) Serial.println(F(" - HEATER_FAULT: Heizer defekt")); + if (e & 0x20) Serial.println(F(" - HEATER_SUPPLY: Versorgungsspannung zu schwach -> 3,3 V / GND pruefen")); +} + +// Liefert false und Erklaerung, wenn ein Schritt scheitert +bool initialisiere() { + // 1) Pegel der Leitungen ohne interne Pull-ups: die Platine zieht SDA/SCL auf ihre 3,3 V + pinMode(A4, INPUT); + pinMode(A5, INPUT); + delay(10); + Serial.print(F("\n[1] Pegel: SDA(A4)=")); + Serial.print(digitalRead(A4) ? F("HIGH") : F("LOW")); + Serial.print(F(" SCL(A5)=")); + Serial.println(digitalRead(A5) ? F("HIGH") : F("LOW")); + if (!digitalRead(A4) || !digitalRead(A5)) { + Serial.println(F(" -> LOW = Leitung haengt auf GND oder Modul hat keinen Strom (VCC an 3,3 V?)")); + } + + // 2) I2C-Scan + Wire.begin(); + Wire.setClock(100000); + Wire.setWireTimeout(25000, true); // kein Haengenbleiben bei blockiertem Bus + Serial.println(F("\n[2] I2C-Scan")); + uint8_t gefunden = 0; + for (uint8_t a = 1; a < 127; a++) { + Wire.beginTransmission(a); + uint8_t r = Wire.endTransmission(); + if (r == 0) { + Serial.print(F(" Geraet bei ")); + hex(a); + Serial.println(); + gefunden++; + if (a == 0x5A || a == 0x5B) adresse = a; + } else if (r == 5) { + Serial.println(F(" Timeout: Bus blockiert (SDA/SCL Kurzschluss oder vertauscht?)")); + return false; + } + } + if (adresse == 0) { + Serial.println(gefunden ? F(" Kein CCS811 bei 0x5A/0x5B (andere Geraete gefunden)") + : F(" KEIN Geraet: SDA/SCL vertauscht? VCC/GND? Loetstellen der Stiftleiste?")); + return false; + } + + // 3) Software-Reset -> Bootloader-Modus + const uint8_t reset[] = {0x11, 0xE5, 0x72, 0x8A}; + schreibe(REG_SW_RESET, reset, 4); + delay(100); + + // 4) Chip-ID und Versionen + uint8_t id, hw, v[2]; + Serial.println(F("\n[3] Chip")); + if (!lese(REG_HW_ID, &id, 1)) { + Serial.println(F(" HW_ID nicht lesbar (I2C-Fehler)")); + return false; + } + Serial.print(F(" HW_ID = ")); + hex(id); + Serial.println(id == 0x81 ? F(" (richtig)") : F(" (FALSCH, erwartet 0x81: kein CCS811 oder gestoerter Bus)")); + if (id != 0x81) return false; + if (lese(REG_HW_VERSION, &hw, 1)) { + Serial.print(F(" HW_VERSION = ")); + hex(hw); + Serial.println(); + } + if (lese(REG_FW_BOOT_VERSION, v, 2)) { + Serial.print(F(" Bootloader = ")); + Serial.print(v[0] >> 4); Serial.print('.'); Serial.print(v[0] & 0x0F); Serial.print('.'); Serial.println(v[1]); + } + if (lese(REG_FW_APP_VERSION, v, 2)) { + Serial.print(F(" App-Firmware = ")); + Serial.print(v[0] >> 4); Serial.print('.'); Serial.print(v[0] & 0x0F); Serial.print('.'); Serial.println(v[1]); + } + + // 5) Firmware starten + uint8_t s; + Serial.println(F("\n[4] Status vor APP_START")); + lese(REG_STATUS, &s, 1); + zeigeStatus(s); + if (!(s & 0x10)) { + Serial.println(F(" APP_VALID=0: keine gueltige Firmware auf dem Sensor (muesste neu geflasht werden)")); + return false; + } + schreibe(REG_APP_START, nullptr, 0); + delay(100); + Serial.println(F("\n[5] Status nach APP_START")); + lese(REG_STATUS, &s, 1); + zeigeStatus(s); + if (s & 0x01) zeigeFehler(); + if (!(s & 0x80)) { + Serial.println(F(" FW_MODE=0: Firmware startet nicht")); + return false; + } + + // 6) Messmodus 1: jede Sekunde ein Wert + const uint8_t modus = 0x10; + schreibe(REG_MEAS_MODE, &modus, 1); + delay(10); + lese(REG_STATUS, &s, 1); + if (s & 0x01) { + Serial.println(F("\n[6] Fehler beim Setzen des Messmodus")); + zeigeFehler(); + return false; + } + Serial.println(F("\n[6] Messung laeuft (die ersten ~20 min sind 400 ppm / 0 ppb normal)\n")); + return true; +} + +void setup() { + Serial.begin(9600); + Serial.println(F("\n===== CCS811 Diagnose =====")); + delay(500); // CCS811 braucht nach dem Einschalten ~20 ms + bereit = initialisiere(); + if (!bereit) { + Serial.println(F("\n##### Abbruch, siehe letzten Schritt #####")); + } +} + +void loop() { + if (!bereit) return; + delay(1000); + + uint8_t d[6]; + if (!lese(REG_ALG_RESULT, d, 6)) { + Serial.println(F("Lesefehler (I2C)")); + return; + } + uint8_t status = d[4]; + if (status & 0x01) { + zeigeStatus(status); + zeigeFehler(); + return; + } + if (!(status & 0x08)) { + Serial.println(F("noch keine neuen Daten")); + return; + } + Serial.print(F("eCO2: ")); + Serial.print(((uint16_t)d[0] << 8) | d[1]); + Serial.print(F(" ppm TVOC: ")); + Serial.print(((uint16_t)d[2] << 8) | d[3]); + Serial.println(F(" ppb")); +} diff --git a/firmware/dht11_lcd/dht11_lcd.ino b/firmware/dht11_lcd/dht11_lcd.ino index af7cc64..e7c36fe 100644 --- a/firmware/dht11_lcd/dht11_lcd.ino +++ b/firmware/dht11_lcd/dht11_lcd.ino @@ -60,6 +60,16 @@ void baselineSpeichern() { EEPROM.put(BASELINE_ADRESSE, b); } +// CCS811 starten: erst 0x5A (ADDR an GND), dann 0x5B (ADDR an VCC). +// Wird in loop() wiederholt, falls der Sensor nicht antwortet (z. B. weil der ESP +// beim Hochfahren die 3,3 V kurz einbrechen laesst). +void ccsStarten() { + ccsOk = ccs.begin(0x5A) || ccs.begin(0x5B); + if (ccsOk) { + baselineLaden(); + } +} + // Anzeige wechselt zwischen zwei Seiten, da das LCD nur 2 Zeilen hat bool zeigeLuftqualitaet = false; @@ -72,12 +82,6 @@ void setup() { // DHT Sensor starten dht.begin(); - // CCS811 starten: erst 0x5A (ADDR an GND), dann 0x5B (ADDR an VCC) - ccsOk = ccs.begin(0x5A) || ccs.begin(0x5B); - if (ccsOk) { - baselineLaden(); - } - // Kurze Startnachricht lcd.setCursor(0, 0); lcd.print("Sensor startet.."); @@ -95,6 +99,9 @@ void setup() { lcd.print(" "); } + // CCS811 erst nach dem ESP starten, dann ist dessen Einschalt-Stromspitze vorbei + ccsStarten(); + // Der DHT11 braucht nach dem Start einen Moment delay(2000); lcd.clear(); @@ -122,6 +129,9 @@ void loop() { zeigeLuftqualitaet = false; lcd.clear(); + if (!ccsOk) { + ccsStarten(); // neuer Versuch, statt dauerhaft "Fehler" zu zeigen + } if (!ccsOk) { lcd.setCursor(0, 0); lcd.print("CCS811 Fehler!"); diff --git a/firmware/dht11_lcd/esp_wifi.h b/firmware/dht11_lcd/esp_wifi.h index bb6c70e..531edd7 100644 --- a/firmware/dht11_lcd/esp_wifi.h +++ b/firmware/dht11_lcd/esp_wifi.h @@ -1,11 +1,11 @@ // 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). +// an das Backend (POST /api/messwerte, Empfaenger: backend/server.py). #include "wifi_config.h" -// --- ESP-01 Verdrahtung (wie Instructables "Add WiFi to Arduino UNO") --- +// --- ESP-01 Verdrahtung (GEKREUZT, anders als Instructables "Add WiFi to Arduino UNO", siehe README) --- // ESP TX -> Uno RX (Pin 0) \ Hardware-Serial! Das Modul teilt sich die Leitung mit dem USB-Anschluss: // ESP RX -> Uno TX (Pin 1) / Beim Hochladen des Sketches ESP TX/RX kurz abziehen. -// ESP VIN und ENABLE -> 3,3 V, ESP GND -> GND +// ESP VCC und EN -> 3,3 V, ESP GND -> GND (gemeinsame Masse mit dem Uno, auch bei fremder 3,3-V-Quelle) // Serial.print() darf im Sketch NICHT fuer Debugging genutzt werden, die Ausgabe ginge an das ESP. #define espSerial Serial @@ -13,6 +13,11 @@ bool wifiOk = false; uint8_t sendeFehler = 0; const uint8_t MAX_SENDEFEHLER = 3; // danach wird das WLAN neu verbunden +// Ein Verbindungsversuch blockiert 10-30 s. Ohne WLAN deshalb hoechstens einmal pro Minute +// neu verbinden, damit das LCD in der Zwischenzeit normal weiterlaeuft. +const unsigned long WIFI_NEUVERSUCH_MS = 60000UL; +unsigned long letzterWifiVersuch = 0; + // Verwirft alles, was der ESP noch geschickt hat void espLeeren() { while (espSerial.available()) { @@ -66,6 +71,7 @@ uint8_t wifiFehler = 0; bool wifiVerbinden() { wifiOk = false; + letzterWifiVersuch = millis(); if (!espBaudrateFinden()) { wifiFehler = 1; @@ -92,8 +98,10 @@ bool wifiVerbinden() { // 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; + if (!wifiOk) { + if (millis() - letzterWifiVersuch < WIFI_NEUVERSUCH_MS || !wifiVerbinden()) { + return false; + } } char temp[8], feuchte[8]; diff --git a/firmware/dht11_lcd/wifi_config.example.h b/firmware/dht11_lcd/wifi_config.example.h new file mode 100644 index 0000000..ae1d7e1 --- /dev/null +++ b/firmware/dht11_lcd/wifi_config.example.h @@ -0,0 +1,10 @@ +// Vorlage: Kopie als "wifi_config.h" im selben Ordner anlegen und Werte eintragen. +// wifi_config.h steht in .gitignore, damit das WLAN-Passwort nicht ins Repo kommt. + +// Der ESP-01 kann nur 2,4 GHz: beim iPhone-Hotspot "Maximale Kompatibilitaet" einschalten. +#define WLAN_SSID "Name des Hotspots" +#define WLAN_PASSWORT "Passwort" + +// IP des Laptops im Hotspot, auf dem backend/server.py laeuft (zeigt server.py beim Start an) +#define SERVER_IP "172.20.10.2" +#define SERVER_PORT 3000