gatherRainData.py holt die Regenmenge fuer die Automatiken
Erster Teil der Abloesung von auto_watering.py im Web-Repo. Das Skript
dort entschied bisher selbst: stuendlich den Regen holen, mit Schwellen
aus einer ZONES-Tabelle vergleichen, schalten. Kuenftig entscheidet der
AutoAction-Runner, und die Schwellen stehen im Editor statt im Quelltext.
Dieser Sammler liefert dafuer nur noch die Zahl. Alle dreissig Minuten
holt er die Niederschlagsdaten von Open-Meteo und legt sie als retained
JSON nach 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 - dieselbe Rechnung wie accumulated_rain() im alten
Skript. 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.
Veroeffentlicht werden immer alle sieben Fenster. Welche davon als
Messwert auftauchen, entscheidet allein das Discovery-Modul im Web-Repo -
so muessen sich die beiden Repositorien nicht ueber eine Fensterliste
einigen, und eine vierte Zone mit einem vierten Zeitraum kostet hier
keine Aenderung.
Ein Dauerlaeufer und kein Cronjob, weil der alte Wert nicht einfach
stehenbleiben darf, wenn Open-Meteo ausfaellt: eine retained "0,0 mm" von
gestern wuerde den Runner bei naechster Gelegenheit giessen lassen. Der
letzte Wert gilt deshalb nur, solange er juenger als max_alter_stunden
ist, danach werden leere Werte geschickt - eine Bedingung auf einem
leeren Messwert gilt im Runner als nicht erfuellt. auto_watering.py tat
dasselbe, nur mit sys.exit(1).
Der Runner selbst bleibt unangetastet: Wetter/Regen ist fuer ihn ein ganz
gewoehnliches MQTT-Geraet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
+6
-1
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user