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.
+4
View File
@@ -17,6 +17,8 @@
--ok: #279c1c; --ok: #279c1c;
--warn: #f1d323; --warn: #f1d323;
--fail: #d12e2e; --fail: #d12e2e;
--auto-hover: #b400eb;
--auto: #e89cff;
--accent-hover: #1266e2; --accent-hover: #1266e2;
--accent-text: var(--bg); --accent-text: var(--bg);
--code: #d81b60; --code: #d81b60;
@@ -38,6 +40,8 @@
--warn: #ddd125; --warn: #ddd125;
--fail: #ce3030; --fail: #ce3030;
--accent-hover: #1b5a2f; --accent-hover: #1b5a2f;
--auto-hover: #cd28ff;
--auto: #5e1075;
--accent-text: var(--bg); --accent-text: var(--bg);
--code: #f06292; --code: #f06292;
--preformatted: #ccc; --preformatted: #ccc;
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
+124 -8
View File
@@ -25,9 +25,45 @@ WiFiManagerParameter custom_ntpServer("ntpServer", "NTP Server", "", 40);
MQTT::MQTT(){ MQTT::MQTT(){
} }
bool MQTT::applyValveJson(JsonDocument &doc){
int valve = doc["valve"];
if (valve > 0 && valve < NUM_VALVES) {
String setState = doc["set"];
if (setState == "open") {
glblData.setValveState[valve] = OPEN;
uint32_t time = doc["timer"] | defTime_min; // fällt auf den konfigurierten Standardwert zurück, wenn kein/kein gültiger Timer angegeben wurde
if(time < 1){
time = 1;
}else if(time > 120){
time = 120;
}
glblData.timers[valve] = time * 60;
remoteLogf("Valve %d opened for %d mins", valve, glblData.timers[valve] / 60);
} else if (setState == "close") {
glblData.setValveState[valve] = CLOSE;
}
return true;
}
return false;
}
void MQTT::handleWebSocketMessage(void *arg, uint8_t *data, size_t len){ void MQTT::handleWebSocketMessage(void *arg, uint8_t *data, size_t len){
data[len] = 0; //ensure termination! data[len] = 0; //ensure termination!
Serial.printf("WebSocket Msg '%s'\n", data); JsonDocument doc;
DeserializationError error = deserializeJson(doc, (const char*)data);
if (error) {
Serial.printf("deserializeJson() failed: %s\n", error.c_str());
return;
}
if(!applyValveJson(doc)){
if(doc["auto"] == "Hoch" || doc["auto"] == "Vorn"){
glblData.autoMode = AUTO_HOCH;
}else if(doc["auto"] == "Trog"){
glblData.autoMode = AUTO_TROG;
}else{
glblData.autoMode = AUTO_CLOSE;
}
}
} }
void MQTT::handleWebSocket(AsyncWebSocket *server, AsyncWebSocketClient *client, AwsEventType type, void *arg, uint8_t *data, size_t len) { void MQTT::handleWebSocket(AsyncWebSocket *server, AsyncWebSocketClient *client, AwsEventType type, void *arg, uint8_t *data, size_t len) {
@@ -51,14 +87,36 @@ void MQTT::handleWebSocket(AsyncWebSocket *server, AsyncWebSocketClient *client,
String MQTT::makeStatusJSON(void){ String MQTT::makeStatusJSON(void){
JsonDocument doc; JsonDocument doc;
JsonArray jArray; JsonArray jArray,jArray2;
jArray = doc["valveStates"].to<JsonArray>(); jArray = doc["valveStates"].to<JsonArray>();
for (int i = 0; i < NUM_VALVES; i++) { for (int i = 0; i < NUM_VALVES; i++) {
jArray.add(valveStateStrings[glblData.valveState[i]]); jArray.add(valveStateStrings[glblData.valveState[i]]);
} }
jArray = doc["desiredValveStates"].to<JsonArray>(); jArray = doc["desiredValveStates"].to<JsonArray>();
for (int i = 0; i < NUM_VALVES; i++) { for (int i = 0; i < NUM_VALVES; i++) {
jArray.add(valveStateStrings[glblData.setValveState[i]]); jArray.add(glblData.setValveState[i] == OPEN ? "OPEN" : "CLOSE");
}
jArray = doc["wateringDateTime1"].to<JsonArray>();
jArray2 = doc["wateringDurationSecs1"].to<JsonArray>();
for(int j = 0; j < 5; j++){
if(glblData.wateringLog[1][(glblData.logIndex[1]+j)%5][0] == 0){
jArray.add("");
jArray2.add(0);
}else{
jArray.add(glblData.wateringLog[1][(glblData.logIndex[1]+j)%5]);
jArray2.add(glblData.wateringLogDuration[1][(glblData.logIndex[1]+j)%5]/2);
}
}
jArray = doc["wateringDateTime6"].to<JsonArray>();
jArray2 = doc["wateringDurationSecs6"].to<JsonArray>();
for(int j = 0; j < 5; j++){
if(glblData.wateringLog[6][(glblData.logIndex[6]+j)%5][0] == 0){
jArray.add("");
jArray2.add(0);
}else{
jArray.add(glblData.wateringLog[6][(glblData.logIndex[6]+j)%5]);
jArray2.add(glblData.wateringLogDuration[6][(glblData.logIndex[6]+j)%5]/2);
}
} }
doc["hostname"] = hostname; doc["hostname"] = hostname;
doc["IP"] = myIP; doc["IP"] = myIP;
@@ -78,7 +136,18 @@ void MQTT::handleWebSocket(AsyncWebSocket *server, AsyncWebSocketClient *client,
JsonArray jArray; JsonArray jArray;
jArray = doc["timers"].to<JsonArray>(); jArray = doc["timers"].to<JsonArray>();
for (int i = 0; i < NUM_VALVES; i++) { for (int i = 0; i < NUM_VALVES; i++) {
jArray.add(glblData.timers[i]); if(glblData.pauseTimers[i] > 0){
jArray.add(-glblData.pauseTimers[i]);
}else{
jArray.add(glblData.timers[i]);
}
}
if(glblData.autoMode == AUTO_HOCH){
doc["auto"] = "Hoch";
}else if(glblData.autoMode == AUTO_TROG){
doc["auto"] = "Trog";
}else{
doc["auto"] = "";
} }
String jsonStr; String jsonStr;
serializeJsonPretty(doc, jsonStr); serializeJsonPretty(doc, jsonStr);
@@ -94,7 +163,7 @@ void MQTT::handleWebSocket(AsyncWebSocket *server, AsyncWebSocketClient *client,
} }
jArray = doc["desiredValveStates"].to<JsonArray>(); jArray = doc["desiredValveStates"].to<JsonArray>();
for (int i = 0; i < NUM_VALVES; i++) { for (int i = 0; i < NUM_VALVES; i++) {
jArray.add(valveStateStrings[glblData.setValveState[i]]); jArray.add(glblData.setValveState[i] == OPEN ? "OPEN" : "CLOSE");
} }
String jsonStr; String jsonStr;
serializeJson(doc, jsonStr); serializeJson(doc, jsonStr);
@@ -148,9 +217,13 @@ void MQTT::serveSetValve(AsyncWebServerRequest *request){
if(request->getParam("set")->value().equals("open")){ if(request->getParam("set")->value().equals("open")){
glblData.setValveState[valvenum] = OPEN; glblData.setValveState[valvenum] = OPEN;
doc["status"] = "Success opening valve"; doc["status"] = "Success opening valve";
if(request->hasParam("timer")){ uint32_t time = request->hasParam("timer") ? request->getParam("timer")->value().toInt() : defTime_min; // fällt auf den konfigurierten Standardwert zurück, wenn kein Timer angegeben wurde
glblData.timers[valvenum] = request->getParam("timer")->value().toInt()*60; if(time < 1){
time = 1;
}else if(time > 120){
time = 120;
} }
glblData.timers[valvenum] = time * 60;
remoteLogf("Valve %d opened for %d mins",valvenum,glblData.timers[valvenum]/60); remoteLogf("Valve %d opened for %d mins",valvenum,glblData.timers[valvenum]/60);
}else{ }else{
glblData.setValveState[valvenum] = CLOSE; glblData.setValveState[valvenum] = CLOSE;
@@ -165,7 +238,41 @@ void MQTT::serveSetValve(AsyncWebServerRequest *request){
} }
void MQTT::handleMqttMessage(char* topic, byte* payload, unsigned int length) {
char payloadBuf[128];
if(length >= sizeof(payloadBuf)){
length = sizeof(payloadBuf) - 1;
}
memcpy(payloadBuf, payload, length);
payloadBuf[length] = 0; //ensure termination!
char autoTopic[60];
strcpy(autoTopic, mqtt_topic);
strcat(autoTopic, "/auto");
char setTopic[60];
strcpy(setTopic, mqtt_topic);
strcat(setTopic, "/set");
if(strcmp(topic, autoTopic) == 0) {
String msg(payloadBuf);
msg.toLowerCase();
if (msg == "hoch" || msg == "vorn") {
glblData.autoMode = AUTO_HOCH;
} else if (msg == "trog") {
glblData.autoMode = AUTO_TROG;
} else {
glblData.autoMode = AUTO_CLOSE;
}
} else if(strcmp(topic, setTopic) == 0) {
JsonDocument doc;
DeserializationError error = deserializeJson(doc, payloadBuf);
if (error) {
Serial.printf("MQTT deserializeJson() failed: %s\n", error.c_str());
return;
}
applyValveJson(doc);
}
}
void MQTT::begin(){ void MQTT::begin(){
saveConfigFlag = false; saveConfigFlag = false;
@@ -356,6 +463,14 @@ void MQTT::reconnect(void) {
if (psclient.connect(hostname)) { if (psclient.connect(hostname)) {
Serial.println("connected"); Serial.println("connected");
// Subscribe // Subscribe
char newtopic[60];
strcpy(newtopic,mqtt_topic);
strcat(newtopic,"/auto");
psclient.subscribe(newtopic);
strcpy(newtopic,mqtt_topic);
strcat(newtopic,"/set");
psclient.subscribe(newtopic);
psclient.setCallback(handleMqttMessage);
glblData.statusChange = true; glblData.statusChange = true;
} else { } else {
Serial.print("failed, rc="); Serial.print("failed, rc=");
@@ -406,8 +521,8 @@ void MQTT::loop(void){
} }
if(glblData.timerChange){ if(glblData.timerChange){
String data = makeTimerJSON();
glblData.timerChange = 0; glblData.timerChange = 0;
String data = makeTimerJSON();
publish_sub("Timers",data.c_str()); publish_sub("Timers",data.c_str());
ws->textAll(data); ws->textAll(data);
} }
@@ -424,6 +539,7 @@ void MQTT::loop(void){
} }
} }
char* MQTT::getMqttServer(void){ char* MQTT::getMqttServer(void){
return mqtt_server; return mqtt_server;
} }
+2
View File
@@ -54,6 +54,8 @@ public:
private: private:
static const char* months[]; static const char* months[];
static void handleMqttMessage(char* topic, byte* payload, unsigned int length);
static bool applyValveJson(JsonDocument &doc);
static void serveData(AsyncWebServerRequest *request); static void serveData(AsyncWebServerRequest *request);
static void serveIndex(AsyncWebServerRequest *request); static void serveIndex(AsyncWebServerRequest *request);
static void serveCss(AsyncWebServerRequest *request); static void serveCss(AsyncWebServerRequest *request);
+11
View File
@@ -59,13 +59,24 @@ typedef enum valvestate_e{
VALVE_NUM_STATES VALVE_NUM_STATES
}valvestate_t; }valvestate_t;
typedef enum automode_e{
AUTO_NONE = 0,
AUTO_HOCH,
AUTO_TROG,
AUTO_CLOSE
} automode_t;
typedef struct dataset_s{ typedef struct dataset_s{
bool setValveState[NUM_VALVES] = {CLOSE}; bool setValveState[NUM_VALVES] = {CLOSE};
valvestate_t valveState[NUM_VALVES] = {VALVE_CLOSED}; valvestate_t valveState[NUM_VALVES] = {VALVE_CLOSED};
uint32_t timers[NUM_VALVES] = {0}; uint32_t timers[NUM_VALVES] = {0};
int32_t pauseTimers[NUM_VALVES] = {0};
bool statusChange = true; bool statusChange = true;
bool timerChange = false; bool timerChange = false;
char wateringLog[NUM_VALVES][5][15] = {0}; //5 entries per valve
int32_t wateringLogDuration[NUM_VALVES][5] = {0}; //5 entries per valve
uint8_t logIndex[NUM_VALVES] = {0}; //index for wateringLog
automode_t autoMode = AUTO_NONE;
}dataset_t; }dataset_t;
extern dataset_t glblData; extern dataset_t glblData;
+101 -4
View File
@@ -12,6 +12,7 @@ SpiShiftRegisterChain ios(latchPin, dataLengthBytes);
Adafruit_NeoPixel pixels(NUM_PIXELS, 12, NEO_GRB + NEO_KHZ800); Adafruit_NeoPixel pixels(NUM_PIXELS, 12, NEO_GRB + NEO_KHZ800);
Ticker valveTiming; Ticker valveTiming;
Ticker closeValves; Ticker closeValves;
Ticker autoModeTicker;
MQTT connections; MQTT connections;
bool init_done = false; bool init_done = false;
void updateTimerRing(void); void updateTimerRing(void);
@@ -19,22 +20,31 @@ void updateTimerRing(void);
void valveState(void){ void valveState(void){
static uint8_t v_powered = MOTOR_POWER_TIME; static uint8_t v_powered = MOTOR_POWER_TIME;
static uint8_t slowdown = 0; static uint8_t slowdown = 0;
tm now;
getLocalTime(&now);
slowdown++; slowdown++;
for(uint8_t i=1;i<NUM_VALVES;i++){ for(uint8_t i=1;i<NUM_VALVES;i++){
if(glblData.timers[i]){ if(glblData.timers[i]){
if(slowdown%2 == 0){ if(slowdown%2 == 0){
glblData.pauseTimers[i] = 0; //reset pause timer
glblData.timers[i]--; glblData.timers[i]--;
if(!glblData.timers[i]){ if(!glblData.timers[i]){
glblData.setValveState[i] = CLOSE; glblData.setValveState[i] = CLOSE;
}else{
glblData.timerChange = true;
} }
glblData.timerChange = true;
}
}else if(glblData.pauseTimers[i]){
if(slowdown%2 == 0){
glblData.pauseTimers[i]--;
glblData.timerChange = true;
} }
} }
switch(glblData.valveState[i]){ switch(glblData.valveState[i]){
case VALVE_CLOSED: case VALVE_CLOSED:
if(glblData.setValveState[i]){ if(glblData.setValveState[i] == OPEN){
MQTT::remoteLog("Open Valve ",i); MQTT::remoteLog("Open Valve ",i);
snprintf(glblData.wateringLog[i][glblData.logIndex[i]],15,"%02d.%02d.%02d %02d:%02d",now.tm_mday,now.tm_mon+1,now.tm_year-100,now.tm_hour,now.tm_min);
glblData.wateringLogDuration[i][glblData.logIndex[i]] = 0;
glblData.valveState[i] = VALVE_OPENING_COILPWR; glblData.valveState[i] = VALVE_OPENING_COILPWR;
glblData.statusChange = true; glblData.statusChange = true;
SET_V(i); SET_V(i);
@@ -46,12 +56,18 @@ void valveState(void){
KEEP_V(i); KEEP_V(i);
break; break;
case VALVE_OPEN: case VALVE_OPEN:
if(!glblData.setValveState[i]){ glblData.wateringLogDuration[i][glblData.logIndex[i]]++;
if(glblData.setValveState[i] == CLOSE){
MQTT::remoteLog("Close Valve ",i); MQTT::remoteLog("Close Valve ",i);
glblData.valveState[i] = VALVE_CLOSING_COILPWR; glblData.valveState[i] = VALVE_CLOSING_COILPWR;
glblData.statusChange = true; glblData.statusChange = true;
glblData.timers[i] = 0; glblData.timers[i] = 0;
glblData.timerChange = true;
RST_V(i); RST_V(i);
glblData.logIndex[i]++;
if(glblData.logIndex[i] >= 5){
glblData.logIndex[i] = 0; //reset log index
}
} }
break; break;
case VALVE_CLOSING_COILPWR: case VALVE_CLOSING_COILPWR:
@@ -112,6 +128,8 @@ void valveState(void){
} }
} }
//called by closeValves Ticker (triggered once at startup) and handsover valve control to the valveState function
void closeValves_tick(uint8_t state){ void closeValves_tick(uint8_t state){
switch(state){ switch(state){
case 0: case 0:
@@ -153,6 +171,64 @@ void init_relays(){
closeValves.once(0.2,closeValves_tick,(uint8_t)0); closeValves.once(0.2,closeValves_tick,(uint8_t)0);
} }
void autoMode_tick(automode_t mode){
static uint32_t time = 0;
static automode_t oldMode = AUTO_NONE;
if(mode != oldMode){
time = 0;
oldMode = mode;
}
switch(mode){
case AUTO_HOCH:
switch(time){
case 0:
glblData.setValveState[6] = OPEN;
glblData.timers[6] = 30*60; //30 mins
break;
case 30:
glblData.setValveState[6] = CLOSE; //should be done via timer value anyways, just for safety
glblData.timers[6] = 0;
glblData.autoMode = AUTO_NONE;
oldMode = AUTO_NONE;
autoModeTicker.detach();
break;
}
break;
case AUTO_TROG:
switch(time){
case 0:
glblData.setValveState[1] = OPEN;
glblData.timers[1] = 5*60; //5mins
break;
case 5:
glblData.setValveState[1] = CLOSE;
glblData.pauseTimers[1] = 10*60; //10mins
glblData.timers[1] = 0;
break;
case 15:
glblData.setValveState[1] = OPEN;
glblData.pauseTimers[1] = 0; //10mins
glblData.timers[1] = 3*60; //3mins
break;
case 18:
glblData.setValveState[1] = CLOSE;
glblData.timers[1] = 0;
glblData.autoMode = AUTO_NONE;
oldMode = AUTO_NONE;
autoModeTicker.detach();
break;
}
break;
default:
glblData.autoMode = AUTO_NONE;
glblData.setValveState[1] = CLOSE;
glblData.setValveState[6] = CLOSE;
oldMode = AUTO_NONE;
break;
}
time++;
}
void setup() { void setup() {
pixels.clear(); pixels.clear();
pixels.show(); pixels.show();
@@ -171,13 +247,34 @@ void setup() {
static long oldTime = 0; static long oldTime = 0;
unsigned long lastTick = millis(); unsigned long lastTick = millis();
automode_t oldAutoMode = AUTO_NONE;
void loop() { void loop() {
connections.loop(); connections.loop();
if (millis() - oldTime > 100){ if (millis() - oldTime > 100){
updateTimerRing(); updateTimerRing();
oldTime += 100; //Do not allow skipping or time shifts here. oldTime += 100; //Do not allow skipping or time shifts here.
}
if(glblData.autoMode != oldAutoMode){
switch(glblData.autoMode){
case AUTO_HOCH:
autoMode_tick(AUTO_HOCH);
autoModeTicker.attach(60,autoMode_tick,AUTO_HOCH);
break;
case AUTO_TROG:
MQTT::remoteLog("Auto Mode: Trog");
autoMode_tick(AUTO_TROG);
autoModeTicker.attach(60,autoMode_tick,AUTO_TROG);
break;
case AUTO_CLOSE:
MQTT::remoteLog("Auto Mode: Close");
autoModeTicker.detach();
autoMode_tick(AUTO_NONE);
case AUTO_NONE:
break;
} }
oldAutoMode = glblData.autoMode;
}
// put your main code here, to run repeatedly: // put your main code here, to run repeatedly:
} }