#!/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()