Unity-Zwilling an server.py angebunden: Steuerbefehle empfangen, Szenario-Simulator

- server.py: POST/GET /api/steuerung nimmt die Entscheidung des Unity-Zwillings an
- simulator.py: --szenario normal|hitze|stickig|kalt|demo fuer Vorfuehrungen ohne Arduino
- README/backend-README: Datenweg Arduino -> ESP -> server.py -> Unity dokumentiert
claude/arduino-unity-simulation-values-90d508
dk 2026-11-03 11:04:11 +01:00
parent cce682def8
commit 027444b0c3
4 changed files with 112 additions and 19 deletions

View File

@ -10,8 +10,12 @@ Sensoren (Arduino UNO) ➔ WiFi/Bluetooth ➔ Backend (Aggregation & Regeln) ➔
Aktuell umgesetzt: Aktuell umgesetzt:
``` ```
DHT11 + CCS811 ─▶ Arduino Uno ─Serial (Pin 0/1)─▶ ESP-01 ─WLAN (Handy-Hotspot)─▶ Laptop: backend/server.py :3000 ◀── Browser DHT11 + CCS811 ─▶ Arduino Uno ─Serial (Pin 0/1)─▶ ESP-01 ─WLAN (Handy-Hotspot)─▶ Laptop: backend/server.py :3000 ◀── Browser
▲
└──▶ Unity-Zwilling (SmartLectureRoom)
``` ```
Der Unity-Zwilling (eigenes Repo `SmartLectureRoom`) holt im Modus **Verbindung → Backend** jede Sekunde `GET /api/messwerte` ab. Temperatur und eCO₂ vom Arduino ersetzen dann die simulierten Werte, das passende Szenario (Hitze, stickige Luft, Wintermorgen …) wird erkannt und Fenster, Lüftung, Heizung, Jalousie und Licht werden automatisch angesteuert. Die Entscheidung meldet Unity per `POST /api/steuerung` zurück. Details: README im Unity-Projekt, Abschnitt „Arduino anbinden“.
## Repo-Struktur ## Repo-Struktur
| Ordner | Inhalt | | Ordner | Inhalt |
|---|---| |---|---|
@ -21,7 +25,7 @@ DHT11 + CCS811 ─▶ Arduino Uno ─Serial (Pin 0/1)─▶ ESP-01 ─WLAN (Hand
| `frontend/` | Responsive Webinterface (Live-Werte, Präferenz-Slider) und 3D-Raum | | `frontend/` | Responsive Webinterface (Live-Werte, Präferenz-Slider) und 3D-Raum |
## Status ## Status
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)). 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. Der Unity-Zwilling ist an den Server angebunden (mit `simulator.py` vorbereitet, mit dem echten Arduino noch nicht getestet). Aggregation/Präferenzen im Backend fehlen noch (siehe [docs/offene-punkte.md](docs/offene-punkte.md)).
## Komponenten und ihre Aufgabe ## Komponenten und ihre Aufgabe
@ -44,10 +48,10 @@ Live-Messwerte laufen Ende-zu-Ende: Der Arduino misst Temperatur, Luftfeuchte, e
| `firmware/dht11_lcd/dht11_lcd.ino` | **Hauptprogramm** auf dem Uno: Sensoren lesen, LCD anzeigen, Senden anstoßen. Speichert außerdem stündlich die Kalibrierung („Baseline“) des CCS811 im EEPROM, damit die Werte nach einem Neustart schneller stimmen. | | `firmware/dht11_lcd/dht11_lcd.ino` | **Hauptprogramm** auf dem Uno: Sensoren lesen, LCD anzeigen, Senden anstoßen. Speichert außerdem stündlich die Kalibrierung („Baseline“) des CCS811 im EEPROM, damit die Werte nach einem Neustart schneller stimmen. |
| `firmware/dht11_lcd/esp_wifi.h` | Steuert den ESP: Baudrate finden, mit dem Hotspot verbinden, Messwerte als **HTTP-POST** an den Laptop schicken. | | `firmware/dht11_lcd/esp_wifi.h` | Steuert den ESP: Baudrate finden, mit dem Hotspot verbinden, Messwerte als **HTTP-POST** an den Laptop schicken. |
| `firmware/dht11_lcd/wifi_config.h` | Zugangsdaten (Hotspot-Name, Passwort, Laptop-IP). Nicht im Repo, Vorlage: `wifi_config.example.h`. | | `firmware/dht11_lcd/wifi_config.h` | Zugangsdaten (Hotspot-Name, Passwort, Laptop-IP). Nicht im Repo, Vorlage: `wifi_config.example.h`. |
| `backend/server.py` | **Server** auf dem Laptop (Port 3000): nimmt die Messwerte vom ESP an, merkt sich die letzten 100 und liefert die Website aus. | | `backend/server.py` | **Server** auf dem Laptop (Port 3000): nimmt die Messwerte vom ESP an, merkt sich die letzten 100, liefert die Website aus und stellt die Werte dem Unity-Zwilling bereit (dessen Steuerbefehle kommen per `POST /api/steuerung` zurück). |
| `backend/start.bat` | Startet den Server per Doppelklick und prüft, ob die Laptop-IP zu `wifi_config.h` passt. | | `backend/start.bat` | Startet den Server per Doppelklick und prüft, ob die Laptop-IP zu `wifi_config.h` passt. |
| `backend/firewall_freigeben.bat` | Gibt Port 3000 in der Windows-Firewall frei, damit der ESP den Laptop erreicht. | | `backend/firewall_freigeben.bat` | Gibt Port 3000 in der Windows-Firewall frei, damit der ESP den Laptop erreicht. |
| `backend/simulator.py` | Schickt erfundene Werte – zum Testen ohne Arduino. | | `backend/simulator.py` | Schickt erfundene Werte – zum Testen ohne Arduino. Mit `--szenario hitze/stickig/kalt/normal/demo` gezielt Szenarien für den Unity-Zwilling auslösen. |
| `frontend/` | **Website**: fragt alle 2 s beim Server die neuesten Werte ab und zeigt Kacheln, Verlaufsdiagramm und CO₂-Alarm ab 1000 ppm. | | `frontend/` | **Website**: fragt alle 2 s beim Server die neuesten Werte ab und zeigt Kacheln, Verlaufsdiagramm und CO₂-Alarm ab 1000 ppm. |
| `firmware/esp_test`, `firmware/ccs811_test`, `firmware/ccs811_diagnose` | Testprogramme zur Fehlersuche, im normalen Betrieb nicht nötig. | | `firmware/esp_test`, `firmware/ccs811_test`, `firmware/ccs811_diagnose` | Testprogramme zur Fehlersuche, im normalen Betrieb nicht nötig. |

