auto_watering.py entfernt - die Bewaesserung laeuft ueber die Automatiken
Die drei Automatiken (Troege, Garten vorn, Hochbeete) sind seit dem Umzug aktiv; das stuendliche Skript im DSM-Aufgabenplaner ist abgeschaltet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,712 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Automatische Gartenbewässerung anhand von Regendaten (Open-Meteo)
|
||||
|
||||
Wird stündlich per Cronjob gestartet. Bei jedem Lauf werden die aktuellen
|
||||
Niederschlagsdaten von Open-Meteo geholt und je Zone (Hochbeete, Tröge,
|
||||
Garten vorn) als retained MQTT-Nachricht veröffentlicht (Regenmenge,
|
||||
Schwellenwert, ob bewässert werden würde) - unabhängig von der Uhrzeit, damit
|
||||
andere Systeme (z.B. das Web-UI) jederzeit den aktuellen Stand abfragen
|
||||
können.
|
||||
|
||||
Tatsächlich ausgelöst (Automatik-Modus per MQTT gestartet) wird die
|
||||
Bewässerung nur innerhalb der aktiven Zeitfenster (WATERING_SCHEDULE, per
|
||||
--schedule umschaltbar):
|
||||
|
||||
morning ab Sonnenaufgang bis SUNRISE_FOLLOW_MINUTES danach. Der Start liegt
|
||||
auf dem Sonnenaufgang selbst; dass tatsächlich "kurz nach"
|
||||
Sonnenaufgang bewässert wird, ergibt sich aus dem stündlichen
|
||||
Cron-Raster. Das Fenster ist nach hinten offen.
|
||||
evening ab WATERING_LEAD_MINUTES vor Sonnenuntergang bis zum Sonnenuntergang
|
||||
selbst. Hier ist der Sonnenuntergang die harte Grenze, das Fenster
|
||||
daher nach vorne offen.
|
||||
both beide Fenster aktiv - dann wird morgens UND abends bewässert.
|
||||
|
||||
Beide Fenster sind bewusst breiter als eine Stunde und haben keine feste
|
||||
Zielminute: jeder stündliche Lauf, der irgendwo hineinfällt, löst aus. Das
|
||||
macht die Auslösung robust gegen Jitter und sogar gegen einen komplett
|
||||
ausgefallenen Lauf (z.B. NAS-Neustart) - ein späterer Lauf holt es nach,
|
||||
solange das Fenster noch nicht vorbei ist. Mehr Puffer gegen Ausfälle gibt es
|
||||
einfach durch größere SUNRISE_FOLLOW_MINUTES- bzw. WATERING_LEAD_MINUTES-Werte.
|
||||
|
||||
Da ein Fenster damit breiter ist als der Cron-Abstand (70 Min. > 1 Std.),
|
||||
würden sonst zwei Läufe desselben Fensters auslösen; deshalb merkt sich das
|
||||
Skript seinen eigenen Auslösezeitpunkt je Fenster im retained
|
||||
RainStatus-Topic der Zone und löst pro Fenster und Kalendertag nur einmal aus.
|
||||
|
||||
Zusätzlich wird eine Zone übersprungen, wenn sie laut dem retained
|
||||
"LastWatering"-MQTT-Topic der Ventilsteuerung bereits innerhalb der letzten
|
||||
RECENT_WATERING_LOOKBACK_HOURS Stunden bewässert wurde. Dieser Schutz greift
|
||||
auch über Fenstergrenzen hinweg und macht den Modus "both" damit
|
||||
selbstregulierend: der Abstand zwischen Morgen- und Abendauslösung entspricht
|
||||
etwa der Tageslänge minus gut einer Stunde, liegt also nur im Hochsommer über
|
||||
den 12 Stunden Standard-Sperre. "both" bewässert deshalb nur bei langen Tagen
|
||||
wirklich zweimal und wirkt sonst wie "morning" - das ist so gewollt, siehe
|
||||
README, Abschnitt "Regenbasierte Automatik".
|
||||
|
||||
Unabhängig vom Regen wird eine Zone bewässert, wenn ihre letzte Bewässerung
|
||||
länger als "max_dry_days" Tage zurückliegt (Trockenheits-Notlauf, siehe ZONES).
|
||||
|
||||
MQTT-Schnittstelle der Ventilsteuerung siehe README.md des Firmware-Projekts
|
||||
(ESP32 Ventilsteuerung, Topics "Gartenwasser/vorn" bzw. "Gartenwasser/hinten").
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import paho.mqtt.client as mqtt
|
||||
import requests
|
||||
|
||||
# ============================================================================
|
||||
# EINSTELLUNGEN (Standardwerte - können unten per Kommandozeilenparameter
|
||||
# überschrieben werden, siehe --help)
|
||||
# ============================================================================
|
||||
|
||||
# Standort für die Regendaten (Open-Meteo)
|
||||
# Mühlenweg 7, Untermaiselstein, 87549 Rettenberg (Allgäu)
|
||||
LATITUDE = 47.5781
|
||||
LONGITUDE = 10.2628
|
||||
TIMEZONE = "Europe/Berlin"
|
||||
|
||||
# Wie viele Stunden zurück geprüft wird, ob eine Zone bereits bewässert wurde
|
||||
# (manuell oder automatisch) - falls ja, wird die Automatik für diese Zone
|
||||
# in diesem Lauf übersprungen. Bewässerungen, die kürzer als der jeweilige
|
||||
# "min_duration_minutes"-Wert der Zone liefen (siehe ZONES), zählen dabei
|
||||
# NICHT als "bereits bewässert" und werden ignoriert (z.B. kurzes manuelles
|
||||
# Antesten).
|
||||
RECENT_WATERING_LOOKBACK_HOURS = 12
|
||||
|
||||
# Welche Zeitfenster aktiv sind: "morning", "evening" oder "both".
|
||||
WATERING_SCHEDULE = "morning"
|
||||
|
||||
# Namen der Zeitfenster in der Reihenfolge, in der sie geprüft werden.
|
||||
SCHEDULES = ("morning", "evening")
|
||||
|
||||
# Morgens: ausgelöst wird bei jedem Lauf zwischen dem Sonnenaufgang und so
|
||||
# vielen Minuten danach - je größer der Wert, desto mehr Puffer gibt es, falls
|
||||
# ein stündlicher Lauf ausfällt oder sich verzögert (siehe Modul-Docstring).
|
||||
SUNRISE_FOLLOW_MINUTES = 70
|
||||
|
||||
# Abends: Zielzeitpunkt für den Bewässerungsstart, so viele Minuten vor
|
||||
# Sonnenuntergang. Ausgelöst wird bei jedem Lauf zwischen diesem Zielzeitpunkt
|
||||
# und dem Sonnenuntergang selbst.
|
||||
WATERING_LEAD_MINUTES = 70
|
||||
|
||||
# MQTT-Broker der Hausautomation
|
||||
MQTT_BROKER = "nas.fritz.box"
|
||||
MQTT_PORT = 1883
|
||||
MQTT_USERNAME = None
|
||||
MQTT_PASSWORD = None
|
||||
|
||||
# Wie lange (Sekunden) auf die retained LastWatering-Nachrichten gewartet wird
|
||||
MQTT_WAIT_TIMEOUT = 5
|
||||
|
||||
# Topic-Vorlage für die stündlich veröffentlichten Regen-Status-Nachrichten
|
||||
# je Zone (retained). {zone_key} wird durch den jeweiligen Zonen-Key ersetzt.
|
||||
RAIN_STATUS_TOPIC_TEMPLATE = "Gartenwasser/AutoWatering/{zone_key}/RainStatus"
|
||||
|
||||
# Format des Feldes "last_trigger" in den RainStatus-Nachrichten. Darin merkt
|
||||
# sich das Skript, wann es zuletzt selbst ausgelöst hat - unabhängig davon,
|
||||
# ob und wann die Ventilsteuerung "LastWatering" aktualisiert.
|
||||
TRIGGER_TIME_FORMAT = "%d.%m.%Y %H:%M:%S"
|
||||
|
||||
# Feldname je Zeitfenster, unter dem der eigene Auslösezeitpunkt abgelegt wird.
|
||||
TRIGGER_FIELD_TEMPLATE = "last_trigger_{schedule}"
|
||||
|
||||
OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast"
|
||||
|
||||
# Zonen-Konfiguration: je Zone eigener Schwellenwert, eigenes MQTT-Auto-Topic
|
||||
# (siehe README: /auto-Topic mit Klartext-Payload "Hoch"/"Trog"/"Vorn") und
|
||||
# das zugehörige LastWatering-Topic + Ventilnummer zur Erkennung kürzlich
|
||||
# erfolgter Bewässerung. Bewässert wird, wenn die letzten "threshold_days"
|
||||
# Tage (der heutige, noch laufende Tag zählt dabei mit) zusammen weniger als
|
||||
# "threshold_mm" mm Regen gebracht haben. Unabhängig vom Regen wird bewässert,
|
||||
# wenn die letzte Bewässerung der Zone länger als "max_dry_days" Tage
|
||||
# zurückliegt (None oder 0 = dieser Trockenheits-Notlauf ist für die Zone aus).
|
||||
ZONES = {
|
||||
"hochbeete": {
|
||||
"label": "Hochbeete",
|
||||
"auto_topic": "Gartenwasser/hinten/auto",
|
||||
"auto_payload": "Hoch",
|
||||
"lastwatering_topic": "Gartenwasser/hinten/LastWatering",
|
||||
"valve": 6,
|
||||
# Schon nach 2 Tagen ohne (nennenswerten) Regen bewässern.
|
||||
"threshold_days": 2,
|
||||
"threshold_mm": 1.0,
|
||||
# Trockenheits-Notlauf: auch bei Regen bewässern, wenn die Hochbeete
|
||||
# seit 5 Tagen nicht mehr bewässert wurden.
|
||||
"max_dry_days": 5,
|
||||
# Manuelle Bewässerungen unter 10 Minuten zählen nicht als "bereits
|
||||
# bewässert" (z.B. kurzes Antesten).
|
||||
"min_duration_minutes": 10,
|
||||
},
|
||||
"troege": {
|
||||
"label": "Tröge",
|
||||
"auto_topic": "Gartenwasser/vorn/auto",
|
||||
"auto_payload": "Trog",
|
||||
"lastwatering_topic": "Gartenwasser/vorn/LastWatering",
|
||||
"valve": 1,
|
||||
# Tröge stehen unter Dach (bekommen kaum natürlichen Regen ab) und
|
||||
# sollen daher praktisch immer bewässert werden - nur bei Starkregen
|
||||
# (hoher mm-Schwellenwert, 1 Tag) wird ausgesetzt.
|
||||
"threshold_days": 1,
|
||||
"threshold_mm": 20.0,
|
||||
# Tröge werden ohnehin fast täglich bewässert - kein Notlauf nötig.
|
||||
"max_dry_days": None,
|
||||
# Bei den Trögen reicht schon eine kurze manuelle Bewässerung (3 Min.).
|
||||
"min_duration_minutes": 3,
|
||||
},
|
||||
"garten_vorn": {
|
||||
"label": "Garten vorn",
|
||||
"auto_topic": "Gartenwasser/vorn/auto",
|
||||
"auto_payload": "Vorn",
|
||||
"lastwatering_topic": "Gartenwasser/vorn/LastWatering",
|
||||
"valve": 6,
|
||||
# Erst nach 5 Tagen ohne (nennenswerten) Regen bewässern.
|
||||
"threshold_days": 5,
|
||||
"threshold_mm": 1.0,
|
||||
"max_dry_days": None,
|
||||
# Manuelle Bewässerungen unter 10 Minuten zählen nicht als "bereits
|
||||
# bewässert" (z.B. kurzes Antesten).
|
||||
"min_duration_minutes": 10,
|
||||
},
|
||||
}
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(message)s",
|
||||
)
|
||||
logger = logging.getLogger("auto_watering")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Kommandozeilenparameter
|
||||
# ============================================================================
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Automatische Gartenbewässerung anhand von Regendaten (Open-Meteo)."
|
||||
)
|
||||
parser.add_argument("--lat", type=float, default=LATITUDE, help="Breitengrad (Standard: %(default)s)")
|
||||
parser.add_argument("--lon", type=float, default=LONGITUDE, help="Längengrad (Standard: %(default)s)")
|
||||
parser.add_argument("--timezone", default=TIMEZONE, help="Zeitzone für Open-Meteo (Standard: %(default)s)")
|
||||
|
||||
parser.add_argument("--days", type=int, default=None,
|
||||
help="Überschreibt die Anzahl Tage, über die der Regen aufsummiert wird, für ALLE Zonen "
|
||||
"ohne zonenspezifischen Override (Standard: je Zone eigener Wert, siehe ZONES)")
|
||||
parser.add_argument("--mm", type=float, default=None,
|
||||
help="Überschreibt die Regenmenge in mm, unterhalb derer bewässert wird, für ALLE Zonen "
|
||||
"ohne zonenspezifischen Override (Standard: je Zone eigener Wert, siehe ZONES)")
|
||||
|
||||
for zone_key, zone in ZONES.items():
|
||||
flag_prefix = zone_key.replace("_", "-")
|
||||
parser.add_argument(f"--{flag_prefix}-days", type=int, default=None,
|
||||
help=f"Schwellenwert Tage nur für Zone '{zone['label']}' (überschreibt --days)")
|
||||
parser.add_argument(f"--{flag_prefix}-mm", type=float, default=None,
|
||||
help=f"Schwellenwert mm nur für Zone '{zone['label']}' (überschreibt --mm)")
|
||||
parser.add_argument(f"--{flag_prefix}-max-dry-days", type=float, default=None,
|
||||
help=f"Bewässert Zone '{zone['label']}' unabhängig vom Regen, wenn die letzte "
|
||||
"Bewässerung so viele Tage zurückliegt (0 = aus, überschreibt --max-dry-days)")
|
||||
parser.add_argument(f"--{flag_prefix}-min-duration-minutes", type=float, default=None,
|
||||
help=f"Mindestdauer (Min.) nur für Zone '{zone['label']}', unterhalb derer eine "
|
||||
"manuelle Bewässerung ignoriert wird (überschreibt --min-duration-minutes)")
|
||||
|
||||
parser.add_argument("--lookback-hours", type=float, default=RECENT_WATERING_LOOKBACK_HOURS,
|
||||
help="Zone wird übersprungen, wenn innerhalb dieser Stundenzahl bereits bewässert "
|
||||
"wurde (Standard: %(default)s)")
|
||||
parser.add_argument("--min-duration-minutes", type=float, default=None,
|
||||
help="Überschreibt die Mindestdauer (Min.), unterhalb derer eine Bewässerung nicht als "
|
||||
"'bereits bewässert' zählt, für ALLE Zonen ohne zonenspezifischen Override "
|
||||
"(Standard: je Zone eigener Wert, siehe ZONES)")
|
||||
|
||||
parser.add_argument("--max-dry-days", type=float, default=None,
|
||||
help="Überschreibt für ALLE Zonen ohne zonenspezifischen Override, nach wie vielen "
|
||||
"Tagen ohne Bewässerung unabhängig vom Regen bewässert wird (0 = aus; "
|
||||
"Standard: je Zone eigener Wert, siehe ZONES)")
|
||||
|
||||
parser.add_argument("--schedule", choices=["morning", "evening", "both"], default=WATERING_SCHEDULE,
|
||||
help="Welche Zeitfenster auslösen dürfen: morgens nach Sonnenaufgang, abends vor "
|
||||
"Sonnenuntergang oder beide (dann wird morgens UND abends bewässert). "
|
||||
"Standard: %(default)s")
|
||||
|
||||
parser.add_argument("--sunrise-follow-minutes", type=float, default=SUNRISE_FOLLOW_MINUTES,
|
||||
help="Morgens wird zwischen Sonnenaufgang und (Sonnenaufgang + sunrise-follow-minutes) "
|
||||
"ausgelöst - ein größerer Wert gibt mehr Puffer gegen ausgefallene/verzögerte "
|
||||
"Läufe (Standard: %(default)s)")
|
||||
|
||||
parser.add_argument("--watering-lead-minutes", type=float, default=WATERING_LEAD_MINUTES,
|
||||
help="Abends wird zwischen (Sonnenuntergang - watering-lead-minutes) und "
|
||||
"Sonnenuntergang ausgelöst - ein größerer Wert gibt mehr Puffer gegen "
|
||||
"ausgefallene/verzögerte Läufe (Standard: %(default)s)")
|
||||
|
||||
parser.add_argument("--zones", default=",".join(ZONES.keys()),
|
||||
help=f"Kommagetrennte Liste der zu prüfenden Zonen aus {list(ZONES.keys())} "
|
||||
"(Standard: alle)")
|
||||
|
||||
parser.add_argument("--mqtt-broker", default=MQTT_BROKER, help="MQTT-Broker (Standard: %(default)s)")
|
||||
parser.add_argument("--mqtt-port", type=int, default=MQTT_PORT, help="MQTT-Port (Standard: %(default)s)")
|
||||
parser.add_argument("--mqtt-username", default=MQTT_USERNAME, help="MQTT-Benutzername (optional)")
|
||||
parser.add_argument("--mqtt-password", default=MQTT_PASSWORD, help="MQTT-Passwort (optional)")
|
||||
|
||||
parser.add_argument("--dry-run", action="store_true",
|
||||
help="Nur auswerten und loggen, keine MQTT-Befehle senden")
|
||||
parser.add_argument("-v", "--verbose", action="store_true", help="Debug-Logging aktivieren")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.zones.strip():
|
||||
selected = [z.strip() for z in args.zones.split(",") if z.strip()]
|
||||
else:
|
||||
selected = list(ZONES.keys())
|
||||
unknown = [z for z in selected if z not in ZONES]
|
||||
if unknown:
|
||||
parser.error(f"Unbekannte Zone(n): {', '.join(unknown)} - gültig sind {list(ZONES.keys())}")
|
||||
args.selected_zones = selected
|
||||
|
||||
return args
|
||||
|
||||
|
||||
def build_zone_config(args):
|
||||
"""Erstellt die effektive Zonen-Konfiguration unter Berücksichtigung der CLI-Overrides."""
|
||||
zones = {}
|
||||
for zone_key in args.selected_zones:
|
||||
zone = dict(ZONES[zone_key])
|
||||
flag_attr = zone_key.replace("-", "_")
|
||||
days_override = getattr(args, f"{flag_attr}_days")
|
||||
mm_override = getattr(args, f"{flag_attr}_mm")
|
||||
min_duration_override = getattr(args, f"{flag_attr}_min_duration_minutes")
|
||||
max_dry_override = getattr(args, f"{flag_attr}_max_dry_days")
|
||||
if days_override is not None:
|
||||
zone["threshold_days"] = days_override
|
||||
elif args.days is not None:
|
||||
zone["threshold_days"] = args.days
|
||||
if mm_override is not None:
|
||||
zone["threshold_mm"] = mm_override
|
||||
elif args.mm is not None:
|
||||
zone["threshold_mm"] = args.mm
|
||||
if min_duration_override is not None:
|
||||
zone["min_duration_minutes"] = min_duration_override
|
||||
elif args.min_duration_minutes is not None:
|
||||
zone["min_duration_minutes"] = args.min_duration_minutes
|
||||
if max_dry_override is not None:
|
||||
zone["max_dry_days"] = max_dry_override
|
||||
elif args.max_dry_days is not None:
|
||||
zone["max_dry_days"] = args.max_dry_days
|
||||
zones[zone_key] = zone
|
||||
return zones
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Regendaten (Open-Meteo)
|
||||
# ============================================================================
|
||||
|
||||
def fetch_weather_data(lat, lon, timezone, past_days):
|
||||
"""
|
||||
Holt von Open-Meteo die Tages-Niederschlagssummen der letzten `past_days`
|
||||
abgeschlossenen Tage sowie den bereits gefallenen Regen des heutigen,
|
||||
noch laufenden Tages (aus den stündlichen Werten, ohne die Vorhersage
|
||||
für die restlichen Stunden des Tages) und Sonnenauf-/-untergang von heute.
|
||||
|
||||
Returns:
|
||||
(daily_precipitation, today_precipitation, sunrise_today, sunset_today)
|
||||
daily_precipitation: Liste von (date, mm) Tupeln, aufsteigend
|
||||
sortiert, ohne den heutigen Tag.
|
||||
today_precipitation: mm Regen, die heute bereits (bis zur aktuellen
|
||||
Stunde) gefallen sind - kein Vorhersagewert für den Rest des
|
||||
Tages.
|
||||
sunrise_today, sunset_today: naive datetimes des heutigen Sonnenauf-
|
||||
und -untergangs, lokale Zeit in der übergebenen Zeitzone (werden
|
||||
mit datetime.now() ohne Zeitzonen-Info verglichen - das System muss
|
||||
daher in derselben Zeitzone laufen).
|
||||
"""
|
||||
params = {
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
"daily": "precipitation_sum,sunrise,sunset",
|
||||
"hourly": "precipitation",
|
||||
"timezone": timezone,
|
||||
"past_days": min(past_days, 92),
|
||||
"forecast_days": 1,
|
||||
}
|
||||
response = requests.get(OPEN_METEO_URL, params=params, timeout=15)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
dates = data["daily"]["time"]
|
||||
sums = data["daily"]["precipitation_sum"]
|
||||
sunrises = data["daily"]["sunrise"]
|
||||
sunsets = data["daily"]["sunset"]
|
||||
|
||||
today = datetime.now().date()
|
||||
daily = []
|
||||
sunrise_today = None
|
||||
sunset_today = None
|
||||
for date_str, mm, sunrise_str, sunset_str in zip(dates, sums, sunrises, sunsets):
|
||||
day = datetime.strptime(date_str, "%Y-%m-%d").date()
|
||||
if day == today:
|
||||
sunrise_today = datetime.fromisoformat(sunrise_str)
|
||||
sunset_today = datetime.fromisoformat(sunset_str)
|
||||
if day >= today:
|
||||
continue
|
||||
daily.append((day, mm if mm is not None else 0.0))
|
||||
|
||||
daily.sort(key=lambda entry: entry[0])
|
||||
|
||||
if sunrise_today is None or sunset_today is None:
|
||||
raise ValueError("Open-Meteo hat keinen Sonnenauf-/-untergang für heute geliefert.")
|
||||
|
||||
now = datetime.now()
|
||||
today_precipitation = 0.0
|
||||
for time_str, mm in zip(data["hourly"]["time"], data["hourly"]["precipitation"]):
|
||||
hour_dt = datetime.fromisoformat(time_str)
|
||||
if hour_dt.date() == today and hour_dt <= now:
|
||||
today_precipitation += mm if mm is not None else 0.0
|
||||
|
||||
return daily, today_precipitation, sunrise_today, sunset_today
|
||||
|
||||
|
||||
def accumulated_rain(daily_precipitation, today_precipitation, days):
|
||||
"""
|
||||
Summe der Regenmenge über die letzten `days` Tage, wobei der heutige
|
||||
(noch laufende) Tag als letzter Tag des Fensters zählt: der bereits
|
||||
gefallene Regen von heute (today_precipitation) plus die davor
|
||||
liegenden (days - 1) abgeschlossenen Tage aus daily_precipitation.
|
||||
"""
|
||||
if days <= 0:
|
||||
return 0.0
|
||||
completed_days_needed = days - 1
|
||||
relevant = daily_precipitation[-completed_days_needed:] if completed_days_needed > 0 else []
|
||||
if len(relevant) < completed_days_needed:
|
||||
logger.warning(
|
||||
"Nur %d von %d angeforderten abgeschlossenen Vortagen an Regendaten verfügbar - Auswertung erfolgt trotzdem.",
|
||||
len(relevant), completed_days_needed,
|
||||
)
|
||||
return today_precipitation + sum(mm for _, mm in relevant)
|
||||
|
||||
|
||||
def build_windows(sunrise_today, sunset_today, schedule, sunrise_follow_minutes, lead_minutes):
|
||||
"""
|
||||
Baut die Zeiträume, in denen ausgelöst werden darf, je gewähltem Modus.
|
||||
|
||||
"morning" beginnt am Sonnenaufgang und ist nach hinten offen (bis
|
||||
sunrise_follow_minutes danach), "evening" endet am Sonnenuntergang und ist
|
||||
nach vorne offen (ab lead_minutes davor). So kann ein verspäteter oder
|
||||
nachgeholter Lauf jeweils noch auslösen.
|
||||
|
||||
Returns:
|
||||
dict {Fenstername: (start, ende)} in der Reihenfolge aus SCHEDULES.
|
||||
"""
|
||||
windows = {}
|
||||
if schedule in ("morning", "both"):
|
||||
windows["morning"] = (sunrise_today, sunrise_today + timedelta(minutes=sunrise_follow_minutes))
|
||||
if schedule in ("evening", "both"):
|
||||
windows["evening"] = (sunset_today - timedelta(minutes=lead_minutes), sunset_today)
|
||||
return windows
|
||||
|
||||
|
||||
def active_window(now, windows):
|
||||
"""Name des Zeitfensters, in dem `now` liegt, sonst None."""
|
||||
for name, (start, end) in windows.items():
|
||||
if start <= now <= end:
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# MQTT-Session: LastWatering lesen, Regen-Status + Auslöse-Befehle senden
|
||||
# ============================================================================
|
||||
|
||||
def open_mqtt_session(broker, port, username, password, subscribe_topics, wait_timeout):
|
||||
"""
|
||||
Baut eine MQTT-Verbindung auf, abonniert `subscribe_topics` (um retained
|
||||
Nachrichten, z.B. LastWatering, einzusammeln) und lässt den Client danach
|
||||
für weitere publish()-Aufrufe verbunden.
|
||||
|
||||
Returns:
|
||||
(client, received) - received wird im Hintergrund weiter befüllt,
|
||||
falls auf einem abonnierten Topic erneut publiziert wird.
|
||||
"""
|
||||
received = {}
|
||||
|
||||
def on_connect(client, userdata, flags, reason_code, properties=None):
|
||||
if reason_code != 0:
|
||||
logger.error("MQTT-Verbindung fehlgeschlagen (reason_code=%s)", reason_code)
|
||||
return
|
||||
for topic in subscribe_topics:
|
||||
client.subscribe(topic)
|
||||
|
||||
def on_message(client, userdata, msg):
|
||||
try:
|
||||
received[msg.topic] = json.loads(msg.payload.decode("utf-8"))
|
||||
except (ValueError, UnicodeDecodeError) as error:
|
||||
logger.warning("Konnte Payload von %s nicht als JSON lesen: %s", msg.topic, error)
|
||||
|
||||
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2,
|
||||
client_id=f"AutoWatering-{datetime.now():%Y%m%d%H%M%S}")
|
||||
if username:
|
||||
client.username_pw_set(username, password)
|
||||
client.on_connect = on_connect
|
||||
client.on_message = on_message
|
||||
|
||||
client.connect(broker, port, keepalive=wait_timeout + 30)
|
||||
client.loop_start()
|
||||
time.sleep(wait_timeout)
|
||||
|
||||
return client, received
|
||||
|
||||
|
||||
def publish_rain_status(client, zone_key, zone, rain_sum, recently_watered, last_start,
|
||||
forced_dry, triggers, window, now):
|
||||
payload = {
|
||||
"zone": zone["label"],
|
||||
"rain_mm": round(rain_sum, 1),
|
||||
"threshold_mm": zone["threshold_mm"],
|
||||
"threshold_days": zone["threshold_days"],
|
||||
"max_dry_days": zone.get("max_dry_days"),
|
||||
"would_water": rain_sum < zone["threshold_mm"] or forced_dry,
|
||||
# True, wenn nicht der Regen, sondern der Trockenheits-Notlauf
|
||||
# (max_dry_days) den Bedarf ausgelöst hat.
|
||||
"forced_dry": forced_dry,
|
||||
"watered_recently": recently_watered,
|
||||
"last_watering": last_start.strftime("%d.%m.%Y %H:%M") if last_start else None,
|
||||
# Zeitfenster, in dem dieser Lauf lag (None = außerhalb).
|
||||
"window": window,
|
||||
# Wann dieses Skript zuletzt selbst ausgelöst hat, insgesamt und je
|
||||
# Zeitfenster - die Fensterwerte werden beim nächsten Lauf wieder
|
||||
# gelesen, damit pro Fenster und Kalendertag nur einmal ausgelöst wird.
|
||||
"last_trigger": max(triggers.values()).strftime(TRIGGER_TIME_FORMAT) if triggers else None,
|
||||
"updated": now.strftime("%d.%m.%Y %H:%M:%S"),
|
||||
}
|
||||
for name in SCHEDULES:
|
||||
payload[TRIGGER_FIELD_TEMPLATE.format(schedule=name)] = (
|
||||
triggers[name].strftime(TRIGGER_TIME_FORMAT) if name in triggers else None
|
||||
)
|
||||
topic = RAIN_STATUS_TOPIC_TEMPLATE.format(zone_key=zone_key)
|
||||
client.publish(topic, json.dumps(payload), qos=1, retain=True)
|
||||
logger.debug("Regen-Status veröffentlicht auf %s: %s", topic, payload)
|
||||
|
||||
|
||||
def watered_recently(last_watering_data, zone, lookback_hours, now):
|
||||
"""
|
||||
Prüft anhand der gesammelten LastWatering-Daten, ob die Zone innerhalb der
|
||||
letzten `lookback_hours` Stunden bereits bewässert wurde. Bewässerungen,
|
||||
die kürzer als `zone["min_duration_minutes"]` liefen (z.B. kurzes
|
||||
manuelles Antesten), werden dabei ignoriert.
|
||||
|
||||
Returns:
|
||||
(bool watered_recently, datetime|None last_watering_start)
|
||||
"""
|
||||
payload = last_watering_data.get(zone["lastwatering_topic"])
|
||||
if not payload:
|
||||
return False, None
|
||||
|
||||
valve = zone["valve"]
|
||||
date_key = f"wateringDateTime{valve}"
|
||||
date_str = payload.get(date_key)
|
||||
if not date_str:
|
||||
return False, None
|
||||
|
||||
try:
|
||||
last_start = datetime.strptime(date_str, "%d.%m.%y %H:%M")
|
||||
except ValueError:
|
||||
logger.warning("Konnte Zeitstempel '%s' aus %s nicht parsen (Format TT.MM.JJ HH:MM erwartet).",
|
||||
date_str, zone["lastwatering_topic"])
|
||||
return False, None
|
||||
|
||||
duration_key = f"wateringDurationSecs{valve}"
|
||||
duration_secs = payload.get(duration_key)
|
||||
min_duration_secs = zone["min_duration_minutes"] * 60
|
||||
if isinstance(duration_secs, (int, float)) and duration_secs < min_duration_secs:
|
||||
logger.debug(
|
||||
"%s: letzte Bewässerung am %s dauerte nur %.0fs (< %.0f Min. Mindestdauer) - wird ignoriert.",
|
||||
zone["label"], last_start.strftime("%d.%m.%Y %H:%M"), duration_secs, zone["min_duration_minutes"],
|
||||
)
|
||||
return False, None
|
||||
|
||||
age = now - last_start
|
||||
return age < timedelta(hours=lookback_hours), last_start
|
||||
|
||||
|
||||
def last_own_triggers(mqtt_data, zone_key):
|
||||
"""
|
||||
Liest aus der eigenen retained RainStatus-Nachricht der Zone, wann dieses
|
||||
Skript je Zeitfenster zuletzt selbst ausgelöst hat.
|
||||
|
||||
Das ist der Schutz gegen doppeltes Auslösen im selben Fenster: die Fenster
|
||||
sind mit SUNRISE_FOLLOW_MINUTES bzw. WATERING_LEAD_MINUTES breiter als der
|
||||
stündliche Cron-Abstand, es fallen also je nach Sonnenauf-/-untergangszeit
|
||||
zwei Läufe hinein. Auf das LastWatering-Topic der Ventilsteuerung ist dabei
|
||||
kein Verlass (es wird u.U. erst nach dem Lauf aktualisiert, und zu kurze
|
||||
Läufe werden bewusst ignoriert), deshalb protokolliert das Skript seinen
|
||||
Auslösezeitpunkt selbst.
|
||||
|
||||
Returns:
|
||||
dict {Fenstername: datetime} - Fenster ohne Eintrag fehlen im dict.
|
||||
"""
|
||||
triggers = {}
|
||||
payload = mqtt_data.get(RAIN_STATUS_TOPIC_TEMPLATE.format(zone_key=zone_key))
|
||||
if not payload:
|
||||
return triggers
|
||||
for name in SCHEDULES:
|
||||
field = TRIGGER_FIELD_TEMPLATE.format(schedule=name)
|
||||
value = payload.get(field)
|
||||
if not value:
|
||||
# Nachricht einer älteren Skriptversion, die nur ein Fenster kannte
|
||||
# und den Zeitpunkt unter "last_trigger" ablegte: konservativ für
|
||||
# alle Fenster übernehmen, damit am Umstellungstag nicht doppelt
|
||||
# bewässert wird.
|
||||
value = payload.get("last_trigger")
|
||||
field = "last_trigger"
|
||||
if not value:
|
||||
continue
|
||||
try:
|
||||
triggers[name] = datetime.strptime(value, TRIGGER_TIME_FORMAT)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning("Konnte '%s' ('%s') aus dem RainStatus von %s nicht parsen.", field, value, zone_key)
|
||||
return triggers
|
||||
|
||||
|
||||
def dry_for_too_long(zone, last_start, now):
|
||||
"""
|
||||
Trockenheits-Notlauf: True, wenn die Zone unabhängig vom Regen bewässert
|
||||
werden soll, weil ihre letzte Bewässerung länger als
|
||||
zone["max_dry_days"] Tage zurückliegt.
|
||||
|
||||
Ist kein Zeitpunkt der letzten Bewässerung bekannt (kein retained
|
||||
LastWatering, oder nur eine zu kurze Bewässerung, die als Antesten
|
||||
ignoriert wird), wird NICHT ausgelöst - dann bleibt es bei der Regenlogik,
|
||||
statt bei jedem Broker-Neustart eine Bewässerung zu erzwingen.
|
||||
"""
|
||||
max_dry_days = zone.get("max_dry_days")
|
||||
if not max_dry_days or max_dry_days <= 0:
|
||||
return False
|
||||
if last_start is None:
|
||||
logger.debug("%s: letzte Bewässerung unbekannt - Trockenheits-Notlauf nicht auswertbar.", zone["label"])
|
||||
return False
|
||||
return now - last_start >= timedelta(days=max_dry_days)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Automatik-Modus starten
|
||||
# ============================================================================
|
||||
|
||||
def start_auto_mode(client, zone, dry_run):
|
||||
if dry_run:
|
||||
logger.debug("[DRY-RUN] Würde MQTT '%s' -> '%s' senden", zone["auto_topic"], zone["auto_payload"])
|
||||
return
|
||||
|
||||
result = client.publish(zone["auto_topic"], zone["auto_payload"], qos=1, retain=False)
|
||||
result.wait_for_publish(timeout=5)
|
||||
logger.debug("MQTT gesendet: '%s' -> '%s'", zone["auto_topic"], zone["auto_payload"])
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Hauptprogramm
|
||||
# ============================================================================
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
if args.verbose:
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
zones = build_zone_config(args)
|
||||
|
||||
max_days = max(zone["threshold_days"] for zone in zones.values())
|
||||
try:
|
||||
daily_precipitation, today_precipitation, sunrise_today, sunset_today = fetch_weather_data(
|
||||
args.lat, args.lon, args.timezone, max(max_days - 1, 0)
|
||||
)
|
||||
except (requests.RequestException, KeyError, ValueError) as error:
|
||||
logger.critical("Wetterdaten konnten nicht von Open-Meteo abgerufen werden, breche ab: %s", error)
|
||||
sys.exit(1)
|
||||
|
||||
logger.debug(
|
||||
"Tages-Niederschlag (letzte %d Tage vor heute): %s | heute bisher: %.1f mm",
|
||||
max_days - 1, daily_precipitation, today_precipitation,
|
||||
)
|
||||
|
||||
now = datetime.now()
|
||||
windows = build_windows(sunrise_today, sunset_today, args.schedule,
|
||||
args.sunrise_follow_minutes, args.watering_lead_minutes)
|
||||
current_window = active_window(now, windows)
|
||||
logger.info(
|
||||
"Sonnenaufgang %s | Sonnenuntergang %s | Auslöse-Zeitraum %s (%s)",
|
||||
sunrise_today.strftime("%H:%M"), sunset_today.strftime("%H:%M"),
|
||||
", ".join(f"{name} {start:%H:%M}-{end:%H:%M}" for name, (start, end) in windows.items()),
|
||||
f"aktiv: {current_window}" if current_window else "inaktiv",
|
||||
)
|
||||
|
||||
# Neben LastWatering (Ventilsteuerung) auch die eigenen RainStatus-Topics
|
||||
# abonnieren - daraus wird der zuletzt selbst ausgelöste Zeitpunkt gelesen.
|
||||
subscribe_topics = sorted(
|
||||
{zone["lastwatering_topic"] for zone in zones.values()}
|
||||
| {RAIN_STATUS_TOPIC_TEMPLATE.format(zone_key=zone_key) for zone_key in zones}
|
||||
)
|
||||
try:
|
||||
client, mqtt_data = open_mqtt_session(
|
||||
args.mqtt_broker, args.mqtt_port, args.mqtt_username, args.mqtt_password,
|
||||
subscribe_topics, MQTT_WAIT_TIMEOUT,
|
||||
)
|
||||
except OSError as error:
|
||||
logger.critical("MQTT-Verbindung zu %s:%s fehlgeschlagen, breche ab: %s",
|
||||
args.mqtt_broker, args.mqtt_port, error)
|
||||
sys.exit(1)
|
||||
|
||||
triggered = []
|
||||
skipped = []
|
||||
try:
|
||||
for zone_key, zone in zones.items():
|
||||
rain_sum = accumulated_rain(daily_precipitation, today_precipitation, zone["threshold_days"])
|
||||
recently_watered, last_start = watered_recently(
|
||||
mqtt_data, zone, args.lookback_hours, now
|
||||
)
|
||||
forced_dry = dry_for_too_long(zone, last_start, now)
|
||||
triggers = last_own_triggers(mqtt_data, zone_key)
|
||||
window_trigger = triggers.get(current_window) if current_window else None
|
||||
triggered_today = window_trigger is not None and window_trigger.date() == now.date()
|
||||
|
||||
needs_water = rain_sum < zone["threshold_mm"] or forced_dry
|
||||
if not needs_water:
|
||||
status = "kein Bedarf"
|
||||
elif recently_watered:
|
||||
status = f"übersprungen (bewässert {last_start:%H:%M})"
|
||||
skipped.append(zone_key)
|
||||
elif triggered_today:
|
||||
status = f"übersprungen ({current_window} heute schon ausgelöst {window_trigger:%H:%M})"
|
||||
skipped.append(zone_key)
|
||||
elif current_window is None:
|
||||
status = "übersprungen (außerhalb Zeitraum)"
|
||||
skipped.append(zone_key)
|
||||
else:
|
||||
start_auto_mode(client, zone, args.dry_run)
|
||||
if not args.dry_run:
|
||||
triggers[current_window] = now
|
||||
status = f"bewässert ({current_window})" + (" [DRY-RUN]" if args.dry_run else "")
|
||||
triggered.append(zone_key)
|
||||
|
||||
# Erst nach der Entscheidung veröffentlichen, damit der gerade
|
||||
# gesetzte Auslösezeitpunkt mit in die retained Nachricht kommt.
|
||||
publish_rain_status(client, zone_key, zone, rain_sum, recently_watered, last_start,
|
||||
forced_dry, triggers, current_window, now)
|
||||
|
||||
logger.info(
|
||||
"%-12s %5.1f/%4.1f mm (%dT)%s -> %s",
|
||||
zone["label"], rain_sum, zone["threshold_mm"], zone["threshold_days"],
|
||||
f" [trocken seit {(now - last_start).days}T]" if forced_dry else "", status,
|
||||
)
|
||||
finally:
|
||||
client.loop_stop()
|
||||
client.disconnect()
|
||||
|
||||
logger.info(
|
||||
"Ergebnis: %d bewässert, %d übersprungen, %d ohne Bedarf",
|
||||
len(triggered), len(skipped), len(zones) - len(triggered) - len(skipped),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user