- Python 53.3%
- JavaScript 29.2%
- CSS 6.4%
- Shell 5.5%
- HTML 4.8%
- Other 0.8%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
"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> |
||
| .vscode | ||
| caddy | ||
| gateway | ||
| mqtt | ||
| tests | ||
| webapp | ||
| .env.example | ||
| .gitignore | ||
| bewaesserung.yaml | ||
| docker-compose.yml | ||
| README.md | ||
| secrets.example.yaml | ||
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:
valuemuss eine endliche Zahl im erlaubten Bereich sein, alle weiteren Query-Parameter werden verworfen. - CSRF: schreibende Aufrufe brauchen den Header
X-CSRF-Tokenmit dem Token aus der Session, dazuSameSite=Laxauf 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:
- Clients → Create client, Typ OpenID Connect, Client ID
bewaesserung. - Client authentication: On (vertraulicher Client). Standard flow aktiviert, Direct access grants aus.
- Valid redirect URIs:
https://bewaesserung.bechnology.de/auth/callback— muss exakt mitPUBLIC_URL+/auth/callbackübereinstimmen. - Valid post logout redirect URIs:
https://bewaesserung.bechnology.de/ - Credentials → Client Secret kopieren, nach
OIDC_CLIENT_SECRET. - Optional: unter Realm roles eine Rolle
bewaesserunganlegen, den berechtigten Benutzern zuweisen undOIDC_REQUIRED_ROLE=bewaesserungsetzen.
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
.enveinSESSION_SECRET=ohne Wert, startet der Container nicht. Er nennt beim Start alle fehlenden Variablen auf einmal —docker compose logszeigt die Liste. - Zertifikate und Passwortdatei fehlen im Git.
sudo bash mqtt/setup.shauf 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.