View File

@ -4,6 +4,8 @@ Minimaler Server mit Python-Bordmitteln (Python ≥ 3.8, kein `pip install`, lä
``` ```
ESP8266 ──POST /api/messwerte──▶ backend/server.py ◀──GET /api/messwerte── Browser (frontend/) ESP8266 ──POST /api/messwerte──▶ backend/server.py ◀──GET /api/messwerte── Browser (frontend/)
▲ ◀──GET /api/messwerte── Unity-Zwilling
└───────────POST /api/steuerung─── Unity-Zwilling
``` ```
## Starten ## Starten
@ -11,7 +13,9 @@ Doppelklick auf `start.bat` oder:
```bash ```bash
python backend/server.py # Port 3000 (oder: python backend/server.py 8080) python backend/server.py # Port 3000 (oder: python backend/server.py 8080)
python backend/simulator.py # optional: Fake-Sensordaten ohne Hardware python backend/simulator.py # optional: Fake-Sensordaten ohne Hardware
python backend/simulator.py --szenario demo # Szenarien nacheinander: normal, stickig, normal, hitze, kalt
``` ```
Szenarien des Simulators: `wechsel` (Standard, Werte schwanken), `normal`, `hitze` (→ ca. 30 °C), `stickig` (→ ca. 1600 ppm), `kalt` (→ ca. 17 °C), `demo`. Die Werte ändern sich langsam wie in echt, damit man im Unity-Zwilling sieht, wann die Steuerung umschaltet.
Frontend: http://localhost:3000, im Hotspot von anderen Geräten: `http://<Laptop-IP>:3000` Frontend: http://localhost:3000, im Hotspot von anderen Geräten: `http://<Laptop-IP>: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). 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).
@ -37,6 +41,22 @@ Alle Felder sind Zahlen; ein Feld darf fehlen oder `null` sein (z. B. CCS811 noc
`GET /api/messwerte` liefert `{"aktuell": {...} | null, "verlauf": [...]}`. `GET /api/messwerte` liefert `{"aktuell": {...} | null, "verlauf": [...]}`.
### Steuerbefehle vom Unity-Zwilling
`POST /api/steuerung` (sendet Unity im Modus „Backend“, sobald sich etwas ändert, sonst alle 10 s):
```json
{"window": "open", "blind": 1.0, "heat": 0.0, "vent": 0.0, "light": 0.4, "beamer": 0, "szenario": "Hitze"}
```
| Feld | Bedeutung |
|---|---|
| `window` | `closed`, `tilted` oder `open` |
| `blind`, `heat`, `vent`, `light` | Sollwert 0 … 1 (Jalousie unten, Heizung, Lüftungsanlage, Licht) |
| `beamer` | 0/1 |
| `szenario` | vom Zwilling erkanntes Szenario |
Der Server merkt sich nur den letzten Stand (`GET /api/steuerung` → `{"aktuell": {...} | null}`) und gibt ihn im Fenster aus. Echte Aktoren gibt es noch nicht.
Test ohne Hardware (PowerShell): Test ohne Hardware (PowerShell):
```powershell ```powershell
Invoke-RestMethod -Method Post -Uri http://localhost:3000/api/messwerte -ContentType "application/json" ` Invoke-RestMethod -Method Post -Uri http://localhost:3000/api/messwerte -ContentType "application/json" `

View File

@ -1,4 +1,5 @@
# Minimaler Empfangs-Server: ESP8266 -> POST /api/messwerte -> Website (GET /api/messwerte) # Minimaler Empfangs-Server: ESP8266 -> POST /api/messwerte -> Website/Unity-Zwilling (GET /api/messwerte)
# Der Unity-Zwilling meldet seine Steuerentscheidung per POST /api/steuerung zurueck (GET liefert sie).
# Nur Python-Bordmittel, kein pip install noetig. Start: python server.py [Port] (oder start.bat) # Nur Python-Bordmittel, kein pip install noetig. Start: python server.py [Port] (oder start.bat)
import json import json
import math import math
@ -22,6 +23,11 @@ FELDER = ["temperatur", "luftfeuchte", "eco2", "tvoc"]
verlauf = deque(maxlen=MAX_VERLAUF) # { zeit: ISO-String, temperatur, luftfeuchte, eco2, tvoc } verlauf = deque(maxlen=MAX_VERLAUF) # { zeit: ISO-String, temperatur, luftfeuchte, eco2, tvoc }
# Steuerbefehle des Unity-Zwillings, z. B. {"window":"open","blind":1.0,"heat":0,"vent":0,"light":0.4,"beamer":0,"szenario":"Hitze"}
STEUER_TEXTFELDER = {"window", "szenario"}
STEUER_ZAHLFELDER = {"blind", "heat", "vent", "light", "beamer"}
steuerung = None
MIME = { MIME = {
".html": "text/html; charset=utf-8", ".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8", ".js": "text/javascript; charset=utf-8",
@ -43,19 +49,31 @@ class Handler(BaseHTTPRequestHandler):
def pfad(self): def pfad(self):
return self.path.split("?", 1)[0] return self.path.split("?", 1)[0]
def do_POST(self): def lies_json_objekt(self):
if self.pfad() != "/api/messwerte": """Liest den Body als JSON-Objekt. Bei Fehlern wird die Antwort gesendet und None geliefert."""
return self.sende_json(405, {"fehler": "Methode nicht erlaubt"})
laenge = int(self.headers.get("Content-Length") or 0) laenge = int(self.headers.get("Content-Length") or 0)
if laenge > MAX_BODY_BYTES: if laenge > MAX_BODY_BYTES:
return self.sende_json(413, {"fehler": "Anfrage zu gross"}) self.sende_json(413, {"fehler": "Anfrage zu gross"})
return None
try: try:
daten = json.loads(self.rfile.read(laenge)) daten = json.loads(self.rfile.read(laenge))
except (ValueError, UnicodeDecodeError): except (ValueError, UnicodeDecodeError):
return self.sende_json(400, {"fehler": "Ungueltiges JSON"}) self.sende_json(400, {"fehler": "Ungueltiges JSON"})
return None
if not isinstance(daten, dict): if not isinstance(daten, dict):
return self.sende_json(400, {"fehler": "JSON-Objekt erwartet"}) self.sende_json(400, {"fehler": "JSON-Objekt erwartet"})
return None
return daten
def do_POST(self):
if self.pfad() == "/api/steuerung":
return self.steuerung_empfangen()
if self.pfad() != "/api/messwerte":
return self.sende_json(405, {"fehler": "Methode nicht erlaubt"})
daten = self.lies_json_objekt()
if daten is None:
return
eintrag = {"zeit": datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")} eintrag = {"zeit": datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")}
for feld in FELDER: for feld in FELDER:
@ -72,10 +90,27 @@ class Handler(BaseHTTPRequestHandler):
print(datetime.now().strftime("%H:%M:%S"), self.client_address[0], json.dumps(eintrag), flush=True) print(datetime.now().strftime("%H:%M:%S"), self.client_address[0], json.dumps(eintrag), flush=True)
self.sende_json(200, {"ok": True}) self.sende_json(200, {"ok": True})
def steuerung_empfangen(self):
global steuerung
daten = self.lies_json_objekt()
if daten is None:
return
eintrag = {"zeit": datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")}
for feld, wert in daten.items():
if feld in STEUER_TEXTFELDER and isinstance(wert, str):
eintrag[feld] = wert[:40]
elif feld in STEUER_ZAHLFELDER and isinstance(wert, (int, float)) and not isinstance(wert, bool) and math.isfinite(wert):
eintrag[feld] = wert
steuerung = eintrag
print(datetime.now().strftime("%H:%M:%S"), "Unity-Steuerung", json.dumps(eintrag, ensure_ascii=False), flush=True)
self.sende_json(200, {"ok": True})
def do_GET(self): def do_GET(self):
pfad = self.pfad() pfad = self.pfad()
if pfad == "/api/messwerte": if pfad == "/api/messwerte":
return self.sende_json(200, {"aktuell": verlauf[-1] if verlauf else None, "verlauf": list(verlauf)}) return self.sende_json(200, {"aktuell": verlauf[-1] if verlauf else None, "verlauf": list(verlauf)})
if pfad == "/api/steuerung":
return self.sende_json(200, {"aktuell": steuerung})
datei = (FRONTEND_DIR / ("index.html" if pfad == "/" else pfad.lstrip("/"))).resolve() datei = (FRONTEND_DIR / ("index.html" if pfad == "/" else pfad.lstrip("/"))).resolve()
if FRONTEND_DIR not in datei.parents: if FRONTEND_DIR not in datei.parents:

View File

@ -1,27 +1,61 @@
# Simuliert den ESP8266: schickt alle 2 s Fantasiewerte an den Server (Entwicklung ohne Hardware). # 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 # Start: python simulator.py [Server-URL] [--szenario NAME] z. B. http://localhost:3000 --szenario hitze
#
# Szenarien (fuer die Vorfuehrung mit dem Unity-Zwilling, Modus "Backend"):
# wechsel (Standard) Temperatur und CO2 schwanken, CO2 ueberschreitet zeitweise 1000 ppm
# normal angenehmes Raumklima (22 °C, ca. 650 ppm)
# hitze Temperatur steigt langsam auf ca. 30 °C -> Unity oeffnet die Fenster
# stickig CO2 steigt langsam auf ca. 1600 ppm -> Unity lueftet (Fenster oder Lueftungsanlage)
# kalt Temperatur faellt auf ca. 17 °C -> Unity heizt
# demo laeuft nacheinander normal -> stickig -> normal -> hitze -> kalt durch (je ca. 1 min)
import argparse
import json import json
import math import math
import sys
import time import time
import urllib.request import urllib.request
basis = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:3000" parser = argparse.ArgumentParser(description="Fake-Messwerte wie vom ESP8266")
parser.add_argument("basis", nargs="?", default="http://localhost:3000", help="Server-URL")
parser.add_argument("--szenario", default="wechsel", choices=["wechsel", "normal", "hitze", "stickig", "kalt", "demo"])
args = parser.parse_args()
ZIELE = { # Zielwerte (Temperatur °C, eCO2 ppm), auf die sich die Werte langsam zubewegen
"normal": (22.0, 650),
"hitze": (30.0, 750),
"stickig": (23.0, 1600),
"kalt": (17.0, 600),
}
DEMO_ABLAUF = ["normal", "stickig", "normal", "hitze", "kalt"]
DEMO_SCHRITTE = 30 # 30 x 2 s = 1 min je Abschnitt
temperatur, eco2 = 22.0, 650.0
schritt = 0 schritt = 0
while True: while True:
schritt += 1 schritt += 1
if args.szenario == "wechsel":
temperatur = 22 + 2 * math.sin(schritt / 20)
eco2 = 800 + 400 * math.sin(schritt / 25) # ueberschreitet zeitweise 1000 ppm
aktiv = "wechsel"
else:
aktiv = args.szenario
if aktiv == "demo":
aktiv = DEMO_ABLAUF[(schritt // DEMO_SCHRITTE) % len(DEMO_ABLAUF)]
ziel_t, ziel_co2 = ZIELE[aktiv]
temperatur += max(-0.4, min(0.4, ziel_t - temperatur))
eco2 += max(-60.0, min(60.0, ziel_co2 - eco2))
messwert = { messwert = {
"temperatur": round(22 + 2 * math.sin(schritt / 20), 1), "temperatur": round(temperatur + 0.1 * math.sin(schritt), 1),
"luftfeuchte": round(45 + 5 * math.sin(schritt / 15), 1), "luftfeuchte": round(45 + 5 * math.sin(schritt / 15), 1),
"eco2": round(800 + 400 * math.sin(schritt / 25)), # ueberschreitet zeitweise 1000 ppm "eco2": round(eco2 + 10 * math.sin(schritt / 3)),
"tvoc": round(70 + 30 * math.sin(schritt / 10)), "tvoc": round(70 + 30 * math.sin(schritt / 10) + max(0.0, eco2 - 800) / 10),
} }
anfrage = urllib.request.Request(basis + "/api/messwerte", data=json.dumps(messwert).encode(), anfrage = urllib.request.Request(args.basis + "/api/messwerte", data=json.dumps(messwert).encode(),
headers={"Content-Type": "application/json"}) headers={"Content-Type": "application/json"})
try: try:
urllib.request.urlopen(anfrage, timeout=3).read() urllib.request.urlopen(anfrage, timeout=3).read()
print("gesendet", json.dumps(messwert)) print(f"gesendet [{aktiv}]", json.dumps(messwert))
except OSError as fehler: except OSError as fehler:
print("Server nicht erreichbar:", fehler) print("Server nicht erreichbar:", fehler)
time.sleep(2) time.sleep(2)