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 <noreply@anthropic.com>
This commit is contained in:
@@ -10,16 +10,42 @@ andere Systeme (z.B. das Web-UI) jederzeit den aktuellen Stand abfragen
|
|||||||
können.
|
können.
|
||||||
|
|
||||||
Tatsächlich ausgelöst (Automatik-Modus per MQTT gestartet) wird die
|
Tatsächlich ausgelöst (Automatik-Modus per MQTT gestartet) wird die
|
||||||
Bewässerung nur im Zeitraum zwischen dem Zielzeitpunkt (WATERING_LEAD_MINUTES
|
Bewässerung nur innerhalb der aktiven Zeitfenster (WATERING_SCHEDULE, per
|
||||||
Minuten vor Sonnenuntergang) und dem Sonnenuntergang selbst - dieser Zeitraum
|
--schedule umschaltbar):
|
||||||
ist bewusst offen nach vorne (keine feste Fensterbreite): jeder stündliche
|
|
||||||
Lauf, der irgendwann in diesem Zeitraum passiert, löst aus. Das macht die
|
morning ab Sonnenaufgang bis SUNRISE_FOLLOW_MINUTES danach. Der Start liegt
|
||||||
Auslösung robust gegen Jitter und sogar gegen einen komplett ausgefallenen
|
auf dem Sonnenaufgang selbst; dass tatsächlich "kurz nach"
|
||||||
Lauf (z.B. NAS-Neustart) - ein späterer Lauf holt es nach, solange der
|
Sonnenaufgang bewässert wird, ergibt sich aus dem stündlichen
|
||||||
Sonnenuntergang noch nicht erreicht ist. Mehr Puffer gegen Ausfälle gibt es
|
Cron-Raster. Das Fenster ist nach hinten offen.
|
||||||
einfach durch einen größeren WATERING_LEAD_MINUTES-Wert. Zusätzlich wird eine
|
evening ab WATERING_LEAD_MINUTES vor Sonnenuntergang bis zum Sonnenuntergang
|
||||||
Zone übersprungen, wenn sie laut dem retained "LastWatering"-MQTT-Topic der
|
selbst. Hier ist der Sonnenuntergang die harte Grenze, das Fenster
|
||||||
Ventilsteuerung bereits innerhalb der letzten X Stunden bewässert wurde.
|
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
|
MQTT-Schnittstelle der Ventilsteuerung siehe README.md des Firmware-Projekts
|
||||||
(ESP32 Ventilsteuerung, Topics "Gartenwasser/vorn" bzw. "Gartenwasser/hinten").
|
(ESP32 Ventilsteuerung, Topics "Gartenwasser/vorn" bzw. "Gartenwasser/hinten").
|
||||||
@@ -54,11 +80,20 @@ TIMEZONE = "Europe/Berlin"
|
|||||||
# Antesten).
|
# Antesten).
|
||||||
RECENT_WATERING_LOOKBACK_HOURS = 12
|
RECENT_WATERING_LOOKBACK_HOURS = 12
|
||||||
|
|
||||||
# Zielzeitpunkt für den Bewässerungsstart: so viele Minuten vor
|
# Welche Zeitfenster aktiv sind: "morning", "evening" oder "both".
|
||||||
# Sonnenuntergang. Ausgelöst wird bei jedem Lauf zwischen diesem
|
WATERING_SCHEDULE = "morning"
|
||||||
# Zielzeitpunkt und dem Sonnenuntergang selbst (kein festes Zeitfenster,
|
|
||||||
# siehe Modul-Docstring) - je größer der Wert, desto mehr Puffer gibt es,
|
# Namen der Zeitfenster in der Reihenfolge, in der sie geprüft werden.
|
||||||
# falls ein stündlicher Lauf ausfällt oder sich verzögert.
|
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
|
WATERING_LEAD_MINUTES = 70
|
||||||
|
|
||||||
# MQTT-Broker der Hausautomation
|
# MQTT-Broker der Hausautomation
|
||||||
@@ -74,13 +109,24 @@ MQTT_WAIT_TIMEOUT = 5
|
|||||||
# je Zone (retained). {zone_key} wird durch den jeweiligen Zonen-Key ersetzt.
|
# je Zone (retained). {zone_key} wird durch den jeweiligen Zonen-Key ersetzt.
|
||||||
RAIN_STATUS_TOPIC_TEMPLATE = "Gartenwasser/AutoWatering/{zone_key}/RainStatus"
|
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"
|
OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast"
|
||||||
|
|
||||||
# Zonen-Konfiguration: je Zone eigener Schwellenwert, eigenes MQTT-Auto-Topic
|
# Zonen-Konfiguration: je Zone eigener Schwellenwert, eigenes MQTT-Auto-Topic
|
||||||
# (siehe README: /auto-Topic mit Klartext-Payload "Hoch"/"Trog"/"Vorn") und
|
# (siehe README: /auto-Topic mit Klartext-Payload "Hoch"/"Trog"/"Vorn") und
|
||||||
# das zugehörige LastWatering-Topic + Ventilnummer zur Erkennung kürzlich
|
# das zugehörige LastWatering-Topic + Ventilnummer zur Erkennung kürzlich
|
||||||
# erfolgter Bewässerung. Bewässert wird, wenn die letzten "threshold_days"
|
# 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 = {
|
ZONES = {
|
||||||
"hochbeete": {
|
"hochbeete": {
|
||||||
"label": "Hochbeete",
|
"label": "Hochbeete",
|
||||||
@@ -91,6 +137,9 @@ ZONES = {
|
|||||||
# Schon nach 2 Tagen ohne (nennenswerten) Regen bewässern.
|
# Schon nach 2 Tagen ohne (nennenswerten) Regen bewässern.
|
||||||
"threshold_days": 2,
|
"threshold_days": 2,
|
||||||
"threshold_mm": 1.0,
|
"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
|
# Manuelle Bewässerungen unter 10 Minuten zählen nicht als "bereits
|
||||||
# bewässert" (z.B. kurzes Antesten).
|
# bewässert" (z.B. kurzes Antesten).
|
||||||
"min_duration_minutes": 10,
|
"min_duration_minutes": 10,
|
||||||
@@ -106,6 +155,8 @@ ZONES = {
|
|||||||
# (hoher mm-Schwellenwert, 1 Tag) wird ausgesetzt.
|
# (hoher mm-Schwellenwert, 1 Tag) wird ausgesetzt.
|
||||||
"threshold_days": 1,
|
"threshold_days": 1,
|
||||||
"threshold_mm": 20.0,
|
"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.).
|
# Bei den Trögen reicht schon eine kurze manuelle Bewässerung (3 Min.).
|
||||||
"min_duration_minutes": 3,
|
"min_duration_minutes": 3,
|
||||||
},
|
},
|
||||||
@@ -118,6 +169,7 @@ ZONES = {
|
|||||||
# Erst nach 5 Tagen ohne (nennenswerten) Regen bewässern.
|
# Erst nach 5 Tagen ohne (nennenswerten) Regen bewässern.
|
||||||
"threshold_days": 5,
|
"threshold_days": 5,
|
||||||
"threshold_mm": 1.0,
|
"threshold_mm": 1.0,
|
||||||
|
"max_dry_days": None,
|
||||||
# Manuelle Bewässerungen unter 10 Minuten zählen nicht als "bereits
|
# Manuelle Bewässerungen unter 10 Minuten zählen nicht als "bereits
|
||||||
# bewässert" (z.B. kurzes Antesten).
|
# bewässert" (z.B. kurzes Antesten).
|
||||||
"min_duration_minutes": 10,
|
"min_duration_minutes": 10,
|
||||||
@@ -156,6 +208,9 @@ def parse_args():
|
|||||||
help=f"Schwellenwert Tage nur für Zone '{zone['label']}' (überschreibt --days)")
|
help=f"Schwellenwert Tage nur für Zone '{zone['label']}' (überschreibt --days)")
|
||||||
parser.add_argument(f"--{flag_prefix}-mm", type=float, default=None,
|
parser.add_argument(f"--{flag_prefix}-mm", type=float, default=None,
|
||||||
help=f"Schwellenwert mm nur für Zone '{zone['label']}' (überschreibt --mm)")
|
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,
|
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 "
|
help=f"Mindestdauer (Min.) nur für Zone '{zone['label']}', unterhalb derer eine "
|
||||||
"manuelle Bewässerung ignoriert wird (überschreibt --min-duration-minutes)")
|
"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 "
|
"'bereits bewässert' zählt, für ALLE Zonen ohne zonenspezifischen Override "
|
||||||
"(Standard: je Zone eigener Wert, siehe ZONES)")
|
"(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,
|
parser.add_argument("--watering-lead-minutes", type=float, default=WATERING_LEAD_MINUTES,
|
||||||
help="Ausgelöst wird zwischen (Sonnenuntergang - watering-lead-minutes) und "
|
help="Abends wird zwischen (Sonnenuntergang - watering-lead-minutes) und "
|
||||||
"Sonnenuntergang - ein größerer Wert gibt mehr Puffer gegen ausgefallene/"
|
"Sonnenuntergang ausgelöst - ein größerer Wert gibt mehr Puffer gegen "
|
||||||
"verzögerte Läufe (Standard: %(default)s)")
|
"ausgefallene/verzögerte Läufe (Standard: %(default)s)")
|
||||||
|
|
||||||
parser.add_argument("--zones", default=",".join(ZONES.keys()),
|
parser.add_argument("--zones", default=",".join(ZONES.keys()),
|
||||||
help=f"Kommagetrennte Liste der zu prüfenden Zonen aus {list(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")
|
days_override = getattr(args, f"{flag_attr}_days")
|
||||||
mm_override = getattr(args, f"{flag_attr}_mm")
|
mm_override = getattr(args, f"{flag_attr}_mm")
|
||||||
min_duration_override = getattr(args, f"{flag_attr}_min_duration_minutes")
|
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:
|
if days_override is not None:
|
||||||
zone["threshold_days"] = days_override
|
zone["threshold_days"] = days_override
|
||||||
elif args.days is not None:
|
elif args.days is not None:
|
||||||
@@ -221,6 +292,10 @@ def build_zone_config(args):
|
|||||||
zone["min_duration_minutes"] = min_duration_override
|
zone["min_duration_minutes"] = min_duration_override
|
||||||
elif args.min_duration_minutes is not None:
|
elif args.min_duration_minutes is not None:
|
||||||
zone["min_duration_minutes"] = args.min_duration_minutes
|
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
|
zones[zone_key] = zone
|
||||||
return zones
|
return zones
|
||||||
|
|
||||||
@@ -231,27 +306,30 @@ def build_zone_config(args):
|
|||||||
|
|
||||||
def fetch_weather_data(lat, lon, timezone, past_days):
|
def fetch_weather_data(lat, lon, timezone, past_days):
|
||||||
"""
|
"""
|
||||||
Holt von Open-Meteo sowohl die Tages-Niederschlagssummen der letzten
|
Holt von Open-Meteo die Tages-Niederschlagssummen der letzten `past_days`
|
||||||
`past_days` abgeschlossenen Tage (heute ausgeschlossen, da der Tag noch
|
abgeschlossenen Tage sowie den bereits gefallenen Regen des heutigen,
|
||||||
nicht vorbei ist) als auch den heutigen Sonnenuntergang.
|
noch laufenden Tages (aus den stündlichen Werten, ohne die Vorhersage
|
||||||
|
für die restlichen Stunden des Tages) und Sonnenauf-/-untergang von heute.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(daily_precipitation, sunset_today)
|
(daily_precipitation, today_precipitation, sunrise_today, sunset_today)
|
||||||
daily_precipitation: Liste von (date, mm) Tupeln, aufsteigend
|
daily_precipitation: Liste von (date, mm) Tupeln, aufsteigend
|
||||||
sortiert, ohne den heutigen Tag.
|
sortiert, ohne den heutigen Tag.
|
||||||
sunset_today: naive datetime des heutigen Sonnenuntergangs, lokale
|
today_precipitation: mm Regen, die heute bereits (bis zur aktuellen
|
||||||
Zeit in der übergebenen Zeitzone (wird mit datetime.now() ohne
|
Stunde) gefallen sind - kein Vorhersagewert für den Rest des
|
||||||
Zeitzonen-Info verglichen - das System muss daher in derselben
|
Tages.
|
||||||
Zeitzone laufen).
|
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 = {
|
params = {
|
||||||
"latitude": lat,
|
"latitude": lat,
|
||||||
"longitude": lon,
|
"longitude": lon,
|
||||||
"daily": "precipitation_sum,sunset",
|
"daily": "precipitation_sum,sunrise,sunset",
|
||||||
|
"hourly": "precipitation",
|
||||||
"timezone": timezone,
|
"timezone": timezone,
|
||||||
# +1, da Open-Meteo den heutigen (noch laufenden) Tag mitliefert,
|
"past_days": min(past_days, 92),
|
||||||
# der unten aus den Niederschlagsdaten explizit herausgefiltert wird.
|
|
||||||
"past_days": min(past_days + 1, 92),
|
|
||||||
"forecast_days": 1,
|
"forecast_days": 1,
|
||||||
}
|
}
|
||||||
response = requests.get(OPEN_METEO_URL, params=params, timeout=15)
|
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"]
|
dates = data["daily"]["time"]
|
||||||
sums = data["daily"]["precipitation_sum"]
|
sums = data["daily"]["precipitation_sum"]
|
||||||
|
sunrises = data["daily"]["sunrise"]
|
||||||
sunsets = data["daily"]["sunset"]
|
sunsets = data["daily"]["sunset"]
|
||||||
|
|
||||||
today = datetime.now().date()
|
today = datetime.now().date()
|
||||||
daily = []
|
daily = []
|
||||||
|
sunrise_today = None
|
||||||
sunset_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()
|
day = datetime.strptime(date_str, "%Y-%m-%d").date()
|
||||||
if day == today:
|
if day == today:
|
||||||
|
sunrise_today = datetime.fromisoformat(sunrise_str)
|
||||||
sunset_today = datetime.fromisoformat(sunset_str)
|
sunset_today = datetime.fromisoformat(sunset_str)
|
||||||
if day >= today:
|
if day >= today:
|
||||||
continue
|
continue
|
||||||
@@ -275,36 +356,64 @@ def fetch_weather_data(lat, lon, timezone, past_days):
|
|||||||
|
|
||||||
daily.sort(key=lambda entry: entry[0])
|
daily.sort(key=lambda entry: entry[0])
|
||||||
|
|
||||||
if sunset_today is None:
|
if sunrise_today is None or sunset_today is None:
|
||||||
raise ValueError("Open-Meteo hat keinen Sonnenuntergang für heute geliefert.")
|
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):
|
def accumulated_rain(daily_precipitation, today_precipitation, days):
|
||||||
"""Summe der letzten `days` abgeschlossenen Tage aus daily_precipitation."""
|
"""
|
||||||
relevant = daily_precipitation[-days:] if days > 0 else []
|
Summe der Regenmenge über die letzten `days` Tage, wobei der heutige
|
||||||
if len(relevant) < days:
|
(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(
|
logger.warning(
|
||||||
"Nur %d von %d angeforderten Tagen an Regendaten verfügbar - Auswertung erfolgt trotzdem.",
|
"Nur %d von %d angeforderten abgeschlossenen Vortagen an Regendaten verfügbar - Auswertung erfolgt trotzdem.",
|
||||||
len(relevant), days,
|
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
|
Baut die Zeiträume, in denen ausgelöst werden darf, je gewähltem Modus.
|
||||||
(lead_minutes vor Sonnenuntergang) bis zum Sonnenuntergang selbst - ohne
|
|
||||||
Deckelung nach oben, damit auch ein verspäteter/nachgeholter Lauf noch
|
"morning" beginnt am Sonnenaufgang und ist nach hinten offen (bis
|
||||||
auslösen kann, solange die Sonne noch nicht untergegangen ist.
|
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)
|
windows = {}
|
||||||
return target, sunset_today
|
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):
|
def active_window(now, windows):
|
||||||
return window_start <= now <= window_end
|
"""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
|
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 = {
|
payload = {
|
||||||
"zone": zone["label"],
|
"zone": zone["label"],
|
||||||
"rain_mm": round(rain_sum, 1),
|
"rain_mm": round(rain_sum, 1),
|
||||||
"threshold_mm": zone["threshold_mm"],
|
"threshold_mm": zone["threshold_mm"],
|
||||||
"threshold_days": zone["threshold_days"],
|
"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,
|
"watered_recently": recently_watered,
|
||||||
"last_watering": last_start.strftime("%d.%m.%Y %H:%M") if last_start else None,
|
"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"),
|
"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)
|
topic = RAIN_STATUS_TOPIC_TEMPLATE.format(zone_key=zone_key)
|
||||||
client.publish(topic, json.dumps(payload), qos=1, retain=True)
|
client.publish(topic, json.dumps(payload), qos=1, retain=True)
|
||||||
logger.debug("Regen-Status veröffentlicht auf %s: %s", topic, payload)
|
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
|
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
|
# Automatik-Modus starten
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -434,27 +617,39 @@ def main():
|
|||||||
|
|
||||||
max_days = max(zone["threshold_days"] for zone in zones.values())
|
max_days = max(zone["threshold_days"] for zone in zones.values())
|
||||||
try:
|
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:
|
except (requests.RequestException, KeyError, ValueError) as error:
|
||||||
logger.critical("Wetterdaten konnten nicht von Open-Meteo abgerufen werden, breche ab: %s", error)
|
logger.critical("Wetterdaten konnten nicht von Open-Meteo abgerufen werden, breche ab: %s", error)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
logger.debug("Tages-Niederschlag (letzte %d Tage): %s", max_days, daily_precipitation)
|
logger.debug(
|
||||||
|
"Tages-Niederschlag (letzte %d Tage vor heute): %s | heute bisher: %.1f mm",
|
||||||
now = datetime.now()
|
max_days - 1, daily_precipitation, today_precipitation,
|
||||||
window_start, window_end = watering_window(sunset_today, args.watering_lead_minutes)
|
|
||||||
trigger_allowed = in_watering_window(now, window_start, window_end)
|
|
||||||
logger.info(
|
|
||||||
"Sonnenuntergang %s | Auslöse-Zeitraum %s-%s (%s)",
|
|
||||||
sunset_today.strftime("%H:%M"), window_start.strftime("%H:%M"), window_end.strftime("%H:%M"),
|
|
||||||
"aktiv" if trigger_allowed else "inaktiv",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
lastwatering_topics = sorted({zone["lastwatering_topic"] for zone in zones.values()})
|
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:
|
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,
|
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:
|
except OSError as error:
|
||||||
logger.critical("MQTT-Verbindung zu %s:%s fehlgeschlagen, breche ab: %s",
|
logger.critical("MQTT-Verbindung zu %s:%s fehlgeschlagen, breche ab: %s",
|
||||||
@@ -465,30 +660,43 @@ def main():
|
|||||||
skipped = []
|
skipped = []
|
||||||
try:
|
try:
|
||||||
for zone_key, zone in zones.items():
|
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(
|
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"] or forced_dry
|
||||||
|
|
||||||
needs_water = rain_sum < zone["threshold_mm"]
|
|
||||||
if not needs_water:
|
if not needs_water:
|
||||||
status = "kein Bedarf"
|
status = "kein Bedarf"
|
||||||
elif recently_watered:
|
elif recently_watered:
|
||||||
status = f"übersprungen (bewässert {last_start:%H:%M})"
|
status = f"übersprungen (bewässert {last_start:%H:%M})"
|
||||||
skipped.append(zone_key)
|
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)"
|
status = "übersprungen (außerhalb Zeitraum)"
|
||||||
skipped.append(zone_key)
|
skipped.append(zone_key)
|
||||||
else:
|
else:
|
||||||
start_auto_mode(client, zone, args.dry_run)
|
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)
|
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(
|
logger.info(
|
||||||
"%-12s %5.1f/%4.1f mm (%dT) -> %s",
|
"%-12s %5.1f/%4.1f mm (%dT)%s -> %s",
|
||||||
zone["label"], rain_sum, zone["threshold_mm"], zone["threshold_days"], status,
|
zone["label"], rain_sum, zone["threshold_mm"], zone["threshold_days"],
|
||||||
|
f" [trocken seit {(now - last_start).days}T]" if forced_dry else "", status,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
client.loop_stop()
|
client.loop_stop()
|
||||||
|
|||||||
Reference in New Issue
Block a user