Files
Smart-Dashboard/restricted/gartenbewaesserung/auto_watering.py
T
adminandClaude Opus 5 9f8c9016c2 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>
2026-08-31 15:49:09 +02:00

713 lines
32 KiB
Python

#!/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()