Zeilenenden vereinheitlichen: LF ueberall ausser im Fremdcode
Bisher entschied jede Arbeitsstation fuer sich, welche Zeilenenden ein Commit bekommt: Git fuer Windows setzt core.autocrlf=input in seiner System-Konfiguration, die NAS setzt nichts. Weil der Bestand ausserdem in sich gemischt war - 34 PHP-Dateien mit LF, 30 mit CRLF -, gab es gar kein richtiges Zeilenende, an das ein Werkzeug sich haette halten koennen. Jede Ergaenzung mit LF in einer CRLF-Datei ergab eine gemischte Datei; helper.php und js/solar/homeMQTT.js waren bereits so entstanden. .gitattributes macht die Zeilenenden zur Eigenschaft des Repositorys. LF, weil dieses Verzeichnis der Web-Ordner der NAS ist und von Linux ausgeliefert wird - und weil Git fuer Windows mit "input" ohnehin schon dorthin zeigt. restricted/WebAuthn bleibt ausgenommen und damit byteweise so, wie die Bibliothek geliefert wurde; darunter liegen 292 Zertifikate. Von den 47 geaenderten Dateien wurde nachgerechnet keine einzige im Inhalt angefasst - der Vergleich HEAD-ohne-CR gegen Index ist ueberall gleich. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,95 +1,95 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Base Module Interface
|
||||
Definiert die einheitliche Schnittstelle für alle Gerätemodule
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import List, Dict, Tuple
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BaseModule(ABC):
|
||||
"""
|
||||
Abstrakte Basisklasse für alle Gerätemodule
|
||||
|
||||
Jedes Modul muss nur discover() implementieren und gibt
|
||||
eine Liste von (actors, sensors) zurück.
|
||||
Die Datenbank-Logik bleibt im Hauptscript.
|
||||
"""
|
||||
|
||||
def __init__(self, config):
|
||||
"""
|
||||
Initialisiert das Modul
|
||||
|
||||
Args:
|
||||
config: Config-Objekt mit allen Einstellungen
|
||||
"""
|
||||
self.config = config
|
||||
self.module_name = self.__class__.__name__.replace('Module', '')
|
||||
|
||||
@abstractmethod
|
||||
def discover(self) -> Tuple[List[Dict], List[Dict]]:
|
||||
"""
|
||||
Führt Device Discovery durch
|
||||
|
||||
Returns:
|
||||
Tuple (actors, sensors) mit Listen von Dicts:
|
||||
|
||||
Actor Dict Format:
|
||||
{
|
||||
'type': str, # z.B. 'RollerShutter'
|
||||
'name': str, # z.B. 'Wohnzimmer Rollo'
|
||||
'url': str, # Eindeutige ID/URL
|
||||
'commands': [ # Liste von Commands
|
||||
{
|
||||
'command': str,
|
||||
'parameters': [
|
||||
{
|
||||
'name': str,
|
||||
'type': str,
|
||||
'min': float (optional),
|
||||
'max': float (optional),
|
||||
'url': str, # Eindeutige ID/URL
|
||||
'values': list (optional)
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
'states': [ # Liste von States
|
||||
{
|
||||
'name': str,
|
||||
'type': int/str,
|
||||
'current_value': any,
|
||||
'unit': str (optional)
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Sensor Dict Format:
|
||||
{
|
||||
'type': str, # z.B. 'TemperatureSensor'
|
||||
'name': str, # z.B. 'Außentemperatur'
|
||||
'url': str, # Eindeutige ID/URL
|
||||
'states': [ # Liste von States
|
||||
{
|
||||
'name': str,
|
||||
'type': int/str,
|
||||
'current_value': any,
|
||||
'url': str, # Eindeutige ID/URL
|
||||
'unit': str (optional)
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
pass
|
||||
|
||||
def is_enabled(self) -> bool:
|
||||
"""Prüft ob Modul aktiviert ist"""
|
||||
return True # Override in Subklassen falls nötig
|
||||
|
||||
def get_name(self) -> str:
|
||||
"""Gibt Modulname zurück"""
|
||||
return self.module_name
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Base Module Interface
|
||||
Definiert die einheitliche Schnittstelle für alle Gerätemodule
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import List, Dict, Tuple
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BaseModule(ABC):
|
||||
"""
|
||||
Abstrakte Basisklasse für alle Gerätemodule
|
||||
|
||||
Jedes Modul muss nur discover() implementieren und gibt
|
||||
eine Liste von (actors, sensors) zurück.
|
||||
Die Datenbank-Logik bleibt im Hauptscript.
|
||||
"""
|
||||
|
||||
def __init__(self, config):
|
||||
"""
|
||||
Initialisiert das Modul
|
||||
|
||||
Args:
|
||||
config: Config-Objekt mit allen Einstellungen
|
||||
"""
|
||||
self.config = config
|
||||
self.module_name = self.__class__.__name__.replace('Module', '')
|
||||
|
||||
@abstractmethod
|
||||
def discover(self) -> Tuple[List[Dict], List[Dict]]:
|
||||
"""
|
||||
Führt Device Discovery durch
|
||||
|
||||
Returns:
|
||||
Tuple (actors, sensors) mit Listen von Dicts:
|
||||
|
||||
Actor Dict Format:
|
||||
{
|
||||
'type': str, # z.B. 'RollerShutter'
|
||||
'name': str, # z.B. 'Wohnzimmer Rollo'
|
||||
'url': str, # Eindeutige ID/URL
|
||||
'commands': [ # Liste von Commands
|
||||
{
|
||||
'command': str,
|
||||
'parameters': [
|
||||
{
|
||||
'name': str,
|
||||
'type': str,
|
||||
'min': float (optional),
|
||||
'max': float (optional),
|
||||
'url': str, # Eindeutige ID/URL
|
||||
'values': list (optional)
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
'states': [ # Liste von States
|
||||
{
|
||||
'name': str,
|
||||
'type': int/str,
|
||||
'current_value': any,
|
||||
'unit': str (optional)
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Sensor Dict Format:
|
||||
{
|
||||
'type': str, # z.B. 'TemperatureSensor'
|
||||
'name': str, # z.B. 'Außentemperatur'
|
||||
'url': str, # Eindeutige ID/URL
|
||||
'states': [ # Liste von States
|
||||
{
|
||||
'name': str,
|
||||
'type': int/str,
|
||||
'current_value': any,
|
||||
'url': str, # Eindeutige ID/URL
|
||||
'unit': str (optional)
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
pass
|
||||
|
||||
def is_enabled(self) -> bool:
|
||||
"""Prüft ob Modul aktiviert ist"""
|
||||
return True # Override in Subklassen falls nötig
|
||||
|
||||
def get_name(self) -> str:
|
||||
"""Gibt Modulname zurück"""
|
||||
return self.module_name
|
||||
|
||||
@@ -1,238 +1,238 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Gartenwasser Module
|
||||
Die beiden Ventilsteuerungen und der Regen - fest beschrieben, nicht gesucht
|
||||
KEINE Datenbank-Operationen!
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List, Dict, Tuple
|
||||
from modules.base_module import BaseModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Topic, auf das gatherRainData.py im SolarManager seine Regensummen legt.
|
||||
# Dasselbe Topic steht dort noch einmal im Quelltext. Bewusst kein
|
||||
# Konfigurationseintrag: er müsste in beiden Repositorien gleich lauten, und
|
||||
# eine Einstellung, die man an zwei Orten gleich halten muss, ist keine.
|
||||
REGEN_TOPIC = "Wetter/Regen"
|
||||
|
||||
# Welche Regenfenster als Messwert im Editor auftauchen. gatherRainData.py
|
||||
# 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
|
||||
# und 5 Tage - der Rest steht da, damit eine neue Zone keinen Eingriff im
|
||||
# SolarManager kostet.
|
||||
REGEN_FENSTER = [1, 2, 3, 5, 7]
|
||||
|
||||
# Die beiden ESP32-Ventilsteuerungen. Sie melden sich nicht per
|
||||
# Home-Assistant-Discovery, das MQTT-Modul findet sie also nicht.
|
||||
#
|
||||
# topic Wurzel aller Topics dieser Steuerung
|
||||
# modi Nutzlast des /auto-Topics -> Beschriftung im Editor. Dieselben
|
||||
# Klartexte stehen in der Whitelist von ajax/watering.php.
|
||||
# zonen Ventilnummer -> Name der Zone. Eine Steuerung bedient mehrere
|
||||
# Zonen; "vorn" zum Beispiel Ventil 1 für die Tröge und Ventil 6
|
||||
# für den Garten. Aus jeder Zone werden zwei Messwerte.
|
||||
STEUERUNGEN = [
|
||||
{
|
||||
"name": "Gartenwasser vorn",
|
||||
"topic": "Gartenwasser/vorn",
|
||||
"modi": [{"Trog": "Tröge"}, {"Vorn": "Garten vorn"}, {"Stop": "Stopp"}],
|
||||
"zonen": [(1, "Tröge"), (6, "Garten vorn")],
|
||||
},
|
||||
{
|
||||
"name": "Gartenwasser hinten",
|
||||
"topic": "Gartenwasser/hinten",
|
||||
"modi": [{"Hoch": "Hochbeete"}, {"Stop": "Stopp"}],
|
||||
"zonen": [(6, "Hochbeete")],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class GartenwasserModule(BaseModule):
|
||||
"""
|
||||
Gartenwasser Modul - Implementiert BaseModule Interface
|
||||
|
||||
Anders als die übrigen Module sucht dieses nichts, es schreibt drei feste
|
||||
Geräte hin - wie das Logic-Modul auch:
|
||||
|
||||
* Die Ventilsteuerungen sprechen MQTT, aber ohne Home-Assistant-
|
||||
Discovery. Ohne dieses Modul kennt die Datenbank sie nicht, und im
|
||||
Automatik-Editor lässt sich die Bewässerung nicht auswählen.
|
||||
* "Regen" ist gar kein Gerät, sondern das, was gatherRainData.py im
|
||||
SolarManager von Open-Meteo holt.
|
||||
|
||||
Von Hand in die Datenbank geschrieben würden die Zeilen zwar jeden
|
||||
Discovery-Lauf überleben (es wird nur mit ON DUPLICATE KEY UPDATE
|
||||
geschrieben, nie gelöscht), aber niemand wüsste mehr, woher sie kommen.
|
||||
|
||||
Zusammen ersetzt das restricted/gartenbewaesserung/auto_watering.py: die
|
||||
Zeitfenster werden Bedingungen auf Sonnenauf- und -untergang, die
|
||||
Regenschwellen Bedingungen auf "Regen ... Tage", und geschaltet wird über
|
||||
das Kommando "Automatik".
|
||||
|
||||
Zwei Zusagen der Ventilsteuerung stecken in den Messwerten unten, und ohne
|
||||
sie stimmt die Automatik nicht mehr:
|
||||
|
||||
* LastWatering trägt je Ventil ein "daysSinceWatering<n>" - die Anzahl
|
||||
Tage, nicht den Zeitstempel. Das Feld fehlt, solange für ein Ventil
|
||||
keine Bewässerung bekannt ist.
|
||||
* WateringToday wird um Mitternacht zurückgesetzt.
|
||||
* Running sagt retained, ob gerade etwas läuft, und fällt über eine
|
||||
Last-Will-Nachricht auch dann auf false, wenn die Steuerung wegbricht.
|
||||
"""
|
||||
|
||||
def is_enabled(self) -> bool:
|
||||
"""Prüft ob Gartenwasser aktiviert ist"""
|
||||
return self.config.gartenwasser_enable
|
||||
|
||||
def discover(self) -> Tuple[List[Dict], List[Dict]]:
|
||||
"""
|
||||
Erzeugt die beiden Ventilsteuerungen als Actors und den Regen als Sensor
|
||||
|
||||
Returns:
|
||||
Tuple (actors, sensors)
|
||||
"""
|
||||
logger.info("\n" + "=" * 60)
|
||||
logger.info("GARTENWASSER-GERÄTE WERDEN ERZEUGT")
|
||||
logger.info("=" * 60)
|
||||
|
||||
actors = [self._steuerung(s) for s in STEUERUNGEN]
|
||||
sensors = [self._regen()]
|
||||
return actors, sensors
|
||||
|
||||
@staticmethod
|
||||
def _steuerung(steuerung: Dict) -> Dict:
|
||||
"""
|
||||
Eine Ventilsteuerung: ein Kommando, zwei Messwerte je Zone.
|
||||
|
||||
Das Kommando trägt seinen Klartext im Parameter und nicht im Namen.
|
||||
Ein Kommando ohne Parameter schickt im Runner eine leere Nutzlast
|
||||
(siehe MQTTTransport.senden), der Modus muss also der Parameter sein -
|
||||
im Editor liest sich das dann als "Automatik Modus = Hochbeete".
|
||||
"""
|
||||
topic = steuerung["topic"]
|
||||
|
||||
# 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
|
||||
# derselben Leitung, zwei gleichzeitig gibt es nicht.
|
||||
#
|
||||
# Gelesen wird das aus Running und nicht aus Timers: Timers ist nicht
|
||||
# retained und wird nur im Sekundentakt gesendet, solange etwas läuft.
|
||||
# Der Runner sähe im Ruhezustand gar nichts und behielte nach einem
|
||||
# Lauf den zuletzt gesehenen Wert - also genau das Gegenteil von
|
||||
# verlässlich. Running ist retained und fällt über eine
|
||||
# Last-Will-Nachricht auch dann auf false, wenn die Steuerung mitten
|
||||
# im Gießen wegbricht.
|
||||
states = [{
|
||||
"name": "Bewässerung läuft",
|
||||
"url": topic + "/Running",
|
||||
"value_path": "running",
|
||||
"type": "bool",
|
||||
}, {
|
||||
# Klartext der laufenden Automatik ("Hoch", "Trog", "Vorn"), leer
|
||||
# wenn von Hand oder gar nicht bewässert wird.
|
||||
"name": "Laufender Modus",
|
||||
"url": topic + "/Running",
|
||||
"value_path": "mode",
|
||||
"type": "string",
|
||||
}]
|
||||
|
||||
for valve, zone in steuerung["zonen"]:
|
||||
if len(steuerung["zonen"]) > 1:
|
||||
# Bei einer Steuerung mit nur einer Zone wäre das derselbe Wert
|
||||
# wie "Bewässerung läuft" - ein zweiter Messwert, der nie etwas
|
||||
# anderes sagt, macht die Auswahl nur länger.
|
||||
states.append({
|
||||
"name": "Bewässerung läuft " + zone,
|
||||
"url": topic + "/Running",
|
||||
"value_path": "running%d" % valve,
|
||||
"type": "bool",
|
||||
})
|
||||
states.append({
|
||||
# Ersetzt die Zwölf-Stunden-Sperre von auto_watering.py:
|
||||
# "heute noch nicht bewässert" heißt hier schlicht "= 0".
|
||||
#
|
||||
# Dass das trägt, liegt an der Steuerung: sie setzt den Wert um
|
||||
# Mitternacht zurück. Die Nachricht ist retained, und der Runner
|
||||
# sieht nur die Zahl hinter dem value_path - den Tag, den die
|
||||
# Nutzlast als "wateringDay" mitführt, kann er nicht prüfen.
|
||||
# Ohne den Reset stünde nach einer bewässerungsfreien Nacht die
|
||||
# Summe von gestern als "heute" da, und die Zone bliebe trocken.
|
||||
"name": "Bewässert heute " + zone,
|
||||
"url": topic + "/WateringToday",
|
||||
"value_path": "wateringDurationTodaySecs%d" % valve,
|
||||
"type": "integer",
|
||||
"unit": "s",
|
||||
})
|
||||
states.append({
|
||||
# Der Trockenheits-Notlauf (max_dry_days in auto_watering.py).
|
||||
# Ein Zeitstempel nützte hier nichts: der Runner vergleicht
|
||||
# Datumsangaben nur gegen einen festen Wert, "länger als fünf
|
||||
# Tage her" ist damit nicht formulierbar. Die Steuerung liefert
|
||||
# deshalb gleich die Anzahl Tage - dafür wurde ihre Firmware
|
||||
# erweitert, das Feld gab es vorher nicht.
|
||||
#
|
||||
# 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
|
||||
# würde den Notlauf für immer unterdrücken; ohne Feld bleibt
|
||||
# der Messwert leer, und die Bedingung greift schlicht nicht.
|
||||
"name": "Tage seit Bewässerung " + zone,
|
||||
"url": topic + "/LastWatering",
|
||||
"value_path": "daysSinceWatering%d" % valve,
|
||||
"type": "integer",
|
||||
"unit": "Tage",
|
||||
})
|
||||
|
||||
return {
|
||||
"type": "Bewässerung",
|
||||
"name": steuerung["name"],
|
||||
"url": "mqtt://" + topic,
|
||||
"commands": [{
|
||||
"command": "Automatik",
|
||||
"url": topic + "/auto",
|
||||
"parameters": [{
|
||||
"name": "Modus",
|
||||
"type": "string",
|
||||
"url": topic + "/auto",
|
||||
"values": steuerung["modi"],
|
||||
}],
|
||||
}],
|
||||
"states": states,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _regen() -> Dict:
|
||||
"""
|
||||
Der Regen als Gerät: je Zeitraum ein Messwert, dazu der Stand.
|
||||
|
||||
"Regen heute" ist der bereits gefallene Regen des laufenden Tages, die
|
||||
übrigen zählen ihn plus die abgeschlossenen Tage davor - so hat auch
|
||||
auto_watering.py gerechnet.
|
||||
|
||||
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,
|
||||
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.
|
||||
"""
|
||||
states = [{
|
||||
"name": "Regen heute" if tage == 1 else "Regen %d Tage" % tage,
|
||||
"url": REGEN_TOPIC,
|
||||
"value_path": "tage%d" % tage,
|
||||
"type": "float",
|
||||
"unit": "mm",
|
||||
} for tage in REGEN_FENSTER]
|
||||
|
||||
states.append({
|
||||
"name": "Stand",
|
||||
"url": REGEN_TOPIC,
|
||||
"value_path": "stand",
|
||||
"type": "string",
|
||||
})
|
||||
|
||||
return {
|
||||
"type": "Wetter",
|
||||
"name": "Regen",
|
||||
"url": "mqtt://" + REGEN_TOPIC,
|
||||
"states": states,
|
||||
}
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Gartenwasser Module
|
||||
Die beiden Ventilsteuerungen und der Regen - fest beschrieben, nicht gesucht
|
||||
KEINE Datenbank-Operationen!
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List, Dict, Tuple
|
||||
from modules.base_module import BaseModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Topic, auf das gatherRainData.py im SolarManager seine Regensummen legt.
|
||||
# Dasselbe Topic steht dort noch einmal im Quelltext. Bewusst kein
|
||||
# Konfigurationseintrag: er müsste in beiden Repositorien gleich lauten, und
|
||||
# eine Einstellung, die man an zwei Orten gleich halten muss, ist keine.
|
||||
REGEN_TOPIC = "Wetter/Regen"
|
||||
|
||||
# Welche Regenfenster als Messwert im Editor auftauchen. gatherRainData.py
|
||||
# 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
|
||||
# und 5 Tage - der Rest steht da, damit eine neue Zone keinen Eingriff im
|
||||
# SolarManager kostet.
|
||||
REGEN_FENSTER = [1, 2, 3, 5, 7]
|
||||
|
||||
# Die beiden ESP32-Ventilsteuerungen. Sie melden sich nicht per
|
||||
# Home-Assistant-Discovery, das MQTT-Modul findet sie also nicht.
|
||||
#
|
||||
# topic Wurzel aller Topics dieser Steuerung
|
||||
# modi Nutzlast des /auto-Topics -> Beschriftung im Editor. Dieselben
|
||||
# Klartexte stehen in der Whitelist von ajax/watering.php.
|
||||
# zonen Ventilnummer -> Name der Zone. Eine Steuerung bedient mehrere
|
||||
# Zonen; "vorn" zum Beispiel Ventil 1 für die Tröge und Ventil 6
|
||||
# für den Garten. Aus jeder Zone werden zwei Messwerte.
|
||||
STEUERUNGEN = [
|
||||
{
|
||||
"name": "Gartenwasser vorn",
|
||||
"topic": "Gartenwasser/vorn",
|
||||
"modi": [{"Trog": "Tröge"}, {"Vorn": "Garten vorn"}, {"Stop": "Stopp"}],
|
||||
"zonen": [(1, "Tröge"), (6, "Garten vorn")],
|
||||
},
|
||||
{
|
||||
"name": "Gartenwasser hinten",
|
||||
"topic": "Gartenwasser/hinten",
|
||||
"modi": [{"Hoch": "Hochbeete"}, {"Stop": "Stopp"}],
|
||||
"zonen": [(6, "Hochbeete")],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class GartenwasserModule(BaseModule):
|
||||
"""
|
||||
Gartenwasser Modul - Implementiert BaseModule Interface
|
||||
|
||||
Anders als die übrigen Module sucht dieses nichts, es schreibt drei feste
|
||||
Geräte hin - wie das Logic-Modul auch:
|
||||
|
||||
* Die Ventilsteuerungen sprechen MQTT, aber ohne Home-Assistant-
|
||||
Discovery. Ohne dieses Modul kennt die Datenbank sie nicht, und im
|
||||
Automatik-Editor lässt sich die Bewässerung nicht auswählen.
|
||||
* "Regen" ist gar kein Gerät, sondern das, was gatherRainData.py im
|
||||
SolarManager von Open-Meteo holt.
|
||||
|
||||
Von Hand in die Datenbank geschrieben würden die Zeilen zwar jeden
|
||||
Discovery-Lauf überleben (es wird nur mit ON DUPLICATE KEY UPDATE
|
||||
geschrieben, nie gelöscht), aber niemand wüsste mehr, woher sie kommen.
|
||||
|
||||
Zusammen ersetzt das restricted/gartenbewaesserung/auto_watering.py: die
|
||||
Zeitfenster werden Bedingungen auf Sonnenauf- und -untergang, die
|
||||
Regenschwellen Bedingungen auf "Regen ... Tage", und geschaltet wird über
|
||||
das Kommando "Automatik".
|
||||
|
||||
Zwei Zusagen der Ventilsteuerung stecken in den Messwerten unten, und ohne
|
||||
sie stimmt die Automatik nicht mehr:
|
||||
|
||||
* LastWatering trägt je Ventil ein "daysSinceWatering<n>" - die Anzahl
|
||||
Tage, nicht den Zeitstempel. Das Feld fehlt, solange für ein Ventil
|
||||
keine Bewässerung bekannt ist.
|
||||
* WateringToday wird um Mitternacht zurückgesetzt.
|
||||
* Running sagt retained, ob gerade etwas läuft, und fällt über eine
|
||||
Last-Will-Nachricht auch dann auf false, wenn die Steuerung wegbricht.
|
||||
"""
|
||||
|
||||
def is_enabled(self) -> bool:
|
||||
"""Prüft ob Gartenwasser aktiviert ist"""
|
||||
return self.config.gartenwasser_enable
|
||||
|
||||
def discover(self) -> Tuple[List[Dict], List[Dict]]:
|
||||
"""
|
||||
Erzeugt die beiden Ventilsteuerungen als Actors und den Regen als Sensor
|
||||
|
||||
Returns:
|
||||
Tuple (actors, sensors)
|
||||
"""
|
||||
logger.info("\n" + "=" * 60)
|
||||
logger.info("GARTENWASSER-GERÄTE WERDEN ERZEUGT")
|
||||
logger.info("=" * 60)
|
||||
|
||||
actors = [self._steuerung(s) for s in STEUERUNGEN]
|
||||
sensors = [self._regen()]
|
||||
return actors, sensors
|
||||
|
||||
@staticmethod
|
||||
def _steuerung(steuerung: Dict) -> Dict:
|
||||
"""
|
||||
Eine Ventilsteuerung: ein Kommando, zwei Messwerte je Zone.
|
||||
|
||||
Das Kommando trägt seinen Klartext im Parameter und nicht im Namen.
|
||||
Ein Kommando ohne Parameter schickt im Runner eine leere Nutzlast
|
||||
(siehe MQTTTransport.senden), der Modus muss also der Parameter sein -
|
||||
im Editor liest sich das dann als "Automatik Modus = Hochbeete".
|
||||
"""
|
||||
topic = steuerung["topic"]
|
||||
|
||||
# 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
|
||||
# derselben Leitung, zwei gleichzeitig gibt es nicht.
|
||||
#
|
||||
# Gelesen wird das aus Running und nicht aus Timers: Timers ist nicht
|
||||
# retained und wird nur im Sekundentakt gesendet, solange etwas läuft.
|
||||
# Der Runner sähe im Ruhezustand gar nichts und behielte nach einem
|
||||
# Lauf den zuletzt gesehenen Wert - also genau das Gegenteil von
|
||||
# verlässlich. Running ist retained und fällt über eine
|
||||
# Last-Will-Nachricht auch dann auf false, wenn die Steuerung mitten
|
||||
# im Gießen wegbricht.
|
||||
states = [{
|
||||
"name": "Bewässerung läuft",
|
||||
"url": topic + "/Running",
|
||||
"value_path": "running",
|
||||
"type": "bool",
|
||||
}, {
|
||||
# Klartext der laufenden Automatik ("Hoch", "Trog", "Vorn"), leer
|
||||
# wenn von Hand oder gar nicht bewässert wird.
|
||||
"name": "Laufender Modus",
|
||||
"url": topic + "/Running",
|
||||
"value_path": "mode",
|
||||
"type": "string",
|
||||
}]
|
||||
|
||||
for valve, zone in steuerung["zonen"]:
|
||||
if len(steuerung["zonen"]) > 1:
|
||||
# Bei einer Steuerung mit nur einer Zone wäre das derselbe Wert
|
||||
# wie "Bewässerung läuft" - ein zweiter Messwert, der nie etwas
|
||||
# anderes sagt, macht die Auswahl nur länger.
|
||||
states.append({
|
||||
"name": "Bewässerung läuft " + zone,
|
||||
"url": topic + "/Running",
|
||||
"value_path": "running%d" % valve,
|
||||
"type": "bool",
|
||||
})
|
||||
states.append({
|
||||
# Ersetzt die Zwölf-Stunden-Sperre von auto_watering.py:
|
||||
# "heute noch nicht bewässert" heißt hier schlicht "= 0".
|
||||
#
|
||||
# Dass das trägt, liegt an der Steuerung: sie setzt den Wert um
|
||||
# Mitternacht zurück. Die Nachricht ist retained, und der Runner
|
||||
# sieht nur die Zahl hinter dem value_path - den Tag, den die
|
||||
# Nutzlast als "wateringDay" mitführt, kann er nicht prüfen.
|
||||
# Ohne den Reset stünde nach einer bewässerungsfreien Nacht die
|
||||
# Summe von gestern als "heute" da, und die Zone bliebe trocken.
|
||||
"name": "Bewässert heute " + zone,
|
||||
"url": topic + "/WateringToday",
|
||||
"value_path": "wateringDurationTodaySecs%d" % valve,
|
||||
"type": "integer",
|
||||
"unit": "s",
|
||||
})
|
||||
states.append({
|
||||
# Der Trockenheits-Notlauf (max_dry_days in auto_watering.py).
|
||||
# Ein Zeitstempel nützte hier nichts: der Runner vergleicht
|
||||
# Datumsangaben nur gegen einen festen Wert, "länger als fünf
|
||||
# Tage her" ist damit nicht formulierbar. Die Steuerung liefert
|
||||
# deshalb gleich die Anzahl Tage - dafür wurde ihre Firmware
|
||||
# erweitert, das Feld gab es vorher nicht.
|
||||
#
|
||||
# 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
|
||||
# würde den Notlauf für immer unterdrücken; ohne Feld bleibt
|
||||
# der Messwert leer, und die Bedingung greift schlicht nicht.
|
||||
"name": "Tage seit Bewässerung " + zone,
|
||||
"url": topic + "/LastWatering",
|
||||
"value_path": "daysSinceWatering%d" % valve,
|
||||
"type": "integer",
|
||||
"unit": "Tage",
|
||||
})
|
||||
|
||||
return {
|
||||
"type": "Bewässerung",
|
||||
"name": steuerung["name"],
|
||||
"url": "mqtt://" + topic,
|
||||
"commands": [{
|
||||
"command": "Automatik",
|
||||
"url": topic + "/auto",
|
||||
"parameters": [{
|
||||
"name": "Modus",
|
||||
"type": "string",
|
||||
"url": topic + "/auto",
|
||||
"values": steuerung["modi"],
|
||||
}],
|
||||
}],
|
||||
"states": states,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _regen() -> Dict:
|
||||
"""
|
||||
Der Regen als Gerät: je Zeitraum ein Messwert, dazu der Stand.
|
||||
|
||||
"Regen heute" ist der bereits gefallene Regen des laufenden Tages, die
|
||||
übrigen zählen ihn plus die abgeschlossenen Tage davor - so hat auch
|
||||
auto_watering.py gerechnet.
|
||||
|
||||
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,
|
||||
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.
|
||||
"""
|
||||
states = [{
|
||||
"name": "Regen heute" if tage == 1 else "Regen %d Tage" % tage,
|
||||
"url": REGEN_TOPIC,
|
||||
"value_path": "tage%d" % tage,
|
||||
"type": "float",
|
||||
"unit": "mm",
|
||||
} for tage in REGEN_FENSTER]
|
||||
|
||||
states.append({
|
||||
"name": "Stand",
|
||||
"url": REGEN_TOPIC,
|
||||
"value_path": "stand",
|
||||
"type": "string",
|
||||
})
|
||||
|
||||
return {
|
||||
"type": "Wetter",
|
||||
"name": "Regen",
|
||||
"url": "mqtt://" + REGEN_TOPIC,
|
||||
"states": states,
|
||||
}
|
||||
|
||||
@@ -1,74 +1,74 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
WLED Module
|
||||
Enthält NUR Logik-spezifische Geräte-Discovery Logik
|
||||
KEINE Datenbank-Operationen!
|
||||
"""
|
||||
|
||||
import requests
|
||||
import socket
|
||||
import concurrent.futures
|
||||
import logging
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
from modules.base_module import BaseModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LogicModule(BaseModule):
|
||||
"""
|
||||
Logic Modul - Implementiert BaseModule Interface
|
||||
Gibt nur Actors zurück, KEINE DB-Operationen
|
||||
"""
|
||||
|
||||
def is_enabled(self) -> bool:
|
||||
"""Prüft ob Logic aktiviert ist"""
|
||||
return self.config.logic_enable
|
||||
|
||||
def discover(self) -> Tuple[List[Dict], List[Dict]]:
|
||||
"""
|
||||
Erzeugt Einträge für Sensorunabhängige Funktionen (Timer, Sonnenauf/untergang, Uhrzeiten, etc.)
|
||||
|
||||
Returns:
|
||||
Tuple (actors, sensors) - WLED sind immer Actors
|
||||
"""
|
||||
logger.info("\n" + "=" * 60)
|
||||
logger.info("LOGIC-GERÄTE WERDEN ERZEUGT")
|
||||
logger.info("=" * 60)
|
||||
|
||||
actors = []
|
||||
sensors = []
|
||||
|
||||
states = []
|
||||
states.append({
|
||||
'name': 'Uhrzeit',
|
||||
'url': 'time',
|
||||
'type': 'time',
|
||||
'current_value': '13:45'
|
||||
})
|
||||
states.append({
|
||||
'name': 'Datum',
|
||||
'url': 'date',
|
||||
'type': 'date',
|
||||
'current_value': '20.03.2026'
|
||||
})
|
||||
states.append({
|
||||
'name': 'Sonnenaufgang',
|
||||
'url': 'sunrise',
|
||||
'type': 'deltatime',
|
||||
'current_value': '00:00'
|
||||
})
|
||||
states.append({
|
||||
'name': 'Sonnenuntergang',
|
||||
'url': 'sunset',
|
||||
'type': 'deltatime',
|
||||
'current_value': '00:00'
|
||||
})
|
||||
|
||||
sensors.append({'type': 'LOGIC',
|
||||
'name': "Zeitpunkt",
|
||||
'url': f"Logic",
|
||||
'commands': [],
|
||||
'states': states})
|
||||
|
||||
return actors, sensors
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
WLED Module
|
||||
Enthält NUR Logik-spezifische Geräte-Discovery Logik
|
||||
KEINE Datenbank-Operationen!
|
||||
"""
|
||||
|
||||
import requests
|
||||
import socket
|
||||
import concurrent.futures
|
||||
import logging
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
from modules.base_module import BaseModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LogicModule(BaseModule):
|
||||
"""
|
||||
Logic Modul - Implementiert BaseModule Interface
|
||||
Gibt nur Actors zurück, KEINE DB-Operationen
|
||||
"""
|
||||
|
||||
def is_enabled(self) -> bool:
|
||||
"""Prüft ob Logic aktiviert ist"""
|
||||
return self.config.logic_enable
|
||||
|
||||
def discover(self) -> Tuple[List[Dict], List[Dict]]:
|
||||
"""
|
||||
Erzeugt Einträge für Sensorunabhängige Funktionen (Timer, Sonnenauf/untergang, Uhrzeiten, etc.)
|
||||
|
||||
Returns:
|
||||
Tuple (actors, sensors) - WLED sind immer Actors
|
||||
"""
|
||||
logger.info("\n" + "=" * 60)
|
||||
logger.info("LOGIC-GERÄTE WERDEN ERZEUGT")
|
||||
logger.info("=" * 60)
|
||||
|
||||
actors = []
|
||||
sensors = []
|
||||
|
||||
states = []
|
||||
states.append({
|
||||
'name': 'Uhrzeit',
|
||||
'url': 'time',
|
||||
'type': 'time',
|
||||
'current_value': '13:45'
|
||||
})
|
||||
states.append({
|
||||
'name': 'Datum',
|
||||
'url': 'date',
|
||||
'type': 'date',
|
||||
'current_value': '20.03.2026'
|
||||
})
|
||||
states.append({
|
||||
'name': 'Sonnenaufgang',
|
||||
'url': 'sunrise',
|
||||
'type': 'deltatime',
|
||||
'current_value': '00:00'
|
||||
})
|
||||
states.append({
|
||||
'name': 'Sonnenuntergang',
|
||||
'url': 'sunset',
|
||||
'type': 'deltatime',
|
||||
'current_value': '00:00'
|
||||
})
|
||||
|
||||
sensors.append({'type': 'LOGIC',
|
||||
'name': "Zeitpunkt",
|
||||
'url': f"Logic",
|
||||
'commands': [],
|
||||
'states': states})
|
||||
|
||||
return actors, sensors
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,479 +1,479 @@
|
||||
|
||||
|
||||
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shelly Module
|
||||
Enthält NUR Shelly-spezifische Geräte-Discovery Logik
|
||||
KEINE Datenbank-Operationen!
|
||||
"""
|
||||
|
||||
import json
|
||||
import requests
|
||||
import socket
|
||||
import concurrent.futures
|
||||
import logging
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
from modules.base_module import BaseModule
|
||||
|
||||
# Logging konfigurieren
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ShellyDiscovery:
|
||||
"""Klasse zum Entdecken von Shelly-Geräten im Netzwerk"""
|
||||
|
||||
SHELLY_MDNS_SERVICE = "_http._tcp.local."
|
||||
COMMON_PORTS = [80]
|
||||
|
||||
def __init__(self):
|
||||
self.devices = []
|
||||
|
||||
|
||||
def scan_network(self, network: str = None, max_threads: int = 50) -> List[str]:
|
||||
"""Scannt das Netzwerk nach Shelly-Geräten"""
|
||||
if network is None:
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.connect(("8.8.8.8", 80))
|
||||
local_ip = s.getsockname()[0]
|
||||
s.close()
|
||||
network_prefix = '.'.join(local_ip.split('.')[:-1])
|
||||
except:
|
||||
logger.warning("Konnte lokale IP nicht ermitteln, verwende 192.168.1.x")
|
||||
network_prefix = "192.168.1"
|
||||
else:
|
||||
network_prefix = '.'.join(network.split('.')[:3])
|
||||
|
||||
logger.info(f"Scanne Netzwerk {network_prefix}.0/24 nach Shelly-Geräten...")
|
||||
|
||||
def check_ip(ip):
|
||||
device = ShellyDiscovery.is_shelly_device(ip)
|
||||
if device:
|
||||
return device
|
||||
return None
|
||||
|
||||
shelly_devices = []
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=max_threads) as executor:
|
||||
futures = [executor.submit(check_ip, f"{network_prefix}.{i}")
|
||||
for i in range(1, 255)]
|
||||
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
result = future.result()
|
||||
if result:
|
||||
shelly_devices.append(result)
|
||||
#logger.info(f"Shelly-Gerät gefunden: {result}")
|
||||
|
||||
return shelly_devices
|
||||
"""def scan_network(self, start_ip: int = 2, end_ip: int = 254, timeout: float = 0.3) -> List[str]:
|
||||
|
||||
active_hosts = []
|
||||
logger.info(f"Scanne Netzwerk {self.network_range}.{start_ip}-{end_ip}...")
|
||||
|
||||
for i in range(start_ip, end_ip + 1):
|
||||
ip = f"{self.network_range}.{i}"
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(timeout)
|
||||
|
||||
try:
|
||||
result = sock.connect_ex((ip, 80))
|
||||
if result == 0:
|
||||
active_hosts.append(ip)
|
||||
logger.debug(f"Host gefunden: {ip}")
|
||||
except:
|
||||
pass
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
logger.info(f"{len(active_hosts)} aktive Hosts gefunden")
|
||||
return active_hosts
|
||||
"""
|
||||
def is_shelly_device(ip: str) -> Optional[Dict]:
|
||||
"""
|
||||
Prüft ob ein Host ein Shelly-Gerät ist
|
||||
|
||||
Args:
|
||||
ip: IP-Adresse des Hosts
|
||||
|
||||
Returns:
|
||||
Device Info Dict wenn Shelly, sonst None
|
||||
"""
|
||||
#logger.info(f"Suche Shelly Getät unter: {ip}")
|
||||
try:
|
||||
# Versuche Gen2 API (neuere Shelly-Geräte)
|
||||
response = requests.get(
|
||||
f"http://{ip}/rpc/Shelly.GetDeviceInfo",
|
||||
timeout=2
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
logger.info(f"Shelly Gen2 Gerät gefunden: {ip} - {data.get('name', 'Unknown')}")
|
||||
return {
|
||||
'ip': ip,
|
||||
'generation': 2,
|
||||
'info': data
|
||||
}
|
||||
except:
|
||||
pass
|
||||
|
||||
try:
|
||||
# Versuche Gen1 API (ältere Shelly-Geräte)
|
||||
response = requests.get(
|
||||
f"http://{ip}/settings",
|
||||
timeout=2
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if 'device' in data and (data['device']['type'].startswith('SHELLY') or data['device']['type'].startswith('SHSW')):
|
||||
logger.info(f"Shelly Gen1 Gerät gefunden: {ip} - {data['device'].get('type', 'Unknown')}")
|
||||
return {
|
||||
'ip': ip,
|
||||
'generation': 1,
|
||||
'info': data
|
||||
}
|
||||
except:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
def get_device_status(self, device: Dict) -> Optional[Dict]:
|
||||
"""
|
||||
Holt den Status eines Shelly-Geräts
|
||||
|
||||
Args:
|
||||
device: Device Info Dictionary
|
||||
|
||||
Returns:
|
||||
Status Dictionary oder None
|
||||
"""
|
||||
ip = device['ip']
|
||||
|
||||
try:
|
||||
if device['generation'] == 2:
|
||||
# Gen2 Status
|
||||
response = requests.get(
|
||||
f"http://{ip}/rpc/Shelly.GetStatus",
|
||||
timeout=2
|
||||
)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
# Gen1 Status
|
||||
response = requests.get(
|
||||
f"http://{ip}/status",
|
||||
timeout=2
|
||||
)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler beim Abrufen des Status von {ip}: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def discover_devices(self) -> List[Dict]:
|
||||
"""
|
||||
Entdeckt alle Shelly-Geräte im Netzwerk
|
||||
|
||||
Returns:
|
||||
Liste von Shelly-Geräten mit Status
|
||||
"""
|
||||
self.devices = self.scan_network()
|
||||
|
||||
for idx, device in enumerate(self.devices):
|
||||
status = self.get_device_status(device)
|
||||
self.devices[idx]['status'] = status
|
||||
|
||||
logger.info(f"Insgesamt {len(self.devices)} Shelly-Geräte entdeckt")
|
||||
return self.devices
|
||||
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# MODULE WRAPPER
|
||||
# ============================================================================
|
||||
|
||||
class ShellyModule(BaseModule):
|
||||
"""
|
||||
Shelly Modul - Implementiert BaseModule Interface
|
||||
Gibt Actors/Sensors zurück, KEINE DB-Operationen
|
||||
"""
|
||||
|
||||
def is_enabled(self) -> bool:
|
||||
"""Prüft ob Shelly aktiviert ist"""
|
||||
return self.config.shelly_enable
|
||||
|
||||
def discover(self) -> Tuple[List[Dict], List[Dict]]:
|
||||
"""
|
||||
Führt Shelly Discovery durch
|
||||
|
||||
Returns:
|
||||
Tuple (actors, sensors)
|
||||
"""
|
||||
logger.info("\n" + "=" * 60)
|
||||
logger.info("SHELLY-GERÄTE WERDEN GESUCHT")
|
||||
logger.info("=" * 60)
|
||||
|
||||
actors = []
|
||||
sensors = []
|
||||
|
||||
try:
|
||||
discovery = ShellyDiscovery()
|
||||
devices = discovery.discover_devices()
|
||||
|
||||
if not devices:
|
||||
logger.info("Keine Shelly-Geräte gefunden")
|
||||
return actors, sensors
|
||||
|
||||
logger.info(f"{len(devices)} Shelly-Geräte gefunden")
|
||||
|
||||
# Verarbeite jedes Gerät
|
||||
for device in devices:
|
||||
if device['generation'] == 2:
|
||||
device_actors, device_sensors = self._parse_gen2_device(device)
|
||||
else:
|
||||
device_actors, device_sensors = self._parse_gen1_device(device)
|
||||
|
||||
actors.extend(device_actors)
|
||||
sensors.extend(device_sensors)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"✗ Shelly Discovery Fehler: {e}")
|
||||
|
||||
logger.info(f"Shelly: {len(actors)} Aktoren, {len(sensors)} Sensoren gefunden")
|
||||
return actors, sensors
|
||||
|
||||
def _parse_gen2_device(self, device: Dict) -> Tuple[List[Dict], List[Dict]]:
|
||||
"""Parst Gen2 Shelly-Gerät"""
|
||||
actors = []
|
||||
sensors = []
|
||||
|
||||
info = device.get('info', {})
|
||||
status = device.get('status', {})
|
||||
ip = device['ip']
|
||||
|
||||
device_name = info.get('name', f"Shelly_{info.get('id', ip)}")
|
||||
device_model = info.get('model', 'Unknown')
|
||||
|
||||
# Switches als Aktoren
|
||||
switch_count = sum(1 for key in status.keys() if key.startswith('switch:'))
|
||||
for i in range(switch_count):
|
||||
switch_data = status.get(f'switch:{i}', {})
|
||||
|
||||
actors.append({
|
||||
'type': f'ShellySwitch_{device_model}'.replace(' ', '_'),
|
||||
'name': f"{device_name}_Switch_{i}",
|
||||
'url': f"http://{ip}/rpc/Switch.Set?id={i}",
|
||||
'commands': [
|
||||
{'command': 'turn_on', 'url': 'on=true', 'parameters': []},
|
||||
{'command': 'turn_off', 'url': 'on=false', 'parameters': []},
|
||||
{'command': 'toggle', 'parameters': []}
|
||||
],
|
||||
'states': [
|
||||
{
|
||||
'name': 'output',
|
||||
'type': 'boolean',
|
||||
'url': 'output',
|
||||
'current_value': switch_data.get('output', False)
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
# Temperatursensoren
|
||||
temp_count = sum(1 for key in status.keys() if key.startswith('temperature:'))
|
||||
for i in range(temp_count):
|
||||
temp_data = status.get(f'temperature:{i}', {})
|
||||
|
||||
sensors.append({
|
||||
'type': 'Temperatur',
|
||||
'name': f"{device_name}_Temp_{i}",
|
||||
'url': f"http://{ip}/rpc/Temperature.GetStatus?id={i}",
|
||||
'states': [
|
||||
{
|
||||
'name': 'temperature',
|
||||
'type': 'number',
|
||||
'current_value': temp_data.get('tC'),
|
||||
'url': f"tC",
|
||||
'unit': '°C'
|
||||
}
|
||||
]
|
||||
})
|
||||
# Energy-Meter
|
||||
em_count = sum(1 for key in status.keys() if key.startswith('em:'))
|
||||
for i in range(em_count):
|
||||
em_data = status.get(f'em:{i}', {})
|
||||
sensors.append({
|
||||
'type': 'Stromzähler',
|
||||
'name': f"{device_name}_EM_{i}",
|
||||
'url': f"http://{ip}/rpc/em.GetStatus?id={i}",
|
||||
'states': []
|
||||
})
|
||||
if(em_data.get('a_voltage') is not None):
|
||||
sensors[len(sensors)-1]['states'].append({
|
||||
'name': 'Spannung Phase A',
|
||||
'type': 'number',
|
||||
'url': 'a_voltage',
|
||||
'current_value': em_data.get('a_voltage'),
|
||||
'unit': 'V'})
|
||||
if(em_data.get('b_voltage') is not None):
|
||||
sensors[len(sensors)-1]['states'].append({
|
||||
'name': 'Spannung Phase B',
|
||||
'type': 'number',
|
||||
'url': 'b_voltage',
|
||||
'current_value': em_data.get('b_voltage'),
|
||||
'unit': 'V'})
|
||||
if(em_data.get('c_voltage') is not None):
|
||||
sensors[len(sensors)-1]['states'].append({
|
||||
'name': 'Spannung Phase C',
|
||||
'type': 'number',
|
||||
'url': 'c_voltage',
|
||||
'current_value': em_data.get('c_voltage'),
|
||||
'unit': 'V'})
|
||||
if(em_data.get('a_current') is not None):
|
||||
sensors[len(sensors)-1]['states'].append({
|
||||
'name': 'Strom Phase A',
|
||||
'type': 'number',
|
||||
'url': 'a_current',
|
||||
'current_value': em_data.get('a_current'),
|
||||
'unit': 'A'})
|
||||
if(em_data.get('b_current') is not None):
|
||||
sensors[len(sensors)-1]['states'].append({
|
||||
'name': 'Strom Phase B',
|
||||
'type': 'number',
|
||||
'url': 'b_current',
|
||||
'current_value': em_data.get('b_current'),
|
||||
'unit': 'A'})
|
||||
if(em_data.get('c_current') is not None):
|
||||
sensors[len(sensors)-1]['states'].append({
|
||||
'name': 'Strom Phase C',
|
||||
'type': 'number',
|
||||
'url': 'c_current',
|
||||
'current_value': em_data.get('c_current'),
|
||||
'unit': 'A'})
|
||||
if(em_data.get('a_act_power') is not None):
|
||||
sensors[len(sensors)-1]['states'].append({
|
||||
'name': 'Wirkleistung Phase A',
|
||||
'type': 'number',
|
||||
'url': 'a_act_power',
|
||||
'current_value': em_data.get('a_act_power'),
|
||||
'unit': 'W'})
|
||||
if(em_data.get('b_act_power') is not None):
|
||||
sensors[len(sensors)-1]['states'].append({
|
||||
'name': 'Wirkleistung Phase B',
|
||||
'type': 'number',
|
||||
'url': 'b_act_power',
|
||||
'current_value': em_data.get('b_act_power'),
|
||||
'unit': 'W'})
|
||||
if(em_data.get('c_act_power') is not None):
|
||||
sensors[len(sensors)-1]['states'].append({
|
||||
'name': 'Wirkleistung Phase C',
|
||||
'type': 'number',
|
||||
'url': 'c_act_power',
|
||||
'current_value': em_data.get('c_act_power'),
|
||||
'unit': 'W'})
|
||||
if(em_data.get('a_aprt_power') is not None):
|
||||
sensors[len(sensors)-1]['states'].append({
|
||||
'name': 'Scheinleistung Phase A',
|
||||
'type': 'number',
|
||||
'url': 'a_aprt_power',
|
||||
'current_value': em_data.get('a_aprt_power'),
|
||||
'unit': 'VA'})
|
||||
if(em_data.get('b_aprt_power') is not None):
|
||||
sensors[len(sensors)-1]['states'].append({
|
||||
'name': 'Scheinleistung Phase B',
|
||||
'type': 'number',
|
||||
'url': 'b_aprt_power',
|
||||
'current_value': em_data.get('b_aprt_power'),
|
||||
'unit': 'VA'})
|
||||
if(em_data.get('c_aprt_power') is not None):
|
||||
sensors[len(sensors)-1]['states'].append({
|
||||
'name': 'Scheinleistung Phase C',
|
||||
'type': 'number',
|
||||
'url': 'c_aprt_power',
|
||||
'current_value': em_data.get('c_aprt_power'),
|
||||
'unit': 'VA'})
|
||||
if(em_data.get('a_freq') is not None):
|
||||
sensors[len(sensors)-1]['states'].append({
|
||||
'name': 'Frequenz Phase A',
|
||||
'type': 'number',
|
||||
'url': 'a_freq',
|
||||
'current_value': em_data.get('a_freq'),
|
||||
'unit': 'Hz'})
|
||||
if(em_data.get('b_freq') is not None):
|
||||
sensors[len(sensors)-1]['states'].append({
|
||||
'name': 'Frequenz Phase B',
|
||||
'type': 'number',
|
||||
'url': 'b_freq',
|
||||
'current_value': em_data.get('b_freq'),
|
||||
'unit': 'Hz'})
|
||||
if(em_data.get('c_freq') is not None):
|
||||
sensors[len(sensors)-1]['states'].append({
|
||||
'name': 'Frequenz Phase C',
|
||||
'type': 'number',
|
||||
'url': 'c_freq',
|
||||
'current_value': em_data.get('c_freq'),
|
||||
'unit': 'Hz'})
|
||||
if(em_data.get('total_act_power') is not None):
|
||||
sensors[len(sensors)-1]['states'].append({
|
||||
'name': 'Wirkleistung gesamt',
|
||||
'type': 'number',
|
||||
'url': 'total_act_power',
|
||||
'current_value': em_data.get('total_act_power'),
|
||||
'unit': 'W'})
|
||||
|
||||
return actors, sensors
|
||||
|
||||
def _parse_gen1_device(self, device: Dict) -> Tuple[List[Dict], List[Dict]]:
|
||||
"""Parst Gen1 Shelly-Gerät"""
|
||||
actors = []
|
||||
sensors = []
|
||||
|
||||
info = device.get('info', {})
|
||||
status = device.get('status', {})
|
||||
ip = device['ip']
|
||||
|
||||
device_name = info.get('name', f"Shelly_{info.get('type', ip)}")
|
||||
device_type = info['device'].get('type', 'Unknown')
|
||||
|
||||
# Relays als Aktoren
|
||||
relays = status.get('relays', [])
|
||||
for i, relay in enumerate(relays):
|
||||
actors.append({
|
||||
'type': f'ShellyRelay_{device_type}'.replace(' ', '_'),
|
||||
'name': f"{device_name}_Relay_{i}",
|
||||
'url': f"http://{ip}/relay/{i}",
|
||||
'commands': [
|
||||
{'command': 'turn_on', 'url': 'turn=on', 'parameters': []},
|
||||
{'command': 'turn_off', 'url': 'turn=off', 'parameters': []},
|
||||
{'command': 'toggle', 'url': 'turn=toggle', 'parameters': []}
|
||||
],
|
||||
'states': [
|
||||
{
|
||||
'name': 'ison',
|
||||
'type': 'boolean',
|
||||
'url': 'ison',
|
||||
'current_value': relay.get('ison', False)
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
# Temperatursensoren
|
||||
temp_data = status.get('tmp', {})
|
||||
if temp_data and 'tC' in temp_data:
|
||||
sensors.append({
|
||||
'type': 'Temperatur',
|
||||
'name': f"{device_name}_Temp",
|
||||
'url': f"http://{ip}/status",
|
||||
'states': [
|
||||
{
|
||||
'name': 'temperature',
|
||||
'type': 'number',
|
||||
'current_value': temp_data.get('tC'),
|
||||
'unit': '°C'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
|
||||
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shelly Module
|
||||
Enthält NUR Shelly-spezifische Geräte-Discovery Logik
|
||||
KEINE Datenbank-Operationen!
|
||||
"""
|
||||
|
||||
import json
|
||||
import requests
|
||||
import socket
|
||||
import concurrent.futures
|
||||
import logging
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
from modules.base_module import BaseModule
|
||||
|
||||
# Logging konfigurieren
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ShellyDiscovery:
|
||||
"""Klasse zum Entdecken von Shelly-Geräten im Netzwerk"""
|
||||
|
||||
SHELLY_MDNS_SERVICE = "_http._tcp.local."
|
||||
COMMON_PORTS = [80]
|
||||
|
||||
def __init__(self):
|
||||
self.devices = []
|
||||
|
||||
|
||||
def scan_network(self, network: str = None, max_threads: int = 50) -> List[str]:
|
||||
"""Scannt das Netzwerk nach Shelly-Geräten"""
|
||||
if network is None:
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.connect(("8.8.8.8", 80))
|
||||
local_ip = s.getsockname()[0]
|
||||
s.close()
|
||||
network_prefix = '.'.join(local_ip.split('.')[:-1])
|
||||
except:
|
||||
logger.warning("Konnte lokale IP nicht ermitteln, verwende 192.168.1.x")
|
||||
network_prefix = "192.168.1"
|
||||
else:
|
||||
network_prefix = '.'.join(network.split('.')[:3])
|
||||
|
||||
logger.info(f"Scanne Netzwerk {network_prefix}.0/24 nach Shelly-Geräten...")
|
||||
|
||||
def check_ip(ip):
|
||||
device = ShellyDiscovery.is_shelly_device(ip)
|
||||
if device:
|
||||
return device
|
||||
return None
|
||||
|
||||
shelly_devices = []
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=max_threads) as executor:
|
||||
futures = [executor.submit(check_ip, f"{network_prefix}.{i}")
|
||||
for i in range(1, 255)]
|
||||
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
result = future.result()
|
||||
if result:
|
||||
shelly_devices.append(result)
|
||||
#logger.info(f"Shelly-Gerät gefunden: {result}")
|
||||
|
||||
return shelly_devices
|
||||
"""def scan_network(self, start_ip: int = 2, end_ip: int = 254, timeout: float = 0.3) -> List[str]:
|
||||
|
||||
active_hosts = []
|
||||
logger.info(f"Scanne Netzwerk {self.network_range}.{start_ip}-{end_ip}...")
|
||||
|
||||
for i in range(start_ip, end_ip + 1):
|
||||
ip = f"{self.network_range}.{i}"
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(timeout)
|
||||
|
||||
try:
|
||||
result = sock.connect_ex((ip, 80))
|
||||
if result == 0:
|
||||
active_hosts.append(ip)
|
||||
logger.debug(f"Host gefunden: {ip}")
|
||||
except:
|
||||
pass
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
logger.info(f"{len(active_hosts)} aktive Hosts gefunden")
|
||||
return active_hosts
|
||||
"""
|
||||
def is_shelly_device(ip: str) -> Optional[Dict]:
|
||||
"""
|
||||
Prüft ob ein Host ein Shelly-Gerät ist
|
||||
|
||||
Args:
|
||||
ip: IP-Adresse des Hosts
|
||||
|
||||
Returns:
|
||||
Device Info Dict wenn Shelly, sonst None
|
||||
"""
|
||||
#logger.info(f"Suche Shelly Getät unter: {ip}")
|
||||
try:
|
||||
# Versuche Gen2 API (neuere Shelly-Geräte)
|
||||
response = requests.get(
|
||||
f"http://{ip}/rpc/Shelly.GetDeviceInfo",
|
||||
timeout=2
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
logger.info(f"Shelly Gen2 Gerät gefunden: {ip} - {data.get('name', 'Unknown')}")
|
||||
return {
|
||||
'ip': ip,
|
||||
'generation': 2,
|
||||
'info': data
|
||||
}
|
||||
except:
|
||||
pass
|
||||
|
||||
try:
|
||||
# Versuche Gen1 API (ältere Shelly-Geräte)
|
||||
response = requests.get(
|
||||
f"http://{ip}/settings",
|
||||
timeout=2
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if 'device' in data and (data['device']['type'].startswith('SHELLY') or data['device']['type'].startswith('SHSW')):
|
||||
logger.info(f"Shelly Gen1 Gerät gefunden: {ip} - {data['device'].get('type', 'Unknown')}")
|
||||
return {
|
||||
'ip': ip,
|
||||
'generation': 1,
|
||||
'info': data
|
||||
}
|
||||
except:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
def get_device_status(self, device: Dict) -> Optional[Dict]:
|
||||
"""
|
||||
Holt den Status eines Shelly-Geräts
|
||||
|
||||
Args:
|
||||
device: Device Info Dictionary
|
||||
|
||||
Returns:
|
||||
Status Dictionary oder None
|
||||
"""
|
||||
ip = device['ip']
|
||||
|
||||
try:
|
||||
if device['generation'] == 2:
|
||||
# Gen2 Status
|
||||
response = requests.get(
|
||||
f"http://{ip}/rpc/Shelly.GetStatus",
|
||||
timeout=2
|
||||
)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
# Gen1 Status
|
||||
response = requests.get(
|
||||
f"http://{ip}/status",
|
||||
timeout=2
|
||||
)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler beim Abrufen des Status von {ip}: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def discover_devices(self) -> List[Dict]:
|
||||
"""
|
||||
Entdeckt alle Shelly-Geräte im Netzwerk
|
||||
|
||||
Returns:
|
||||
Liste von Shelly-Geräten mit Status
|
||||
"""
|
||||
self.devices = self.scan_network()
|
||||
|
||||
for idx, device in enumerate(self.devices):
|
||||
status = self.get_device_status(device)
|
||||
self.devices[idx]['status'] = status
|
||||
|
||||
logger.info(f"Insgesamt {len(self.devices)} Shelly-Geräte entdeckt")
|
||||
return self.devices
|
||||
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# MODULE WRAPPER
|
||||
# ============================================================================
|
||||
|
||||
class ShellyModule(BaseModule):
|
||||
"""
|
||||
Shelly Modul - Implementiert BaseModule Interface
|
||||
Gibt Actors/Sensors zurück, KEINE DB-Operationen
|
||||
"""
|
||||
|
||||
def is_enabled(self) -> bool:
|
||||
"""Prüft ob Shelly aktiviert ist"""
|
||||
return self.config.shelly_enable
|
||||
|
||||
def discover(self) -> Tuple[List[Dict], List[Dict]]:
|
||||
"""
|
||||
Führt Shelly Discovery durch
|
||||
|
||||
Returns:
|
||||
Tuple (actors, sensors)
|
||||
"""
|
||||
logger.info("\n" + "=" * 60)
|
||||
logger.info("SHELLY-GERÄTE WERDEN GESUCHT")
|
||||
logger.info("=" * 60)
|
||||
|
||||
actors = []
|
||||
sensors = []
|
||||
|
||||
try:
|
||||
discovery = ShellyDiscovery()
|
||||
devices = discovery.discover_devices()
|
||||
|
||||
if not devices:
|
||||
logger.info("Keine Shelly-Geräte gefunden")
|
||||
return actors, sensors
|
||||
|
||||
logger.info(f"{len(devices)} Shelly-Geräte gefunden")
|
||||
|
||||
# Verarbeite jedes Gerät
|
||||
for device in devices:
|
||||
if device['generation'] == 2:
|
||||
device_actors, device_sensors = self._parse_gen2_device(device)
|
||||
else:
|
||||
device_actors, device_sensors = self._parse_gen1_device(device)
|
||||
|
||||
actors.extend(device_actors)
|
||||
sensors.extend(device_sensors)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"✗ Shelly Discovery Fehler: {e}")
|
||||
|
||||
logger.info(f"Shelly: {len(actors)} Aktoren, {len(sensors)} Sensoren gefunden")
|
||||
return actors, sensors
|
||||
|
||||
def _parse_gen2_device(self, device: Dict) -> Tuple[List[Dict], List[Dict]]:
|
||||
"""Parst Gen2 Shelly-Gerät"""
|
||||
actors = []
|
||||
sensors = []
|
||||
|
||||
info = device.get('info', {})
|
||||
status = device.get('status', {})
|
||||
ip = device['ip']
|
||||
|
||||
device_name = info.get('name', f"Shelly_{info.get('id', ip)}")
|
||||
device_model = info.get('model', 'Unknown')
|
||||
|
||||
# Switches als Aktoren
|
||||
switch_count = sum(1 for key in status.keys() if key.startswith('switch:'))
|
||||
for i in range(switch_count):
|
||||
switch_data = status.get(f'switch:{i}', {})
|
||||
|
||||
actors.append({
|
||||
'type': f'ShellySwitch_{device_model}'.replace(' ', '_'),
|
||||
'name': f"{device_name}_Switch_{i}",
|
||||
'url': f"http://{ip}/rpc/Switch.Set?id={i}",
|
||||
'commands': [
|
||||
{'command': 'turn_on', 'url': 'on=true', 'parameters': []},
|
||||
{'command': 'turn_off', 'url': 'on=false', 'parameters': []},
|
||||
{'command': 'toggle', 'parameters': []}
|
||||
],
|
||||
'states': [
|
||||
{
|
||||
'name': 'output',
|
||||
'type': 'boolean',
|
||||
'url': 'output',
|
||||
'current_value': switch_data.get('output', False)
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
# Temperatursensoren
|
||||
temp_count = sum(1 for key in status.keys() if key.startswith('temperature:'))
|
||||
for i in range(temp_count):
|
||||
temp_data = status.get(f'temperature:{i}', {})
|
||||
|
||||
sensors.append({
|
||||
'type': 'Temperatur',
|
||||
'name': f"{device_name}_Temp_{i}",
|
||||
'url': f"http://{ip}/rpc/Temperature.GetStatus?id={i}",
|
||||
'states': [
|
||||
{
|
||||
'name': 'temperature',
|
||||
'type': 'number',
|
||||
'current_value': temp_data.get('tC'),
|
||||
'url': f"tC",
|
||||
'unit': '°C'
|
||||
}
|
||||
]
|
||||
})
|
||||
# Energy-Meter
|
||||
em_count = sum(1 for key in status.keys() if key.startswith('em:'))
|
||||
for i in range(em_count):
|
||||
em_data = status.get(f'em:{i}', {})
|
||||
sensors.append({
|
||||
'type': 'Stromzähler',
|
||||
'name': f"{device_name}_EM_{i}",
|
||||
'url': f"http://{ip}/rpc/em.GetStatus?id={i}",
|
||||
'states': []
|
||||
})
|
||||
if(em_data.get('a_voltage') is not None):
|
||||
sensors[len(sensors)-1]['states'].append({
|
||||
'name': 'Spannung Phase A',
|
||||
'type': 'number',
|
||||
'url': 'a_voltage',
|
||||
'current_value': em_data.get('a_voltage'),
|
||||
'unit': 'V'})
|
||||
if(em_data.get('b_voltage') is not None):
|
||||
sensors[len(sensors)-1]['states'].append({
|
||||
'name': 'Spannung Phase B',
|
||||
'type': 'number',
|
||||
'url': 'b_voltage',
|
||||
'current_value': em_data.get('b_voltage'),
|
||||
'unit': 'V'})
|
||||
if(em_data.get('c_voltage') is not None):
|
||||
sensors[len(sensors)-1]['states'].append({
|
||||
'name': 'Spannung Phase C',
|
||||
'type': 'number',
|
||||
'url': 'c_voltage',
|
||||
'current_value': em_data.get('c_voltage'),
|
||||
'unit': 'V'})
|
||||
if(em_data.get('a_current') is not None):
|
||||
sensors[len(sensors)-1]['states'].append({
|
||||
'name': 'Strom Phase A',
|
||||
'type': 'number',
|
||||
'url': 'a_current',
|
||||
'current_value': em_data.get('a_current'),
|
||||
'unit': 'A'})
|
||||
if(em_data.get('b_current') is not None):
|
||||
sensors[len(sensors)-1]['states'].append({
|
||||
'name': 'Strom Phase B',
|
||||
'type': 'number',
|
||||
'url': 'b_current',
|
||||
'current_value': em_data.get('b_current'),
|
||||
'unit': 'A'})
|
||||
if(em_data.get('c_current') is not None):
|
||||
sensors[len(sensors)-1]['states'].append({
|
||||
'name': 'Strom Phase C',
|
||||
'type': 'number',
|
||||
'url': 'c_current',
|
||||
'current_value': em_data.get('c_current'),
|
||||
'unit': 'A'})
|
||||
if(em_data.get('a_act_power') is not None):
|
||||
sensors[len(sensors)-1]['states'].append({
|
||||
'name': 'Wirkleistung Phase A',
|
||||
'type': 'number',
|
||||
'url': 'a_act_power',
|
||||
'current_value': em_data.get('a_act_power'),
|
||||
'unit': 'W'})
|
||||
if(em_data.get('b_act_power') is not None):
|
||||
sensors[len(sensors)-1]['states'].append({
|
||||
'name': 'Wirkleistung Phase B',
|
||||
'type': 'number',
|
||||
'url': 'b_act_power',
|
||||
'current_value': em_data.get('b_act_power'),
|
||||
'unit': 'W'})
|
||||
if(em_data.get('c_act_power') is not None):
|
||||
sensors[len(sensors)-1]['states'].append({
|
||||
'name': 'Wirkleistung Phase C',
|
||||
'type': 'number',
|
||||
'url': 'c_act_power',
|
||||
'current_value': em_data.get('c_act_power'),
|
||||
'unit': 'W'})
|
||||
if(em_data.get('a_aprt_power') is not None):
|
||||
sensors[len(sensors)-1]['states'].append({
|
||||
'name': 'Scheinleistung Phase A',
|
||||
'type': 'number',
|
||||
'url': 'a_aprt_power',
|
||||
'current_value': em_data.get('a_aprt_power'),
|
||||
'unit': 'VA'})
|
||||
if(em_data.get('b_aprt_power') is not None):
|
||||
sensors[len(sensors)-1]['states'].append({
|
||||
'name': 'Scheinleistung Phase B',
|
||||
'type': 'number',
|
||||
'url': 'b_aprt_power',
|
||||
'current_value': em_data.get('b_aprt_power'),
|
||||
'unit': 'VA'})
|
||||
if(em_data.get('c_aprt_power') is not None):
|
||||
sensors[len(sensors)-1]['states'].append({
|
||||
'name': 'Scheinleistung Phase C',
|
||||
'type': 'number',
|
||||
'url': 'c_aprt_power',
|
||||
'current_value': em_data.get('c_aprt_power'),
|
||||
'unit': 'VA'})
|
||||
if(em_data.get('a_freq') is not None):
|
||||
sensors[len(sensors)-1]['states'].append({
|
||||
'name': 'Frequenz Phase A',
|
||||
'type': 'number',
|
||||
'url': 'a_freq',
|
||||
'current_value': em_data.get('a_freq'),
|
||||
'unit': 'Hz'})
|
||||
if(em_data.get('b_freq') is not None):
|
||||
sensors[len(sensors)-1]['states'].append({
|
||||
'name': 'Frequenz Phase B',
|
||||
'type': 'number',
|
||||
'url': 'b_freq',
|
||||
'current_value': em_data.get('b_freq'),
|
||||
'unit': 'Hz'})
|
||||
if(em_data.get('c_freq') is not None):
|
||||
sensors[len(sensors)-1]['states'].append({
|
||||
'name': 'Frequenz Phase C',
|
||||
'type': 'number',
|
||||
'url': 'c_freq',
|
||||
'current_value': em_data.get('c_freq'),
|
||||
'unit': 'Hz'})
|
||||
if(em_data.get('total_act_power') is not None):
|
||||
sensors[len(sensors)-1]['states'].append({
|
||||
'name': 'Wirkleistung gesamt',
|
||||
'type': 'number',
|
||||
'url': 'total_act_power',
|
||||
'current_value': em_data.get('total_act_power'),
|
||||
'unit': 'W'})
|
||||
|
||||
return actors, sensors
|
||||
|
||||
def _parse_gen1_device(self, device: Dict) -> Tuple[List[Dict], List[Dict]]:
|
||||
"""Parst Gen1 Shelly-Gerät"""
|
||||
actors = []
|
||||
sensors = []
|
||||
|
||||
info = device.get('info', {})
|
||||
status = device.get('status', {})
|
||||
ip = device['ip']
|
||||
|
||||
device_name = info.get('name', f"Shelly_{info.get('type', ip)}")
|
||||
device_type = info['device'].get('type', 'Unknown')
|
||||
|
||||
# Relays als Aktoren
|
||||
relays = status.get('relays', [])
|
||||
for i, relay in enumerate(relays):
|
||||
actors.append({
|
||||
'type': f'ShellyRelay_{device_type}'.replace(' ', '_'),
|
||||
'name': f"{device_name}_Relay_{i}",
|
||||
'url': f"http://{ip}/relay/{i}",
|
||||
'commands': [
|
||||
{'command': 'turn_on', 'url': 'turn=on', 'parameters': []},
|
||||
{'command': 'turn_off', 'url': 'turn=off', 'parameters': []},
|
||||
{'command': 'toggle', 'url': 'turn=toggle', 'parameters': []}
|
||||
],
|
||||
'states': [
|
||||
{
|
||||
'name': 'ison',
|
||||
'type': 'boolean',
|
||||
'url': 'ison',
|
||||
'current_value': relay.get('ison', False)
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
# Temperatursensoren
|
||||
temp_data = status.get('tmp', {})
|
||||
if temp_data and 'tC' in temp_data:
|
||||
sensors.append({
|
||||
'type': 'Temperatur',
|
||||
'name': f"{device_name}_Temp",
|
||||
'url': f"http://{ip}/status",
|
||||
'states': [
|
||||
{
|
||||
'name': 'temperature',
|
||||
'type': 'number',
|
||||
'current_value': temp_data.get('tC'),
|
||||
'unit': '°C'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
return actors, sensors
|
||||
@@ -1,445 +1,445 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tahoma Module
|
||||
Enthält NUR Tahoma-spezifische Geräte-Discovery Logik
|
||||
KEINE Datenbank-Operationen!
|
||||
"""
|
||||
|
||||
import requests
|
||||
import urllib3
|
||||
import re
|
||||
import logging
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
from modules.base_module import BaseModule
|
||||
|
||||
# SSL-Warnungen deaktivieren
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TahomaAPI:
|
||||
"""Original TahomaAPI Klasse - unverändert"""
|
||||
|
||||
def __init__(self, gateway_ip: str, api_token: str):
|
||||
self.base_url = f"https://{gateway_ip}:8443/enduser-mobile-web/1/enduserAPI"
|
||||
self.headers = {
|
||||
"Authorization": f"Bearer {api_token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
def get_setup(self) -> Optional[Dict]:
|
||||
try:
|
||||
url = f"{self.base_url}/setup"
|
||||
response = requests.get(url, headers=self.headers, verify=False, timeout=10)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Fehler beim Abrufen der Setup-Daten: {e}")
|
||||
return None
|
||||
|
||||
def get_devices(self) -> List[Dict]:
|
||||
setup = self.get_setup()
|
||||
if not setup:
|
||||
return []
|
||||
devices = setup.get('devices', [])
|
||||
logger.info(f"{len(devices)} Tahoma-Geräte gefunden")
|
||||
return devices
|
||||
|
||||
|
||||
class DeviceClassifier:
|
||||
"""Original DeviceClassifier - unverändert"""
|
||||
|
||||
ACTOR_TYPES = {
|
||||
'RollerShutter': 'Rollladen',
|
||||
'ExteriorScreen': 'Außenrollo',
|
||||
'Awning': 'Markise',
|
||||
'ExteriorVenetianBlind':'Außenjalousie',
|
||||
'Blind': 'Jalousie',
|
||||
'GarageDoor': 'Garagentor',
|
||||
'Window': 'Fenster',
|
||||
'Light': 'Beleuchtung',
|
||||
'OnOff': 'Schalter',
|
||||
'DimmableLight': 'Dimmbare Beleuchtung',
|
||||
'HeatingSystem': 'Heizungssystem',
|
||||
'Valve': 'Ventil',
|
||||
'Switch': 'Schalter',
|
||||
'Door': 'Tür',
|
||||
'Curtain': 'Vorhang',
|
||||
'VenetianBlind': 'Jalousie',
|
||||
'PergolaScreen': 'Pergola-Rollo'
|
||||
}
|
||||
|
||||
SENSOR_TYPES = {
|
||||
'TemperatureSensor': 'Temperatur',
|
||||
'LightSensor': 'Licht',
|
||||
'HumiditySensor': 'Feuchtigkeit',
|
||||
'ContactSensor': 'Kontakt',
|
||||
'OccupancySensor': 'Anwesenheit',
|
||||
'SmokeSensor': 'Rauch',
|
||||
'WaterDetectionSensor': 'Wasser',
|
||||
'WindowHandle': 'Fenstergriff',
|
||||
'MotionSensor': 'Bewegung',
|
||||
'SunSensor': 'Sonne',
|
||||
'WindSensor': 'Wind',
|
||||
'RainSensor': 'Regen',
|
||||
'ConsumptionSensor': 'Verbrauch'
|
||||
}
|
||||
|
||||
STATE_NAMES = {
|
||||
"core:BatteryLevelState": 'Ladestand',
|
||||
"core:BatteryState": 'Batteriezustand',
|
||||
"core:ClosureState": 'Position',
|
||||
"core:CommandLockLevelsState": 'Gesperrt',
|
||||
"core:Memorized1OrientationState": 'Gespeicherte Neigung',
|
||||
"core:Memorized1PositionState": 'Gespeicherte Position',
|
||||
"core:MovingState": 'Fährt gerade',
|
||||
"core:OpenClosedState": 'Geöffnet/Geschlossen',
|
||||
"core:PriorityLockTimerState": 'Verriegelungstimer',
|
||||
"core:DiscreteRSSILevelState": 'RSSI',
|
||||
"core:SlateOrientationState": 'Lamellenausrichtung',
|
||||
"core:StatusState": 'Status',
|
||||
"core:LuminanceState": 'Helligkeit',
|
||||
"core:SmokeState": 'Rauch',
|
||||
"core:SunEnergyState": 'Sonnenenergie',
|
||||
"core:TemperatureState": 'Temperatur',
|
||||
"io:MaintenanceRadioPartBatteryState": 'Ladestand Funkmodul',
|
||||
"io:MaintenanceSensorPartBatteryState": 'Ladestand Sensor',
|
||||
"core:SensorDefectState": 'Sensor defekt'
|
||||
}
|
||||
|
||||
STATE_ENUMS = {
|
||||
"core:BatteryLevelState": ["full","normal","low","verylow"],
|
||||
"core:BatteryState": ["full","normal","low","verylow"],
|
||||
"core:ClosureState": '',
|
||||
"core:CommandLockLevelsState": '',
|
||||
"core:Memorized1OrientationState": '',
|
||||
"core:Memorized1PositionState": '',
|
||||
"core:MovingState": ["true","false"],
|
||||
"core:OpenClosedState": ["open","close"],
|
||||
"core:PriorityLockTimerState": '',
|
||||
"core:DiscreteRSSILevelState": ["good","normal","low"],
|
||||
"core:SlateOrientationState": '',
|
||||
"core:StatusState": ["available","unavailable"],
|
||||
"core:TargetClosureState": '',
|
||||
"core:LuminanceState": 'Helligkeit',
|
||||
"core:SmokeState": ["notDetected","detected"],
|
||||
"core:SunEnergyState": '',
|
||||
"core:TemperatureState": '',
|
||||
"io:MaintenanceRadioPartBatteryState": ["full","normal","low","verylow"],
|
||||
"io:MaintenanceSensorPartBatteryState": ["full","normal","low","verylow"],
|
||||
"core:SensorDefectState": ["true","false"]
|
||||
}
|
||||
|
||||
"""
|
||||
"core:TargetClosureState": 'Zielposition',
|
||||
"io:PriorityLockOriginatorState": 'Sperrendes Gerät',
|
||||
"core:ManufacturerDiagnosticsState": 'Diagnose',
|
||||
"core:OpenClosedUnknownState": 'Statusanzeige wenn unbekannt',
|
||||
"core:ManufacturerSettingsState": 'Einstellungen',
|
||||
"core:RSSILevelState": 'RSSI',
|
||||
"core:NameState": 'Name',
|
||||
"core:SecuredPositionState": 'Sicherer Zustand',
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def get_sensor_type_translation(cls, sensor):
|
||||
return cls.SENSOR_TYPES.get(sensor, sensor)
|
||||
|
||||
@classmethod
|
||||
def get_actor_type_translation(cls, actor_type):
|
||||
return cls.ACTOR_TYPES.get(actor_type, actor_type)
|
||||
|
||||
PARAMETER_TYPES = {
|
||||
0: "bool",
|
||||
1: "integer", # Integer value
|
||||
2: "float", # Floating point number
|
||||
3: "string", # Text string
|
||||
4: "undefined",
|
||||
6: "bool",
|
||||
11: "array"
|
||||
}
|
||||
|
||||
TAHOMA_COMMANDS = {
|
||||
"setClosure": 'Position',
|
||||
"setClosureAndOrientation": "Position+Neigung",
|
||||
"setOrientation": "Neigung",
|
||||
"up": "Auf",
|
||||
"down": "Zu",
|
||||
"my": "my-Position anfahren",
|
||||
"stop": "Stop",
|
||||
"refresh": "Aktualisieren",
|
||||
"wink": "Winken",
|
||||
"setMyPosition": "my-Position einstellen",
|
||||
"on": "Anschalten",
|
||||
"off": "Ausschalten",
|
||||
"toggle": "Toggle",
|
||||
"setIntensity": "Intensität",
|
||||
"setColor": "Farbe",
|
||||
"setColorTemperature": "Farbtemperatur",
|
||||
"setTargetTemperature": "Solltemperatur",
|
||||
"setMode": "Modus",
|
||||
"pulse": "Impuls",
|
||||
"setLevel": "Wert",
|
||||
"trigger": "Trigger",
|
||||
}
|
||||
# Tahoma Commands mit Parametern
|
||||
TAHOMA_COMMAND_PARAMS = {
|
||||
"setClosure": [{"name": "Position","url":"0", "type": "integer", "min": 0, "max": 100}],
|
||||
"setClosureAndOrientation": [
|
||||
{"name": "Position", "url":"0", "type": "integer", "min": 0, "max": 100},
|
||||
{"name": "Neigung", "url":"1", "type": "integer", "min": 0, "max": 100}
|
||||
],
|
||||
"setOrientation": [{"name": "Neigung", "url":"0", "type": "integer", "min": 0, "max": 100}],
|
||||
"up": [], "down": [], "my": [], "stop": [], "refresh": [], "wink":[],
|
||||
"setMyPosition": [{"name": "Position", "url":"0", "type": "integer", "min": 0, "max": 100}],
|
||||
"on": [], "off": [], "toggle": [],
|
||||
"setIntensity": [{"name": "Helligkeit", "url":"0", "type": "integer", "min": 0, "max": 100}],
|
||||
"setColor": [
|
||||
{"name": "Farbton", "url":"0", "type": "integer", "min": 0, "max": 360},
|
||||
{"name": "Sättigung", "url":"1", "type": "integer", "min": 0, "max": 100}
|
||||
],
|
||||
"setColorTemperature": [{"name": "Farbtemperatur", "url":"0", "type": "integer", "min": 2000, "max": 6500}],
|
||||
"setTargetTemperature": [{"name": "Temperatur", "url":"0", "type": "float", "min": 5.0, "max": 30.0}],
|
||||
"setMode": [{"name": "Betriebsart", "url":"0", "type": "string"}],
|
||||
"pulse": [{"name": "Impulsdauer", "url":"0", "type": "integer", "min": 1, "max": 3600}],
|
||||
"setLevel": [{"name": "Ausgangslevel", "url":"0", "type": "integer", "min": 0, "max": 100}],
|
||||
"trigger": [],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def is_actor(cls, device: Dict) -> bool:
|
||||
"""Prüft ob Gerät ein Aktor ist"""
|
||||
device_type = device.get('definition').get('uiClass', '')
|
||||
|
||||
if device_type in cls.ACTOR_TYPES:
|
||||
return True
|
||||
|
||||
commands = device.get('definition', {}).get('commands', [])
|
||||
if commands:
|
||||
command_names = [cmd.get('commandName', '') for cmd in commands]
|
||||
actor_commands = {'open', 'close', 'on', 'off', 'up', 'down', 'setPosition', 'dim'}
|
||||
if any(cmd in actor_commands for cmd in command_names):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def is_sensor(cls, device: Dict) -> bool:
|
||||
"""Prüft ob Gerät ein Sensor ist"""
|
||||
device_type = device.get('definition').get('uiClass', '')
|
||||
|
||||
if device_type in cls.SENSOR_TYPES:
|
||||
return True
|
||||
|
||||
states = device.get('states', [])
|
||||
commands = device.get('definition', {}).get('commands', [])
|
||||
|
||||
if states and len(states) > 0 and len(commands) <= 1:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def extract_actor_data(cls, device: Dict) -> tuple:
|
||||
"""Extrahiert Commands und States aus Aktor"""
|
||||
commands = []
|
||||
states = []
|
||||
|
||||
cmd_definitions = device.get('definition', {}).get('commands', [])
|
||||
|
||||
for cmd in cmd_definitions:
|
||||
command_url = cmd.get('commandName', '')
|
||||
command_name = cls.TAHOMA_COMMANDS.get(command_url, command_url)
|
||||
cmd_params = cls.TAHOMA_COMMAND_PARAMS.get(command_url, "Not in List")
|
||||
if(cmd_params != "Not in List"): #only append command, if it is on of the listed commands, to prevent flooding the DB with bullshit.
|
||||
command_entry = {
|
||||
'command': command_name,
|
||||
'url': command_url,
|
||||
'parameters': []
|
||||
}
|
||||
|
||||
for cmd_param in cmd_params:
|
||||
param_detail = {'name': cmd_param.get('name', '')}
|
||||
|
||||
if 'type' in cmd_param:
|
||||
param_detail['type'] = cmd_param['type']
|
||||
if 'min' in cmd_param:
|
||||
param_detail['min'] = cmd_param['min']
|
||||
if 'max' in cmd_param:
|
||||
param_detail['max'] = cmd_param['max']
|
||||
if 'values' in cmd_param:
|
||||
param_detail['values'] = cmd_param['values']
|
||||
if 'url' in cmd_param:
|
||||
param_detail['url'] = cmd_param['url']
|
||||
if param_detail['name']:
|
||||
command_entry['parameters'].append(param_detail)
|
||||
|
||||
commands.append(command_entry)
|
||||
|
||||
# States extrahieren
|
||||
state_definitions = device.get('states', [])
|
||||
for state in state_definitions:
|
||||
state_url = state.get('name', '')
|
||||
state_name = cls.STATE_NAMES.get(state_url, '')
|
||||
state_enums = cls.STATE_ENUMS.get(state_url,'')
|
||||
if state_name:
|
||||
state_entry = {
|
||||
'name': state_name,
|
||||
'url': state_url,
|
||||
'type': cls.PARAMETER_TYPES.get(state.get('type', 4)),
|
||||
'values': state_enums
|
||||
}
|
||||
if 'value' in state:
|
||||
state_entry['current_value'] = state['value']
|
||||
states.append(state_entry)
|
||||
|
||||
return commands, states
|
||||
|
||||
@classmethod
|
||||
def extract_sensor_data(cls, device: Dict) -> list:
|
||||
"""Extrahiert States aus Sensor"""
|
||||
states = []
|
||||
|
||||
state_definitions = device.get('states', [])
|
||||
for state in state_definitions:
|
||||
state_url = state.get('name', '')
|
||||
state_name = cls.STATE_NAMES.get(state_url, '')
|
||||
state_enums = cls.STATE_ENUMS.get(state_url,'')
|
||||
if state_name:
|
||||
state_entry = {
|
||||
'name': state_name,
|
||||
'url': state_url,
|
||||
'type': cls.PARAMETER_TYPES.get(state.get('type', 4)),
|
||||
'values': state_enums
|
||||
}
|
||||
if 'value' in state:
|
||||
state_entry['current_value'] = state['value']
|
||||
states.append(state_entry)
|
||||
|
||||
return states
|
||||
|
||||
|
||||
class TahomaModule(BaseModule):
|
||||
"""
|
||||
Tahoma Modul - Implementiert BaseModule Interface
|
||||
Gibt nur Actors/Sensors zurück, KEINE DB-Operationen
|
||||
"""
|
||||
|
||||
def is_enabled(self) -> bool:
|
||||
"""Prüft ob Tahoma aktiviert ist"""
|
||||
return (self.config.tahoma_enable and
|
||||
self.config.tahoma_ip and
|
||||
self.config.tahoma_token)
|
||||
|
||||
def discover(self) -> Tuple[List[Dict], List[Dict]]:
|
||||
"""
|
||||
Führt Tahoma Discovery durch
|
||||
|
||||
Returns:
|
||||
Tuple (actors, sensors) - Listen von Dicts im vereinheitlichten Format
|
||||
"""
|
||||
logger.info("\n" + "=" * 60)
|
||||
logger.info("TAHOMA-GERÄTE WERDEN ABGERUFEN")
|
||||
logger.info("=" * 60)
|
||||
|
||||
actors = []
|
||||
sensors = []
|
||||
|
||||
# TahomaAPI initialisieren
|
||||
tahoma = TahomaAPI(self.config.tahoma_ip, self.config.tahoma_token)
|
||||
devices = tahoma.get_devices()
|
||||
|
||||
if not devices:
|
||||
logger.warning("Keine Tahoma-Geräte gefunden")
|
||||
return actors, sensors
|
||||
|
||||
# Geräte gruppieren (Original-Logik)
|
||||
device_groups = {}
|
||||
standalone_devices = []
|
||||
|
||||
for device in devices:
|
||||
device_url = device.get('deviceURL', '')
|
||||
match = re.match(r'(.+)#(\d+)$', device_url)
|
||||
|
||||
if match:
|
||||
base_url = match.group(1)
|
||||
if base_url not in device_groups:
|
||||
device_groups[base_url] = []
|
||||
device_groups[base_url].append(device)
|
||||
else:
|
||||
standalone_devices.append(device)
|
||||
|
||||
# Gruppierte Geräte verarbeiten
|
||||
for base_url, group_devices in device_groups.items():
|
||||
main_device = None
|
||||
for dev in group_devices:
|
||||
if dev.get('deviceURL', '').endswith('#1'):
|
||||
main_device = dev
|
||||
break
|
||||
|
||||
if not main_device and group_devices:
|
||||
main_device = group_devices[0]
|
||||
|
||||
main_name = main_device.get('label', 'Unbekannt') if main_device else 'Unbekannt'
|
||||
|
||||
for device in group_devices:
|
||||
if(device.get('label','').startswith("IO (") == False):
|
||||
actor, sensor = self._process_device(device, device.get('label',main_name))
|
||||
else:
|
||||
actor, sensor = self._process_device(device, main_name)
|
||||
if actor:
|
||||
actors.append(actor)
|
||||
if sensor:
|
||||
sensors.append(sensor)
|
||||
|
||||
# Standalone Geräte verarbeiten
|
||||
for device in standalone_devices:
|
||||
device_name = device.get('label', 'Unbekannt')
|
||||
actor, sensor = self._process_device(device, device_name)
|
||||
if actor:
|
||||
actors.append(actor)
|
||||
if sensor:
|
||||
sensors.append(sensor)
|
||||
|
||||
logger.info(f"Tahoma: {len(actors)} Aktoren, {len(sensors)} Sensoren gefunden")
|
||||
return actors, sensors
|
||||
|
||||
def _process_device(self, device: Dict, device_name: str) -> Tuple[Optional[Dict], Optional[Dict]]:
|
||||
"""
|
||||
Verarbeitet ein einzelnes Gerät
|
||||
|
||||
Returns:
|
||||
Tuple (actor_dict or None, sensor_dict or None)
|
||||
"""
|
||||
device_url = device.get('deviceURL', '')
|
||||
device_type = device.get('definition').get('uiClass', 'Unknown')
|
||||
|
||||
is_actor = DeviceClassifier.is_actor(device)
|
||||
is_sensor = DeviceClassifier.is_sensor(device)
|
||||
|
||||
actor = None
|
||||
sensor = None
|
||||
|
||||
if is_actor:
|
||||
commands, states = DeviceClassifier.extract_actor_data(device)
|
||||
device_type = DeviceClassifier.get_actor_type_translation(device_type)
|
||||
actor = {
|
||||
'type': device_type,
|
||||
'name': device_name,
|
||||
'url': device_url,
|
||||
'commands': commands,
|
||||
'states': states
|
||||
}
|
||||
|
||||
elif is_sensor:
|
||||
states = DeviceClassifier.extract_sensor_data(device)
|
||||
device_type = DeviceClassifier.get_sensor_type_translation(device_type)
|
||||
sensor = {
|
||||
'type': device_type,
|
||||
'name': device_name,
|
||||
'url': device_url,
|
||||
'states': states
|
||||
}
|
||||
|
||||
return actor, sensor
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tahoma Module
|
||||
Enthält NUR Tahoma-spezifische Geräte-Discovery Logik
|
||||
KEINE Datenbank-Operationen!
|
||||
"""
|
||||
|
||||
import requests
|
||||
import urllib3
|
||||
import re
|
||||
import logging
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
from modules.base_module import BaseModule
|
||||
|
||||
# SSL-Warnungen deaktivieren
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TahomaAPI:
|
||||
"""Original TahomaAPI Klasse - unverändert"""
|
||||
|
||||
def __init__(self, gateway_ip: str, api_token: str):
|
||||
self.base_url = f"https://{gateway_ip}:8443/enduser-mobile-web/1/enduserAPI"
|
||||
self.headers = {
|
||||
"Authorization": f"Bearer {api_token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
def get_setup(self) -> Optional[Dict]:
|
||||
try:
|
||||
url = f"{self.base_url}/setup"
|
||||
response = requests.get(url, headers=self.headers, verify=False, timeout=10)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Fehler beim Abrufen der Setup-Daten: {e}")
|
||||
return None
|
||||
|
||||
def get_devices(self) -> List[Dict]:
|
||||
setup = self.get_setup()
|
||||
if not setup:
|
||||
return []
|
||||
devices = setup.get('devices', [])
|
||||
logger.info(f"{len(devices)} Tahoma-Geräte gefunden")
|
||||
return devices
|
||||
|
||||
|
||||
class DeviceClassifier:
|
||||
"""Original DeviceClassifier - unverändert"""
|
||||
|
||||
ACTOR_TYPES = {
|
||||
'RollerShutter': 'Rollladen',
|
||||
'ExteriorScreen': 'Außenrollo',
|
||||
'Awning': 'Markise',
|
||||
'ExteriorVenetianBlind':'Außenjalousie',
|
||||
'Blind': 'Jalousie',
|
||||
'GarageDoor': 'Garagentor',
|
||||
'Window': 'Fenster',
|
||||
'Light': 'Beleuchtung',
|
||||
'OnOff': 'Schalter',
|
||||
'DimmableLight': 'Dimmbare Beleuchtung',
|
||||
'HeatingSystem': 'Heizungssystem',
|
||||
'Valve': 'Ventil',
|
||||
'Switch': 'Schalter',
|
||||
'Door': 'Tür',
|
||||
'Curtain': 'Vorhang',
|
||||
'VenetianBlind': 'Jalousie',
|
||||
'PergolaScreen': 'Pergola-Rollo'
|
||||
}
|
||||
|
||||
SENSOR_TYPES = {
|
||||
'TemperatureSensor': 'Temperatur',
|
||||
'LightSensor': 'Licht',
|
||||
'HumiditySensor': 'Feuchtigkeit',
|
||||
'ContactSensor': 'Kontakt',
|
||||
'OccupancySensor': 'Anwesenheit',
|
||||
'SmokeSensor': 'Rauch',
|
||||
'WaterDetectionSensor': 'Wasser',
|
||||
'WindowHandle': 'Fenstergriff',
|
||||
'MotionSensor': 'Bewegung',
|
||||
'SunSensor': 'Sonne',
|
||||
'WindSensor': 'Wind',
|
||||
'RainSensor': 'Regen',
|
||||
'ConsumptionSensor': 'Verbrauch'
|
||||
}
|
||||
|
||||
STATE_NAMES = {
|
||||
"core:BatteryLevelState": 'Ladestand',
|
||||
"core:BatteryState": 'Batteriezustand',
|
||||
"core:ClosureState": 'Position',
|
||||
"core:CommandLockLevelsState": 'Gesperrt',
|
||||
"core:Memorized1OrientationState": 'Gespeicherte Neigung',
|
||||
"core:Memorized1PositionState": 'Gespeicherte Position',
|
||||
"core:MovingState": 'Fährt gerade',
|
||||
"core:OpenClosedState": 'Geöffnet/Geschlossen',
|
||||
"core:PriorityLockTimerState": 'Verriegelungstimer',
|
||||
"core:DiscreteRSSILevelState": 'RSSI',
|
||||
"core:SlateOrientationState": 'Lamellenausrichtung',
|
||||
"core:StatusState": 'Status',
|
||||
"core:LuminanceState": 'Helligkeit',
|
||||
"core:SmokeState": 'Rauch',
|
||||
"core:SunEnergyState": 'Sonnenenergie',
|
||||
"core:TemperatureState": 'Temperatur',
|
||||
"io:MaintenanceRadioPartBatteryState": 'Ladestand Funkmodul',
|
||||
"io:MaintenanceSensorPartBatteryState": 'Ladestand Sensor',
|
||||
"core:SensorDefectState": 'Sensor defekt'
|
||||
}
|
||||
|
||||
STATE_ENUMS = {
|
||||
"core:BatteryLevelState": ["full","normal","low","verylow"],
|
||||
"core:BatteryState": ["full","normal","low","verylow"],
|
||||
"core:ClosureState": '',
|
||||
"core:CommandLockLevelsState": '',
|
||||
"core:Memorized1OrientationState": '',
|
||||
"core:Memorized1PositionState": '',
|
||||
"core:MovingState": ["true","false"],
|
||||
"core:OpenClosedState": ["open","close"],
|
||||
"core:PriorityLockTimerState": '',
|
||||
"core:DiscreteRSSILevelState": ["good","normal","low"],
|
||||
"core:SlateOrientationState": '',
|
||||
"core:StatusState": ["available","unavailable"],
|
||||
"core:TargetClosureState": '',
|
||||
"core:LuminanceState": 'Helligkeit',
|
||||
"core:SmokeState": ["notDetected","detected"],
|
||||
"core:SunEnergyState": '',
|
||||
"core:TemperatureState": '',
|
||||
"io:MaintenanceRadioPartBatteryState": ["full","normal","low","verylow"],
|
||||
"io:MaintenanceSensorPartBatteryState": ["full","normal","low","verylow"],
|
||||
"core:SensorDefectState": ["true","false"]
|
||||
}
|
||||
|
||||
"""
|
||||
"core:TargetClosureState": 'Zielposition',
|
||||
"io:PriorityLockOriginatorState": 'Sperrendes Gerät',
|
||||
"core:ManufacturerDiagnosticsState": 'Diagnose',
|
||||
"core:OpenClosedUnknownState": 'Statusanzeige wenn unbekannt',
|
||||
"core:ManufacturerSettingsState": 'Einstellungen',
|
||||
"core:RSSILevelState": 'RSSI',
|
||||
"core:NameState": 'Name',
|
||||
"core:SecuredPositionState": 'Sicherer Zustand',
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def get_sensor_type_translation(cls, sensor):
|
||||
return cls.SENSOR_TYPES.get(sensor, sensor)
|
||||
|
||||
@classmethod
|
||||
def get_actor_type_translation(cls, actor_type):
|
||||
return cls.ACTOR_TYPES.get(actor_type, actor_type)
|
||||
|
||||
PARAMETER_TYPES = {
|
||||
0: "bool",
|
||||
1: "integer", # Integer value
|
||||
2: "float", # Floating point number
|
||||
3: "string", # Text string
|
||||
4: "undefined",
|
||||
6: "bool",
|
||||
11: "array"
|
||||
}
|
||||
|
||||
TAHOMA_COMMANDS = {
|
||||
"setClosure": 'Position',
|
||||
"setClosureAndOrientation": "Position+Neigung",
|
||||
"setOrientation": "Neigung",
|
||||
"up": "Auf",
|
||||
"down": "Zu",
|
||||
"my": "my-Position anfahren",
|
||||
"stop": "Stop",
|
||||
"refresh": "Aktualisieren",
|
||||
"wink": "Winken",
|
||||
"setMyPosition": "my-Position einstellen",
|
||||
"on": "Anschalten",
|
||||
"off": "Ausschalten",
|
||||
"toggle": "Toggle",
|
||||
"setIntensity": "Intensität",
|
||||
"setColor": "Farbe",
|
||||
"setColorTemperature": "Farbtemperatur",
|
||||
"setTargetTemperature": "Solltemperatur",
|
||||
"setMode": "Modus",
|
||||
"pulse": "Impuls",
|
||||
"setLevel": "Wert",
|
||||
"trigger": "Trigger",
|
||||
}
|
||||
# Tahoma Commands mit Parametern
|
||||
TAHOMA_COMMAND_PARAMS = {
|
||||
"setClosure": [{"name": "Position","url":"0", "type": "integer", "min": 0, "max": 100}],
|
||||
"setClosureAndOrientation": [
|
||||
{"name": "Position", "url":"0", "type": "integer", "min": 0, "max": 100},
|
||||
{"name": "Neigung", "url":"1", "type": "integer", "min": 0, "max": 100}
|
||||
],
|
||||
"setOrientation": [{"name": "Neigung", "url":"0", "type": "integer", "min": 0, "max": 100}],
|
||||
"up": [], "down": [], "my": [], "stop": [], "refresh": [], "wink":[],
|
||||
"setMyPosition": [{"name": "Position", "url":"0", "type": "integer", "min": 0, "max": 100}],
|
||||
"on": [], "off": [], "toggle": [],
|
||||
"setIntensity": [{"name": "Helligkeit", "url":"0", "type": "integer", "min": 0, "max": 100}],
|
||||
"setColor": [
|
||||
{"name": "Farbton", "url":"0", "type": "integer", "min": 0, "max": 360},
|
||||
{"name": "Sättigung", "url":"1", "type": "integer", "min": 0, "max": 100}
|
||||
],
|
||||
"setColorTemperature": [{"name": "Farbtemperatur", "url":"0", "type": "integer", "min": 2000, "max": 6500}],
|
||||
"setTargetTemperature": [{"name": "Temperatur", "url":"0", "type": "float", "min": 5.0, "max": 30.0}],
|
||||
"setMode": [{"name": "Betriebsart", "url":"0", "type": "string"}],
|
||||
"pulse": [{"name": "Impulsdauer", "url":"0", "type": "integer", "min": 1, "max": 3600}],
|
||||
"setLevel": [{"name": "Ausgangslevel", "url":"0", "type": "integer", "min": 0, "max": 100}],
|
||||
"trigger": [],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def is_actor(cls, device: Dict) -> bool:
|
||||
"""Prüft ob Gerät ein Aktor ist"""
|
||||
device_type = device.get('definition').get('uiClass', '')
|
||||
|
||||
if device_type in cls.ACTOR_TYPES:
|
||||
return True
|
||||
|
||||
commands = device.get('definition', {}).get('commands', [])
|
||||
if commands:
|
||||
command_names = [cmd.get('commandName', '') for cmd in commands]
|
||||
actor_commands = {'open', 'close', 'on', 'off', 'up', 'down', 'setPosition', 'dim'}
|
||||
if any(cmd in actor_commands for cmd in command_names):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def is_sensor(cls, device: Dict) -> bool:
|
||||
"""Prüft ob Gerät ein Sensor ist"""
|
||||
device_type = device.get('definition').get('uiClass', '')
|
||||
|
||||
if device_type in cls.SENSOR_TYPES:
|
||||
return True
|
||||
|
||||
states = device.get('states', [])
|
||||
commands = device.get('definition', {}).get('commands', [])
|
||||
|
||||
if states and len(states) > 0 and len(commands) <= 1:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def extract_actor_data(cls, device: Dict) -> tuple:
|
||||
"""Extrahiert Commands und States aus Aktor"""
|
||||
commands = []
|
||||
states = []
|
||||
|
||||
cmd_definitions = device.get('definition', {}).get('commands', [])
|
||||
|
||||
for cmd in cmd_definitions:
|
||||
command_url = cmd.get('commandName', '')
|
||||
command_name = cls.TAHOMA_COMMANDS.get(command_url, command_url)
|
||||
cmd_params = cls.TAHOMA_COMMAND_PARAMS.get(command_url, "Not in List")
|
||||
if(cmd_params != "Not in List"): #only append command, if it is on of the listed commands, to prevent flooding the DB with bullshit.
|
||||
command_entry = {
|
||||
'command': command_name,
|
||||
'url': command_url,
|
||||
'parameters': []
|
||||
}
|
||||
|
||||
for cmd_param in cmd_params:
|
||||
param_detail = {'name': cmd_param.get('name', '')}
|
||||
|
||||
if 'type' in cmd_param:
|
||||
param_detail['type'] = cmd_param['type']
|
||||
if 'min' in cmd_param:
|
||||
param_detail['min'] = cmd_param['min']
|
||||
if 'max' in cmd_param:
|
||||
param_detail['max'] = cmd_param['max']
|
||||
if 'values' in cmd_param:
|
||||
param_detail['values'] = cmd_param['values']
|
||||
if 'url' in cmd_param:
|
||||
param_detail['url'] = cmd_param['url']
|
||||
if param_detail['name']:
|
||||
command_entry['parameters'].append(param_detail)
|
||||
|
||||
commands.append(command_entry)
|
||||
|
||||
# States extrahieren
|
||||
state_definitions = device.get('states', [])
|
||||
for state in state_definitions:
|
||||
state_url = state.get('name', '')
|
||||
state_name = cls.STATE_NAMES.get(state_url, '')
|
||||
state_enums = cls.STATE_ENUMS.get(state_url,'')
|
||||
if state_name:
|
||||
state_entry = {
|
||||
'name': state_name,
|
||||
'url': state_url,
|
||||
'type': cls.PARAMETER_TYPES.get(state.get('type', 4)),
|
||||
'values': state_enums
|
||||
}
|
||||
if 'value' in state:
|
||||
state_entry['current_value'] = state['value']
|
||||
states.append(state_entry)
|
||||
|
||||
return commands, states
|
||||
|
||||
@classmethod
|
||||
def extract_sensor_data(cls, device: Dict) -> list:
|
||||
"""Extrahiert States aus Sensor"""
|
||||
states = []
|
||||
|
||||
state_definitions = device.get('states', [])
|
||||
for state in state_definitions:
|
||||
state_url = state.get('name', '')
|
||||
state_name = cls.STATE_NAMES.get(state_url, '')
|
||||
state_enums = cls.STATE_ENUMS.get(state_url,'')
|
||||
if state_name:
|
||||
state_entry = {
|
||||
'name': state_name,
|
||||
'url': state_url,
|
||||
'type': cls.PARAMETER_TYPES.get(state.get('type', 4)),
|
||||
'values': state_enums
|
||||
}
|
||||
if 'value' in state:
|
||||
state_entry['current_value'] = state['value']
|
||||
states.append(state_entry)
|
||||
|
||||
return states
|
||||
|
||||
|
||||
class TahomaModule(BaseModule):
|
||||
"""
|
||||
Tahoma Modul - Implementiert BaseModule Interface
|
||||
Gibt nur Actors/Sensors zurück, KEINE DB-Operationen
|
||||
"""
|
||||
|
||||
def is_enabled(self) -> bool:
|
||||
"""Prüft ob Tahoma aktiviert ist"""
|
||||
return (self.config.tahoma_enable and
|
||||
self.config.tahoma_ip and
|
||||
self.config.tahoma_token)
|
||||
|
||||
def discover(self) -> Tuple[List[Dict], List[Dict]]:
|
||||
"""
|
||||
Führt Tahoma Discovery durch
|
||||
|
||||
Returns:
|
||||
Tuple (actors, sensors) - Listen von Dicts im vereinheitlichten Format
|
||||
"""
|
||||
logger.info("\n" + "=" * 60)
|
||||
logger.info("TAHOMA-GERÄTE WERDEN ABGERUFEN")
|
||||
logger.info("=" * 60)
|
||||
|
||||
actors = []
|
||||
sensors = []
|
||||
|
||||
# TahomaAPI initialisieren
|
||||
tahoma = TahomaAPI(self.config.tahoma_ip, self.config.tahoma_token)
|
||||
devices = tahoma.get_devices()
|
||||
|
||||
if not devices:
|
||||
logger.warning("Keine Tahoma-Geräte gefunden")
|
||||
return actors, sensors
|
||||
|
||||
# Geräte gruppieren (Original-Logik)
|
||||
device_groups = {}
|
||||
standalone_devices = []
|
||||
|
||||
for device in devices:
|
||||
device_url = device.get('deviceURL', '')
|
||||
match = re.match(r'(.+)#(\d+)$', device_url)
|
||||
|
||||
if match:
|
||||
base_url = match.group(1)
|
||||
if base_url not in device_groups:
|
||||
device_groups[base_url] = []
|
||||
device_groups[base_url].append(device)
|
||||
else:
|
||||
standalone_devices.append(device)
|
||||
|
||||
# Gruppierte Geräte verarbeiten
|
||||
for base_url, group_devices in device_groups.items():
|
||||
main_device = None
|
||||
for dev in group_devices:
|
||||
if dev.get('deviceURL', '').endswith('#1'):
|
||||
main_device = dev
|
||||
break
|
||||
|
||||
if not main_device and group_devices:
|
||||
main_device = group_devices[0]
|
||||
|
||||
main_name = main_device.get('label', 'Unbekannt') if main_device else 'Unbekannt'
|
||||
|
||||
for device in group_devices:
|
||||
if(device.get('label','').startswith("IO (") == False):
|
||||
actor, sensor = self._process_device(device, device.get('label',main_name))
|
||||
else:
|
||||
actor, sensor = self._process_device(device, main_name)
|
||||
if actor:
|
||||
actors.append(actor)
|
||||
if sensor:
|
||||
sensors.append(sensor)
|
||||
|
||||
# Standalone Geräte verarbeiten
|
||||
for device in standalone_devices:
|
||||
device_name = device.get('label', 'Unbekannt')
|
||||
actor, sensor = self._process_device(device, device_name)
|
||||
if actor:
|
||||
actors.append(actor)
|
||||
if sensor:
|
||||
sensors.append(sensor)
|
||||
|
||||
logger.info(f"Tahoma: {len(actors)} Aktoren, {len(sensors)} Sensoren gefunden")
|
||||
return actors, sensors
|
||||
|
||||
def _process_device(self, device: Dict, device_name: str) -> Tuple[Optional[Dict], Optional[Dict]]:
|
||||
"""
|
||||
Verarbeitet ein einzelnes Gerät
|
||||
|
||||
Returns:
|
||||
Tuple (actor_dict or None, sensor_dict or None)
|
||||
"""
|
||||
device_url = device.get('deviceURL', '')
|
||||
device_type = device.get('definition').get('uiClass', 'Unknown')
|
||||
|
||||
is_actor = DeviceClassifier.is_actor(device)
|
||||
is_sensor = DeviceClassifier.is_sensor(device)
|
||||
|
||||
actor = None
|
||||
sensor = None
|
||||
|
||||
if is_actor:
|
||||
commands, states = DeviceClassifier.extract_actor_data(device)
|
||||
device_type = DeviceClassifier.get_actor_type_translation(device_type)
|
||||
actor = {
|
||||
'type': device_type,
|
||||
'name': device_name,
|
||||
'url': device_url,
|
||||
'commands': commands,
|
||||
'states': states
|
||||
}
|
||||
|
||||
elif is_sensor:
|
||||
states = DeviceClassifier.extract_sensor_data(device)
|
||||
device_type = DeviceClassifier.get_sensor_type_translation(device_type)
|
||||
sensor = {
|
||||
'type': device_type,
|
||||
'name': device_name,
|
||||
'url': device_url,
|
||||
'states': states
|
||||
}
|
||||
|
||||
return actor, sensor
|
||||
|
||||
@@ -1,316 +1,316 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
WLED Module
|
||||
Enthält NUR WLED-spezifische Geräte-Discovery Logik
|
||||
KEINE Datenbank-Operationen!
|
||||
"""
|
||||
|
||||
import requests
|
||||
import socket
|
||||
import concurrent.futures
|
||||
import logging
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
from modules.base_module import BaseModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WLEDDiscovery:
|
||||
"""Original WLEDDiscovery Klasse - unverändert"""
|
||||
|
||||
@staticmethod
|
||||
def discover_devices(timeout: int = 5) -> List[str]:
|
||||
"""Sucht nach WLED-Geräten via mDNS"""
|
||||
try:
|
||||
from zeroconf import ServiceBrowser, ServiceListener, Zeroconf
|
||||
import time
|
||||
|
||||
class WLEDListener(ServiceListener):
|
||||
def __init__(self):
|
||||
self.devices = []
|
||||
|
||||
def add_service(self, zc, type_, name):
|
||||
info = zc.get_service_info(type_, name)
|
||||
if info:
|
||||
addresses = [socket.inet_ntoa(addr) for addr in info.addresses]
|
||||
for addr in addresses:
|
||||
if addr not in self.devices:
|
||||
self.devices.append(addr)
|
||||
logger.info(f"WLED-Gerät gefunden: {name} ({addr})")
|
||||
|
||||
def remove_service(self, zc, type_, name):
|
||||
pass
|
||||
|
||||
def update_service(self, zc, type_, name):
|
||||
pass
|
||||
|
||||
zeroconf = Zeroconf()
|
||||
listener = WLEDListener()
|
||||
browser = ServiceBrowser(zeroconf, "_http._tcp.local.", listener)
|
||||
|
||||
logger.info(f"Suche nach WLED-Geräten (Timeout: {timeout}s)...")
|
||||
time.sleep(timeout)
|
||||
|
||||
zeroconf.close()
|
||||
|
||||
# Nur WLED-Geräte filtern
|
||||
wled_devices = []
|
||||
for ip in listener.devices:
|
||||
if WLEDDiscovery.is_wled_device(ip):
|
||||
wled_devices.append(ip)
|
||||
|
||||
logger.info(f"{len(wled_devices)} WLED-Geräte gefunden")
|
||||
return wled_devices
|
||||
|
||||
except ImportError:
|
||||
logger.warning("zeroconf nicht installiert. Verwende Netzwerk-Scan...")
|
||||
return WLEDDiscovery.scan_network()
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler bei WLED-Discovery: {e}")
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def scan_network(network: str = None, max_threads: int = 50) -> List[str]:
|
||||
"""Scannt das Netzwerk nach WLED-Geräten"""
|
||||
if network is None:
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.connect(("8.8.8.8", 80))
|
||||
local_ip = s.getsockname()[0]
|
||||
s.close()
|
||||
network_prefix = '.'.join(local_ip.split('.')[:-1])
|
||||
except:
|
||||
logger.warning("Konnte lokale IP nicht ermitteln, verwende 192.168.1.x")
|
||||
network_prefix = "192.168.1"
|
||||
else:
|
||||
network_prefix = '.'.join(network.split('.')[:3])
|
||||
|
||||
logger.info(f"Scanne Netzwerk {network_prefix}.0/24 nach WLED-Geräten...")
|
||||
|
||||
def check_ip(ip):
|
||||
if WLEDDiscovery.is_wled_device(ip):
|
||||
return ip
|
||||
return None
|
||||
|
||||
wled_devices = []
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=max_threads) as executor:
|
||||
futures = [executor.submit(check_ip, f"{network_prefix}.{i}")
|
||||
for i in range(1, 255)]
|
||||
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
result = future.result()
|
||||
if result:
|
||||
wled_devices.append(result)
|
||||
logger.info(f"WLED-Gerät gefunden: {result}")
|
||||
|
||||
return wled_devices
|
||||
|
||||
@staticmethod
|
||||
def is_wled_device(ip: str, timeout: float = 2.0) -> bool:
|
||||
"""Prüft ob IP ein WLED-Gerät ist"""
|
||||
try:
|
||||
response = requests.get(
|
||||
f"http://{ip}/json/info",
|
||||
timeout=timeout,
|
||||
headers={'User-Agent': 'DeviceDiscovery/1.0'}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
return 'ver' in data or 'name' in data
|
||||
except:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
class WLEDAPI:
|
||||
"""Original WLEDAPI Klasse - unverändert"""
|
||||
|
||||
def __init__(self, ip: str):
|
||||
self.ip = ip
|
||||
self.base_url = f"http://{ip}"
|
||||
|
||||
def get_info(self) -> Optional[Dict]:
|
||||
try:
|
||||
response = requests.get(f"{self.base_url}/json/info", timeout=2)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler beim Abrufen der WLED-Info von {self.ip}: {e}")
|
||||
return None
|
||||
|
||||
def get_presets(self) -> Optional[List]:
|
||||
try:
|
||||
response = requests.get(f"{self.base_url}/presets.json", timeout=2)
|
||||
response.raise_for_status()
|
||||
presets_data = response.json()
|
||||
preset_list = []
|
||||
if isinstance(presets_data, dict):
|
||||
for preset_id, preset_data in presets_data.items():
|
||||
preset_name = preset_data.get('n', f'Preset {preset_id}')
|
||||
preset_list.append({int(preset_id): preset_name})
|
||||
return preset_list
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler beim Abrufen der WLED-Presets von {self.ip}: {e}")
|
||||
return None
|
||||
|
||||
def get_effects(self) -> Optional[List]:
|
||||
try:
|
||||
response = requests.get(f"{self.base_url}/json/eff", timeout=2)
|
||||
response.raise_for_status()
|
||||
eff_data = response.json()
|
||||
eff_list = []
|
||||
for eff_id, eff_name in enumerate(eff_data):
|
||||
if not eff_name:
|
||||
eff_name = f"Effect {eff_id}"
|
||||
eff_list.append({int(eff_id): eff_name})
|
||||
return eff_list
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler beim Abrufen der WLED-Effects von {self.ip}: {e}")
|
||||
return None
|
||||
|
||||
def get_state(self) -> Optional[Dict]:
|
||||
try:
|
||||
response = requests.get(f"{self.base_url}/json/state", timeout=2)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler beim Abrufen des WLED-State von {self.ip}: {e}")
|
||||
return None
|
||||
|
||||
def get_device_data(self) -> Optional[Dict]:
|
||||
"""Erstellt Geräte-Dict im vereinheitlichten Format"""
|
||||
info = self.get_info()
|
||||
state = self.get_state()
|
||||
|
||||
if not info:
|
||||
return None
|
||||
|
||||
name = info.get('name', f"WLED {self.ip}")
|
||||
preset_values = self.get_presets()
|
||||
eff_values = self.get_effects()
|
||||
|
||||
commands = [
|
||||
{'command': 'An', 'url': '{"on":true}', 'parameters': []},
|
||||
{'command': 'Aus', 'url': '{"on":false}', 'parameters': []},
|
||||
{
|
||||
'command': 'Helligkeit', 'url': '{"bri":%brightness%}',
|
||||
'parameters': [
|
||||
{'name': 'brightness', 'type': 'integer', 'min': 0, 'max': 255}
|
||||
]
|
||||
},
|
||||
{
|
||||
'command': 'Farbe', 'url': '{"seg":[{"col":[[%red%,%green%,%blue%]]}]}',
|
||||
'parameters': [
|
||||
{'name': 'red', 'type': 'integer', 'min': 0, 'max': 255},
|
||||
{'name': 'green', 'type': 'integer', 'min': 0, 'max': 255},
|
||||
{'name': 'blue', 'type': 'integer', 'min': 0, 'max': 255}
|
||||
]
|
||||
},
|
||||
{
|
||||
'command': 'Effekt', 'url': '{"seg":[{"fx":%effect%}]}',
|
||||
'parameters': [
|
||||
{'name': 'effect', 'type': 'integer', 'min': 0, 'max': 255, 'values': eff_values}
|
||||
]
|
||||
},
|
||||
{
|
||||
'command': 'Preset', 'url': '{"ps":%preset%}',
|
||||
'parameters': [
|
||||
{'name': 'preset', 'type': 'integer', 'min': 1, 'max': 250, 'values': preset_values}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
states = []
|
||||
if state:
|
||||
states.append({
|
||||
'name': 'An',
|
||||
'url': 'on',
|
||||
'type': 'boolean',
|
||||
'current_value': state.get('on', False)
|
||||
})
|
||||
states.append({
|
||||
'name': 'Helligkeit',
|
||||
'url': 'bri',
|
||||
'type': 'integer',
|
||||
'current_value': state.get('bri', 0)
|
||||
})
|
||||
|
||||
segments = state.get('seg', [])
|
||||
if segments and len(segments) > 0:
|
||||
colors = segments[0].get('col', [[0,0,0]])
|
||||
if colors and len(colors) > 0:
|
||||
states.append({
|
||||
'name': 'Farbe',
|
||||
'url': 'seg[0].col[0]',
|
||||
'type': 'array',
|
||||
'current_value': colors[0]
|
||||
})
|
||||
|
||||
return {
|
||||
'type': 'WLED',
|
||||
'name': name,
|
||||
'url': f"wled://{self.ip}",
|
||||
'commands': commands,
|
||||
'states': states
|
||||
}
|
||||
|
||||
|
||||
class WLEDModule(BaseModule):
|
||||
"""
|
||||
WLED Modul - Implementiert BaseModule Interface
|
||||
Gibt nur Actors zurück, KEINE DB-Operationen
|
||||
"""
|
||||
|
||||
def is_enabled(self) -> bool:
|
||||
"""Prüft ob WLED aktiviert ist"""
|
||||
return self.config.wled_enable
|
||||
|
||||
def discover(self) -> Tuple[List[Dict], List[Dict]]:
|
||||
"""
|
||||
Führt WLED Discovery durch
|
||||
|
||||
Returns:
|
||||
Tuple (actors, sensors) - WLED sind immer Actors
|
||||
"""
|
||||
logger.info("\n" + "=" * 60)
|
||||
logger.info("WLED-GERÄTE WERDEN GESUCHT")
|
||||
logger.info("=" * 60)
|
||||
|
||||
actors = []
|
||||
sensors = []
|
||||
|
||||
# Discovery
|
||||
wled_ips = WLEDDiscovery.discover_devices(timeout=self.config.wled_discovery_timeout)
|
||||
|
||||
# Manuelle IPs hinzufügen
|
||||
if self.config.wled_manual_ips:
|
||||
logger.info(f"Füge {len(self.config.wled_manual_ips)} manuelle WLED-IPs hinzu...")
|
||||
for manual_ip in self.config.wled_manual_ips:
|
||||
if manual_ip not in wled_ips:
|
||||
if WLEDDiscovery.is_wled_device(manual_ip):
|
||||
wled_ips.append(manual_ip)
|
||||
logger.info(f"✓ Manuelles WLED-Gerät: {manual_ip}")
|
||||
else:
|
||||
logger.warning(f"⚠ {manual_ip} ist kein WLED-Gerät")
|
||||
|
||||
if not wled_ips:
|
||||
logger.info("Keine WLED-Geräte gefunden")
|
||||
return actors, sensors
|
||||
|
||||
logger.info(f"{len(wled_ips)} WLED-Geräte gefunden")
|
||||
|
||||
# Gerätedaten abrufen
|
||||
for ip in wled_ips:
|
||||
try:
|
||||
wled = WLEDAPI(ip)
|
||||
device_data = wled.get_device_data()
|
||||
|
||||
if device_data:
|
||||
actors.append(device_data)
|
||||
else:
|
||||
logger.warning(f"⚠ Konnte keine Daten von WLED {ip} abrufen")
|
||||
except Exception as e:
|
||||
logger.error(f"✗ Fehler beim Verarbeiten von WLED {ip}: {e}")
|
||||
|
||||
logger.info(f"WLED: {len(actors)} Aktoren gefunden")
|
||||
return actors, sensors
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
WLED Module
|
||||
Enthält NUR WLED-spezifische Geräte-Discovery Logik
|
||||
KEINE Datenbank-Operationen!
|
||||
"""
|
||||
|
||||
import requests
|
||||
import socket
|
||||
import concurrent.futures
|
||||
import logging
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
from modules.base_module import BaseModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WLEDDiscovery:
|
||||
"""Original WLEDDiscovery Klasse - unverändert"""
|
||||
|
||||
@staticmethod
|
||||
def discover_devices(timeout: int = 5) -> List[str]:
|
||||
"""Sucht nach WLED-Geräten via mDNS"""
|
||||
try:
|
||||
from zeroconf import ServiceBrowser, ServiceListener, Zeroconf
|
||||
import time
|
||||
|
||||
class WLEDListener(ServiceListener):
|
||||
def __init__(self):
|
||||
self.devices = []
|
||||
|
||||
def add_service(self, zc, type_, name):
|
||||
info = zc.get_service_info(type_, name)
|
||||
if info:
|
||||
addresses = [socket.inet_ntoa(addr) for addr in info.addresses]
|
||||
for addr in addresses:
|
||||
if addr not in self.devices:
|
||||
self.devices.append(addr)
|
||||
logger.info(f"WLED-Gerät gefunden: {name} ({addr})")
|
||||
|
||||
def remove_service(self, zc, type_, name):
|
||||
pass
|
||||
|
||||
def update_service(self, zc, type_, name):
|
||||
pass
|
||||
|
||||
zeroconf = Zeroconf()
|
||||
listener = WLEDListener()
|
||||
browser = ServiceBrowser(zeroconf, "_http._tcp.local.", listener)
|
||||
|
||||
logger.info(f"Suche nach WLED-Geräten (Timeout: {timeout}s)...")
|
||||
time.sleep(timeout)
|
||||
|
||||
zeroconf.close()
|
||||
|
||||
# Nur WLED-Geräte filtern
|
||||
wled_devices = []
|
||||
for ip in listener.devices:
|
||||
if WLEDDiscovery.is_wled_device(ip):
|
||||
wled_devices.append(ip)
|
||||
|
||||
logger.info(f"{len(wled_devices)} WLED-Geräte gefunden")
|
||||
return wled_devices
|
||||
|
||||
except ImportError:
|
||||
logger.warning("zeroconf nicht installiert. Verwende Netzwerk-Scan...")
|
||||
return WLEDDiscovery.scan_network()
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler bei WLED-Discovery: {e}")
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def scan_network(network: str = None, max_threads: int = 50) -> List[str]:
|
||||
"""Scannt das Netzwerk nach WLED-Geräten"""
|
||||
if network is None:
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.connect(("8.8.8.8", 80))
|
||||
local_ip = s.getsockname()[0]
|
||||
s.close()
|
||||
network_prefix = '.'.join(local_ip.split('.')[:-1])
|
||||
except:
|
||||
logger.warning("Konnte lokale IP nicht ermitteln, verwende 192.168.1.x")
|
||||
network_prefix = "192.168.1"
|
||||
else:
|
||||
network_prefix = '.'.join(network.split('.')[:3])
|
||||
|
||||
logger.info(f"Scanne Netzwerk {network_prefix}.0/24 nach WLED-Geräten...")
|
||||
|
||||
def check_ip(ip):
|
||||
if WLEDDiscovery.is_wled_device(ip):
|
||||
return ip
|
||||
return None
|
||||
|
||||
wled_devices = []
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=max_threads) as executor:
|
||||
futures = [executor.submit(check_ip, f"{network_prefix}.{i}")
|
||||
for i in range(1, 255)]
|
||||
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
result = future.result()
|
||||
if result:
|
||||
wled_devices.append(result)
|
||||
logger.info(f"WLED-Gerät gefunden: {result}")
|
||||
|
||||
return wled_devices
|
||||
|
||||
@staticmethod
|
||||
def is_wled_device(ip: str, timeout: float = 2.0) -> bool:
|
||||
"""Prüft ob IP ein WLED-Gerät ist"""
|
||||
try:
|
||||
response = requests.get(
|
||||
f"http://{ip}/json/info",
|
||||
timeout=timeout,
|
||||
headers={'User-Agent': 'DeviceDiscovery/1.0'}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
return 'ver' in data or 'name' in data
|
||||
except:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
class WLEDAPI:
|
||||
"""Original WLEDAPI Klasse - unverändert"""
|
||||
|
||||
def __init__(self, ip: str):
|
||||
self.ip = ip
|
||||
self.base_url = f"http://{ip}"
|
||||
|
||||
def get_info(self) -> Optional[Dict]:
|
||||
try:
|
||||
response = requests.get(f"{self.base_url}/json/info", timeout=2)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler beim Abrufen der WLED-Info von {self.ip}: {e}")
|
||||
return None
|
||||
|
||||
def get_presets(self) -> Optional[List]:
|
||||
try:
|
||||
response = requests.get(f"{self.base_url}/presets.json", timeout=2)
|
||||
response.raise_for_status()
|
||||
presets_data = response.json()
|
||||
preset_list = []
|
||||
if isinstance(presets_data, dict):
|
||||
for preset_id, preset_data in presets_data.items():
|
||||
preset_name = preset_data.get('n', f'Preset {preset_id}')
|
||||
preset_list.append({int(preset_id): preset_name})
|
||||
return preset_list
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler beim Abrufen der WLED-Presets von {self.ip}: {e}")
|
||||
return None
|
||||
|
||||
def get_effects(self) -> Optional[List]:
|
||||
try:
|
||||
response = requests.get(f"{self.base_url}/json/eff", timeout=2)
|
||||
response.raise_for_status()
|
||||
eff_data = response.json()
|
||||
eff_list = []
|
||||
for eff_id, eff_name in enumerate(eff_data):
|
||||
if not eff_name:
|
||||
eff_name = f"Effect {eff_id}"
|
||||
eff_list.append({int(eff_id): eff_name})
|
||||
return eff_list
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler beim Abrufen der WLED-Effects von {self.ip}: {e}")
|
||||
return None
|
||||
|
||||
def get_state(self) -> Optional[Dict]:
|
||||
try:
|
||||
response = requests.get(f"{self.base_url}/json/state", timeout=2)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler beim Abrufen des WLED-State von {self.ip}: {e}")
|
||||
return None
|
||||
|
||||
def get_device_data(self) -> Optional[Dict]:
|
||||
"""Erstellt Geräte-Dict im vereinheitlichten Format"""
|
||||
info = self.get_info()
|
||||
state = self.get_state()
|
||||
|
||||
if not info:
|
||||
return None
|
||||
|
||||
name = info.get('name', f"WLED {self.ip}")
|
||||
preset_values = self.get_presets()
|
||||
eff_values = self.get_effects()
|
||||
|
||||
commands = [
|
||||
{'command': 'An', 'url': '{"on":true}', 'parameters': []},
|
||||
{'command': 'Aus', 'url': '{"on":false}', 'parameters': []},
|
||||
{
|
||||
'command': 'Helligkeit', 'url': '{"bri":%brightness%}',
|
||||
'parameters': [
|
||||
{'name': 'brightness', 'type': 'integer', 'min': 0, 'max': 255}
|
||||
]
|
||||
},
|
||||
{
|
||||
'command': 'Farbe', 'url': '{"seg":[{"col":[[%red%,%green%,%blue%]]}]}',
|
||||
'parameters': [
|
||||
{'name': 'red', 'type': 'integer', 'min': 0, 'max': 255},
|
||||
{'name': 'green', 'type': 'integer', 'min': 0, 'max': 255},
|
||||
{'name': 'blue', 'type': 'integer', 'min': 0, 'max': 255}
|
||||
]
|
||||
},
|
||||
{
|
||||
'command': 'Effekt', 'url': '{"seg":[{"fx":%effect%}]}',
|
||||
'parameters': [
|
||||
{'name': 'effect', 'type': 'integer', 'min': 0, 'max': 255, 'values': eff_values}
|
||||
]
|
||||
},
|
||||
{
|
||||
'command': 'Preset', 'url': '{"ps":%preset%}',
|
||||
'parameters': [
|
||||
{'name': 'preset', 'type': 'integer', 'min': 1, 'max': 250, 'values': preset_values}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
states = []
|
||||
if state:
|
||||
states.append({
|
||||
'name': 'An',
|
||||
'url': 'on',
|
||||
'type': 'boolean',
|
||||
'current_value': state.get('on', False)
|
||||
})
|
||||
states.append({
|
||||
'name': 'Helligkeit',
|
||||
'url': 'bri',
|
||||
'type': 'integer',
|
||||
'current_value': state.get('bri', 0)
|
||||
})
|
||||
|
||||
segments = state.get('seg', [])
|
||||
if segments and len(segments) > 0:
|
||||
colors = segments[0].get('col', [[0,0,0]])
|
||||
if colors and len(colors) > 0:
|
||||
states.append({
|
||||
'name': 'Farbe',
|
||||
'url': 'seg[0].col[0]',
|
||||
'type': 'array',
|
||||
'current_value': colors[0]
|
||||
})
|
||||
|
||||
return {
|
||||
'type': 'WLED',
|
||||
'name': name,
|
||||
'url': f"wled://{self.ip}",
|
||||
'commands': commands,
|
||||
'states': states
|
||||
}
|
||||
|
||||
|
||||
class WLEDModule(BaseModule):
|
||||
"""
|
||||
WLED Modul - Implementiert BaseModule Interface
|
||||
Gibt nur Actors zurück, KEINE DB-Operationen
|
||||
"""
|
||||
|
||||
def is_enabled(self) -> bool:
|
||||
"""Prüft ob WLED aktiviert ist"""
|
||||
return self.config.wled_enable
|
||||
|
||||
def discover(self) -> Tuple[List[Dict], List[Dict]]:
|
||||
"""
|
||||
Führt WLED Discovery durch
|
||||
|
||||
Returns:
|
||||
Tuple (actors, sensors) - WLED sind immer Actors
|
||||
"""
|
||||
logger.info("\n" + "=" * 60)
|
||||
logger.info("WLED-GERÄTE WERDEN GESUCHT")
|
||||
logger.info("=" * 60)
|
||||
|
||||
actors = []
|
||||
sensors = []
|
||||
|
||||
# Discovery
|
||||
wled_ips = WLEDDiscovery.discover_devices(timeout=self.config.wled_discovery_timeout)
|
||||
|
||||
# Manuelle IPs hinzufügen
|
||||
if self.config.wled_manual_ips:
|
||||
logger.info(f"Füge {len(self.config.wled_manual_ips)} manuelle WLED-IPs hinzu...")
|
||||
for manual_ip in self.config.wled_manual_ips:
|
||||
if manual_ip not in wled_ips:
|
||||
if WLEDDiscovery.is_wled_device(manual_ip):
|
||||
wled_ips.append(manual_ip)
|
||||
logger.info(f"✓ Manuelles WLED-Gerät: {manual_ip}")
|
||||
else:
|
||||
logger.warning(f"⚠ {manual_ip} ist kein WLED-Gerät")
|
||||
|
||||
if not wled_ips:
|
||||
logger.info("Keine WLED-Geräte gefunden")
|
||||
return actors, sensors
|
||||
|
||||
logger.info(f"{len(wled_ips)} WLED-Geräte gefunden")
|
||||
|
||||
# Gerätedaten abrufen
|
||||
for ip in wled_ips:
|
||||
try:
|
||||
wled = WLEDAPI(ip)
|
||||
device_data = wled.get_device_data()
|
||||
|
||||
if device_data:
|
||||
actors.append(device_data)
|
||||
else:
|
||||
logger.warning(f"⚠ Konnte keine Daten von WLED {ip} abrufen")
|
||||
except Exception as e:
|
||||
logger.error(f"✗ Fehler beim Verarbeiten von WLED {ip}: {e}")
|
||||
|
||||
logger.info(f"WLED: {len(actors)} Aktoren gefunden")
|
||||
return actors, sensors
|
||||
|
||||
Reference in New Issue
Block a user