gartenwasser_module.py mit CRLF wie seine Nachbarn

Git fuer Windows setzt core.autocrlf=input in seiner System-Konfiguration,
die Synology-Seite hat das nicht. Dieselbe Arbeitskopie liegt auf beiden
Systemen - unter /volume1/web/smart und als Z:\ -, und die Datei war deshalb
im Arbeitsverzeichnis CRLF, im Repository aber LF: Windows sah sie sauber,
die NAS dauerhaft geaendert. Ein "git pull" dort waere daran haengengeblieben.

Jetzt liegt sie so im Repository, wie sie im Arbeitsverzeichnis steht - und
wie die uebrigen Discovery-Module, die ebenfalls mit CRLF eingecheckt sind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 16:30:22 +02:00
co-authored by Claude Opus 5
parent 7b9c9362a6
commit 71f2f844c8
@@ -1,238 +1,238 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Gartenwasser Module Gartenwasser Module
Die beiden Ventilsteuerungen und der Regen - fest beschrieben, nicht gesucht Die beiden Ventilsteuerungen und der Regen - fest beschrieben, nicht gesucht
KEINE Datenbank-Operationen! KEINE Datenbank-Operationen!
""" """
import logging import logging
from typing import List, Dict, Tuple from typing import List, Dict, Tuple
from modules.base_module import BaseModule from modules.base_module import BaseModule
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Topic, auf das gatherRainData.py im SolarManager seine Regensummen legt. # Topic, auf das gatherRainData.py im SolarManager seine Regensummen legt.
# Dasselbe Topic steht dort noch einmal im Quelltext. Bewusst kein # Dasselbe Topic steht dort noch einmal im Quelltext. Bewusst kein
# Konfigurationseintrag: er müsste in beiden Repositorien gleich lauten, und # Konfigurationseintrag: er müsste in beiden Repositorien gleich lauten, und
# eine Einstellung, die man an zwei Orten gleich halten muss, ist keine. # eine Einstellung, die man an zwei Orten gleich halten muss, ist keine.
REGEN_TOPIC = "Wetter/Regen" REGEN_TOPIC = "Wetter/Regen"
# Welche Regenfenster als Messwert im Editor auftauchen. gatherRainData.py # Welche Regenfenster als Messwert im Editor auftauchen. gatherRainData.py
# veröffentlicht immer tage1 bis tage7; welche davon jemand braucht, entscheidet # veröffentlicht immer tage1 bis tage7; welche davon jemand braucht, entscheidet
# allein diese Liste. Die drei Zonen der alten auto_watering.py brauchten 1, 2 # allein diese Liste. Die drei Zonen der alten auto_watering.py brauchten 1, 2
# und 5 Tage - der Rest steht da, damit eine neue Zone keinen Eingriff im # und 5 Tage - der Rest steht da, damit eine neue Zone keinen Eingriff im
# SolarManager kostet. # SolarManager kostet.
REGEN_FENSTER = [1, 2, 3, 5, 7] REGEN_FENSTER = [1, 2, 3, 5, 7]
# Die beiden ESP32-Ventilsteuerungen. Sie melden sich nicht per # Die beiden ESP32-Ventilsteuerungen. Sie melden sich nicht per
# Home-Assistant-Discovery, das MQTT-Modul findet sie also nicht. # Home-Assistant-Discovery, das MQTT-Modul findet sie also nicht.
# #
# topic Wurzel aller Topics dieser Steuerung # topic Wurzel aller Topics dieser Steuerung
# modi Nutzlast des /auto-Topics -> Beschriftung im Editor. Dieselben # modi Nutzlast des /auto-Topics -> Beschriftung im Editor. Dieselben
# Klartexte stehen in der Whitelist von ajax/watering.php. # Klartexte stehen in der Whitelist von ajax/watering.php.
# zonen Ventilnummer -> Name der Zone. Eine Steuerung bedient mehrere # zonen Ventilnummer -> Name der Zone. Eine Steuerung bedient mehrere
# Zonen; "vorn" zum Beispiel Ventil 1 für die Tröge und Ventil 6 # Zonen; "vorn" zum Beispiel Ventil 1 für die Tröge und Ventil 6
# für den Garten. Aus jeder Zone werden zwei Messwerte. # für den Garten. Aus jeder Zone werden zwei Messwerte.
STEUERUNGEN = [ STEUERUNGEN = [
{ {
"name": "Gartenwasser vorn", "name": "Gartenwasser vorn",
"topic": "Gartenwasser/vorn", "topic": "Gartenwasser/vorn",
"modi": [{"Trog": "Tröge"}, {"Vorn": "Garten vorn"}, {"Stop": "Stopp"}], "modi": [{"Trog": "Tröge"}, {"Vorn": "Garten vorn"}, {"Stop": "Stopp"}],
"zonen": [(1, "Tröge"), (6, "Garten vorn")], "zonen": [(1, "Tröge"), (6, "Garten vorn")],
}, },
{ {
"name": "Gartenwasser hinten", "name": "Gartenwasser hinten",
"topic": "Gartenwasser/hinten", "topic": "Gartenwasser/hinten",
"modi": [{"Hoch": "Hochbeete"}, {"Stop": "Stopp"}], "modi": [{"Hoch": "Hochbeete"}, {"Stop": "Stopp"}],
"zonen": [(6, "Hochbeete")], "zonen": [(6, "Hochbeete")],
}, },
] ]
class GartenwasserModule(BaseModule): class GartenwasserModule(BaseModule):
""" """
Gartenwasser Modul - Implementiert BaseModule Interface Gartenwasser Modul - Implementiert BaseModule Interface
Anders als die übrigen Module sucht dieses nichts, es schreibt drei feste Anders als die übrigen Module sucht dieses nichts, es schreibt drei feste
Geräte hin - wie das Logic-Modul auch: Geräte hin - wie das Logic-Modul auch:
* Die Ventilsteuerungen sprechen MQTT, aber ohne Home-Assistant- * Die Ventilsteuerungen sprechen MQTT, aber ohne Home-Assistant-
Discovery. Ohne dieses Modul kennt die Datenbank sie nicht, und im Discovery. Ohne dieses Modul kennt die Datenbank sie nicht, und im
Automatik-Editor lässt sich die Bewässerung nicht auswählen. Automatik-Editor lässt sich die Bewässerung nicht auswählen.
* "Regen" ist gar kein Gerät, sondern das, was gatherRainData.py im * "Regen" ist gar kein Gerät, sondern das, was gatherRainData.py im
SolarManager von Open-Meteo holt. SolarManager von Open-Meteo holt.
Von Hand in die Datenbank geschrieben würden die Zeilen zwar jeden Von Hand in die Datenbank geschrieben würden die Zeilen zwar jeden
Discovery-Lauf überleben (es wird nur mit ON DUPLICATE KEY UPDATE Discovery-Lauf überleben (es wird nur mit ON DUPLICATE KEY UPDATE
geschrieben, nie gelöscht), aber niemand wüsste mehr, woher sie kommen. geschrieben, nie gelöscht), aber niemand wüsste mehr, woher sie kommen.
Zusammen ersetzt das restricted/gartenbewaesserung/auto_watering.py: die Zusammen ersetzt das restricted/gartenbewaesserung/auto_watering.py: die
Zeitfenster werden Bedingungen auf Sonnenauf- und -untergang, die Zeitfenster werden Bedingungen auf Sonnenauf- und -untergang, die
Regenschwellen Bedingungen auf "Regen ... Tage", und geschaltet wird über Regenschwellen Bedingungen auf "Regen ... Tage", und geschaltet wird über
das Kommando "Automatik". das Kommando "Automatik".
Zwei Zusagen der Ventilsteuerung stecken in den Messwerten unten, und ohne Zwei Zusagen der Ventilsteuerung stecken in den Messwerten unten, und ohne
sie stimmt die Automatik nicht mehr: sie stimmt die Automatik nicht mehr:
* LastWatering trägt je Ventil ein "daysSinceWatering<n>" - die Anzahl * LastWatering trägt je Ventil ein "daysSinceWatering<n>" - die Anzahl
Tage, nicht den Zeitstempel. Das Feld fehlt, solange für ein Ventil Tage, nicht den Zeitstempel. Das Feld fehlt, solange für ein Ventil
keine Bewässerung bekannt ist. keine Bewässerung bekannt ist.
* WateringToday wird um Mitternacht zurückgesetzt. * WateringToday wird um Mitternacht zurückgesetzt.
* Running sagt retained, ob gerade etwas läuft, und fällt über eine * Running sagt retained, ob gerade etwas läuft, und fällt über eine
Last-Will-Nachricht auch dann auf false, wenn die Steuerung wegbricht. Last-Will-Nachricht auch dann auf false, wenn die Steuerung wegbricht.
""" """
def is_enabled(self) -> bool: def is_enabled(self) -> bool:
"""Prüft ob Gartenwasser aktiviert ist""" """Prüft ob Gartenwasser aktiviert ist"""
return self.config.gartenwasser_enable return self.config.gartenwasser_enable
def discover(self) -> Tuple[List[Dict], List[Dict]]: def discover(self) -> Tuple[List[Dict], List[Dict]]:
""" """
Erzeugt die beiden Ventilsteuerungen als Actors und den Regen als Sensor Erzeugt die beiden Ventilsteuerungen als Actors und den Regen als Sensor
Returns: Returns:
Tuple (actors, sensors) Tuple (actors, sensors)
""" """
logger.info("\n" + "=" * 60) logger.info("\n" + "=" * 60)
logger.info("GARTENWASSER-GERÄTE WERDEN ERZEUGT") logger.info("GARTENWASSER-GERÄTE WERDEN ERZEUGT")
logger.info("=" * 60) logger.info("=" * 60)
actors = [self._steuerung(s) for s in STEUERUNGEN] actors = [self._steuerung(s) for s in STEUERUNGEN]
sensors = [self._regen()] sensors = [self._regen()]
return actors, sensors return actors, sensors
@staticmethod @staticmethod
def _steuerung(steuerung: Dict) -> Dict: def _steuerung(steuerung: Dict) -> Dict:
""" """
Eine Ventilsteuerung: ein Kommando, zwei Messwerte je Zone. Eine Ventilsteuerung: ein Kommando, zwei Messwerte je Zone.
Das Kommando trägt seinen Klartext im Parameter und nicht im Namen. Das Kommando trägt seinen Klartext im Parameter und nicht im Namen.
Ein Kommando ohne Parameter schickt im Runner eine leere Nutzlast Ein Kommando ohne Parameter schickt im Runner eine leere Nutzlast
(siehe MQTTTransport.senden), der Modus muss also der Parameter sein - (siehe MQTTTransport.senden), der Modus muss also der Parameter sein -
im Editor liest sich das dann als "Automatik Modus = Hochbeete". im Editor liest sich das dann als "Automatik Modus = Hochbeete".
""" """
topic = steuerung["topic"] topic = steuerung["topic"]
# Läuft an dieser Steuerung gerade etwas? Der Wächter für jede Regel, # Läuft an dieser Steuerung gerade etwas? Der Wächter für jede Regel,
# die hier etwas anstoßen will - die Zonen einer Steuerung hängen an # die hier etwas anstoßen will - die Zonen einer Steuerung hängen an
# derselben Leitung, zwei gleichzeitig gibt es nicht. # derselben Leitung, zwei gleichzeitig gibt es nicht.
# #
# Gelesen wird das aus Running und nicht aus Timers: Timers ist nicht # Gelesen wird das aus Running und nicht aus Timers: Timers ist nicht
# retained und wird nur im Sekundentakt gesendet, solange etwas läuft. # retained und wird nur im Sekundentakt gesendet, solange etwas läuft.
# Der Runner sähe im Ruhezustand gar nichts und behielte nach einem # Der Runner sähe im Ruhezustand gar nichts und behielte nach einem
# Lauf den zuletzt gesehenen Wert - also genau das Gegenteil von # Lauf den zuletzt gesehenen Wert - also genau das Gegenteil von
# verlässlich. Running ist retained und fällt über eine # verlässlich. Running ist retained und fällt über eine
# Last-Will-Nachricht auch dann auf false, wenn die Steuerung mitten # Last-Will-Nachricht auch dann auf false, wenn die Steuerung mitten
# im Gießen wegbricht. # im Gießen wegbricht.
states = [{ states = [{
"name": "Bewässerung läuft", "name": "Bewässerung läuft",
"url": topic + "/Running", "url": topic + "/Running",
"value_path": "running", "value_path": "running",
"type": "bool", "type": "bool",
}, { }, {
# Klartext der laufenden Automatik ("Hoch", "Trog", "Vorn"), leer # Klartext der laufenden Automatik ("Hoch", "Trog", "Vorn"), leer
# wenn von Hand oder gar nicht bewässert wird. # wenn von Hand oder gar nicht bewässert wird.
"name": "Laufender Modus", "name": "Laufender Modus",
"url": topic + "/Running", "url": topic + "/Running",
"value_path": "mode", "value_path": "mode",
"type": "string", "type": "string",
}] }]
for valve, zone in steuerung["zonen"]: for valve, zone in steuerung["zonen"]:
if len(steuerung["zonen"]) > 1: if len(steuerung["zonen"]) > 1:
# Bei einer Steuerung mit nur einer Zone wäre das derselbe Wert # Bei einer Steuerung mit nur einer Zone wäre das derselbe Wert
# wie "Bewässerung läuft" - ein zweiter Messwert, der nie etwas # wie "Bewässerung läuft" - ein zweiter Messwert, der nie etwas
# anderes sagt, macht die Auswahl nur länger. # anderes sagt, macht die Auswahl nur länger.
states.append({ states.append({
"name": "Bewässerung läuft " + zone, "name": "Bewässerung läuft " + zone,
"url": topic + "/Running", "url": topic + "/Running",
"value_path": "running%d" % valve, "value_path": "running%d" % valve,
"type": "bool", "type": "bool",
}) })
states.append({ states.append({
# Ersetzt die Zwölf-Stunden-Sperre von auto_watering.py: # Ersetzt die Zwölf-Stunden-Sperre von auto_watering.py:
# "heute noch nicht bewässert" heißt hier schlicht "= 0". # "heute noch nicht bewässert" heißt hier schlicht "= 0".
# #
# Dass das trägt, liegt an der Steuerung: sie setzt den Wert um # Dass das trägt, liegt an der Steuerung: sie setzt den Wert um
# Mitternacht zurück. Die Nachricht ist retained, und der Runner # Mitternacht zurück. Die Nachricht ist retained, und der Runner
# sieht nur die Zahl hinter dem value_path - den Tag, den die # sieht nur die Zahl hinter dem value_path - den Tag, den die
# Nutzlast als "wateringDay" mitführt, kann er nicht prüfen. # Nutzlast als "wateringDay" mitführt, kann er nicht prüfen.
# Ohne den Reset stünde nach einer bewässerungsfreien Nacht die # Ohne den Reset stünde nach einer bewässerungsfreien Nacht die
# Summe von gestern als "heute" da, und die Zone bliebe trocken. # Summe von gestern als "heute" da, und die Zone bliebe trocken.
"name": "Bewässert heute " + zone, "name": "Bewässert heute " + zone,
"url": topic + "/WateringToday", "url": topic + "/WateringToday",
"value_path": "wateringDurationTodaySecs%d" % valve, "value_path": "wateringDurationTodaySecs%d" % valve,
"type": "integer", "type": "integer",
"unit": "s", "unit": "s",
}) })
states.append({ states.append({
# Der Trockenheits-Notlauf (max_dry_days in auto_watering.py). # Der Trockenheits-Notlauf (max_dry_days in auto_watering.py).
# Ein Zeitstempel nützte hier nichts: der Runner vergleicht # Ein Zeitstempel nützte hier nichts: der Runner vergleicht
# Datumsangaben nur gegen einen festen Wert, "länger als fünf # Datumsangaben nur gegen einen festen Wert, "länger als fünf
# Tage her" ist damit nicht formulierbar. Die Steuerung liefert # Tage her" ist damit nicht formulierbar. Die Steuerung liefert
# deshalb gleich die Anzahl Tage - dafür wurde ihre Firmware # deshalb gleich die Anzahl Tage - dafür wurde ihre Firmware
# erweitert, das Feld gab es vorher nicht. # erweitert, das Feld gab es vorher nicht.
# #
# Für ein Ventil, das noch nie lief, lässt sie das Feld weg # Für ein Ventil, das noch nie lief, lässt sie das Feld weg
# statt eine 0 zu schicken. Eine 0 hieße "heute bewässert" und # statt eine 0 zu schicken. Eine 0 hieße "heute bewässert" und
# würde den Notlauf für immer unterdrücken; ohne Feld bleibt # würde den Notlauf für immer unterdrücken; ohne Feld bleibt
# der Messwert leer, und die Bedingung greift schlicht nicht. # der Messwert leer, und die Bedingung greift schlicht nicht.
"name": "Tage seit Bewässerung " + zone, "name": "Tage seit Bewässerung " + zone,
"url": topic + "/LastWatering", "url": topic + "/LastWatering",
"value_path": "daysSinceWatering%d" % valve, "value_path": "daysSinceWatering%d" % valve,
"type": "integer", "type": "integer",
"unit": "Tage", "unit": "Tage",
}) })
return { return {
"type": "Bewässerung", "type": "Bewässerung",
"name": steuerung["name"], "name": steuerung["name"],
"url": "mqtt://" + topic, "url": "mqtt://" + topic,
"commands": [{ "commands": [{
"command": "Automatik", "command": "Automatik",
"url": topic + "/auto", "url": topic + "/auto",
"parameters": [{ "parameters": [{
"name": "Modus", "name": "Modus",
"type": "string", "type": "string",
"url": topic + "/auto", "url": topic + "/auto",
"values": steuerung["modi"], "values": steuerung["modi"],
}], }],
}], }],
"states": states, "states": states,
} }
@staticmethod @staticmethod
def _regen() -> Dict: def _regen() -> Dict:
""" """
Der Regen als Gerät: je Zeitraum ein Messwert, dazu der Stand. Der Regen als Gerät: je Zeitraum ein Messwert, dazu der Stand.
"Regen heute" ist der bereits gefallene Regen des laufenden Tages, die "Regen heute" ist der bereits gefallene Regen des laufenden Tages, die
übrigen zählen ihn plus die abgeschlossenen Tage davor - so hat auch übrigen zählen ihn plus die abgeschlossenen Tage davor - so hat auch
auto_watering.py gerechnet. auto_watering.py gerechnet.
Der Stand ist kein Messwert zum Vergleichen, sondern zum Nachsehen: im Der Stand ist kein Messwert zum Vergleichen, sondern zum Nachsehen: im
Editor steht er hinter dem Namen ("= 04.09.2026 09:00:00") und verrät, Editor steht er hinter dem Namen ("= 04.09.2026 09:00:00") und verrät,
ob der Sammler noch läuft. Bleibt er länger aus, leert gatherRainData.py ob der Sammler noch läuft. Bleibt er länger aus, leert gatherRainData.py
die Zahlen von sich aus, und die Bewässerung setzt aus. die Zahlen von sich aus, und die Bewässerung setzt aus.
""" """
states = [{ states = [{
"name": "Regen heute" if tage == 1 else "Regen %d Tage" % tage, "name": "Regen heute" if tage == 1 else "Regen %d Tage" % tage,
"url": REGEN_TOPIC, "url": REGEN_TOPIC,
"value_path": "tage%d" % tage, "value_path": "tage%d" % tage,
"type": "float", "type": "float",
"unit": "mm", "unit": "mm",
} for tage in REGEN_FENSTER] } for tage in REGEN_FENSTER]
states.append({ states.append({
"name": "Stand", "name": "Stand",
"url": REGEN_TOPIC, "url": REGEN_TOPIC,
"value_path": "stand", "value_path": "stand",
"type": "string", "type": "string",
}) })
return { return {
"type": "Wetter", "type": "Wetter",
"name": "Regen", "name": "Regen",
"url": "mqtt://" + REGEN_TOPIC, "url": "mqtt://" + REGEN_TOPIC,
"states": states, "states": states,
} }