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()