Behoben: Keine automatische Abschaltuk bei t<2 t jetzt limitiert auf 1-120 Dokumentation angelegt
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
- Hardware
- Ventil-Zustandsautomat
- Software-Architektur
- Kommunikationswege
- Automatik-Modi
- Konfiguration & Erstinbetriebnahme
- Boot-Ablauf
- Logging
- Firmware-Updates (OTA)
- Projektstruktur
- Bekannte Einschränkungen / TODO
Systemüberblick
flowchart LR
Browser[Web-Browser<br/>index.html] -- HTTP + WebSocket /ws --> ESP
App[MQTT-Client / Home-Automation] -- MQTT --> Broker[(MQTT-Broker<br/>nas.local)]
Broker <-- MQTT --> ESP
NTPsrv[(NTP-Server<br/>nas.local)] --> ESP
Syslog[(Syslog-Server<br/>NAS, UDP 13377)] <-- UDP --> ESP
Dev[Entwickler-PC] -- ArduinoOTA / espota --> ESP
subgraph ESP["ESP32 Steuerung"]
FW[Firmware<br/>main.cpp / MQTT.cpp]
end
ESP -- SPI, Latch GPIO4 --> SR[3x 74HC595<br/>Schieberegister, 24 Bit]
SR -- Transistor + bistabiles Relais<br/>G5RL-K1-E --> Valves[(Ventile Kanal 1-6<br/>12V motorisierte Kugelhähne)]
ESP -- WS2812, GPIO12 --> Ring[NeoPixel-Ring<br/>12 LEDs, Schaltschrank]
Die Firmware übernimmt drei Rollen gleichzeitig:
- Webserver (
ESPAsyncWebServer) liefert die Bedienoberfläche (data/index.html,data/simple.cssaus SPIFFS) und eine REST-Schnittstelle. - WebSocket-Server (
/ws) für Live-Status/Steuerung der Weboberfläche. - 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 (Rendering), docs/hardware/ventilsteuerung2.sch (Eagle-Schaltplan) und 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.
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 |
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:
{ "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). 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) 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.
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<br/>{"valve":6,"set":"open","timer":30}
B->>ESP: handleMqttMessage(topic, payload)
ESP->>ESP: applyValveJson()<br/>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):
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
flowchart TD
A[Start] --> B[Preferences laden<br/>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<br/>kein Anwendungsbetrieb]
D --> G[ArduinoOTA einrichten]
G --> H[Webserver-Routen registrieren<br/>/, /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<br/>closeValves_tick 0..4]
L --> M[Ventil-Zustandsautomat startet<br/>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 (<hostname>.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 geteiltenAsyncWebServer-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.