#!/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 innerhalb der aktiven Zeitfenster (WATERING_SCHEDULE, per --schedule umschaltbar): morning ab Sonnenaufgang bis SUNRISE_FOLLOW_MINUTES danach. Der Start liegt auf dem Sonnenaufgang selbst; dass tatsächlich "kurz nach" Sonnenaufgang bewässert wird, ergibt sich aus dem stündlichen Cron-Raster. Das Fenster ist nach hinten offen. evening ab WATERING_LEAD_MINUTES vor Sonnenuntergang bis zum Sonnenuntergang selbst. Hier ist der Sonnenuntergang die harte Grenze, das Fenster daher nach vorne offen. both beide Fenster aktiv - dann wird morgens UND abends bewässert. Beide Fenster sind bewusst breiter als eine Stunde und haben keine feste Zielminute: jeder stündliche Lauf, der irgendwo hineinfällt, 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 das Fenster noch nicht vorbei ist. Mehr Puffer gegen Ausfälle gibt es einfach durch größere SUNRISE_FOLLOW_MINUTES- bzw. WATERING_LEAD_MINUTES-Werte. Da ein Fenster damit breiter ist als der Cron-Abstand (70 Min. > 1 Std.), würden sonst zwei Läufe desselben Fensters auslösen; deshalb merkt sich das Skript seinen eigenen Auslösezeitpunkt je Fenster im retained RainStatus-Topic der Zone und löst pro Fenster und Kalendertag nur einmal aus. Zusätzlich wird eine Zone übersprungen, wenn sie laut dem retained "LastWatering"-MQTT-Topic der Ventilsteuerung bereits innerhalb der letzten RECENT_WATERING_LOOKBACK_HOURS Stunden bewässert wurde. Dieser Schutz greift auch über Fenstergrenzen hinweg und macht den Modus "both" damit selbstregulierend: der Abstand zwischen Morgen- und Abendauslösung entspricht etwa der Tageslänge minus gut einer Stunde, liegt also nur im Hochsommer über den 12 Stunden Standard-Sperre. "both" bewässert deshalb nur bei langen Tagen wirklich zweimal und wirkt sonst wie "morning" - das ist so gewollt, siehe README, Abschnitt "Regenbasierte Automatik". Unabhängig vom Regen wird eine Zone bewässert, wenn ihre letzte Bewässerung länger als "max_dry_days" Tage zurückliegt (Trockenheits-Notlauf, siehe ZONES). 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 # Welche Zeitfenster aktiv sind: "morning", "evening" oder "both". WATERING_SCHEDULE = "morning" # Namen der Zeitfenster in der Reihenfolge, in der sie geprüft werden. SCHEDULES = ("morning", "evening") # Morgens: ausgelöst wird bei jedem Lauf zwischen dem Sonnenaufgang und so # vielen Minuten danach - je größer der Wert, desto mehr Puffer gibt es, falls # ein stündlicher Lauf ausfällt oder sich verzögert (siehe Modul-Docstring). SUNRISE_FOLLOW_MINUTES = 70 # Abends: Zielzeitpunkt für den Bewässerungsstart, so viele Minuten vor # Sonnenuntergang. Ausgelöst wird bei jedem Lauf zwischen diesem Zielzeitpunkt # und dem Sonnenuntergang selbst. 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" # Format des Feldes "last_trigger" in den RainStatus-Nachrichten. Darin merkt # sich das Skript, wann es zuletzt selbst ausgelöst hat - unabhängig davon, # ob und wann die Ventilsteuerung "LastWatering" aktualisiert. TRIGGER_TIME_FORMAT = "%d.%m.%Y %H:%M:%S" # Feldname je Zeitfenster, unter dem der eigene Auslösezeitpunkt abgelegt wird. TRIGGER_FIELD_TEMPLATE = "last_trigger_{schedule}" 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 (der heutige, noch laufende Tag zählt dabei mit) zusammen weniger als # "threshold_mm" mm Regen gebracht haben. Unabhängig vom Regen wird bewässert, # wenn die letzte Bewässerung der Zone länger als "max_dry_days" Tage # zurückliegt (None oder 0 = dieser Trockenheits-Notlauf ist für die Zone aus). 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, # Trockenheits-Notlauf: auch bei Regen bewässern, wenn die Hochbeete # seit 5 Tagen nicht mehr bewässert wurden. "max_dry_days": 5, # 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, # Tröge werden ohnehin fast täglich bewässert - kein Notlauf nötig. "max_dry_days": None, # 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, "max_dry_days": None, # 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}-max-dry-days", type=float, default=None, help=f"Bewässert Zone '{zone['label']}' unabhängig vom Regen, wenn die letzte " "Bewässerung so viele Tage zurückliegt (0 = aus, überschreibt --max-dry-days)") 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("--max-dry-days", type=float, default=None, help="Überschreibt für ALLE Zonen ohne zonenspezifischen Override, nach wie vielen " "Tagen ohne Bewässerung unabhängig vom Regen bewässert wird (0 = aus; " "Standard: je Zone eigener Wert, siehe ZONES)") parser.add_argument("--schedule", choices=["morning", "evening", "both"], default=WATERING_SCHEDULE, help="Welche Zeitfenster auslösen dürfen: morgens nach Sonnenaufgang, abends vor " "Sonnenuntergang oder beide (dann wird morgens UND abends bewässert). " "Standard: %(default)s") parser.add_argument("--sunrise-follow-minutes", type=float, default=SUNRISE_FOLLOW_MINUTES, help="Morgens wird zwischen Sonnenaufgang und (Sonnenaufgang + sunrise-follow-minutes) " "ausgelöst - ein größerer Wert gibt mehr Puffer gegen ausgefallene/verzögerte " "Läufe (Standard: %(default)s)") parser.add_argument("--watering-lead-minutes", type=float, default=WATERING_LEAD_MINUTES, help="Abends wird zwischen (Sonnenuntergang - watering-lead-minutes) und " "Sonnenuntergang ausgelöst - 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") max_dry_override = getattr(args, f"{flag_attr}_max_dry_days") 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 if max_dry_override is not None: zone["max_dry_days"] = max_dry_override elif args.max_dry_days is not None: zone["max_dry_days"] = args.max_dry_days zones[zone_key] = zone return zones # ============================================================================ # Regendaten (Open-Meteo) # ============================================================================ def fetch_weather_data(lat, lon, timezone, past_days): """ Holt von Open-Meteo die Tages-Niederschlagssummen der letzten `past_days` abgeschlossenen Tage sowie den bereits gefallenen Regen des heutigen, noch laufenden Tages (aus den stündlichen Werten, ohne die Vorhersage für die restlichen Stunden des Tages) und Sonnenauf-/-untergang von heute. Returns: (daily_precipitation, today_precipitation, sunrise_today, sunset_today) daily_precipitation: Liste von (date, mm) Tupeln, aufsteigend sortiert, ohne den heutigen Tag. today_precipitation: mm Regen, die heute bereits (bis zur aktuellen Stunde) gefallen sind - kein Vorhersagewert für den Rest des Tages. sunrise_today, sunset_today: naive datetimes des heutigen Sonnenauf- und -untergangs, lokale Zeit in der übergebenen Zeitzone (werden mit datetime.now() ohne Zeitzonen-Info verglichen - das System muss daher in derselben Zeitzone laufen). """ params = { "latitude": lat, "longitude": lon, "daily": "precipitation_sum,sunrise,sunset", "hourly": "precipitation", "timezone": timezone, "past_days": min(past_days, 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"] sunrises = data["daily"]["sunrise"] sunsets = data["daily"]["sunset"] today = datetime.now().date() daily = [] sunrise_today = None sunset_today = None for date_str, mm, sunrise_str, sunset_str in zip(dates, sums, sunrises, sunsets): day = datetime.strptime(date_str, "%Y-%m-%d").date() if day == today: sunrise_today = datetime.fromisoformat(sunrise_str) 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 sunrise_today is None or sunset_today is None: raise ValueError("Open-Meteo hat keinen Sonnenauf-/-untergang für heute geliefert.") now = datetime.now() today_precipitation = 0.0 for time_str, mm in zip(data["hourly"]["time"], data["hourly"]["precipitation"]): hour_dt = datetime.fromisoformat(time_str) if hour_dt.date() == today and hour_dt <= now: today_precipitation += mm if mm is not None else 0.0 return daily, today_precipitation, sunrise_today, sunset_today def accumulated_rain(daily_precipitation, today_precipitation, days): """ Summe der Regenmenge über die letzten `days` Tage, wobei der heutige (noch laufende) Tag als letzter Tag des Fensters zählt: der bereits gefallene Regen von heute (today_precipitation) plus die davor liegenden (days - 1) abgeschlossenen Tage aus daily_precipitation. """ if days <= 0: return 0.0 completed_days_needed = days - 1 relevant = daily_precipitation[-completed_days_needed:] if completed_days_needed > 0 else [] if len(relevant) < completed_days_needed: logger.warning( "Nur %d von %d angeforderten abgeschlossenen Vortagen an Regendaten verfügbar - Auswertung erfolgt trotzdem.", len(relevant), completed_days_needed, ) return today_precipitation + sum(mm for _, mm in relevant) def build_windows(sunrise_today, sunset_today, schedule, sunrise_follow_minutes, lead_minutes): """ Baut die Zeiträume, in denen ausgelöst werden darf, je gewähltem Modus. "morning" beginnt am Sonnenaufgang und ist nach hinten offen (bis sunrise_follow_minutes danach), "evening" endet am Sonnenuntergang und ist nach vorne offen (ab lead_minutes davor). So kann ein verspäteter oder nachgeholter Lauf jeweils noch auslösen. Returns: dict {Fenstername: (start, ende)} in der Reihenfolge aus SCHEDULES. """ windows = {} if schedule in ("morning", "both"): windows["morning"] = (sunrise_today, sunrise_today + timedelta(minutes=sunrise_follow_minutes)) if schedule in ("evening", "both"): windows["evening"] = (sunset_today - timedelta(minutes=lead_minutes), sunset_today) return windows def active_window(now, windows): """Name des Zeitfensters, in dem `now` liegt, sonst None.""" for name, (start, end) in windows.items(): if start <= now <= end: return name return None # ============================================================================ # 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, forced_dry, triggers, window, now): payload = { "zone": zone["label"], "rain_mm": round(rain_sum, 1), "threshold_mm": zone["threshold_mm"], "threshold_days": zone["threshold_days"], "max_dry_days": zone.get("max_dry_days"), "would_water": rain_sum < zone["threshold_mm"] or forced_dry, # True, wenn nicht der Regen, sondern der Trockenheits-Notlauf # (max_dry_days) den Bedarf ausgelöst hat. "forced_dry": forced_dry, "watered_recently": recently_watered, "last_watering": last_start.strftime("%d.%m.%Y %H:%M") if last_start else None, # Zeitfenster, in dem dieser Lauf lag (None = außerhalb). "window": window, # Wann dieses Skript zuletzt selbst ausgelöst hat, insgesamt und je # Zeitfenster - die Fensterwerte werden beim nächsten Lauf wieder # gelesen, damit pro Fenster und Kalendertag nur einmal ausgelöst wird. "last_trigger": max(triggers.values()).strftime(TRIGGER_TIME_FORMAT) if triggers else None, "updated": now.strftime("%d.%m.%Y %H:%M:%S"), } for name in SCHEDULES: payload[TRIGGER_FIELD_TEMPLATE.format(schedule=name)] = ( triggers[name].strftime(TRIGGER_TIME_FORMAT) if name in triggers else None ) 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 def last_own_triggers(mqtt_data, zone_key): """ Liest aus der eigenen retained RainStatus-Nachricht der Zone, wann dieses Skript je Zeitfenster zuletzt selbst ausgelöst hat. Das ist der Schutz gegen doppeltes Auslösen im selben Fenster: die Fenster sind mit SUNRISE_FOLLOW_MINUTES bzw. WATERING_LEAD_MINUTES breiter als der stündliche Cron-Abstand, es fallen also je nach Sonnenauf-/-untergangszeit zwei Läufe hinein. Auf das LastWatering-Topic der Ventilsteuerung ist dabei kein Verlass (es wird u.U. erst nach dem Lauf aktualisiert, und zu kurze Läufe werden bewusst ignoriert), deshalb protokolliert das Skript seinen Auslösezeitpunkt selbst. Returns: dict {Fenstername: datetime} - Fenster ohne Eintrag fehlen im dict. """ triggers = {} payload = mqtt_data.get(RAIN_STATUS_TOPIC_TEMPLATE.format(zone_key=zone_key)) if not payload: return triggers for name in SCHEDULES: field = TRIGGER_FIELD_TEMPLATE.format(schedule=name) value = payload.get(field) if not value: # Nachricht einer älteren Skriptversion, die nur ein Fenster kannte # und den Zeitpunkt unter "last_trigger" ablegte: konservativ für # alle Fenster übernehmen, damit am Umstellungstag nicht doppelt # bewässert wird. value = payload.get("last_trigger") field = "last_trigger" if not value: continue try: triggers[name] = datetime.strptime(value, TRIGGER_TIME_FORMAT) except (TypeError, ValueError): logger.warning("Konnte '%s' ('%s') aus dem RainStatus von %s nicht parsen.", field, value, zone_key) return triggers def dry_for_too_long(zone, last_start, now): """ Trockenheits-Notlauf: True, wenn die Zone unabhängig vom Regen bewässert werden soll, weil ihre letzte Bewässerung länger als zone["max_dry_days"] Tage zurückliegt. Ist kein Zeitpunkt der letzten Bewässerung bekannt (kein retained LastWatering, oder nur eine zu kurze Bewässerung, die als Antesten ignoriert wird), wird NICHT ausgelöst - dann bleibt es bei der Regenlogik, statt bei jedem Broker-Neustart eine Bewässerung zu erzwingen. """ max_dry_days = zone.get("max_dry_days") if not max_dry_days or max_dry_days <= 0: return False if last_start is None: logger.debug("%s: letzte Bewässerung unbekannt - Trockenheits-Notlauf nicht auswertbar.", zone["label"]) return False return now - last_start >= timedelta(days=max_dry_days) # ============================================================================ # 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, today_precipitation, sunrise_today, sunset_today = fetch_weather_data( args.lat, args.lon, args.timezone, max(max_days - 1, 0) ) 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 vor heute): %s | heute bisher: %.1f mm", max_days - 1, daily_precipitation, today_precipitation, ) now = datetime.now() windows = build_windows(sunrise_today, sunset_today, args.schedule, args.sunrise_follow_minutes, args.watering_lead_minutes) current_window = active_window(now, windows) logger.info( "Sonnenaufgang %s | Sonnenuntergang %s | Auslöse-Zeitraum %s (%s)", sunrise_today.strftime("%H:%M"), sunset_today.strftime("%H:%M"), ", ".join(f"{name} {start:%H:%M}-{end:%H:%M}" for name, (start, end) in windows.items()), f"aktiv: {current_window}" if current_window else "inaktiv", ) # Neben LastWatering (Ventilsteuerung) auch die eigenen RainStatus-Topics # abonnieren - daraus wird der zuletzt selbst ausgelöste Zeitpunkt gelesen. subscribe_topics = sorted( {zone["lastwatering_topic"] for zone in zones.values()} | {RAIN_STATUS_TOPIC_TEMPLATE.format(zone_key=zone_key) for zone_key in zones} ) try: client, mqtt_data = open_mqtt_session( args.mqtt_broker, args.mqtt_port, args.mqtt_username, args.mqtt_password, subscribe_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, today_precipitation, zone["threshold_days"]) recently_watered, last_start = watered_recently( mqtt_data, zone, args.lookback_hours, now ) forced_dry = dry_for_too_long(zone, last_start, now) triggers = last_own_triggers(mqtt_data, zone_key) window_trigger = triggers.get(current_window) if current_window else None triggered_today = window_trigger is not None and window_trigger.date() == now.date() needs_water = rain_sum < zone["threshold_mm"] or forced_dry if not needs_water: status = "kein Bedarf" elif recently_watered: status = f"übersprungen (bewässert {last_start:%H:%M})" skipped.append(zone_key) elif triggered_today: status = f"übersprungen ({current_window} heute schon ausgelöst {window_trigger:%H:%M})" skipped.append(zone_key) elif current_window is None: status = "übersprungen (außerhalb Zeitraum)" skipped.append(zone_key) else: start_auto_mode(client, zone, args.dry_run) if not args.dry_run: triggers[current_window] = now status = f"bewässert ({current_window})" + (" [DRY-RUN]" if args.dry_run else "") triggered.append(zone_key) # Erst nach der Entscheidung veröffentlichen, damit der gerade # gesetzte Auslösezeitpunkt mit in die retained Nachricht kommt. publish_rain_status(client, zone_key, zone, rain_sum, recently_watered, last_start, forced_dry, triggers, current_window, now) logger.info( "%-12s %5.1f/%4.1f mm (%dT)%s -> %s", zone["label"], rain_sum, zone["threshold_mm"], zone["threshold_days"], f" [trocken seit {(now - last_start).days}T]" if forced_dry else "", 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()