From 9f8c9016c293016c279461d287b7ea2f570a0799 Mon Sep 17 00:00:00 2001 From: Moirtz Wagner Date: Mon, 31 Aug 2026 15:49:09 +0200 Subject: [PATCH] Gartenbewaesserung: Morgenfenster, Trockenheits-Notlauf, Regen von heute Vier Aenderungen an auto_watering.py: Zeitfenster morgens statt nur abends. Ueber --schedule laesst sich "morning", "evening" oder "both" waehlen; morgens reicht das Fenster vom Sonnenaufgang bis SUNRISE_FOLLOW_MINUTES danach, abends wie bisher von WATERING_LEAD_MINUTES vor Sonnenuntergang bis zum Sonnenuntergang. Im Modus "both" haelt die 12-Stunden-Sperre aus RECENT_WATERING_LOOKBACK_HOURS das Ganze selbstregulierend: zweimal am Tag wird nur bei langen Sommertagen wirklich bewaessert. Eigener Ausloesezeitpunkt je Fenster. Die Fenster sind mit 70 Minuten breiter als das stuendliche Cron-Raster, es fallen also je nach Sonnenzeit zwei Laeufe hinein. Auf das LastWatering-Topic der Ventilsteuerung ist als Schutz kein Verlass - es wird unter Umstaenden erst spaeter aktualisiert, und zu kurze Laeufe werden bewusst ignoriert. Das Skript merkt sich seinen Auslusezeitpunkt deshalb selbst im retained RainStatus der Zone und loest pro Fenster und Kalendertag nur einmal aus. Nachrichten der Vorversion, die nur ein "last_trigger" kannten, werden konservativ fuer beide Fenster uebernommen, damit am Umstellungstag nicht doppelt bewaessert wird. Der heutige Regen zaehlt mit. Bisher wurden nur abgeschlossene Tage summiert - hatte es seit dem Morgen geschuettet, half das der Entscheidung am Abend nicht. Jetzt kommt der bereits gefallene Regen des laufenden Tages aus den stuendlichen Werten dazu, ohne die Vorhersage fuer die restlichen Stunden. Das Fenster verschiebt sich entsprechend: threshold_days zaehlt heute als letzten Tag mit. Trockenheits-Notlauf. Unabhaengig vom Regen wird bewaessert, wenn die letzte Bewaesserung laenger als max_dry_days zurueckliegt - fuer die Hochbeete auf fuenf Tage gesetzt, fuer Troege und Garten vorn aus. Ist kein Zeitpunkt bekannt, wird nicht ausgeloest: sonst erzwaenge jeder Broker-Neustart eine Bewaesserung. Geprueft mit --dry-run in beiden Modi: die Fenster werden richtig gerechnet (06:36-07:46 und 18:52-20:02), und die Zonen entscheiden wie erwartet. Co-Authored-By: Claude Opus 5 --- .../gartenbewaesserung/auto_watering.py | 358 ++++++++++++++---- 1 file changed, 283 insertions(+), 75 deletions(-) diff --git a/restricted/gartenbewaesserung/auto_watering.py b/restricted/gartenbewaesserung/auto_watering.py index bc7ca2f..ae81165 100644 --- a/restricted/gartenbewaesserung/auto_watering.py +++ b/restricted/gartenbewaesserung/auto_watering.py @@ -10,16 +10,42 @@ 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. +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"). @@ -54,11 +80,20 @@ TIMEZONE = "Europe/Berlin" # 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. +# 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 @@ -74,13 +109,24 @@ MQTT_WAIT_TIMEOUT = 5 # 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 zusammen weniger als "threshold_mm" mm Regen gebracht haben. +# 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", @@ -91,6 +137,9 @@ ZONES = { # 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, @@ -106,6 +155,8 @@ ZONES = { # (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, }, @@ -118,6 +169,7 @@ ZONES = { # 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, @@ -156,6 +208,9 @@ def parse_args(): 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)") @@ -168,10 +223,25 @@ def parse_args(): "'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="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)") + 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())} " @@ -209,6 +279,7 @@ def build_zone_config(args): 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: @@ -221,6 +292,10 @@ def build_zone_config(args): 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 @@ -231,27 +306,30 @@ def build_zone_config(args): 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. + 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, sunset_today) + (daily_precipitation, today_precipitation, sunrise_today, 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). + 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,sunset", + "daily": "precipitation_sum,sunrise,sunset", + "hourly": "precipitation", "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), + "past_days": min(past_days, 92), "forecast_days": 1, } response = requests.get(OPEN_METEO_URL, params=params, timeout=15) @@ -260,14 +338,17 @@ def fetch_weather_data(lat, lon, timezone, past_days): 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, sunset_str in zip(dates, sums, sunsets): + 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 @@ -275,36 +356,64 @@ def fetch_weather_data(lat, lon, timezone, past_days): daily.sort(key=lambda entry: entry[0]) - if sunset_today is None: - raise ValueError("Open-Meteo hat keinen Sonnenuntergang für heute geliefert.") + if sunrise_today is None or sunset_today is None: + raise ValueError("Open-Meteo hat keinen Sonnenauf-/-untergang für heute geliefert.") - return daily, sunset_today + 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, days): - """Summe der letzten `days` abgeschlossenen Tage aus daily_precipitation.""" - relevant = daily_precipitation[-days:] if days > 0 else [] - if len(relevant) < days: +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 Tagen an Regendaten verfügbar - Auswertung erfolgt trotzdem.", - len(relevant), days, + "Nur %d von %d angeforderten abgeschlossenen Vortagen an Regendaten verfügbar - Auswertung erfolgt trotzdem.", + len(relevant), completed_days_needed, ) - return sum(mm for _, mm in relevant) + return today_precipitation + sum(mm for _, mm in relevant) -def watering_window(sunset_today, lead_minutes): +def build_windows(sunrise_today, sunset_today, schedule, sunrise_follow_minutes, 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. + 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. """ - target = sunset_today - timedelta(minutes=lead_minutes) - return target, sunset_today + 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 in_watering_window(now, window_start, window_end): - return window_start <= now <= window_end +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 # ============================================================================ @@ -350,17 +459,32 @@ def open_mqtt_session(broker, port, username, password, subscribe_topics, wait_t return client, received -def publish_rain_status(client, zone_key, zone, rain_sum, recently_watered, last_start, now): +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"], - "would_water": rain_sum < zone["threshold_mm"], + "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) @@ -407,6 +531,65 @@ def watered_recently(last_watering_data, zone, lookback_hours, now): 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 # ============================================================================ @@ -434,27 +617,39 @@ def main(): 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) + 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): %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", + logger.debug( + "Tages-Niederschlag (letzte %d Tage vor heute): %s | heute bisher: %.1f mm", + max_days - 1, daily_precipitation, today_precipitation, ) - lastwatering_topics = sorted({zone["lastwatering_topic"] for zone in zones.values()}) + 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, last_watering_data = open_mqtt_session( + client, mqtt_data = open_mqtt_session( args.mqtt_broker, args.mqtt_port, args.mqtt_username, args.mqtt_password, - lastwatering_topics, MQTT_WAIT_TIMEOUT, + subscribe_topics, MQTT_WAIT_TIMEOUT, ) except OSError as error: logger.critical("MQTT-Verbindung zu %s:%s fehlgeschlagen, breche ab: %s", @@ -465,30 +660,43 @@ def main(): skipped = [] try: for zone_key, zone in zones.items(): - rain_sum = accumulated_rain(daily_precipitation, zone["threshold_days"]) + rain_sum = accumulated_rain(daily_precipitation, today_precipitation, zone["threshold_days"]) recently_watered, last_start = watered_recently( - last_watering_data, zone, args.lookback_hours, now + 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() - publish_rain_status(client, zone_key, zone, rain_sum, recently_watered, last_start, now) - - needs_water = rain_sum < zone["threshold_mm"] + 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 not trigger_allowed: + 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) - status = "bewässert" + (" [DRY-RUN]" if args.dry_run else "") + 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", - zone["label"], rain_sum, zone["threshold_mm"], zone["threshold_days"], status, + "%-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()