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
|
||||
|
||||
+207
-207
@@ -1,208 +1,208 @@
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<!--begin::Head-->
|
||||
<?php
|
||||
// Die Raumtabelle traegt die Etagen - das Menue baut daraus seine Punkte.
|
||||
// Hier und nicht erst in home.php: der Kopf wird zuerst eingebunden.
|
||||
require_once(__DIR__ . "/rooms.php");
|
||||
|
||||
// $_GET["action"] ist von index.php bereits auf eine gueltige Seite gesetzt.
|
||||
if (!isset($_GET["floor"]))
|
||||
$_GET["floor"] = "OG";
|
||||
?>
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<title>Smarthome control</title>
|
||||
<!--begin::Accessibility Meta Tags-->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="theme-color" content="#007bff" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#1a1a1a" media="(prefers-color-scheme: dark)" />
|
||||
<link rel="icon" type="image/png" href="assets/img/favicon.png">
|
||||
<!--end::Accessibility Meta Tags-->
|
||||
|
||||
<!--begin::Primary Meta Tags-->
|
||||
<meta name="title" content="Smarthome control" />
|
||||
<meta name="author" content="ColorlibHQ" />
|
||||
<meta name="description" content="Smarthome control panel by m0." />
|
||||
<meta name="keywords" content="smarthome dashboard, admin panel" />
|
||||
<!--end::Primary Meta Tags-->
|
||||
|
||||
<!--begin::Accessibility Features-->
|
||||
<!-- Skip links will be dynamically added by accessibility.js -->
|
||||
<meta name="supported-color-schemes" content="light dark" />
|
||||
<link rel="preload" href="./css/adminlte.min.css?v=2" as="style" />
|
||||
<link rel="stylesheet" href="./css/solar.css?v=<?= filemtime("css/solar.css") ?>" />
|
||||
<!--end::Accessibility Features-->
|
||||
|
||||
<!--begin::Fonts-->
|
||||
<link rel="stylesheet" href="assets/fonts/font_poppins.css" media="print" onload="this.media='all'" />
|
||||
<!--end::Fonts-->
|
||||
|
||||
<!--begin::Third Party Plugin(Bootstrap Icons)-->
|
||||
<link rel="stylesheet" href="css/bootstrap-icons.min.css" />
|
||||
<!--end::Third Party Plugin(Bootstrap Icons)-->
|
||||
|
||||
<!--begin::Required Plugin(AdminLTE)-->
|
||||
<link rel="stylesheet" href="./css/adminlte.min.css?v=2" />
|
||||
<!--end::Required Plugin(AdminLTE)-->
|
||||
|
||||
<!--begin::Karte (Leaflet) - nur auf Seiten mit Karte-->
|
||||
<?php if ($page["leaflet"]): ?>
|
||||
<link rel="stylesheet" href="css/leaflet.css" />
|
||||
<?php endif; ?>
|
||||
<!--end::Karte-->
|
||||
|
||||
|
||||
</head>
|
||||
<!--end::Head-->
|
||||
<!--begin::Body-->
|
||||
|
||||
<body class="layout-fixed sidebar-expand-lg bg-body-tertiary" data-bs-theme="dark">
|
||||
<!--begin::App Wrapper-->
|
||||
<div class="app-wrapper">
|
||||
<!--begin::Header-->
|
||||
<nav class="app-header navbar navbar-expand bg-body">
|
||||
<!--begin::Container-->
|
||||
<div class="container-fluid">
|
||||
<!--begin::Start Navbar Links-->
|
||||
<ul class="navbar-nav">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" data-lte-toggle="sidebar" href="#" role="button">
|
||||
<i class="bi bi-list"></i>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<!--end::Start Navbar Links-->
|
||||
|
||||
<!--begin::End Navbar Links-->
|
||||
<ul class="navbar-nav ms-auto">
|
||||
<!--begin::Fullscreen Toggle-->
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="#" data-lte-toggle="fullscreen">
|
||||
<i data-lte-icon="maximize" class="bi bi-arrows-fullscreen"></i>
|
||||
<i data-lte-icon="minimize" class="bi bi-fullscreen-exit" style="display: none"></i>
|
||||
</a>
|
||||
</li>
|
||||
<?php
|
||||
if ($_SESSION["local"] == true)
|
||||
echo "<li class='nav-item'><a class='nav-link' href='addUser.php'><i data-lte-icon='Add user' class='bi bi-person-add'></i></a></li>";
|
||||
?>
|
||||
<!--end::Fullscreen Toggle-->
|
||||
</ul>
|
||||
<!--end::End Navbar Links-->
|
||||
</div>
|
||||
<!--end::Container-->
|
||||
</nav>
|
||||
<!--end::Header-->
|
||||
<!--begin::Sidebar-->
|
||||
<aside class="app-sidebar bg-body-secondary shadow">
|
||||
<!--begin::Sidebar Brand-->
|
||||
<div class="sidebar-brand">
|
||||
<!--begin::Brand Link-->
|
||||
<a href="./index.php" class="brand-link">
|
||||
<!--begin::Brand Image-->
|
||||
<img src="./assets/img/AdminLTELogo.png" alt="Logo"
|
||||
class="brand-image opacity-75 shadow" />
|
||||
<!--end::Brand Image-->
|
||||
<!--begin::Brand Text-->
|
||||
<span class="brand-text fw-light">Smart controller</span>
|
||||
<!--end::Brand Text-->
|
||||
</a>
|
||||
<!--end::Brand Link-->
|
||||
</div>
|
||||
<!--end::Sidebar Brand-->
|
||||
<!--begin::Sidebar Wrapper-->
|
||||
<div class="sidebar-wrapper">
|
||||
<nav class="mt-2">
|
||||
<!--begin::Sidebar Menu-->
|
||||
<ul class="nav sidebar-menu flex-column" data-lte-toggle="treeview" role="navigation"
|
||||
aria-label="Main navigation" data-accordion="false" id="navigation">
|
||||
<li class="nav-item menu-open">
|
||||
<a href="#" class="nav-link active">
|
||||
<i class="nav-icon bi bi-speedometer"></i>
|
||||
<p>
|
||||
Dashboards
|
||||
<i class="nav-arrow bi bi-chevron-right"></i>
|
||||
</p>
|
||||
</a>
|
||||
<ul class="nav nav-treeview ps-4">
|
||||
<li class="nav-item">
|
||||
<a href="?action=solar" class="nav-link<?php if($_GET["action"]=="solar"){echo " active";} ?>">
|
||||
<i class="nav-icon bi bi-brightness-alt-high"></i>
|
||||
<p>Solar</p>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="?action=heat" class="nav-link <?php if($_GET["action"]=="heat"){echo " active";} ?>">
|
||||
<i class="nav-icon bi bi-fire"></i>
|
||||
<p>Heizung</p>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="?action=history" class="nav-link <?php if($_GET["action"]=="history"){echo " active";} ?>">
|
||||
<i class="nav-icon bi bi-graph-up"></i>
|
||||
<p>Historie</p>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="?action=weather" class="nav-link <?php if($_GET["action"]=="weather"){echo " active";} ?>">
|
||||
<i class="nav-icon bi bi-cloud-sun"></i>
|
||||
<p>Wetter</p>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="?action=skoda" class="nav-link <?php if($_GET["action"]=="skoda"){echo " active";} ?>">
|
||||
<i class="nav-icon bi bi-ev-front"></i>
|
||||
<p>Škoda</p>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item menu-open">
|
||||
<a href="#" class="nav-link active">
|
||||
<i class="nav-icon bi bi-house"></i>
|
||||
<p>
|
||||
Home
|
||||
<i class="nav-arrow bi bi-chevron-right"></i>
|
||||
</p>
|
||||
</a>
|
||||
<!-- Nur Etagen mit Grundriss: ein Menuepunkt auf das
|
||||
Aussengelaende fuehrte auf eine leere Zeichenflaeche.
|
||||
Bekommt dessen erster Raum eine Kachel, steht es hier
|
||||
von selbst. -->
|
||||
<ul class="nav nav-treeview ps-4">
|
||||
<?php foreach (floorsWithPlan() as $etage): ?>
|
||||
<li class="nav-item">
|
||||
<a href="?action=home&floor=<?= $etage ?>" title="<?= htmlspecialchars(floorLabel($etage)) ?>"
|
||||
class="nav-link <?php if($_GET["action"]=="home" && $_GET["floor"] == $etage){echo " active";} ?>">
|
||||
<p> <?= $etage ?></p>
|
||||
</a>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</li>
|
||||
<!-- Einstellungen stehen fuer sich: kein Dashboard,
|
||||
sondern die Parameter dahinter. Genauso die
|
||||
Protokolle - sie zeigen nicht das Haus, sondern
|
||||
die Prozesse, die es steuern. -->
|
||||
<li class="nav-item">
|
||||
<a href="?action=settings" class="nav-link<?php if($_GET["action"]=="settings"){echo " active";} ?>">
|
||||
<i class="nav-icon bi bi-gear"></i>
|
||||
<p>Einstellungen</p>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="?action=logs" class="nav-link<?php if($_GET["action"]=="logs"){echo " active";} ?>">
|
||||
<i class="nav-icon bi bi-journal-text"></i>
|
||||
<p>Protokolle</p>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<!--end::Sidebar Menu-->
|
||||
</nav>
|
||||
</div>
|
||||
<!--end::Sidebar Wrapper-->
|
||||
</aside>
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<!--begin::Head-->
|
||||
<?php
|
||||
// Die Raumtabelle traegt die Etagen - das Menue baut daraus seine Punkte.
|
||||
// Hier und nicht erst in home.php: der Kopf wird zuerst eingebunden.
|
||||
require_once(__DIR__ . "/rooms.php");
|
||||
|
||||
// $_GET["action"] ist von index.php bereits auf eine gueltige Seite gesetzt.
|
||||
if (!isset($_GET["floor"]))
|
||||
$_GET["floor"] = "OG";
|
||||
?>
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<title>Smarthome control</title>
|
||||
<!--begin::Accessibility Meta Tags-->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="theme-color" content="#007bff" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#1a1a1a" media="(prefers-color-scheme: dark)" />
|
||||
<link rel="icon" type="image/png" href="assets/img/favicon.png">
|
||||
<!--end::Accessibility Meta Tags-->
|
||||
|
||||
<!--begin::Primary Meta Tags-->
|
||||
<meta name="title" content="Smarthome control" />
|
||||
<meta name="author" content="ColorlibHQ" />
|
||||
<meta name="description" content="Smarthome control panel by m0." />
|
||||
<meta name="keywords" content="smarthome dashboard, admin panel" />
|
||||
<!--end::Primary Meta Tags-->
|
||||
|
||||
<!--begin::Accessibility Features-->
|
||||
<!-- Skip links will be dynamically added by accessibility.js -->
|
||||
<meta name="supported-color-schemes" content="light dark" />
|
||||
<link rel="preload" href="./css/adminlte.min.css?v=2" as="style" />
|
||||
<link rel="stylesheet" href="./css/solar.css?v=<?= filemtime("css/solar.css") ?>" />
|
||||
<!--end::Accessibility Features-->
|
||||
|
||||
<!--begin::Fonts-->
|
||||
<link rel="stylesheet" href="assets/fonts/font_poppins.css" media="print" onload="this.media='all'" />
|
||||
<!--end::Fonts-->
|
||||
|
||||
<!--begin::Third Party Plugin(Bootstrap Icons)-->
|
||||
<link rel="stylesheet" href="css/bootstrap-icons.min.css" />
|
||||
<!--end::Third Party Plugin(Bootstrap Icons)-->
|
||||
|
||||
<!--begin::Required Plugin(AdminLTE)-->
|
||||
<link rel="stylesheet" href="./css/adminlte.min.css?v=2" />
|
||||
<!--end::Required Plugin(AdminLTE)-->
|
||||
|
||||
<!--begin::Karte (Leaflet) - nur auf Seiten mit Karte-->
|
||||
<?php if ($page["leaflet"]): ?>
|
||||
<link rel="stylesheet" href="css/leaflet.css" />
|
||||
<?php endif; ?>
|
||||
<!--end::Karte-->
|
||||
|
||||
|
||||
</head>
|
||||
<!--end::Head-->
|
||||
<!--begin::Body-->
|
||||
|
||||
<body class="layout-fixed sidebar-expand-lg bg-body-tertiary" data-bs-theme="dark">
|
||||
<!--begin::App Wrapper-->
|
||||
<div class="app-wrapper">
|
||||
<!--begin::Header-->
|
||||
<nav class="app-header navbar navbar-expand bg-body">
|
||||
<!--begin::Container-->
|
||||
<div class="container-fluid">
|
||||
<!--begin::Start Navbar Links-->
|
||||
<ul class="navbar-nav">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" data-lte-toggle="sidebar" href="#" role="button">
|
||||
<i class="bi bi-list"></i>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<!--end::Start Navbar Links-->
|
||||
|
||||
<!--begin::End Navbar Links-->
|
||||
<ul class="navbar-nav ms-auto">
|
||||
<!--begin::Fullscreen Toggle-->
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="#" data-lte-toggle="fullscreen">
|
||||
<i data-lte-icon="maximize" class="bi bi-arrows-fullscreen"></i>
|
||||
<i data-lte-icon="minimize" class="bi bi-fullscreen-exit" style="display: none"></i>
|
||||
</a>
|
||||
</li>
|
||||
<?php
|
||||
if ($_SESSION["local"] == true)
|
||||
echo "<li class='nav-item'><a class='nav-link' href='addUser.php'><i data-lte-icon='Add user' class='bi bi-person-add'></i></a></li>";
|
||||
?>
|
||||
<!--end::Fullscreen Toggle-->
|
||||
</ul>
|
||||
<!--end::End Navbar Links-->
|
||||
</div>
|
||||
<!--end::Container-->
|
||||
</nav>
|
||||
<!--end::Header-->
|
||||
<!--begin::Sidebar-->
|
||||
<aside class="app-sidebar bg-body-secondary shadow">
|
||||
<!--begin::Sidebar Brand-->
|
||||
<div class="sidebar-brand">
|
||||
<!--begin::Brand Link-->
|
||||
<a href="./index.php" class="brand-link">
|
||||
<!--begin::Brand Image-->
|
||||
<img src="./assets/img/AdminLTELogo.png" alt="Logo"
|
||||
class="brand-image opacity-75 shadow" />
|
||||
<!--end::Brand Image-->
|
||||
<!--begin::Brand Text-->
|
||||
<span class="brand-text fw-light">Smart controller</span>
|
||||
<!--end::Brand Text-->
|
||||
</a>
|
||||
<!--end::Brand Link-->
|
||||
</div>
|
||||
<!--end::Sidebar Brand-->
|
||||
<!--begin::Sidebar Wrapper-->
|
||||
<div class="sidebar-wrapper">
|
||||
<nav class="mt-2">
|
||||
<!--begin::Sidebar Menu-->
|
||||
<ul class="nav sidebar-menu flex-column" data-lte-toggle="treeview" role="navigation"
|
||||
aria-label="Main navigation" data-accordion="false" id="navigation">
|
||||
<li class="nav-item menu-open">
|
||||
<a href="#" class="nav-link active">
|
||||
<i class="nav-icon bi bi-speedometer"></i>
|
||||
<p>
|
||||
Dashboards
|
||||
<i class="nav-arrow bi bi-chevron-right"></i>
|
||||
</p>
|
||||
</a>
|
||||
<ul class="nav nav-treeview ps-4">
|
||||
<li class="nav-item">
|
||||
<a href="?action=solar" class="nav-link<?php if($_GET["action"]=="solar"){echo " active";} ?>">
|
||||
<i class="nav-icon bi bi-brightness-alt-high"></i>
|
||||
<p>Solar</p>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="?action=heat" class="nav-link <?php if($_GET["action"]=="heat"){echo " active";} ?>">
|
||||
<i class="nav-icon bi bi-fire"></i>
|
||||
<p>Heizung</p>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="?action=history" class="nav-link <?php if($_GET["action"]=="history"){echo " active";} ?>">
|
||||
<i class="nav-icon bi bi-graph-up"></i>
|
||||
<p>Historie</p>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="?action=weather" class="nav-link <?php if($_GET["action"]=="weather"){echo " active";} ?>">
|
||||
<i class="nav-icon bi bi-cloud-sun"></i>
|
||||
<p>Wetter</p>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="?action=skoda" class="nav-link <?php if($_GET["action"]=="skoda"){echo " active";} ?>">
|
||||
<i class="nav-icon bi bi-ev-front"></i>
|
||||
<p>Škoda</p>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item menu-open">
|
||||
<a href="#" class="nav-link active">
|
||||
<i class="nav-icon bi bi-house"></i>
|
||||
<p>
|
||||
Home
|
||||
<i class="nav-arrow bi bi-chevron-right"></i>
|
||||
</p>
|
||||
</a>
|
||||
<!-- Nur Etagen mit Grundriss: ein Menuepunkt auf das
|
||||
Aussengelaende fuehrte auf eine leere Zeichenflaeche.
|
||||
Bekommt dessen erster Raum eine Kachel, steht es hier
|
||||
von selbst. -->
|
||||
<ul class="nav nav-treeview ps-4">
|
||||
<?php foreach (floorsWithPlan() as $etage): ?>
|
||||
<li class="nav-item">
|
||||
<a href="?action=home&floor=<?= $etage ?>" title="<?= htmlspecialchars(floorLabel($etage)) ?>"
|
||||
class="nav-link <?php if($_GET["action"]=="home" && $_GET["floor"] == $etage){echo " active";} ?>">
|
||||
<p> <?= $etage ?></p>
|
||||
</a>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</li>
|
||||
<!-- Einstellungen stehen fuer sich: kein Dashboard,
|
||||
sondern die Parameter dahinter. Genauso die
|
||||
Protokolle - sie zeigen nicht das Haus, sondern
|
||||
die Prozesse, die es steuern. -->
|
||||
<li class="nav-item">
|
||||
<a href="?action=settings" class="nav-link<?php if($_GET["action"]=="settings"){echo " active";} ?>">
|
||||
<i class="nav-icon bi bi-gear"></i>
|
||||
<p>Einstellungen</p>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="?action=logs" class="nav-link<?php if($_GET["action"]=="logs"){echo " active";} ?>">
|
||||
<i class="nav-icon bi bi-journal-text"></i>
|
||||
<p>Protokolle</p>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<!--end::Sidebar Menu-->
|
||||
</nav>
|
||||
</div>
|
||||
<!--end::Sidebar Wrapper-->
|
||||
</aside>
|
||||
<!--end::Sidebar-->
|
||||
+1572
-1572
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+235
-235
@@ -1,235 +1,235 @@
|
||||
<?php require_once(__DIR__ . "/rooms.php"); ?>
|
||||
<!--begin::App Main-->
|
||||
<main class="app-main">
|
||||
<!--begin::App Content Header-->
|
||||
<div class="app-content-header">
|
||||
<!--begin::Container-->
|
||||
<div class="container-fluid">
|
||||
<!--begin::Row-->
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<h3 class="mb-0">Home</h3>
|
||||
</div>
|
||||
</div>
|
||||
<!--end::Row-->
|
||||
</div>
|
||||
<!--end::Container-->
|
||||
</div>
|
||||
<!--end::App Content Header-->
|
||||
<!--begin::App Content-->
|
||||
<div class="app-content">
|
||||
<!--begin::Container-->
|
||||
<div class="container-fluid">
|
||||
<!--begin::Row-->
|
||||
<div class="row">
|
||||
<!-- Start col -->
|
||||
<div class="col-xxl-12 col-xl-12 col-lg-12 col-md-12 d-flex align-items-stretch">
|
||||
<div class="card p-0 mb-4" style="width:100%">
|
||||
<div class="card-header pb-0 pt-1">
|
||||
<h3 class="card-title">Home</h3>
|
||||
<div class="card-tools">
|
||||
<button type="button" class="btn btn-tool" data-lte-toggle="card-remove">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body pb-1">
|
||||
<div lass="img-overlay-wrap">
|
||||
<svg viewBox="0 0 400 300" width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">
|
||||
|
||||
<defs>
|
||||
<g id="floorBtn">
|
||||
<path fill="transparent" stroke-width="4"
|
||||
d="M 99.86386438745993 22.00011880076356 A 50 50 0 1 0 100 22" />
|
||||
</g>
|
||||
<g id="heater" filter="url(#shadow)" transform="translate(1.5,1),scale(0.2,0.2)" fill="#993311" stroke="#FF2211">
|
||||
<path clip-path="M-22 2.24h42V22h-42z"
|
||||
d="M16.543 8.028c-.023 1.503-.523 3.538-2.867 4.327.734-1.746.846-3.417.326-4.979-.695-2.097-3.014-3.735-4.557-4.627-.527-.306-1.203.074-1.193.683.02 1.112-.318 2.737-1.959 4.378C4.107 9.994 3 12.251 3 14.517 3 17.362 5 21 9 21c-4.041-4.041-1-7.483-1-7.483C8.846 19.431 12.988 21 15 21c1.711 0 5-1.25 5-6.448 0-3.133-1.332-5.511-2.385-6.899-.347-.458-1.064-.198-1.072.375" />
|
||||
</g>
|
||||
<g id="buffer" filter="url(#shadow)" transform="translate(18,1),scale(0.3,0.3)" fill="#ffffff">
|
||||
<path d="M5 12.5a1.5 1.5 0 1 1-2-1.415V2.5a.5.5 0 0 1 1 0v8.585A1.5 1.5 0 0 1 5 12.5"/>
|
||||
<path d="M1 2.5a2.5 2.5 0 0 1 5 0v7.55a3.5 3.5 0 1 1-5 0zM3.5 1A1.5 1.5 0 0 0 2 2.5v7.987l-.167.15a2.5 2.5 0 1 0 3.333 0L5 10.486V2.5A1.5 1.5 0 0 0 3.5 1m5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-1 0v-1a.5.5 0 0 1 .5-.5m4.243 1.757a.5.5 0 0 1 0 .707l-.707.708a.5.5 0 1 1-.708-.708l.708-.707a.5.5 0 0 1 .707 0M8 5.5a.5.5 0 0 1 .5-.5 3 3 0 1 1 0 6 .5.5 0 0 1 0-1 2 2 0 0 0 0-4 .5.5 0 0 1-.5-.5M12.5 8a.5.5 0 0 1 .5-.5h1a.5.5 0 1 1 0 1h-1a.5.5 0 0 1-.5-.5m-1.172 2.828a.5.5 0 0 1 .708 0l.707.708a.5.5 0 0 1-.707.707l-.708-.707a.5.5 0 0 1 0-.708M8.5 12a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-1 0v-1a.5.5 0 0 1 .5-.5"/>
|
||||
</g>
|
||||
</defs>
|
||||
<g>
|
||||
<?php
|
||||
/*
|
||||
* Ein Bild je Etage, dazu fuer jede Etage eine Blende.
|
||||
* Ausgeschrieben waren das bei drei Etagen neun Zeilen
|
||||
* und bei vier schon sechzehn - und jede neue Etage
|
||||
* haette alle anderen mit angefasst.
|
||||
*
|
||||
* floorsWithPlan() liefert nur Etagen, deren Raeume
|
||||
* Koordinaten haben. Eine Etage ohne angeklickte
|
||||
* Kachelpositionen taucht hier also gar nicht erst auf.
|
||||
*/
|
||||
$etagen = floorsWithPlan();
|
||||
?>
|
||||
<?php /* Von unten nach oben gestapelt, wie bisher: waehrend der Blende
|
||||
sind zwei Bilder gleichzeitig halb sichtbar, und dann soll das
|
||||
obere Geschoss obenauf liegen. */ ?>
|
||||
<?php foreach (array_reverse($etagen) as $etage): ?>
|
||||
<g id="<?= $etage ?>img" <?php if ($_GET["floor"] != $etage) echo "opacity='0'"; ?> >
|
||||
<?php foreach ($etagen as $andere): ?>
|
||||
<animate begin="<?= $andere ?>.click" attributetype="CSS" attributeName="opacity"
|
||||
to=<?= $andere === $etage ? "1.0" : "0.0" ?> dur="0.7s"
|
||||
repeatCount="1" calcMode="spline" keyTimes="0;1" keySplines=".1,0,.27,1" fill="freeze" />
|
||||
<?php endforeach; ?>
|
||||
<image href="assets/img/<?= $etage ?>.png" height="100%" width="100%" x=0 y=0 />
|
||||
</g>
|
||||
<?php endforeach; ?>
|
||||
<?php
|
||||
/*
|
||||
* Der Kreis ist 50 hoch und sitzt 11 unter der Oberkante
|
||||
* seiner Gruppe. Bei drei Etagen bleibt es bei den
|
||||
* gewohnten 100 Einheiten Abstand; ab der vierten ruecken
|
||||
* sie zusammen, sonst faellt die unterste aus der 300
|
||||
* hohen viewBox heraus.
|
||||
*/
|
||||
$abstand = count($etagen) > 1
|
||||
? min(100, (295 - 61) / (count($etagen) - 1))
|
||||
: 0;
|
||||
// Von Hand mittig gerueckt, je nach Breite der Buchstaben.
|
||||
$textX = ["OG" => 62, "EG" => 64, "UG" => 63, "AG" => 62];
|
||||
?>
|
||||
<?php foreach ($etagen as $i => $etage): ?>
|
||||
<g id="<?= $etage ?>" onclick="switchFloor('<?= $etage ?>')" cursor="pointer"
|
||||
transform="translate(0,<?= round($i * $abstand, 1) ?>), scale(0.50,0.50)">
|
||||
<animate id="show<?= $etage ?>" begin=click attributetype="CSS" attributeName="opacity"
|
||||
from=0 to=1 dur="0.1s" repeatCount="1" calcMode="spline" keyTimes="0;1"
|
||||
keySplines=".1,0,.27,1" fill="freeze"></animate>
|
||||
<use href="#floorBtn" stroke="#aa7713" />
|
||||
<text fill="#aa7713" style="font: 3em sans-serif;"
|
||||
transform="translate(<?= $textX[$etage] ?? 62 ?>,90)"><?= $etage ?></text>
|
||||
</g>
|
||||
<?php endforeach; ?>
|
||||
<?php foreach (floorsWithPlan() as $floor): ?>
|
||||
<g id="<?= $floor ?>_Info" <?php if ($floor != "OG") echo 'opacity="0" '; ?><?php if($_GET["floor"] != $floor){echo "display='none'";} ?> >
|
||||
<?php foreach (array_map("mitWerten", roomsOnFloor($floor)) as $room): ?>
|
||||
<?php
|
||||
/*
|
||||
* Grundlinien je nach Anzahl der Werte. Die Kachel ist 19 hoch; mit
|
||||
* drei Werten sind es dieselben Zeilen wie eh und je (klein, gross,
|
||||
* klein), mit weniger ruecken sie in die Mitte.
|
||||
*/
|
||||
$zeilen = [1 => [12], 2 => [9, 16], 3 => [5, 11.5, 16.5]];
|
||||
$y = $zeilen[count($room["werte"])] ?? [];
|
||||
?>
|
||||
<g transform="translate(<?= $room["x"] ?>,<?= $room["y"] ?>)" onclick="openHeaterSettings('<?= $floor ?>_<?= $room["mqtt"] ?>');" cursor="pointer">
|
||||
<rect width="24" height="19" rx="5" fill="#0006" />
|
||||
<?php if ($room["heizung"]): ?>
|
||||
<use id="<?= $floor ?>_<?= $room["id"] ?>_heater" href="#heater" display="none" />
|
||||
<use id="<?= $floor ?>_<?= $room["id"] ?>_buffer" href="#buffer" display="none" />
|
||||
<?php endif; ?>
|
||||
<text fill="#fff" style="font: 4px sans-serif;">
|
||||
<?php if (!$room["werte"]): ?>
|
||||
<?php
|
||||
/*
|
||||
* Kein Messwert: die Kachel traegt ihren Namen und oeffnet den Raum.
|
||||
* Ein leeres Rechteck saehe aus wie ein Fehler.
|
||||
*
|
||||
* Lange Namen brechen am letzten Leerzeichen um. Die Kachel ist 24
|
||||
* Einheiten breit, und "Terrasse OG" stand einzeilig links und rechts
|
||||
* darueber hinaus. Aufgefallen ist das erst mit dem Aussengelaende:
|
||||
* dort besteht jeder Raumname aus zwei Woertern.
|
||||
*/
|
||||
$luecke = strrpos($room["mqtt"], " ");
|
||||
$teile = (strlen($room["mqtt"]) > 8 && $luecke !== false)
|
||||
? [substr($room["mqtt"], 0, $luecke), substr($room["mqtt"], $luecke + 1)]
|
||||
: [$room["mqtt"]];
|
||||
$namenszeilen = count($teile) === 2 ? [9, 15.5] : [11.5];
|
||||
?>
|
||||
<?php foreach ($teile as $i => $teil): ?>
|
||||
<tspan x="12" y="<?= $namenszeilen[$i] ?>" font-size="1.2em" text-anchor="middle"><?= htmlspecialchars($teil) ?></tspan>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($room["werte"] as $i => $wert): ?>
|
||||
<!-- Der Platzhalter ist nur ein Strich: bevor der
|
||||
erste Wert da ist, soll hier keine Einheit
|
||||
stehen, die vielleicht gar nicht passt. -->
|
||||
<tspan id="<?= $floor ?>_w<?= $i ?>_<?= $room["id"] ?>" x="12" y="<?= $y[$i] ?>"
|
||||
<?= empty($wert["gross"]) ? "" : 'font-size="1.6em"' ?> text-anchor="middle">–</tspan>
|
||||
<?php endforeach; ?>
|
||||
</text>
|
||||
</g>
|
||||
<?php endforeach; ?>
|
||||
</g>
|
||||
<?php endforeach; ?>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- /.card -->
|
||||
</div>
|
||||
<div class="col-xxl-12 col-xl-12 col-lg-12 col-md-12 d-flex align-items-stretch">
|
||||
<div class="card p-0 mb-4" style="width:100%">
|
||||
<div class="card-header pb-0 pt-1">
|
||||
<h3 class="card-title">Automatismen</h3>
|
||||
<div class="card-tools">
|
||||
<!-- Zuordnung Geraet -> Raum: sie ordnet die Geraeteliste im
|
||||
Editor nach Raeumen statt nach Geraeteart. -->
|
||||
<button type="button" class="btn btn-tool" title="Geräte den Räumen zuordnen"
|
||||
onclick="openRoomModal()">
|
||||
<i class="bi bi-house-gear"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-tool" data-lte-toggle="card-remove">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="card-body pb-1">
|
||||
<div class="card-header p-0 pt-1">
|
||||
<ul class="nav nav-tabs" id="actions-tab" role="tablist">
|
||||
<?php foreach ($floors as $etage): $offen = $_GET["floor"] == $etage; ?>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <?php if ($offen) echo "active"; ?>" id="actions-<?= $etage ?>-tab"
|
||||
href="#actions-<?= $etage ?>" onclick="switchTab('actions-<?= $etage ?>')"
|
||||
title="<?= htmlspecialchars(floorLabel($etage)) ?>"
|
||||
aria-controls="actions-<?= $etage ?>" aria-selected="<?= $offen ? "true" : "false" ?>"><?= $etage ?></a>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="tab-content" id="actions-tabContent">
|
||||
<?php foreach ($floors as $etage): ?>
|
||||
<div class="tab-pane fade <?php if ($_GET["floor"] == $etage) echo "active show"; ?>"
|
||||
id="actions-<?= $etage ?>" role="tabpanel" aria-labelledby="actions-<?= $etage ?>-tab">
|
||||
<!-- Inhalt kommt aus ajax/AutoAction.php?action=list, gefuellt von
|
||||
refreshAutomations() in js/solar/autoActionFuncs.js -->
|
||||
<div id="actions-<?= $etage ?>-list">Wird geladen...</div>
|
||||
<button type="button" class="btn btn-success" title="Neue Automatik"
|
||||
onclick="openAutoActionModal('?action=editor&floor=<?= $etage ?>')"><i class="bi bi-plus-square"></i></button>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- /.Start col -->
|
||||
</div>
|
||||
|
||||
<!-- /.row (main row) -->
|
||||
</div>
|
||||
<!--end::Container-->
|
||||
</div>
|
||||
<!--end::App Content-->
|
||||
</main>
|
||||
<!--end::App Main-->
|
||||
|
||||
<script>
|
||||
// Raumtabelle aus restricted/rooms.php - homeMQTT.js aktualisiert darueber
|
||||
// die SVG-Kacheln, ohne die Raeume noch einmal aufzufuehren. In "werte"
|
||||
// steht, welche Messwerte eine Kachel zeigt; homeTopics sind die Zweige,
|
||||
// die der Browser dafuer beim Broker anmelden muss.
|
||||
const homeRooms = <?= json_encode(roomsWithTile(), JSON_UNESCAPED_UNICODE) ?>;
|
||||
const homeTopics = <?= json_encode(tileTopics(), JSON_UNESCAPED_UNICODE) ?>;
|
||||
<?php if ($_GET["floor"] != "OG" && in_array($_GET["floor"], floorsWithPlan())): ?>
|
||||
document.addEventListener('readystatechange', function () {
|
||||
if (event.target.readyState === 'complete') {
|
||||
switchFloor('<?= $_GET["floor"] ?>');
|
||||
}
|
||||
});
|
||||
<?php endif; ?>
|
||||
</script>
|
||||
<?php require_once(__DIR__ . "/rooms.php"); ?>
|
||||
<!--begin::App Main-->
|
||||
<main class="app-main">
|
||||
<!--begin::App Content Header-->
|
||||
<div class="app-content-header">
|
||||
<!--begin::Container-->
|
||||
<div class="container-fluid">
|
||||
<!--begin::Row-->
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<h3 class="mb-0">Home</h3>
|
||||
</div>
|
||||
</div>
|
||||
<!--end::Row-->
|
||||
</div>
|
||||
<!--end::Container-->
|
||||
</div>
|
||||
<!--end::App Content Header-->
|
||||
<!--begin::App Content-->
|
||||
<div class="app-content">
|
||||
<!--begin::Container-->
|
||||
<div class="container-fluid">
|
||||
<!--begin::Row-->
|
||||
<div class="row">
|
||||
<!-- Start col -->
|
||||
<div class="col-xxl-12 col-xl-12 col-lg-12 col-md-12 d-flex align-items-stretch">
|
||||
<div class="card p-0 mb-4" style="width:100%">
|
||||
<div class="card-header pb-0 pt-1">
|
||||
<h3 class="card-title">Home</h3>
|
||||
<div class="card-tools">
|
||||
<button type="button" class="btn btn-tool" data-lte-toggle="card-remove">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body pb-1">
|
||||
<div lass="img-overlay-wrap">
|
||||
<svg viewBox="0 0 400 300" width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">
|
||||
|
||||
<defs>
|
||||
<g id="floorBtn">
|
||||
<path fill="transparent" stroke-width="4"
|
||||
d="M 99.86386438745993 22.00011880076356 A 50 50 0 1 0 100 22" />
|
||||
</g>
|
||||
<g id="heater" filter="url(#shadow)" transform="translate(1.5,1),scale(0.2,0.2)" fill="#993311" stroke="#FF2211">
|
||||
<path clip-path="M-22 2.24h42V22h-42z"
|
||||
d="M16.543 8.028c-.023 1.503-.523 3.538-2.867 4.327.734-1.746.846-3.417.326-4.979-.695-2.097-3.014-3.735-4.557-4.627-.527-.306-1.203.074-1.193.683.02 1.112-.318 2.737-1.959 4.378C4.107 9.994 3 12.251 3 14.517 3 17.362 5 21 9 21c-4.041-4.041-1-7.483-1-7.483C8.846 19.431 12.988 21 15 21c1.711 0 5-1.25 5-6.448 0-3.133-1.332-5.511-2.385-6.899-.347-.458-1.064-.198-1.072.375" />
|
||||
</g>
|
||||
<g id="buffer" filter="url(#shadow)" transform="translate(18,1),scale(0.3,0.3)" fill="#ffffff">
|
||||
<path d="M5 12.5a1.5 1.5 0 1 1-2-1.415V2.5a.5.5 0 0 1 1 0v8.585A1.5 1.5 0 0 1 5 12.5"/>
|
||||
<path d="M1 2.5a2.5 2.5 0 0 1 5 0v7.55a3.5 3.5 0 1 1-5 0zM3.5 1A1.5 1.5 0 0 0 2 2.5v7.987l-.167.15a2.5 2.5 0 1 0 3.333 0L5 10.486V2.5A1.5 1.5 0 0 0 3.5 1m5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-1 0v-1a.5.5 0 0 1 .5-.5m4.243 1.757a.5.5 0 0 1 0 .707l-.707.708a.5.5 0 1 1-.708-.708l.708-.707a.5.5 0 0 1 .707 0M8 5.5a.5.5 0 0 1 .5-.5 3 3 0 1 1 0 6 .5.5 0 0 1 0-1 2 2 0 0 0 0-4 .5.5 0 0 1-.5-.5M12.5 8a.5.5 0 0 1 .5-.5h1a.5.5 0 1 1 0 1h-1a.5.5 0 0 1-.5-.5m-1.172 2.828a.5.5 0 0 1 .708 0l.707.708a.5.5 0 0 1-.707.707l-.708-.707a.5.5 0 0 1 0-.708M8.5 12a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-1 0v-1a.5.5 0 0 1 .5-.5"/>
|
||||
</g>
|
||||
</defs>
|
||||
<g>
|
||||
<?php
|
||||
/*
|
||||
* Ein Bild je Etage, dazu fuer jede Etage eine Blende.
|
||||
* Ausgeschrieben waren das bei drei Etagen neun Zeilen
|
||||
* und bei vier schon sechzehn - und jede neue Etage
|
||||
* haette alle anderen mit angefasst.
|
||||
*
|
||||
* floorsWithPlan() liefert nur Etagen, deren Raeume
|
||||
* Koordinaten haben. Eine Etage ohne angeklickte
|
||||
* Kachelpositionen taucht hier also gar nicht erst auf.
|
||||
*/
|
||||
$etagen = floorsWithPlan();
|
||||
?>
|
||||
<?php /* Von unten nach oben gestapelt, wie bisher: waehrend der Blende
|
||||
sind zwei Bilder gleichzeitig halb sichtbar, und dann soll das
|
||||
obere Geschoss obenauf liegen. */ ?>
|
||||
<?php foreach (array_reverse($etagen) as $etage): ?>
|
||||
<g id="<?= $etage ?>img" <?php if ($_GET["floor"] != $etage) echo "opacity='0'"; ?> >
|
||||
<?php foreach ($etagen as $andere): ?>
|
||||
<animate begin="<?= $andere ?>.click" attributetype="CSS" attributeName="opacity"
|
||||
to=<?= $andere === $etage ? "1.0" : "0.0" ?> dur="0.7s"
|
||||
repeatCount="1" calcMode="spline" keyTimes="0;1" keySplines=".1,0,.27,1" fill="freeze" />
|
||||
<?php endforeach; ?>
|
||||
<image href="assets/img/<?= $etage ?>.png" height="100%" width="100%" x=0 y=0 />
|
||||
</g>
|
||||
<?php endforeach; ?>
|
||||
<?php
|
||||
/*
|
||||
* Der Kreis ist 50 hoch und sitzt 11 unter der Oberkante
|
||||
* seiner Gruppe. Bei drei Etagen bleibt es bei den
|
||||
* gewohnten 100 Einheiten Abstand; ab der vierten ruecken
|
||||
* sie zusammen, sonst faellt die unterste aus der 300
|
||||
* hohen viewBox heraus.
|
||||
*/
|
||||
$abstand = count($etagen) > 1
|
||||
? min(100, (295 - 61) / (count($etagen) - 1))
|
||||
: 0;
|
||||
// Von Hand mittig gerueckt, je nach Breite der Buchstaben.
|
||||
$textX = ["OG" => 62, "EG" => 64, "UG" => 63, "AG" => 62];
|
||||
?>
|
||||
<?php foreach ($etagen as $i => $etage): ?>
|
||||
<g id="<?= $etage ?>" onclick="switchFloor('<?= $etage ?>')" cursor="pointer"
|
||||
transform="translate(0,<?= round($i * $abstand, 1) ?>), scale(0.50,0.50)">
|
||||
<animate id="show<?= $etage ?>" begin=click attributetype="CSS" attributeName="opacity"
|
||||
from=0 to=1 dur="0.1s" repeatCount="1" calcMode="spline" keyTimes="0;1"
|
||||
keySplines=".1,0,.27,1" fill="freeze"></animate>
|
||||
<use href="#floorBtn" stroke="#aa7713" />
|
||||
<text fill="#aa7713" style="font: 3em sans-serif;"
|
||||
transform="translate(<?= $textX[$etage] ?? 62 ?>,90)"><?= $etage ?></text>
|
||||
</g>
|
||||
<?php endforeach; ?>
|
||||
<?php foreach (floorsWithPlan() as $floor): ?>
|
||||
<g id="<?= $floor ?>_Info" <?php if ($floor != "OG") echo 'opacity="0" '; ?><?php if($_GET["floor"] != $floor){echo "display='none'";} ?> >
|
||||
<?php foreach (array_map("mitWerten", roomsOnFloor($floor)) as $room): ?>
|
||||
<?php
|
||||
/*
|
||||
* Grundlinien je nach Anzahl der Werte. Die Kachel ist 19 hoch; mit
|
||||
* drei Werten sind es dieselben Zeilen wie eh und je (klein, gross,
|
||||
* klein), mit weniger ruecken sie in die Mitte.
|
||||
*/
|
||||
$zeilen = [1 => [12], 2 => [9, 16], 3 => [5, 11.5, 16.5]];
|
||||
$y = $zeilen[count($room["werte"])] ?? [];
|
||||
?>
|
||||
<g transform="translate(<?= $room["x"] ?>,<?= $room["y"] ?>)" onclick="openHeaterSettings('<?= $floor ?>_<?= $room["mqtt"] ?>');" cursor="pointer">
|
||||
<rect width="24" height="19" rx="5" fill="#0006" />
|
||||
<?php if ($room["heizung"]): ?>
|
||||
<use id="<?= $floor ?>_<?= $room["id"] ?>_heater" href="#heater" display="none" />
|
||||
<use id="<?= $floor ?>_<?= $room["id"] ?>_buffer" href="#buffer" display="none" />
|
||||
<?php endif; ?>
|
||||
<text fill="#fff" style="font: 4px sans-serif;">
|
||||
<?php if (!$room["werte"]): ?>
|
||||
<?php
|
||||
/*
|
||||
* Kein Messwert: die Kachel traegt ihren Namen und oeffnet den Raum.
|
||||
* Ein leeres Rechteck saehe aus wie ein Fehler.
|
||||
*
|
||||
* Lange Namen brechen am letzten Leerzeichen um. Die Kachel ist 24
|
||||
* Einheiten breit, und "Terrasse OG" stand einzeilig links und rechts
|
||||
* darueber hinaus. Aufgefallen ist das erst mit dem Aussengelaende:
|
||||
* dort besteht jeder Raumname aus zwei Woertern.
|
||||
*/
|
||||
$luecke = strrpos($room["mqtt"], " ");
|
||||
$teile = (strlen($room["mqtt"]) > 8 && $luecke !== false)
|
||||
? [substr($room["mqtt"], 0, $luecke), substr($room["mqtt"], $luecke + 1)]
|
||||
: [$room["mqtt"]];
|
||||
$namenszeilen = count($teile) === 2 ? [9, 15.5] : [11.5];
|
||||
?>
|
||||
<?php foreach ($teile as $i => $teil): ?>
|
||||
<tspan x="12" y="<?= $namenszeilen[$i] ?>" font-size="1.2em" text-anchor="middle"><?= htmlspecialchars($teil) ?></tspan>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($room["werte"] as $i => $wert): ?>
|
||||
<!-- Der Platzhalter ist nur ein Strich: bevor der
|
||||
erste Wert da ist, soll hier keine Einheit
|
||||
stehen, die vielleicht gar nicht passt. -->
|
||||
<tspan id="<?= $floor ?>_w<?= $i ?>_<?= $room["id"] ?>" x="12" y="<?= $y[$i] ?>"
|
||||
<?= empty($wert["gross"]) ? "" : 'font-size="1.6em"' ?> text-anchor="middle">–</tspan>
|
||||
<?php endforeach; ?>
|
||||
</text>
|
||||
</g>
|
||||
<?php endforeach; ?>
|
||||
</g>
|
||||
<?php endforeach; ?>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- /.card -->
|
||||
</div>
|
||||
<div class="col-xxl-12 col-xl-12 col-lg-12 col-md-12 d-flex align-items-stretch">
|
||||
<div class="card p-0 mb-4" style="width:100%">
|
||||
<div class="card-header pb-0 pt-1">
|
||||
<h3 class="card-title">Automatismen</h3>
|
||||
<div class="card-tools">
|
||||
<!-- Zuordnung Geraet -> Raum: sie ordnet die Geraeteliste im
|
||||
Editor nach Raeumen statt nach Geraeteart. -->
|
||||
<button type="button" class="btn btn-tool" title="Geräte den Räumen zuordnen"
|
||||
onclick="openRoomModal()">
|
||||
<i class="bi bi-house-gear"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-tool" data-lte-toggle="card-remove">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="card-body pb-1">
|
||||
<div class="card-header p-0 pt-1">
|
||||
<ul class="nav nav-tabs" id="actions-tab" role="tablist">
|
||||
<?php foreach ($floors as $etage): $offen = $_GET["floor"] == $etage; ?>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <?php if ($offen) echo "active"; ?>" id="actions-<?= $etage ?>-tab"
|
||||
href="#actions-<?= $etage ?>" onclick="switchTab('actions-<?= $etage ?>')"
|
||||
title="<?= htmlspecialchars(floorLabel($etage)) ?>"
|
||||
aria-controls="actions-<?= $etage ?>" aria-selected="<?= $offen ? "true" : "false" ?>"><?= $etage ?></a>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="tab-content" id="actions-tabContent">
|
||||
<?php foreach ($floors as $etage): ?>
|
||||
<div class="tab-pane fade <?php if ($_GET["floor"] == $etage) echo "active show"; ?>"
|
||||
id="actions-<?= $etage ?>" role="tabpanel" aria-labelledby="actions-<?= $etage ?>-tab">
|
||||
<!-- Inhalt kommt aus ajax/AutoAction.php?action=list, gefuellt von
|
||||
refreshAutomations() in js/solar/autoActionFuncs.js -->
|
||||
<div id="actions-<?= $etage ?>-list">Wird geladen...</div>
|
||||
<button type="button" class="btn btn-success" title="Neue Automatik"
|
||||
onclick="openAutoActionModal('?action=editor&floor=<?= $etage ?>')"><i class="bi bi-plus-square"></i></button>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- /.Start col -->
|
||||
</div>
|
||||
|
||||
<!-- /.row (main row) -->
|
||||
</div>
|
||||
<!--end::Container-->
|
||||
</div>
|
||||
<!--end::App Content-->
|
||||
</main>
|
||||
<!--end::App Main-->
|
||||
|
||||
<script>
|
||||
// Raumtabelle aus restricted/rooms.php - homeMQTT.js aktualisiert darueber
|
||||
// die SVG-Kacheln, ohne die Raeume noch einmal aufzufuehren. In "werte"
|
||||
// steht, welche Messwerte eine Kachel zeigt; homeTopics sind die Zweige,
|
||||
// die der Browser dafuer beim Broker anmelden muss.
|
||||
const homeRooms = <?= json_encode(roomsWithTile(), JSON_UNESCAPED_UNICODE) ?>;
|
||||
const homeTopics = <?= json_encode(tileTopics(), JSON_UNESCAPED_UNICODE) ?>;
|
||||
<?php if ($_GET["floor"] != "OG" && in_array($_GET["floor"], floorsWithPlan())): ?>
|
||||
document.addEventListener('readystatechange', function () {
|
||||
if (event.target.readyState === 'complete') {
|
||||
switchFloor('<?= $_GET["floor"] ?>');
|
||||
}
|
||||
});
|
||||
<?php endif; ?>
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
//$tahoma_PIN = "2040-3358-7811"; //NEU
|
||||
$tahoma_PIN = "1215-2900-8489"; //CLASSIC
|
||||
//$tahoma_token = "693e87db02b3667c519d"; //NEU
|
||||
$tahoma_token = "67ebd23e3a61763386d9"; //CLASSIC
|
||||
$tahoma_devlist = "../restricted/tahoma_devices_classic.json";
|
||||
<?php
|
||||
//$tahoma_PIN = "2040-3358-7811"; //NEU
|
||||
$tahoma_PIN = "1215-2900-8489"; //CLASSIC
|
||||
//$tahoma_token = "693e87db02b3667c519d"; //NEU
|
||||
$tahoma_token = "67ebd23e3a61763386d9"; //CLASSIC
|
||||
$tahoma_devlist = "../restricted/tahoma_devices_classic.json";
|
||||
?>
|
||||
Reference in New Issue
Block a user