neu: Steuerung über MQTT
Behoben: Keine automatische Abschaltuk bei t<2 t jetzt limitiert auf 1-120 Dokumentation angelegt
This commit is contained in:
@@ -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 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<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.
|
||||
@@ -17,6 +17,8 @@
|
||||
--ok: #279c1c;
|
||||
--warn: #f1d323;
|
||||
--fail: #d12e2e;
|
||||
--auto-hover: #b400eb;
|
||||
--auto: #e89cff;
|
||||
--accent-hover: #1266e2;
|
||||
--accent-text: var(--bg);
|
||||
--code: #d81b60;
|
||||
@@ -38,6 +40,8 @@
|
||||
--warn: #ddd125;
|
||||
--fail: #ce3030;
|
||||
--accent-hover: #1b5a2f;
|
||||
--auto-hover: #cd28ff;
|
||||
--auto: #5e1075;
|
||||
--accent-text: var(--bg);
|
||||
--code: #f06292;
|
||||
--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
@@ -25,9 +25,45 @@ WiFiManagerParameter custom_ntpServer("ntpServer", "NTP Server", "", 40);
|
||||
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){
|
||||
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) {
|
||||
@@ -51,14 +87,36 @@ void MQTT::handleWebSocket(AsyncWebSocket *server, AsyncWebSocketClient *client,
|
||||
|
||||
String MQTT::makeStatusJSON(void){
|
||||
JsonDocument doc;
|
||||
JsonArray jArray;
|
||||
JsonArray jArray,jArray2;
|
||||
jArray = doc["valveStates"].to<JsonArray>();
|
||||
for (int i = 0; i < NUM_VALVES; i++) {
|
||||
jArray.add(valveStateStrings[glblData.valveState[i]]);
|
||||
}
|
||||
jArray = doc["desiredValveStates"].to<JsonArray>();
|
||||
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["IP"] = myIP;
|
||||
@@ -78,7 +136,18 @@ void MQTT::handleWebSocket(AsyncWebSocket *server, AsyncWebSocketClient *client,
|
||||
JsonArray jArray;
|
||||
jArray = doc["timers"].to<JsonArray>();
|
||||
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;
|
||||
serializeJsonPretty(doc, jsonStr);
|
||||
@@ -94,7 +163,7 @@ void MQTT::handleWebSocket(AsyncWebSocket *server, AsyncWebSocketClient *client,
|
||||
}
|
||||
jArray = doc["desiredValveStates"].to<JsonArray>();
|
||||
for (int i = 0; i < NUM_VALVES; i++) {
|
||||
jArray.add(valveStateStrings[glblData.setValveState[i]]);
|
||||
jArray.add(glblData.setValveState[i] == OPEN ? "OPEN" : "CLOSE");
|
||||
}
|
||||
String jsonStr;
|
||||
serializeJson(doc, jsonStr);
|
||||
@@ -148,9 +217,13 @@ void MQTT::serveSetValve(AsyncWebServerRequest *request){
|
||||
if(request->getParam("set")->value().equals("open")){
|
||||
glblData.setValveState[valvenum] = OPEN;
|
||||
doc["status"] = "Success opening valve";
|
||||
if(request->hasParam("timer")){
|
||||
glblData.timers[valvenum] = request->getParam("timer")->value().toInt()*60;
|
||||
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
|
||||
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);
|
||||
}else{
|
||||
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(){
|
||||
saveConfigFlag = false;
|
||||
@@ -356,6 +463,14 @@ void MQTT::reconnect(void) {
|
||||
if (psclient.connect(hostname)) {
|
||||
Serial.println("connected");
|
||||
// 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;
|
||||
} else {
|
||||
Serial.print("failed, rc=");
|
||||
@@ -406,8 +521,8 @@ void MQTT::loop(void){
|
||||
}
|
||||
|
||||
if(glblData.timerChange){
|
||||
String data = makeTimerJSON();
|
||||
glblData.timerChange = 0;
|
||||
String data = makeTimerJSON();
|
||||
publish_sub("Timers",data.c_str());
|
||||
ws->textAll(data);
|
||||
}
|
||||
@@ -424,6 +539,7 @@ void MQTT::loop(void){
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
char* MQTT::getMqttServer(void){
|
||||
return mqtt_server;
|
||||
}
|
||||
|
||||
@@ -54,6 +54,8 @@ public:
|
||||
|
||||
private:
|
||||
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 serveIndex(AsyncWebServerRequest *request);
|
||||
static void serveCss(AsyncWebServerRequest *request);
|
||||
|
||||
@@ -59,13 +59,24 @@ typedef enum valvestate_e{
|
||||
VALVE_NUM_STATES
|
||||
}valvestate_t;
|
||||
|
||||
typedef enum automode_e{
|
||||
AUTO_NONE = 0,
|
||||
AUTO_HOCH,
|
||||
AUTO_TROG,
|
||||
AUTO_CLOSE
|
||||
} automode_t;
|
||||
|
||||
typedef struct dataset_s{
|
||||
bool setValveState[NUM_VALVES] = {CLOSE};
|
||||
valvestate_t valveState[NUM_VALVES] = {VALVE_CLOSED};
|
||||
uint32_t timers[NUM_VALVES] = {0};
|
||||
int32_t pauseTimers[NUM_VALVES] = {0};
|
||||
bool statusChange = true;
|
||||
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;
|
||||
|
||||
extern dataset_t glblData;
|
||||
|
||||
+101
-4
@@ -12,6 +12,7 @@ SpiShiftRegisterChain ios(latchPin, dataLengthBytes);
|
||||
Adafruit_NeoPixel pixels(NUM_PIXELS, 12, NEO_GRB + NEO_KHZ800);
|
||||
Ticker valveTiming;
|
||||
Ticker closeValves;
|
||||
Ticker autoModeTicker;
|
||||
MQTT connections;
|
||||
bool init_done = false;
|
||||
void updateTimerRing(void);
|
||||
@@ -19,22 +20,31 @@ void updateTimerRing(void);
|
||||
void valveState(void){
|
||||
static uint8_t v_powered = MOTOR_POWER_TIME;
|
||||
static uint8_t slowdown = 0;
|
||||
tm now;
|
||||
getLocalTime(&now);
|
||||
slowdown++;
|
||||
for(uint8_t i=1;i<NUM_VALVES;i++){
|
||||
if(glblData.timers[i]){
|
||||
if(slowdown%2 == 0){
|
||||
glblData.pauseTimers[i] = 0; //reset pause timer
|
||||
glblData.timers[i]--;
|
||||
if(!glblData.timers[i]){
|
||||
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]){
|
||||
case VALVE_CLOSED:
|
||||
if(glblData.setValveState[i]){
|
||||
if(glblData.setValveState[i] == OPEN){
|
||||
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.statusChange = true;
|
||||
SET_V(i);
|
||||
@@ -46,12 +56,18 @@ void valveState(void){
|
||||
KEEP_V(i);
|
||||
break;
|
||||
case VALVE_OPEN:
|
||||
if(!glblData.setValveState[i]){
|
||||
glblData.wateringLogDuration[i][glblData.logIndex[i]]++;
|
||||
if(glblData.setValveState[i] == CLOSE){
|
||||
MQTT::remoteLog("Close Valve ",i);
|
||||
glblData.valveState[i] = VALVE_CLOSING_COILPWR;
|
||||
glblData.statusChange = true;
|
||||
glblData.timers[i] = 0;
|
||||
glblData.timerChange = true;
|
||||
RST_V(i);
|
||||
glblData.logIndex[i]++;
|
||||
if(glblData.logIndex[i] >= 5){
|
||||
glblData.logIndex[i] = 0; //reset log index
|
||||
}
|
||||
}
|
||||
break;
|
||||
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){
|
||||
switch(state){
|
||||
case 0:
|
||||
@@ -153,6 +171,64 @@ void init_relays(){
|
||||
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() {
|
||||
pixels.clear();
|
||||
pixels.show();
|
||||
@@ -171,13 +247,34 @@ void setup() {
|
||||
|
||||
static long oldTime = 0;
|
||||
unsigned long lastTick = millis();
|
||||
automode_t oldAutoMode = AUTO_NONE;
|
||||
void loop() {
|
||||
connections.loop();
|
||||
|
||||
if (millis() - oldTime > 100){
|
||||
updateTimerRing();
|
||||
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:
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user