Regenbasierte automatische Gartenbewässerung + homeMesh-Anbindung für AutoAction-Regeleditor

- Neues stündliches Python-Script (restricted/gartenbewaesserung/auto_watering.py): holt Niederschlags-
  und Sonnenuntergangsdaten von Open-Meteo, wertet je Zone (Hochbeete/Tröge/Garten vorn) individuelle
  Regen-Schwellenwerte und Mindest-Bewässerungsdauern aus und startet bei Bedarf per MQTT den passenden
  Automatik-Modus der Ventilsteuerung im Zeitraum vor Sonnenuntergang; veröffentlicht den Regen-Status
  zusätzlich stündlich als retained MQTT-Nachricht je Zone.
- Web-UI: neue Bewässerungs-Steuerung/-Anzeige (ajax/watering.php, js/solar/solarMQTT.js,
  assets/img/realtime.svg) inkl. Live-Status-Icons (aus/geplant/pausiert/aktiv per Farbe und Symbol).
- AutoAction-Regeleditor (ajax/actorDetails.php, sensorDetails.php, fillActorDD.php, fillSensorDD.php,
  tahoma.php, AutoAction.php) liest Actor-/Sensor-Parameter jetzt live aus der homeMesh-Datenbank statt
  aus stark vereinfachten Altfeldern, inkl. zugehöriger Anpassungen am Device-Discovery-Tooling
  (restricted/deviceDiscovery/*, neues logic_module.py).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-26 20:19:27 +02:00
co-authored by Claude Sonnet 5
parent 4de95c1a29
commit 29f752eafe
22 changed files with 1693 additions and 234 deletions
@@ -0,0 +1,504 @@
#!/usr/bin/env python3
"""
Automatische Gartenbewässerung anhand von Regendaten (Open-Meteo)
Wird stündlich per Cronjob gestartet. Bei jedem Lauf werden die aktuellen
Niederschlagsdaten von Open-Meteo geholt und je Zone (Hochbeete, Tröge,
Garten vorn) als retained MQTT-Nachricht veröffentlicht (Regenmenge,
Schwellenwert, ob bewässert werden würde) - unabhängig von der Uhrzeit, damit
andere Systeme (z.B. das Web-UI) jederzeit den aktuellen Stand abfragen
können.
Tatsächlich ausgelöst (Automatik-Modus per MQTT gestartet) wird die
Bewässerung nur im Zeitraum zwischen dem Zielzeitpunkt (WATERING_LEAD_MINUTES
Minuten vor Sonnenuntergang) und dem Sonnenuntergang selbst - dieser Zeitraum
ist bewusst offen nach vorne (keine feste Fensterbreite): jeder stündliche
Lauf, der irgendwann in diesem Zeitraum passiert, löst aus. Das macht die
Auslösung robust gegen Jitter und sogar gegen einen komplett ausgefallenen
Lauf (z.B. NAS-Neustart) - ein späterer Lauf holt es nach, solange der
Sonnenuntergang noch nicht erreicht ist. Mehr Puffer gegen Ausfälle gibt es
einfach durch einen größeren WATERING_LEAD_MINUTES-Wert. Zusätzlich wird eine
Zone übersprungen, wenn sie laut dem retained "LastWatering"-MQTT-Topic der
Ventilsteuerung bereits innerhalb der letzten X Stunden bewässert wurde.
MQTT-Schnittstelle der Ventilsteuerung siehe README.md des Firmware-Projekts
(ESP32 Ventilsteuerung, Topics "Gartenwasser/vorn" bzw. "Gartenwasser/hinten").
"""
import argparse
import json
import logging
import sys
import time
from datetime import datetime, timedelta
import paho.mqtt.client as mqtt
import requests
# ============================================================================
# EINSTELLUNGEN (Standardwerte - können unten per Kommandozeilenparameter
# überschrieben werden, siehe --help)
# ============================================================================
# Standort für die Regendaten (Open-Meteo)
# Mühlenweg 7, Untermaiselstein, 87549 Rettenberg (Allgäu)
LATITUDE = 47.5781
LONGITUDE = 10.2628
TIMEZONE = "Europe/Berlin"
# Wie viele Stunden zurück geprüft wird, ob eine Zone bereits bewässert wurde
# (manuell oder automatisch) - falls ja, wird die Automatik für diese Zone
# in diesem Lauf übersprungen. Bewässerungen, die kürzer als der jeweilige
# "min_duration_minutes"-Wert der Zone liefen (siehe ZONES), zählen dabei
# NICHT als "bereits bewässert" und werden ignoriert (z.B. kurzes manuelles
# Antesten).
RECENT_WATERING_LOOKBACK_HOURS = 12
# Zielzeitpunkt für den Bewässerungsstart: so viele Minuten vor
# Sonnenuntergang. Ausgelöst wird bei jedem Lauf zwischen diesem
# Zielzeitpunkt und dem Sonnenuntergang selbst (kein festes Zeitfenster,
# siehe Modul-Docstring) - je größer der Wert, desto mehr Puffer gibt es,
# falls ein stündlicher Lauf ausfällt oder sich verzögert.
WATERING_LEAD_MINUTES = 70
# MQTT-Broker der Hausautomation
MQTT_BROKER = "nas.fritz.box"
MQTT_PORT = 1883
MQTT_USERNAME = None
MQTT_PASSWORD = None
# Wie lange (Sekunden) auf die retained LastWatering-Nachrichten gewartet wird
MQTT_WAIT_TIMEOUT = 5
# Topic-Vorlage für die stündlich veröffentlichten Regen-Status-Nachrichten
# je Zone (retained). {zone_key} wird durch den jeweiligen Zonen-Key ersetzt.
RAIN_STATUS_TOPIC_TEMPLATE = "Gartenwasser/AutoWatering/{zone_key}/RainStatus"
OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast"
# Zonen-Konfiguration: je Zone eigener Schwellenwert, eigenes MQTT-Auto-Topic
# (siehe README: /auto-Topic mit Klartext-Payload "Hoch"/"Trog"/"Vorn") und
# das zugehörige LastWatering-Topic + Ventilnummer zur Erkennung kürzlich
# erfolgter Bewässerung. Bewässert wird, wenn die letzten "threshold_days"
# Tage zusammen weniger als "threshold_mm" mm Regen gebracht haben.
ZONES = {
"hochbeete": {
"label": "Hochbeete",
"auto_topic": "Gartenwasser/hinten/auto",
"auto_payload": "Hoch",
"lastwatering_topic": "Gartenwasser/hinten/LastWatering",
"valve": 6,
# Schon nach 2 Tagen ohne (nennenswerten) Regen bewässern.
"threshold_days": 2,
"threshold_mm": 1.0,
# Manuelle Bewässerungen unter 10 Minuten zählen nicht als "bereits
# bewässert" (z.B. kurzes Antesten).
"min_duration_minutes": 10,
},
"troege": {
"label": "Tröge",
"auto_topic": "Gartenwasser/vorn/auto",
"auto_payload": "Trog",
"lastwatering_topic": "Gartenwasser/vorn/LastWatering",
"valve": 1,
# Tröge stehen unter Dach (bekommen kaum natürlichen Regen ab) und
# sollen daher praktisch immer bewässert werden - nur bei Starkregen
# (hoher mm-Schwellenwert, 1 Tag) wird ausgesetzt.
"threshold_days": 1,
"threshold_mm": 20.0,
# Bei den Trögen reicht schon eine kurze manuelle Bewässerung (3 Min.).
"min_duration_minutes": 3,
},
"garten_vorn": {
"label": "Garten vorn",
"auto_topic": "Gartenwasser/vorn/auto",
"auto_payload": "Vorn",
"lastwatering_topic": "Gartenwasser/vorn/LastWatering",
"valve": 6,
# Erst nach 5 Tagen ohne (nennenswerten) Regen bewässern.
"threshold_days": 5,
"threshold_mm": 1.0,
# Manuelle Bewässerungen unter 10 Minuten zählen nicht als "bereits
# bewässert" (z.B. kurzes Antesten).
"min_duration_minutes": 10,
},
}
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
)
logger = logging.getLogger("auto_watering")
# ============================================================================
# Kommandozeilenparameter
# ============================================================================
def parse_args():
parser = argparse.ArgumentParser(
description="Automatische Gartenbewässerung anhand von Regendaten (Open-Meteo)."
)
parser.add_argument("--lat", type=float, default=LATITUDE, help="Breitengrad (Standard: %(default)s)")
parser.add_argument("--lon", type=float, default=LONGITUDE, help="Längengrad (Standard: %(default)s)")
parser.add_argument("--timezone", default=TIMEZONE, help="Zeitzone für Open-Meteo (Standard: %(default)s)")
parser.add_argument("--days", type=int, default=None,
help="Überschreibt die Anzahl Tage, über die der Regen aufsummiert wird, für ALLE Zonen "
"ohne zonenspezifischen Override (Standard: je Zone eigener Wert, siehe ZONES)")
parser.add_argument("--mm", type=float, default=None,
help="Überschreibt die Regenmenge in mm, unterhalb derer bewässert wird, für ALLE Zonen "
"ohne zonenspezifischen Override (Standard: je Zone eigener Wert, siehe ZONES)")
for zone_key, zone in ZONES.items():
flag_prefix = zone_key.replace("_", "-")
parser.add_argument(f"--{flag_prefix}-days", type=int, default=None,
help=f"Schwellenwert Tage nur für Zone '{zone['label']}' (überschreibt --days)")
parser.add_argument(f"--{flag_prefix}-mm", type=float, default=None,
help=f"Schwellenwert mm nur für Zone '{zone['label']}' (überschreibt --mm)")
parser.add_argument(f"--{flag_prefix}-min-duration-minutes", type=float, default=None,
help=f"Mindestdauer (Min.) nur für Zone '{zone['label']}', unterhalb derer eine "
"manuelle Bewässerung ignoriert wird (überschreibt --min-duration-minutes)")
parser.add_argument("--lookback-hours", type=float, default=RECENT_WATERING_LOOKBACK_HOURS,
help="Zone wird übersprungen, wenn innerhalb dieser Stundenzahl bereits bewässert "
"wurde (Standard: %(default)s)")
parser.add_argument("--min-duration-minutes", type=float, default=None,
help="Überschreibt die Mindestdauer (Min.), unterhalb derer eine Bewässerung nicht als "
"'bereits bewässert' zählt, für ALLE Zonen ohne zonenspezifischen Override "
"(Standard: je Zone eigener Wert, siehe ZONES)")
parser.add_argument("--watering-lead-minutes", type=float, default=WATERING_LEAD_MINUTES,
help="Ausgelöst wird zwischen (Sonnenuntergang - watering-lead-minutes) und "
"Sonnenuntergang - ein größerer Wert gibt mehr Puffer gegen ausgefallene/"
"verzögerte Läufe (Standard: %(default)s)")
parser.add_argument("--zones", default=",".join(ZONES.keys()),
help=f"Kommagetrennte Liste der zu prüfenden Zonen aus {list(ZONES.keys())} "
"(Standard: alle)")
parser.add_argument("--mqtt-broker", default=MQTT_BROKER, help="MQTT-Broker (Standard: %(default)s)")
parser.add_argument("--mqtt-port", type=int, default=MQTT_PORT, help="MQTT-Port (Standard: %(default)s)")
parser.add_argument("--mqtt-username", default=MQTT_USERNAME, help="MQTT-Benutzername (optional)")
parser.add_argument("--mqtt-password", default=MQTT_PASSWORD, help="MQTT-Passwort (optional)")
parser.add_argument("--dry-run", action="store_true",
help="Nur auswerten und loggen, keine MQTT-Befehle senden")
parser.add_argument("-v", "--verbose", action="store_true", help="Debug-Logging aktivieren")
args = parser.parse_args()
if args.zones.strip():
selected = [z.strip() for z in args.zones.split(",") if z.strip()]
else:
selected = list(ZONES.keys())
unknown = [z for z in selected if z not in ZONES]
if unknown:
parser.error(f"Unbekannte Zone(n): {', '.join(unknown)} - gültig sind {list(ZONES.keys())}")
args.selected_zones = selected
return args
def build_zone_config(args):
"""Erstellt die effektive Zonen-Konfiguration unter Berücksichtigung der CLI-Overrides."""
zones = {}
for zone_key in args.selected_zones:
zone = dict(ZONES[zone_key])
flag_attr = zone_key.replace("-", "_")
days_override = getattr(args, f"{flag_attr}_days")
mm_override = getattr(args, f"{flag_attr}_mm")
min_duration_override = getattr(args, f"{flag_attr}_min_duration_minutes")
if days_override is not None:
zone["threshold_days"] = days_override
elif args.days is not None:
zone["threshold_days"] = args.days
if mm_override is not None:
zone["threshold_mm"] = mm_override
elif args.mm is not None:
zone["threshold_mm"] = args.mm
if min_duration_override is not None:
zone["min_duration_minutes"] = min_duration_override
elif args.min_duration_minutes is not None:
zone["min_duration_minutes"] = args.min_duration_minutes
zones[zone_key] = zone
return zones
# ============================================================================
# Regendaten (Open-Meteo)
# ============================================================================
def fetch_weather_data(lat, lon, timezone, past_days):
"""
Holt von Open-Meteo sowohl die Tages-Niederschlagssummen der letzten
`past_days` abgeschlossenen Tage (heute ausgeschlossen, da der Tag noch
nicht vorbei ist) als auch den heutigen Sonnenuntergang.
Returns:
(daily_precipitation, sunset_today)
daily_precipitation: Liste von (date, mm) Tupeln, aufsteigend
sortiert, ohne den heutigen Tag.
sunset_today: naive datetime des heutigen Sonnenuntergangs, lokale
Zeit in der übergebenen Zeitzone (wird mit datetime.now() ohne
Zeitzonen-Info verglichen - das System muss daher in derselben
Zeitzone laufen).
"""
params = {
"latitude": lat,
"longitude": lon,
"daily": "precipitation_sum,sunset",
"timezone": timezone,
# +1, da Open-Meteo den heutigen (noch laufenden) Tag mitliefert,
# der unten aus den Niederschlagsdaten explizit herausgefiltert wird.
"past_days": min(past_days + 1, 92),
"forecast_days": 1,
}
response = requests.get(OPEN_METEO_URL, params=params, timeout=15)
response.raise_for_status()
data = response.json()
dates = data["daily"]["time"]
sums = data["daily"]["precipitation_sum"]
sunsets = data["daily"]["sunset"]
today = datetime.now().date()
daily = []
sunset_today = None
for date_str, mm, sunset_str in zip(dates, sums, sunsets):
day = datetime.strptime(date_str, "%Y-%m-%d").date()
if day == today:
sunset_today = datetime.fromisoformat(sunset_str)
if day >= today:
continue
daily.append((day, mm if mm is not None else 0.0))
daily.sort(key=lambda entry: entry[0])
if sunset_today is None:
raise ValueError("Open-Meteo hat keinen Sonnenuntergang für heute geliefert.")
return daily, sunset_today
def accumulated_rain(daily_precipitation, days):
"""Summe der letzten `days` abgeschlossenen Tage aus daily_precipitation."""
relevant = daily_precipitation[-days:] if days > 0 else []
if len(relevant) < days:
logger.warning(
"Nur %d von %d angeforderten Tagen an Regendaten verfügbar - Auswertung erfolgt trotzdem.",
len(relevant), days,
)
return sum(mm for _, mm in relevant)
def watering_window(sunset_today, lead_minutes):
"""
Berechnet den Zeitraum, in dem ausgelöst werden darf: vom Zielzeitpunkt
(lead_minutes vor Sonnenuntergang) bis zum Sonnenuntergang selbst - ohne
Deckelung nach oben, damit auch ein verspäteter/nachgeholter Lauf noch
auslösen kann, solange die Sonne noch nicht untergegangen ist.
"""
target = sunset_today - timedelta(minutes=lead_minutes)
return target, sunset_today
def in_watering_window(now, window_start, window_end):
return window_start <= now <= window_end
# ============================================================================
# MQTT-Session: LastWatering lesen, Regen-Status + Auslöse-Befehle senden
# ============================================================================
def open_mqtt_session(broker, port, username, password, subscribe_topics, wait_timeout):
"""
Baut eine MQTT-Verbindung auf, abonniert `subscribe_topics` (um retained
Nachrichten, z.B. LastWatering, einzusammeln) und lässt den Client danach
für weitere publish()-Aufrufe verbunden.
Returns:
(client, received) - received wird im Hintergrund weiter befüllt,
falls auf einem abonnierten Topic erneut publiziert wird.
"""
received = {}
def on_connect(client, userdata, flags, reason_code, properties=None):
if reason_code != 0:
logger.error("MQTT-Verbindung fehlgeschlagen (reason_code=%s)", reason_code)
return
for topic in subscribe_topics:
client.subscribe(topic)
def on_message(client, userdata, msg):
try:
received[msg.topic] = json.loads(msg.payload.decode("utf-8"))
except (ValueError, UnicodeDecodeError) as error:
logger.warning("Konnte Payload von %s nicht als JSON lesen: %s", msg.topic, error)
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2,
client_id=f"AutoWatering-{datetime.now():%Y%m%d%H%M%S}")
if username:
client.username_pw_set(username, password)
client.on_connect = on_connect
client.on_message = on_message
client.connect(broker, port, keepalive=wait_timeout + 30)
client.loop_start()
time.sleep(wait_timeout)
return client, received
def publish_rain_status(client, zone_key, zone, rain_sum, recently_watered, last_start, now):
payload = {
"zone": zone["label"],
"rain_mm": round(rain_sum, 1),
"threshold_mm": zone["threshold_mm"],
"threshold_days": zone["threshold_days"],
"would_water": rain_sum < zone["threshold_mm"],
"watered_recently": recently_watered,
"last_watering": last_start.strftime("%d.%m.%Y %H:%M") if last_start else None,
"updated": now.strftime("%d.%m.%Y %H:%M:%S"),
}
topic = RAIN_STATUS_TOPIC_TEMPLATE.format(zone_key=zone_key)
client.publish(topic, json.dumps(payload), qos=1, retain=True)
logger.debug("Regen-Status veröffentlicht auf %s: %s", topic, payload)
def watered_recently(last_watering_data, zone, lookback_hours, now):
"""
Prüft anhand der gesammelten LastWatering-Daten, ob die Zone innerhalb der
letzten `lookback_hours` Stunden bereits bewässert wurde. Bewässerungen,
die kürzer als `zone["min_duration_minutes"]` liefen (z.B. kurzes
manuelles Antesten), werden dabei ignoriert.
Returns:
(bool watered_recently, datetime|None last_watering_start)
"""
payload = last_watering_data.get(zone["lastwatering_topic"])
if not payload:
return False, None
valve = zone["valve"]
date_key = f"wateringDateTime{valve}"
date_str = payload.get(date_key)
if not date_str:
return False, None
try:
last_start = datetime.strptime(date_str, "%d.%m.%y %H:%M")
except ValueError:
logger.warning("Konnte Zeitstempel '%s' aus %s nicht parsen (Format TT.MM.JJ HH:MM erwartet).",
date_str, zone["lastwatering_topic"])
return False, None
duration_key = f"wateringDurationSecs{valve}"
duration_secs = payload.get(duration_key)
min_duration_secs = zone["min_duration_minutes"] * 60
if isinstance(duration_secs, (int, float)) and duration_secs < min_duration_secs:
logger.debug(
"%s: letzte Bewässerung am %s dauerte nur %.0fs (< %.0f Min. Mindestdauer) - wird ignoriert.",
zone["label"], last_start.strftime("%d.%m.%Y %H:%M"), duration_secs, zone["min_duration_minutes"],
)
return False, None
age = now - last_start
return age < timedelta(hours=lookback_hours), last_start
# ============================================================================
# Automatik-Modus starten
# ============================================================================
def start_auto_mode(client, zone, dry_run):
if dry_run:
logger.debug("[DRY-RUN] Würde MQTT '%s' -> '%s' senden", zone["auto_topic"], zone["auto_payload"])
return
result = client.publish(zone["auto_topic"], zone["auto_payload"], qos=1, retain=False)
result.wait_for_publish(timeout=5)
logger.debug("MQTT gesendet: '%s' -> '%s'", zone["auto_topic"], zone["auto_payload"])
# ============================================================================
# Hauptprogramm
# ============================================================================
def main():
args = parse_args()
if args.verbose:
logger.setLevel(logging.DEBUG)
zones = build_zone_config(args)
max_days = max(zone["threshold_days"] for zone in zones.values())
try:
daily_precipitation, sunset_today = fetch_weather_data(args.lat, args.lon, args.timezone, max_days)
except (requests.RequestException, KeyError, ValueError) as error:
logger.critical("Wetterdaten konnten nicht von Open-Meteo abgerufen werden, breche ab: %s", error)
sys.exit(1)
logger.debug("Tages-Niederschlag (letzte %d Tage): %s", max_days, daily_precipitation)
now = datetime.now()
window_start, window_end = watering_window(sunset_today, args.watering_lead_minutes)
trigger_allowed = in_watering_window(now, window_start, window_end)
logger.info(
"Sonnenuntergang %s | Auslöse-Zeitraum %s-%s (%s)",
sunset_today.strftime("%H:%M"), window_start.strftime("%H:%M"), window_end.strftime("%H:%M"),
"aktiv" if trigger_allowed else "inaktiv",
)
lastwatering_topics = sorted({zone["lastwatering_topic"] for zone in zones.values()})
try:
client, last_watering_data = open_mqtt_session(
args.mqtt_broker, args.mqtt_port, args.mqtt_username, args.mqtt_password,
lastwatering_topics, MQTT_WAIT_TIMEOUT,
)
except OSError as error:
logger.critical("MQTT-Verbindung zu %s:%s fehlgeschlagen, breche ab: %s",
args.mqtt_broker, args.mqtt_port, error)
sys.exit(1)
triggered = []
skipped = []
try:
for zone_key, zone in zones.items():
rain_sum = accumulated_rain(daily_precipitation, zone["threshold_days"])
recently_watered, last_start = watered_recently(
last_watering_data, zone, args.lookback_hours, now
)
publish_rain_status(client, zone_key, zone, rain_sum, recently_watered, last_start, now)
needs_water = rain_sum < zone["threshold_mm"]
if not needs_water:
status = "kein Bedarf"
elif recently_watered:
status = f"übersprungen (bewässert {last_start:%H:%M})"
skipped.append(zone_key)
elif not trigger_allowed:
status = "übersprungen (außerhalb Zeitraum)"
skipped.append(zone_key)
else:
start_auto_mode(client, zone, args.dry_run)
status = "bewässert" + (" [DRY-RUN]" if args.dry_run else "")
triggered.append(zone_key)
logger.info(
"%-12s %5.1f/%4.1f mm (%dT) -> %s",
zone["label"], rain_sum, zone["threshold_mm"], zone["threshold_days"], status,
)
finally:
client.loop_stop()
client.disconnect()
logger.info(
"Ergebnis: %d bewässert, %d übersprungen, %d ohne Bedarf",
len(triggered), len(skipped), len(zones) - len(triggered) - len(skipped),
)
if __name__ == "__main__":
main()