diff --git a/config.ini.example b/config.ini.example index ce09193..007df30 100644 --- a/config.ini.example +++ b/config.ini.example @@ -38,3 +38,22 @@ database = alarm host = password = mqtt_host = nas.fritz.box + +; =========================================================================== +; Regen (gatherRainData.py) +; =========================================================================== +; Standort fuer die Niederschlagsdaten von Open-Meteo. Der Abschnitt darf +; ganz fehlen - dann gelten diese Werte als Vorgabe. +[regen] +lat = 47.5781 +lon = 10.2628 +timezone = Europe/Berlin +mqtt_broker = localhost +mqtt_port = 1883 +; Abstand der Abfrage. Open-Meteo rechnet stuendlich, oefter als halbstuendlich +; lohnt sich also nicht. +intervall_minuten = 30 +; Wie lange der letzte gute Wert eine Stoerung ueberlebt. Danach werden die +; Messwerte geleert, und es wird nicht mehr bewaessert - eine retained "0,0 mm" +; von gestern wuerde den Runner sonst giessen lassen. +max_alter_stunden = 3 diff --git a/gatherRainData.py b/gatherRainData.py new file mode 100644 index 0000000..fdfd182 --- /dev/null +++ b/gatherRainData.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +""" +Regenmengen von Open-Meteo, als MQTT-Nachricht fuer die Automatiken. + +Die Gartenbewaesserung entschied bisher in einem eigenen Skript +(restricted/gartenbewaesserung/auto_watering.py im Web-Repo): es holte +stuendlich den Regen, verglich ihn mit Schwellen aus einer ZONES-Tabelle im +Quelltext und schaltete selbst. Kuenftig entscheidet der AutoAction-Runner, +und die Schwellen stehen im Editor statt im Quelltext. + +Dieses Skript liefert dafuer nur noch die Zahl. Es veroeffentlicht die +Regensumme der letzten ein bis sieben Tage als retained JSON-Nachricht; der +Runner liest sie als ganz gewoehnliches MQTT-Geraet - angelegt wird es vom +Discovery-Modul restricted/deviceDiscovery/modules/gartenwasser_module.py im +Web-Repo, dort steht auch dasselbe Topic noch einmal. + + Wetter/Regen {"tage1": 0.4, ..., "tage7": 3.4, "stand": "04.09.2026 09:00:00"} + +"tageN" ist die Summe ueber N Tage, wobei der heutige, noch laufende Tag als +letzter zaehlt: der bereits gefallene Regen von heute plus die N-1 +abgeschlossenen Tage davor. "tage1" ist also allein der heutige Tag. + +Veroeffentlicht werden immer alle sieben. Welche davon als Messwert im Editor +auftauchen, entscheidet allein das Discovery-Modul - so muessen sich die +beiden Repositorien nicht ueber eine Fensterliste einigen, und eine vierte +Zone mit einem vierten Zeitraum kostet keine Aenderung hier. + +Warum ein Dauerlaeufer und kein Cronjob: wenn Open-Meteo nicht erreichbar +ist, darf der alte Wert nicht einfach stehenbleiben. Eine retained Nachricht +mit "0,0 mm" von gestern wuerde den Runner bei naechster Gelegenheit giessen +lassen. Das Skript haelt den letzten Wert deshalb nur, solange er juenger als +max_alter_stunden ist, und schickt danach leere Werte - eine Bedingung auf +einem leeren Messwert gilt im Runner als nicht erfuellt, es wird also nicht +bewaessert. Ein kurzer Aussetzer kostet damit nichts, ein langer schaltet die +Bewaesserung ab, statt sie zu beluegen. Genau das tat auch auto_watering.py, +nur mit sys.exit(1). + +Gestartet wird das Skript von startSolarServer.sh, Zugangsdaten und Standort +stehen in config.ini (siehe config.ini.example, Abschnitt [regen]). +""" + +import json +import logging +import time +from datetime import datetime, timedelta + +import paho.mqtt.client as mqtt +import requests + +import konfig + +# Dasselbe Topic steht im Discovery-Modul des Web-Repos. Bewusst kein +# Konfigurationseintrag: er muesste in beiden Repositorien gleich lauten, und +# eine Einstellung, die man an zwei Orten gleich halten muss, ist keine. +TOPIC = "Wetter/Regen" + +# Ueber wie viele Tage summiert wird. Die laengste Reihe bestimmt zugleich, +# wie viele abgeschlossene Tage von Open-Meteo geholt werden. +FENSTER = range(1, 8) + +OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast" +STAND_FORMAT = "%d.%m.%Y %H:%M:%S" + +logging.basicConfig(level=logging.INFO, + format="%(asctime)s %(levelname)s %(message)s") +logger = logging.getLogger("gatherRainData") + + +def regendaten(lat, lon, zeitzone): + """ + Die Tagessummen der abgeschlossenen Vortage und den heute bereits + gefallenen Regen. + + Der heutige Tag kommt aus den Stundenwerten und nicht aus der Tagessumme: + die traegt die Vorhersage fuer den Rest des Tages mit, und Regen, der + erst am Abend kommen soll, hat eine Bewaesserung am Morgen nicht zu + verhindern. + + Returns: + (vortage, heute_bisher) - vortage ist eine Liste von mm, aufsteigend + nach Datum, ohne den heutigen Tag. + """ + antwort = requests.get(OPEN_METEO_URL, params={ + "latitude": lat, + "longitude": lon, + "daily": "precipitation_sum", + "hourly": "precipitation", + "timezone": zeitzone, + "past_days": max(FENSTER) - 1, + "forecast_days": 1, + }, timeout=15) + antwort.raise_for_status() + daten = antwort.json() + + heute = datetime.now().date() + vortage = [] + for tag_text, mm in zip(daten["daily"]["time"], daten["daily"]["precipitation_sum"]): + tag = datetime.strptime(tag_text, "%Y-%m-%d").date() + if tag < heute: + vortage.append((tag, mm if mm is not None else 0.0)) + vortage.sort(key=lambda eintrag: eintrag[0]) + + jetzt = datetime.now() + heute_bisher = 0.0 + for zeit_text, mm in zip(daten["hourly"]["time"], daten["hourly"]["precipitation"]): + stunde = datetime.fromisoformat(zeit_text) + if stunde.date() == heute and stunde <= jetzt: + heute_bisher += mm if mm is not None else 0.0 + + return [mm for _, mm in vortage], heute_bisher + + +def summen(vortage, heute_bisher): + """Regensumme je Fenster: der heutige Tag plus die N-1 Tage davor.""" + if len(vortage) < max(FENSTER) - 1: + # Kein Grund abzubrechen - die kurzen Fenster stimmen trotzdem, und + # die langen sind eher zu trocken als zu nass, also nicht die + # gefaehrliche Richtung. + logger.warning("Nur %d von %d Vortagen geliefert - die langen Fenster " + "sind entsprechend kuerzer.", len(vortage), max(FENSTER) - 1) + werte = {} + for tage in FENSTER: + davor = vortage[-(tage - 1):] if tage > 1 else [] + werte["tage%d" % tage] = round(heute_bisher + sum(davor), 1) + return werte + + +def leere_summen(): + """ + Was geschickt wird, wenn kein verlaesslicher Wert vorliegt. + + Leerer Text und nicht 0.0: eine Bedingung "Regen < 1 mm" waere mit einer + Null erfuellt und wuerde giessen. Auf einem leeren Messwert gilt im + Runner gar keine Bedingung als erfuellt (siehe bedingung_erfuellt()). + """ + werte = {"tage%d" % tage: "" for tage in FENSTER} + werte["stand"] = "" + return werte + + +def verbinden(broker, port): + client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, + client_id="gatherRainData") + client.connect(broker, port, 60) + client.loop_start() + return client + + +def senden(client, nutzlast): + ergebnis = client.publish(TOPIC, json.dumps(nutzlast), qos=1, retain=True) + ergebnis.wait_for_publish(timeout=10) + + +def main(): + lat = float(konfig.wert("regen", "lat", "47.5781")) + lon = float(konfig.wert("regen", "lon", "10.2628")) + zeitzone = konfig.wert("regen", "timezone", "Europe/Berlin") + abstand = int(konfig.wert("regen", "intervall_minuten", "30")) * 60 + max_alter = timedelta(hours=float(konfig.wert("regen", "max_alter_stunden", "3"))) + + client = verbinden(konfig.wert("regen", "mqtt_broker", "localhost"), + int(konfig.wert("regen", "mqtt_port", "1883"))) + logger.info("Regen fuer %.4f/%.4f, alle %d Minuten nach %s", + lat, lon, abstand // 60, TOPIC) + + letzter_erfolg = None + while True: + try: + vortage, heute = regendaten(lat, lon, zeitzone) + nutzlast = summen(vortage, heute) + letzter_erfolg = datetime.now() + nutzlast["stand"] = letzter_erfolg.strftime(STAND_FORMAT) + logger.info("heute %.1f mm | %s", heute, + " | ".join("%dT %.1f" % (t, nutzlast["tage%d" % t]) for t in FENSTER)) + senden(client, nutzlast) + except Exception as fehler: + # Nach einem Neustart ist letzter_erfolg None: dann ist unbekannt, + # wie alt die retained Nachricht ist, die noch am Broker haengt - + # sie gilt also nicht weiter. + if letzter_erfolg is not None and datetime.now() - letzter_erfolg < max_alter: + logger.warning("Regendaten nicht abrufbar (%s) - der Stand von " + "%s gilt weiter.", fehler, + letzter_erfolg.strftime(STAND_FORMAT)) + else: + logger.error("Regendaten seit zu langem nicht abrufbar (%s) - " + "die Messwerte werden geleert, es wird nicht " + "bewaessert.", fehler) + try: + senden(client, leere_summen()) + except Exception as sendefehler: + logger.error("Auch das Leeren ging nicht: %s", sendefehler) + time.sleep(abstand) + + +if __name__ == "__main__": + main() diff --git a/logrotate.conf b/logrotate.conf index 7671c37..cf5d588 100644 --- a/logrotate.conf +++ b/logrotate.conf @@ -19,6 +19,7 @@ /volume1/homes/wagner/SolarManager/solarOutput.log /volume1/homes/wagner/SolarManager/autoActions.log +/volume1/homes/wagner/SolarManager/rainOutput.log /volume1/homes/wagner/SolarManager/wattpilotshell.log /volume1/homes/wagner/SolarManager/wsMQTTbridge.log /volume1/homes/wagner/SolarManager/wecker.log diff --git a/startSolarServer.sh b/startSolarServer.sh index c8e7b46..bdafa4c 100755 --- a/startSolarServer.sh +++ b/startSolarServer.sh @@ -1,10 +1,14 @@ #!/bin/bash -# Startet die beiden Dauerprozesse des SolarManagers neu: +# Startet die Dauerprozesse des SolarManagers neu: # # solarManager.py Messwerte einsammeln, Wallbox und # Heizung regeln # autoActions/autoaction_runner.py die Automatiken aus dem Web-UI # auswerten und die Kommandos schicken +# gatherRainData.py die Regenmenge von Open-Meteo holen und +# nach Wetter/Regen legen, damit die +# Bewaesserungs-Automatiken sie als +# Messwert bekommen # # Aufgerufen wird das Skript vom Aufgabenplaner beim Hochfahren (als root), # es laesst sich aber jederzeit auch von Hand starten: eine schon laufende @@ -57,3 +61,4 @@ starte() { starte "solarManager.py" "solarOutput.log" starte "autoActions/autoaction_runner.py" "autoActions.log" +starte "gatherRainData.py" "rainOutput.log"