diff --git a/restricted/gartenbewaesserung/auto_watering.py b/restricted/gartenbewaesserung/auto_watering.py deleted file mode 100644 index ae81165..0000000 --- a/restricted/gartenbewaesserung/auto_watering.py +++ /dev/null @@ -1,712 +0,0 @@ -#!/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()