Files
Bewaesserung/watering
admin 33c07c7b42 neu: Steuerung über MQTT
Behoben: Keine automatische Abschaltuk bei t<2 t jetzt limitiert auf 1-120
Dokumentation angelegt
2026-08-26 12:36:39 +02:00
..
2025-04-19 19:45:16 +02:00
2026-08-26 12:36:39 +02:00
2026-08-26 12:36:39 +02:00
2025-04-19 19:45:16 +02:00
2026-08-26 12:36:39 +02:00
2025-05-13 19:09:10 +02:00
2026-08-26 12:36:39 +02:00

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

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:

  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 (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 06), 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 06 (7 Kanäle = 3 Byte = 24 Bit, davon 21 genutzt):

Funktion Bit-Offset Bits (Kanal 06)
SET-Spule i 06
RESET-Spule i + 8 814
Status-LED i + 16 1622

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 16 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 (16) 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)
25 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 1120, greift als Fallback defaultTimerMins (Gerätekonfiguration) bzw. wird der Wert auf 1120 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 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.