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>
713 lines
32 KiB
Python
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()
|