No description
  • Python 53.3%
  • JavaScript 29.2%
  • CSS 6.4%
  • Shell 5.5%
  • HTML 4.8%
  • Other 0.8%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Dominik Bechtel 3e6f5df1ab Flash-Befehl nennt die richtige Umgebung
"esphome run" schlaegt aus .venv fehl, dort liegt es nicht. Dazu der
Grund fuer die Trennung genauer: esphome nagelt paho-mqtt 1.6 fest, 2.x
kommt von aiomqtt im Gateway -- nicht vom Testbroker, amqtt braucht paho
gar nicht.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 13:36:38 +02:00
.vscode Bewaesserungssteuerung: einstellbare Automatik, PWA und Keycloak-Gateway 2026-07-25 20:26:48 +02:00
caddy Doku und Healthcheck an den Aufbau ohne gemeinsames Docker-Netz anpassen 2026-07-26 00:28:47 +02:00
gateway Ventilstellung anzeigen und befristetes Oeffnen 2026-08-05 13:29:29 +02:00
mqtt Ventilstellung anzeigen und befristetes Oeffnen 2026-08-05 13:29:29 +02:00
tests Ventilstellung anzeigen und befristetes Oeffnen 2026-08-05 13:29:29 +02:00
webapp Ventilstellung anzeigen und befristetes Oeffnen 2026-08-05 13:29:29 +02:00
.env.example Umstellung auf MQTT: der ESP32 sendet selbst, DS-Lite braucht keine Freigabe 2026-07-26 00:13:03 +02:00
.gitignore device.py fehlte im Image -- Dockerfile kopiert jetzt alle Module 2026-07-26 00:37:16 +02:00
bewaesserung.yaml Ventilstellung anzeigen und befristetes Oeffnen 2026-08-05 13:29:29 +02:00
docker-compose.yml Doku und Healthcheck an den Aufbau ohne gemeinsames Docker-Netz anpassen 2026-07-26 00:28:47 +02:00
README.md Flash-Befehl nennt die richtige Umgebung 2026-08-05 13:36:38 +02:00
secrets.example.yaml Bewaesserungssteuerung: einstellbare Automatik, PWA und Keycloak-Gateway 2026-07-25 20:26:48 +02:00

Solarbewässerung

Automatische Gartenbewässerung auf einem ESP32-C3 mit ESPHome, dazu eine PWA zur Bedienung und ein Gateway-Container, der die Bedienung hinter der Keycloak- Anmeldung von id.bechnology.de absichert.

                ── bechnology.de ───────────────────┐
Browser ─HTTPS─► Caddy ─► Gateway ─► Broker :8883   │◄─ausgehend, TLS─ ESP32
   │                       │  prueft Session,       │                (DS-Lite,
   │                       │  nur erlaubte Pfade    │            nicht erreichbar)
   └── Anmeldung ──► id.bechnology.de (Keycloak)     │

Der ESP32 hängt an einem DS-Lite-Anschluss: keine öffentliche IPv4, keine Portfreigabe. Er ist von außen nicht erreichbar und baut die Verbindung deshalb selbst zum Broker auf — Messwerte hinaus, Befehle zurück. Einzelheiten in mqtt/README.md.

Verzeichnis Inhalt
bewaesserung.yaml ESPHome-Firmware: Sensor, Automatik, Ventil
webapp/ die PWA (eigene README)
gateway/ Gateway-Dienst und Container-Image
mqtt/ Broker, Zertifikate, Topics
docker-compose.yml Betrieb des Gateways
caddy/ Caddy-Konfiguration und Netzaufbau

Firmware

Zur Laufzeit einstellbar und reboot-fest: Schwellwert, Bewässerungsdauer, Offenzeit, Kalibrierung des Bodenfeuchtesensors sowie ein Hauptschalter für die Automatik.

Das Prüfintervall folgt der Bewässerungsdauer: nach dem Gießen wird genauso lange gewartet, bis erneut gemessen wird (Prüfintervall = 2 × Dauer, mindestens 5 Minuten). Der Takt läuft im Minutenraster mit Zähler, damit eine Änderung sofort wirkt — ein interval: ist zur Laufzeit nicht änderbar.

Die beiden Ventilrelais sind über interlock gegeneinander verriegelt: schaltet eines ein, wird das andere zwingend abgeschaltet und erst 500 ms später eingeschaltet. Beide Wicklungen können also nie gleichzeitig anliegen, unabhängig davon, wer schaltet — WebApp, Home Assistant, ESPHome-Oberfläche oder Automatik. Nach einem Reboot liegt kein Relais an (restore_mode: ALWAYS_OFF).

Ventilstellung und Selbstschluss

Das Ventil ist bistabil und hat keinen Endlagenschalter — die Stellung ist also nicht messbar. binary_sensor/Ventil offen meldet, was die Firmware zuletzt geschaltet hat; der Wert wird gespeichert, denn stromlos bleibt das Ventil stehen, wo es ist.

