# Gartenbewässerung – ESP32 Ventilsteuerung Firmware für eine ESP32-basierte Steuerung motorisierter Gartenventile (Kugelhähne), bedienbar über eine Weboberfläche (WebSocket + REST) und MQTT. Es existieren zwei baugleiche Steuerungen im Einsatz: | Gerät (Hostname) | Standort / Zonen | Belegte Ventilkanäle | |------------------------|--------------------------------------------|-----------------------| | `Gartenwasser_vorn` | Tröge + Garten vorn | Kanal 1 (Tröge), Kanal 6 (Garten vorn) | | `Gartenwasser_hinten` | Hochbeete | Kanal 6 (Hochbeete) | Beide laufen mit derselben Firmware, unterscheiden sich nur in der über den WiFiManager-Captive-Portal gespeicherten Konfiguration (Hostname, MQTT-Topic, `numValves`, …), die in den ESP32-NVS-Preferences abgelegt wird. ## Inhaltsverzeichnis - [Systemüberblick](#systemüberblick) - [Hardware](#hardware) - [Ventil-Zustandsautomat](#ventil-zustandsautomat) - [Software-Architektur](#software-architektur) - [Kommunikationswege](#kommunikationswege) - [Automatik-Modi](#automatik-modi) - [Konfiguration & Erstinbetriebnahme](#konfiguration--erstinbetriebnahme) - [Boot-Ablauf](#boot-ablauf) - [Logging](#logging) - [Firmware-Updates (OTA)](#firmware-updates-ota) - [Projektstruktur](#projektstruktur) - [Bekannte Einschränkungen / TODO](#bekannte-einschränkungen--todo) ## Systemüberblick ```mermaid flowchart LR Browser[Web-Browser
index.html] -- HTTP + WebSocket /ws --> ESP App[MQTT-Client / Home-Automation] -- MQTT --> Broker[(MQTT-Broker
nas.local)] Broker <-- MQTT --> ESP NTPsrv[(NTP-Server
nas.local)] --> ESP Syslog[(Syslog-Server
NAS, UDP 13377)] <-- UDP --> ESP Dev[Entwickler-PC] -- ArduinoOTA / espota --> ESP subgraph ESP["ESP32 Steuerung"] FW[Firmware
main.cpp / MQTT.cpp] end ESP -- SPI, Latch GPIO4 --> SR[3x 74HC595
Schieberegister, 24 Bit] SR -- Transistor + bistabiles Relais
G5RL-K1-E --> Valves[(Ventile Kanal 1-6
12V motorisierte Kugelhähne)] ESP -- WS2812, GPIO12 --> Ring[NeoPixel-Ring
12 LEDs, Schaltschrank] ``` Die Firmware übernimmt drei Rollen gleichzeitig: 1. **Webserver** (`ESPAsyncWebServer`) liefert die Bedienoberfläche (`data/index.html`, `data/simple.css` aus SPIFFS) und eine REST-Schnittstelle. 2. **WebSocket-Server** (`/ws`) für Live-Status/Steuerung der Weboberfläche. 3. **MQTT-Client** (`PubSubClient`) für Steuerung/Status-Publishing aus der Hausautomation. Zusätzlich: `WiFiManager` für WLAN-Konfiguration per Captive Portal, `ArduinoOTA` für Firmware-Updates über das Netzwerk, NTP für die Systemzeit (u. a. für die Bewässerungs-Logs), und ein einfacher UDP-Syslog-Client fürs zentrale Logging. ## Hardware ### Steuerplatine (`ventilsteuerung2.sch`, 2 Blätter) Der vollständige Schaltplan liegt unter [`docs/hardware/ventilsteuerung2.pdf`](docs/hardware/ventilsteuerung2.pdf) (Rendering), [`docs/hardware/ventilsteuerung2.sch`](docs/hardware/ventilsteuerung2.sch) (Eagle-Schaltplan) und [`docs/hardware/ventilsteuerung2.brd`](docs/hardware/ventilsteuerung2.brd) (Eagle-Platinenlayout). Die folgende Beschreibung basiert auf dem Schaltplan und der Firmware. **Sheet 1 – Steuerlogik:** - **MCU:** ESP32 Dev-Board (im Schaltplan auf einem Arduino-Nano-kompatiblen Sockel). Die Firmware ist für `esp32dev` (Espressif32-Plattform, Arduino-Framework) gebaut. - **Ausgangserweiterung:** 2× 74HC595-Schieberegister in Reihe, per Hardware-SPI angesprochen (`SpiShiftRegisterChain`, Latch-Pin GPIO4). Zusammen 24 Bit, wovon 21 genutzt werden. - **7 identische Relais-Treiberkanäle** (Kanal 0–6), je bestehend aus: - 1× Transistor-Treiberstufe für die **SET-Spule** (oberer Block im Schaltplan) - 1× Transistor-Treiberstufe für die **RESET-Spule** (unterer Block im Schaltplan) - 1× **bistabiles Relais Omron G5RL-K1-E** (2-Spulen-Stromstoßrelais, 12V) mit Freilaufdiode (Schottky, BAT85) je Spule zum Schutz der Treibertransistoren - 1× Status-LED je Kanal (direkt am Schieberegister-Ausgang) - 3-poliger Steckverbinder zur Ventilverkabelung - Da das Relais bistabil ist, genügt ein kurzer Steuerimpuls, um die Spulenposition dauerhaft (ohne Haltestrom) zu wechseln — daher `KEEP_V(x)` in der Firmware, das beide Spulen wieder stromlos schaltet, nachdem ein Impuls gesetzt wurde. **Sheet 2 – Spannungsversorgung:** - Eingangsklemme für die 12V-Versorgung mit Verpolungs-/Filterschaltung (Drossel + Eingangskondensatoren). - Schaltregler erzeugt eine **5V-Schiene** (Logik/Relais-Treiber) und eine **3,3V-Schiene** (ESP32) aus der 12V-Eingangsspannung. ### Bit-Belegung des Schieberegisters (`glblData.h`) Kanal-Index `i` läuft von 0–6 (7 Kanäle = 3 Byte = 24 Bit, davon 21 genutzt): | Funktion | Bit-Offset | Bits (Kanal 0–6) | |-------------------|-----------:|-------------------| | SET-Spule | `i` | 0–6 | | RESET-Spule | `i + 8` | 8–14 | | Status-LED | `i + 16` | 16–22 | **Kanal 0 ist kein Gartenventil**, sondern schaltet die gemeinsame 12V-Motorspannung für alle Ventile ein/aus (`SET_V(0)` / `RST_V(0)` / `KEEP_V(0)` in `main.cpp`). Kanäle 1–6 sind die eigentlichen Ventile: Ihr SET/RESET-Impuls wählt die Laufrichtung (auf/zu) des jeweiligen Ventilaktors, bevor die gemeinsame Motorspannung (Kanal 0) für die Dauer der Ventillaufzeit angelegt wird. ``` #define RST_V(x) { RESET-Spule(x)=an; SET-Spule(x)=aus; LED(x)=aus; } #define SET_V(x) { RESET-Spule(x)=aus; SET-Spule(x)=an; LED(x)=an; } #define KEEP_V(x){ SET-Spule(x)=aus; RESET-Spule(x)=aus; } // beide Spulen stromlos ``` `MOTOR_POWER_TIME` = 15 s (als 30 Ticks à 500 ms) ist die Laufzeit, für die die gemeinsame Motorspannung je Öffnungs-/Schließvorgang anliegt. ### NeoPixel-Ring 12× WS2812-LEDs an GPIO12, montiert am Schaltschrank. Zeigt die verbleibende Bewässerungsrestzeit des länger laufenden der beiden aktiven Timer (`glblData.timers[1]`/`timers[6]`) als umlaufenden Ring an (Minuten als blauer Fortschrittsring, Sekunden als grün/rot abklingender "Kometenschweif", Stunden als rote Segmente). ## Ventil-Zustandsautomat Jedes Ventil `i` (1–6) durchläuft beim Öffnen/Schließen denselben zweistufigen Ablauf: zuerst Richtungsauswahl (Kanal `i`), danach gemeinsame Motorspannung (Kanal 0) für 15 s. `valveState()` in `main.cpp` wird alle 500 ms über einen `Ticker` aufgerufen. ```mermaid stateDiagram-v2 [*] --> VALVE_CLOSED VALVE_CLOSED --> VALVE_OPENING_COILPWR: setValveState[i]=OPEN\nSET_V(i) VALVE_OPENING_COILPWR --> VALVE_OPENING_POWER: KEEP_V(i) VALVE_OPENING_POWER --> VALVE_OPENING_POWER_COILPWR: SET_V(0) VALVE_OPENING_POWER_COILPWR --> VALVE_OPEN_MOTORIZED: KEEP_V(0)\nv_powered=0 VALVE_OPEN_MOTORIZED --> VALVE_OPEN_COILPWR: 15s vergangen\nRST_V(0) VALVE_OPEN_COILPWR --> VALVE_OPEN: KEEP_V(0) VALVE_OPEN --> VALVE_CLOSING_COILPWR: setValveState[i]=CLOSE\nRST_V(i) VALVE_CLOSING_COILPWR --> VALVE_CLOSING_POWER: KEEP_V(i) VALVE_CLOSING_POWER --> VALVE_CLOSING_POWER_COILPWR: SET_V(0) VALVE_CLOSING_POWER_COILPWR --> VALVE_CLOSED_MOTORIZED: KEEP_V(0)\nv_powered=0 VALVE_CLOSED_MOTORIZED --> VALVE_CLOSED_COILPWR: 15s vergangen\nRST_V(0) VALVE_CLOSED_COILPWR --> VALVE_CLOSED: KEEP_V(0) ``` Nebenbei protokolliert die Firmware pro Ventil die letzten 5 Bewässerungsvorgänge (Startzeit + Dauer in 2er-Schritten, da der Timer-Tick alle 500 ms läuft) als Ringpuffer (`wateringLog`, `wateringLogDuration`, `logIndex`) für die Anzeige im Web-UI. ## Software-Architektur | Datei | Verantwortung | |--------------------|----------------| | `main.cpp` | Setup/Loop, Ventil-Zustandsautomat (`valveState`), Relais-Initialisierung (`init_relays`/`closeValves_tick`), Automatik-Modi (`autoMode_tick`), NeoPixel-Ring (`updateTimerRing`) | | `MQTT.h` / `.cpp` | Sammelklasse `MQTT` (erbt von `PubSubClient`, enthält zusätzlich einen `PubSubClient`-Member `psclient`) — bündelt WLAN (`WiFiManager`), Webserver (`ESPAsyncWebServer`), WebSocket, MQTT, OTA, NTP und UDP-Syslog | | `glblData.h` / `.cpp` | Globaler Zustand (`dataset_t glblData`): Soll-/Ist-Zustand je Ventil, Timer, Automatikmodus, Watering-Log | ```mermaid classDiagram class MQTT { +begin() +loop() +publish(msg) +publish_sub(subtopic, msg) -handleMqttMessage(topic, payload, length) -handleWebSocket(...) -handleWebSocketMessage(...) -applyValveJson(doc) bool -reconnect() -saveConfigToFlash() +psclient : PubSubClient +wm : WiFiManager +prefs : Preferences } class dataset_t { +setValveState[7] : bool +valveState[7] : valvestate_t +timers[7] : uint32 +pauseTimers[7] : int32 +autoMode : automode_t +statusChange : bool +timerChange : bool +wateringLog[7][5] } MQTT ..> dataset_t : liest/schreibt glblData (extern) main ..> MQTT : connections.begin()/loop() main ..> dataset_t : valveState(), autoMode_tick() ``` `glblData` ist eine globale, von `main.cpp` und `MQTT.cpp` gemeinsam genutzte Datenstruktur — der eigentliche "Shared State" zwischen dem Ventil-Zustandsautomaten (der die Hardware treibt) und den Kommunikationsschnittstellen (die Sollwerte setzen und Ist-Werte auslesen). ## Kommunikationswege ### WebSocket (`/ws`) Beim Verbindungsaufbau sendet der Server sofort den aktuellen Status (`makeStatusJSON`) und die Timer (`makeTimerJSON`). Änderungen werden alle 1 s geprüft und bei Bedarf per Broadcast an alle Clients gesendet (`glblData.statusChange` / `glblData.timerChange`). Eingehende Nachrichten (JSON) werden von `handleWebSocketMessage` verarbeitet: ```json { "valve": 6, "set": "open", "timer": 30 } { "valve": 6, "set": "close" } { "auto": "Hoch" } ``` Enthält die Nachricht kein gültiges `valve`-Feld, wird stattdessen `auto` ausgewertet (`"Hoch"`/`"Vorn"` → `AUTO_HOCH`, `"Trog"` → `AUTO_TROG`, alles andere → `AUTO_CLOSE`). ### Ventilnummern (`valve` / `N`) Der numerische Ventilkanal in WebSocket-, REST- und MQTT-`/set`-Befehlen entspricht der physischen Kanalnummer am Schieberegister (siehe [Bit-Belegung](#hardware)). Aktuell sind pro Gerät nur folgende Kanäle tatsächlich verkabelt: | Kanal-Nr. | Zone | `Gartenwasser_vorn` | `Gartenwasser_hinten` | |---|---|---|---| | 1 | Tröge | verkabelt | unbenutzt | | 6 | Garten vorn / Hochbeete | verkabelt (Garten vorn) | verkabelt (Hochbeete) | | 2–5 | — | unbenutzt (Hardware-Reserve) | unbenutzt (Hardware-Reserve) | Kanal 0 ist kein ansteuerbares Ventil, sondern die gemeinsame Motorspannung (siehe [Hardware](#hardware)) und darf nicht als `valve`-Wert verwendet werden. ### REST | Endpunkt | Beschreibung | |-------------------------|--------------| | `GET /` | Liefert `index.html` (mit Platzhaltern `%HOSTNAME%`, `%NAME_VALVE1%`, `%NAME_VALVE2%`) | | `GET /simple.css` | Stylesheet | | `GET /data` | Aktueller Status als JSON (wie `makeStatusJSON`) | | `GET /setValve?valve=N&set=open\|close&timer=M` | Ventil N öffnen/schließen, optional mit Laufzeit in Minuten | ### MQTT Basis-Topic ist konfigurierbar (`mqtt_topic`, z. B. `Gartenwasser/vorn`). **Publish (alle 1s bei Änderung, bzw. beim Verbindungsaufbau):** | Topic (relativ zu `mqtt_topic`) | Inhalt | |---|---| | `/status` | JSON mit Ist-/Soll-Ventilzuständen | | `/hostname`, `/IP`, `/numValves`, `/defaultTimerMins`, `/NTP-Server` | Gerätestatus | | `/Timers` | JSON mit Restlaufzeiten je Ventil + Automatikmodus | **Subscribe:** | Topic | Payload | Wirkung | |---|---|---| | `/auto` | `"Hoch"` / `"Vorn"` / `"Trog"` / sonst | setzt `autoMode` (`AUTO_HOCH` / `AUTO_TROG` / `AUTO_CLOSE`), case-insensitive | | `/set` | `{"valve":N,"set":"open"\|"close","timer":M}` | steuert ein einzelnes Ventil, analog zur WebSocket-/REST-API | `timer` (Minuten) ist bei `"set":"open"` optional: Fehlt das Feld oder liegt der Wert außerhalb 1–120, greift als Fallback `defaultTimerMins` (Gerätekonfiguration) bzw. wird der Wert auf 1–120 geklemmt — ein Ventil wird also nie ohne Restlaufzeit geöffnet (kein unbegrenztes Offenbleiben). Dieselbe Absicherung gilt auch für den REST-Endpunkt `/setValve`. ```mermaid sequenceDiagram participant U as MQTT-Client (Hausautomation) participant B as MQTT-Broker (nas.local) participant ESP as ESP32 Firmware participant HW as Ventil-Hardware U->>B: publish Gartenwasser/vorn/set
{"valve":6,"set":"open","timer":30} B->>ESP: handleMqttMessage(topic, payload) ESP->>ESP: applyValveJson()
setValveState[6]=OPEN, timers[6]=1800 loop alle 500ms ESP->>HW: Zustandsautomat treibt Relais end ESP-->>B: publish .../status, .../Timers ESP-->>U: WebSocket-Broadcast an Weboberfläche ``` ## Automatik-Modi Zwei vordefinierte Bewässerungsprogramme, gesteuert über `glblData.autoMode` und einen minütlichen `Ticker` (`autoMode_tick`): ```mermaid sequenceDiagram participant T as autoModeTicker (60s-Takt) participant V6 as Ventil 6 (Hoch/Vorn) participant V1 as Ventil 1 (Tröge) Note over T,V6: AUTO_HOCH T->>V6: t=0 min: OPEN, 30 min T->>V6: t=30 min: CLOSE, Modus beendet Note over T,V1: AUTO_TROG T->>V1: t=0 min: OPEN, 5 min T->>V1: t=5 min: CLOSE, Pause 10 min T->>V1: t=15 min: OPEN, 3 min T->>V1: t=18 min: CLOSE, Modus beendet ``` `AUTO_TROG` fährt also einen Quell-/Einsickerzyklus: kurz befüllen, ausgiebig ziehen lassen, kurz nachlegen. ## Konfiguration & Erstinbetriebnahme Beim ersten Start (bzw. wenn kein WLAN erreichbar ist) öffnet `WiFiManager` einen Access-Point **`AutoConnectAP`** mit Konfigurationsportal. Dort werden neben den WLAN-Zugangsdaten folgende Parameter abgefragt und in den ESP32-NVS-Preferences (Namespace `settings`) gespeichert: | Parameter (Portal) | Preferences-Key | Bedeutung | Default | |---|---|---|---| | Hostname | `HOST` | Gerätename (WLAN, mDNS, OTA) | `Gartenwasser_vorn` | | mqtt server | `MQTT_SERVER` | MQTT-Broker-Adresse | `nas.local` | | mqtt port | `MQTT_PORT` | MQTT-Broker-Port | `1883` | | topic | `MQTT_TOPIC` | Basis-MQTT-Topic | `Gartenwasser/vorn` | | Default ON Time (min) | `DEF_TIME_MIN` | Standard-Bewässerungsdauer | `60` | | Available Valves | `NUM_VALVES` | Anzahl im Web-UI angezeigter Zonen (1 oder 2) | `1` | | NTP Server | `NTP_SERVER` | Zeitserver | `nas.local` | `numValves` steuert **nur die Web-UI-Beschriftung/Sichtbarkeit** ("Hochbeete" bei 1 Zone, "Garten Vorn"/"Tröge" bei 2 Zonen) — die Hardware unterstützt unabhängig davon bis zu 6 Ventilkanäle. Nach dem Speichern trennt die Firmware die MQTT-Verbindung, um die neuen Einstellungen zu übernehmen (`psclient.disconnect()`); WLAN-Hostname, mDNS und OTA-Hostname werden aktuell nur beim Boot gesetzt — eine Hostname-Änderung wird daher erst nach einem Neustart vollständig wirksam. Solange das Gerät verbunden ist, bleibt das Konfigurationsportal zusätzlich unter Port **8080** erreichbar (die eigentliche Anwendung belegt Port 80). ## Boot-Ablauf ```mermaid flowchart TD A[Start] --> B[Preferences laden
SPIFFS mounten] B --> C[WiFiManager: autoConnect] C -->|verbunden| D[NTP-Zeit synchronisieren] C -->|nicht verbunden, 1. Versuch| E[5s warten, erneut versuchen] E -->|verbunden| D E -->|weiterhin offline| F[Nur Config-Portal starten
kein Anwendungsbetrieb] D --> G[ArduinoOTA einrichten] G --> H[Webserver-Routen registrieren
/, /simple.css, /data, /setValve] H --> I[mDNS starten: hostname.local] I --> J[WebSocket-Handler registrieren] J --> K[MQTT verbinden + Topics abonnieren] K --> L[Relais initialisieren
closeValves_tick 0..4] L --> M[Ventil-Zustandsautomat startet
500ms Ticker] ``` Die Relais-Initialisierung (`init_relays`/`closeValves_tick`) fährt beim Start einmalig alle Ausgänge in einen definierten (stromlosen) Zustand, bevor der reguläre Zustandsautomat übernimmt. ## Logging `MQTT::remoteLog*` sendet Log-Nachrichten im BSD-Syslog-ähnlichen Format per UDP an einen zentralen Log-Server im lokalen Netz (aktuell `192.168.179.174:13377`, bereitgestellt vom NAS). Genutzt für Ventilschaltungen, Automatik-Moduswechsel und Verbindungsstatus. Es gibt aktuell keine Bestätigung/Fehlerbehandlung, falls der Log-Server nicht erreichbar ist (Fire-and-forget per UDP). ## Firmware-Updates (OTA) `upload_protocol = espota` in `platformio.ini` lädt neue Firmware über WLAN auf das per `upload_port` (`.local`) adressierte Gerät hoch — kein serieller Zugriff nötig, sofern das Gerät bereits im WLAN und per mDNS erreichbar ist. Beide Geräte müssen daher beim Flashen über den jeweils passenden Hostnamen adressiert werden (`upload_port` in `platformio.ini` anpassen, aktuell `gartenwasser_hinten.local`). ## Projektstruktur ``` src/ main.cpp Setup/Loop, Ventil-Zustandsautomat, Automatik-Modi, LED-Ring MQTT.h / .cpp WLAN/Web/WebSocket/MQTT/OTA-Sammelklasse glblData.h / .cpp Globaler Anwendungszustand data/ index.html Weboberfläche (SPIFFS) simple.css Stylesheet (SPIFFS) lib/ SpiShiftRegisterChain Ansteuerung der 74HC595-Kette PubSubClient MQTT-Client WiFiManager WLAN-Provisioning / Captive Portal ESPAsyncWebServer, AsyncTCP Webserver + WebSocket ArduinoJson JSON (de)serialisierung Adafruit_NeoPixel WS2812-Ansteuerung (Statusring) platformio.ini Board esp32dev, Framework Arduino, espressif32@6.10.0 ``` ## Bekannte Einschränkungen / TODO - Hostname-/mDNS-/OTA-Namensänderungen über das Konfigurationsportal werden erst nach einem manuellen Neustart vollständig wirksam (werden nur in `begin()` gesetzt). - Laut Code-Kommentar in `MQTT::begin()` ist die Art, wie der Webserver bei Bedarf neu aufgesetzt wird, "not the safest way" — mögliche Abstürze durch bereits registrierte Callbacks auf dem geteilten `AsyncWebServer`-Pointer sind nicht ausgeschlossen. - Der Syslog-Ziel-Host (`192.168.179.174:13377`) ist aktuell fest im Code hinterlegt statt konfigurierbar. - Die Weboberfläche (`index.html`) geht fest von Ventil 1 ("Tröge") und Ventil 6 ("Garten vorn"/"Hochbeete") aus; eine allgemeinere Zuordnung (z. B. für mehr als 2 sichtbare Zonen) ist nicht vorgesehen.