neu: Steuerung über MQTT

Behoben: Keine automatische Abschaltuk bei t<2 t jetzt limitiert auf 1-120
Dokumentation angelegt
This commit is contained in:
2026-08-26 12:36:39 +02:00
parent d9f8f56c20
commit 33c07c7b42
9 changed files with 29214 additions and 12 deletions
+341
View File
@@ -0,0 +1,341 @@
# 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<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`](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 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.
```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) |
| 25 | — | 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 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`.
```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<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`):
```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<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.