Geschaltet wird ausschließlich über das Skript valve_set. Es impulst, merkt die Stellung und setzt die Restzeit — alles an einer Stelle, damit gemerkte und tatsächliche Stellung nicht auseinanderlaufen können. mode: queued reiht Aufträge hintereinander; die Offenzeit ist Parameter des Auftrags und wird nicht beim Knopfdruck geschrieben. Andernfalls überschreibt ein noch laufender Auftrag den Zähler eines späteren: „Ventil zu“ gedrückt, gleich danach befristet geöffnet — und das nachlaufende Schließen löscht den Selbstschluss. Das Ventil wäre offen geblieben.

Geschlossen wird über einen gespeicherten Minutenzähler im 1-Minuten-Takt, nicht über delay im Skript. Ein delay überlebt keinen Reboot: ein Stromausfall mitten in einem Bewässerungsdurchlauf hätte das Ventil dauerhaft offen gelassen — bei einem solarversorgten Gerät kein theoretischer Fall. Derselbe Zähler bedient Automatik und befristetes Öffnen; es gibt nur einen Weg, das Ventil zuzumachen.

Der Zähler ruht, solange ein Impuls läuft (script.is_running: valve_set), damit sich kein Schließen hinter ein Öffnen einreiht. Die Automatik greift nur bei geschlossenem Ventil — ein offenes hat jemand absichtlich geöffnet.

Flashen:

.venv-esphome/bin/esphome run bewaesserung.yaml

Nicht aus .venv heraus — dort liegt esphome nicht. Die beiden Umgebungen sind getrennt, weil esphome paho-mqtt 1.6 festnagelt, das Gateway über aiomqtt aber 2.x braucht; in einer gemeinsamen Umgebung überschreiben sie sich gegenseitig. Näheres unter Tests.

Das geht nur im gleichen WLAN wie der ESP32: über MQTT gibt es kein OTA, und eingehend ist das Gerät hinter DS-Lite nicht erreichbar. ESPHome fragt beim Aufruf, wohin — OTA und die Adresse des Geräts wählen.

Zugangsdaten stehen in secrets.yaml, Vorlage in secrets.example.yaml.

Gateway

Der Container liefert die PWA aus und stellt die Geräte-Schnittstelle unter /api bereit — ausschließlich für angemeldete Benutzer.

Warum eine Session per Cookie und keine Bearer-Token im Browser: EventSource kann keine eigenen Header setzen. Der Live-Stream wäre mit Bearer-Token nur über Tokens in der URL möglich, und die landen in Logs und im Verlauf. Ein HttpOnly-Cookie wird automatisch mitgesendet und ist für JavaScript unsichtbar.

Was der Dienst absichert:

  • Anmeldung über Keycloak, Authorization Code Flow mit PKCE. Ohne gültige Session gibt es 401 (für die App) beziehungsweise eine Weiterleitung zur Anmeldung (für den Seitenaufruf).
  • Autorisierung optional über eine Rolle (OIDC_REQUIRED_ROLE). Ohne diese Rolle endet die Anmeldung mit 403, ohne dass eine Session entsteht.
  • Allowlist: nur die Pfade, die die App wirklich braucht, erreichen den ESP32. Alles andere — OTA, Logs, Debug-Endpunkte der ESPHome-Oberfläche — bleibt draußen, auch für angemeldete Benutzer. Die Relais sind nur lesbar; geschaltet wird ausschließlich über die beiden Impuls-Knöpfe, damit ein Relais nicht dauerhaft anliegen kann.
  • Wertegrenzen: value muss eine endliche Zahl im erlaubten Bereich sein, alle weiteren Query-Parameter werden verworfen.
  • CSRF: schreibende Aufrufe brauchen den Header X-CSRF-Token mit dem Token aus der Session, dazu SameSite=Lax auf dem Cookie.
  • TLS zum Broker mit eigener CA, beidseitig. Benutzer und Passwort kennen nur Gerät und Gateway; der Broker prüft sie.
  • Schutz-Header auf allen Antworten, inklusive strikter Content-Security-Policy (default-src 'none', keine Inline-Skripte).

Keycloak-Client anlegen

In id.bechnology.de im gewünschten Realm:

  1. Clients → Create client, Typ OpenID Connect, Client ID bewaesserung.
  2. Client authentication: On (vertraulicher Client). Standard flow aktiviert, Direct access grants aus.
  3. Valid redirect URIs: https://bewaesserung.bechnology.de/auth/callback — muss exakt mit PUBLIC_URL + /auth/callback übereinstimmen.
  4. Valid post logout redirect URIs: https://bewaesserung.bechnology.de/
  5. Credentials → Client Secret kopieren, nach OIDC_CLIENT_SECRET.
  6. Optional: unter Realm roles eine Rolle bewaesserung anlegen, den berechtigten Benutzern zuweisen und OIDC_REQUIRED_ROLE=bewaesserung setzen.

Web Origins sind nicht nötig: der Browser spricht nur mit dem Gateway, nie direkt mit Keycloak per XHR.

Starten

cp .env.example .env
python3 -c "import secrets; print(secrets.token_urlsafe(48))"   # SESSION_SECRET
sudo bash mqtt/setup.sh        # Zertifikate, Passwortdatei, Rechte
docker compose up -d --build

mqtt/setup.sh ist nicht optional: ohne die Passwortdatei legt Docker an ihrer Stelle ein Verzeichnis an, und Mosquitto lehnt Dateien ab, die ihm nicht gehören. Einzelheiten in mqtt/README.md.

Aktualisieren

git pull
docker compose up -d --build

--build gehört dazu: die WebApp liegt im Image (COPY webapp/), nicht als Bind-Mount. Ein blosses up -d erneuert den Container nicht, wenn sich seine Konfiguration nicht geändert hat — geänderte Dateien im Arbeitsverzeichnis merkt es nicht. Man sieht dann weiter die alte Oberfläche. Was der Container wirklich ausliefert, zeigt:

docker compose exec gateway head -1 /srv/webapp/app.js

Der Container veröffentlicht Port 8000 auf dem Host; Caddy erreicht ihn dort und beendet TLS:

bewaesserung.bechnology.de {
	encode zstd gzip
	reverse_proxy 127.0.0.1:8000
}

Läuft Caddy selbst in einem Container, ist 127.0.0.1 dort dessen eigene Loopback-Adresse — dann muss die Adresse des Hosts im Docker-Netz stehen oder beide Container brauchen ein gemeinsames Netz.

Caddy setzt X-Forwarded-* selbst und leert den Puffer bei text/event-stream automatisch. X-Forwarded-Proto ist dabei wichtig: daraus baut der Dienst die Redirect-URI und entscheidet über das Secure-Flag des Cookies.

Warum der Container bleibt und nicht in Caddy aufgeht, steht in caddy/README.md — dort auch der Hinweis zur Portfreigabe.

Vier Fallstricke

  • Leere Pflichtwerte. Steht in der .env ein SESSION_SECRET= ohne Wert, startet der Container nicht. Er nennt beim Start alle fehlenden Variablen auf einmal — docker compose logs zeigt die Liste.
  • Zertifikate und Passwortdatei fehlen im Git. sudo bash mqtt/setup.sh auf dem Server erzeugt sie und setzt die Rechte. Fehlt die Datei beim ersten Start, legt Docker an ihrer Stelle ein Verzeichnis an — das muss dann erst weg.
  • Port 8883 muss von außen offen sein. Dort meldet sich der ESP32 — das ist die einzige Richtung, die bei DS-Lite funktioniert. Veröffentlicht Docker den Port, heißt das noch nicht, dass die Firewall des Servers ihn durchlässt; prüfen von außen, siehe mqtt/README.md.
  • Zugangsdaten an drei Stellen. secrets.yaml (Firmware), mqtt/passwd (Broker) und .env (Gateway) müssen dieselben Werte tragen.

Grenzen

  • Wer im heimischen WLAN ist, erreicht den ESP32 direkt über seine Weboberfläche; davor steht nur die Digest-Anmeldung.
  • Die eigene CA läuft bis 2036. Danach — oder wenn der Schlüssel abhandenkommt — muss der ESP32 einmal neu geflasht werden, und zwar vor Ort.
  • Fällt der Broker aus, bewässert das Gerät weiter nach den zuletzt gespeicherten Einstellungen. Bedienen lässt es sich dann nur im lokalen WLAN.
  • Es gibt keine Ratenbegrenzung. Für eine Handvoll Benutzer hinter Keycloak ist das vertretbar; öffentlich erreichbar sollte der Dienst nicht ohne stehen.
  • Die Session liegt signiert (nicht verschlüsselt) im Cookie. Sie enthält Name, E-Mail, Rollen und das ID-Token für den Logout — keine Geräte-Zugangsdaten.
  • GPIO2 ist auf dem ESP32-C3 ein Strapping-Pin. ESPHome warnt beim Übersetzen darauf; sofern die Schaltung damit läuft, kann die Warnung stehen bleiben.

Tests

WebApp und Gateway laufen gegen ein nachgestelltes Gerät, einen echten MQTT-Broker mit TLS und einen Fake-Keycloak — ohne Hardware:

python3 -m venv .venv
.venv/bin/pip install -r gateway/requirements.txt amqtt
bash tests/run_all.sh

ESPHome braucht eine eigene Umgebung, weil es paho-mqtt 1.6 festnagelt, das Gateway über aiomqtt aber 2.x braucht. Wer beides in dieselbe Umgebung installiert, macht mit dem letzten pip install das jeweils andere kaputt:

python3 -m venv .venv-esphome && .venv-esphome/bin/pip install esphome
.venv-esphome/bin/esphome config bewaesserung.yaml

Geprüft werden Anmeldeweg mit PKCE, 401/403, Allowlist, CSRF, Wertegrenzen, Event-Stream, die Relais-Verriegelung und der Fall „Gerät offline" — Details in tests/README.md.