Der go-eCharger schickt sechzehn Zahlen als JSON-Feld auf einem einzigen
Topic, die Wechselrichter und die WLAN-Felder machen es aehnlich. Welcher
Teil der Nutzlast gemeint ist, steht im value_template der
Home-Assistant-Discovery - das Modul las es bisher nur, um den Datentyp zu
raten, und warf es dann weg. Alle sechzehn Messwerte bekamen denselben
Rohtext.
actor_states hat jetzt eine Spalte value_path: ein Pfad in die Nutzlast, in
derselben Schreibweise, die WLED schon benutzt ("[4]", "ssid",
"seg[0].col[0]"). NULL heisst weiterhin: die ganze Nutzlast.
Gelesen wird aus dem Template nur der einfache Fall - ein Zugriff auf
value_json und was danach an Punkten und Klammern folgt. Von den 40
Vorlagen, die hier auf dem Broker liegen, sind 28 genau das. Die uebrigen
zwoelf sind entweder die ganze Nutzlast (dann ist NULL richtig) oder
Werttabellen wie ['Idle','Charging'][value_json|int] - das ist kein Pfad,
sondern eine Uebersetzung von Zahl nach Text, und dort bleibt es beim
bisherigen Verhalten. Kein einziger Fehltreffer: die Werttabellen liefern
sauber None statt eines erfundenen Pfades.
Die Pfad-Auswertung stand schon im WLED-Transport; sie ist jetzt eine
gemeinsame Funktion, die sich beide teilen. MQTT wertet die Nutzlast einmal
je Nachricht aus und verteilt sie danach an alle Messwerte des Topics.
Nachgemessen: die sechzehn nrg-Werte liefern jetzt sechzehn eigene Zahlen
statt sechzehnmal dasselbe, die Leseabdeckung bleibt bei 440 von 441, und
ein weiterer Discovery-Lauf aendert keine Zeile.
Die restlichen geteilten Topics sind kein Fehler: bei den Wechselrichtern
lesen "Limit Persistent" und "Limit NonPersistent" wirklich denselben Wert
und unterscheiden sich nur im Kommando-Topic, und beim Thermostat liest die
Klima-Entity dieselbe Temperatur wie der Sensor.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
731 lines
28 KiB
Python
731 lines
28 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
MQTT/Home Assistant Discovery Integration
|
|
Erweitert das Tahoma Script um MQTT-Geräte via Home Assistant Discovery
|
|
"""
|
|
|
|
import paho.mqtt.client as mqtt
|
|
import json
|
|
import re
|
|
import time
|
|
from typing import Dict, List, Optional
|
|
import logging
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class HomeAssistantDiscovery:
|
|
"""Klasse für Home Assistant MQTT Discovery"""
|
|
|
|
# Bekannte Discovery-Komponenten
|
|
COMPONENTS = [
|
|
'binary_sensor', 'sensor', 'switch', 'light', 'cover',
|
|
'climate', 'fan', 'lock', 'camera', 'vacuum', 'alarm_control_panel',
|
|
'device_tracker', 'number', 'select', 'button', 'text'
|
|
]
|
|
|
|
def __init__(self, broker: str, port: int = 1883, username: str = None,
|
|
password: str = None, discovery_prefix: str = 'homeassistant'):
|
|
"""
|
|
Initialisiert Home Assistant Discovery
|
|
|
|
Args:
|
|
broker: MQTT Broker IP/Hostname
|
|
port: MQTT Port (Standard: 1883)
|
|
username: MQTT Benutzername (optional)
|
|
password: MQTT Passwort (optional)
|
|
discovery_prefix: Discovery Prefix (Standard: 'homeassistant')
|
|
"""
|
|
self.broker = broker
|
|
self.port = port
|
|
self.username = username
|
|
self.password = password
|
|
self.discovery_prefix = discovery_prefix
|
|
self.client = None
|
|
self.discovered_devices = {}
|
|
|
|
def connect(self) -> bool:
|
|
"""
|
|
Verbindet mit dem MQTT Broker
|
|
|
|
Returns:
|
|
True bei Erfolg, False bei Fehler
|
|
"""
|
|
try:
|
|
self.client = mqtt.Client()
|
|
|
|
if self.username and self.password:
|
|
self.client.username_pw_set(self.username, self.password)
|
|
|
|
self.client.on_connect = self._on_connect
|
|
self.client.on_message = self._on_message
|
|
|
|
self.client.connect(self.broker, self.port, 60)
|
|
logger.info(f"Verbunden mit MQTT Broker {self.broker}:{self.port}")
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(f"MQTT Verbindungsfehler: {e}")
|
|
return False
|
|
|
|
def _on_connect(self, client, userdata, flags, rc):
|
|
"""Callback wenn Verbindung hergestellt wurde"""
|
|
if rc == 0:
|
|
logger.info("MQTT Verbindung erfolgreich")
|
|
# Alle Discovery Topics abonnieren mit Wildcard für object_id
|
|
for component in self.COMPONENTS:
|
|
# Unterstützt beide Topic-Formate:
|
|
# homeassistant/component/node_id/config (4 Teile)
|
|
# homeassistant/component/node_id/object_id/config (5 Teile)
|
|
topic = f"{self.discovery_prefix}/{component}/+/+/config"
|
|
client.subscribe(topic)
|
|
logger.debug(f"Abonniert: {topic}")
|
|
# Zusätzlich auch das kürzere Format abonnieren
|
|
topic_short = f"{self.discovery_prefix}/{component}/+/config"
|
|
client.subscribe(topic_short)
|
|
logger.debug(f"Abonniert: {topic_short}")
|
|
else:
|
|
logger.error(f"MQTT Verbindung fehlgeschlagen, Code: {rc}")
|
|
|
|
def _on_message(self, client, userdata, msg):
|
|
"""Callback wenn Nachricht empfangen wurde"""
|
|
try:
|
|
# Topic analysieren - unterstützt beide Formate:
|
|
# homeassistant/component/node_id/config (4 Teile)
|
|
# homeassistant/component/node_id/object_id/config (5 Teile)
|
|
topic_parts = msg.topic.split('/')
|
|
|
|
if topic_parts[-1] != 'config':
|
|
return # Kein Config-Topic
|
|
|
|
if len(topic_parts) == 4:
|
|
# Format: homeassistant/component/node_id/config
|
|
component = topic_parts[1]
|
|
node_id = topic_parts[2]
|
|
object_id = None
|
|
elif len(topic_parts) == 5:
|
|
# Format: homeassistant/component/node_id/object_id/config
|
|
component = topic_parts[1]
|
|
node_id = topic_parts[2]
|
|
object_id = topic_parts[3]
|
|
else:
|
|
logger.debug(f"Unbekanntes Topic-Format: {msg.topic}")
|
|
return
|
|
|
|
# Payload parsen
|
|
if msg.payload:
|
|
config = json.loads(msg.payload.decode('utf-8'))
|
|
|
|
# Eindeutigen Key erstellen
|
|
if object_id:
|
|
device_key = f"{component}_{node_id}_{object_id}"
|
|
else:
|
|
device_key = f"{component}_{node_id}"
|
|
|
|
# Gerät speichern
|
|
self.discovered_devices[device_key] = {
|
|
'component': component,
|
|
'node_id': node_id,
|
|
'object_id': object_id,
|
|
'config': config,
|
|
'topic': msg.topic
|
|
}
|
|
|
|
device_name = config.get('device', config.get('dev',{'name': node_id})).get('name',node_id)
|
|
logger.debug(f"Gerät gefunden: {device_name} ({component}) - {msg.topic}")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Fehler beim Verarbeiten der MQTT-Nachricht von {msg.topic}: {e}")
|
|
|
|
def discover_devices(self, timeout: int = 10) -> Dict:
|
|
"""
|
|
Sucht nach Home Assistant Discovery Geräten
|
|
|
|
Args:
|
|
timeout: Timeout in Sekunden
|
|
|
|
Returns:
|
|
Dictionary mit gefundenen Geräten
|
|
"""
|
|
logger.info(f"Starte Home Assistant Discovery (Timeout: {timeout}s)...")
|
|
logger.info(f"Lausche auf {self.discovery_prefix}/+/+/+/config und {self.discovery_prefix}/+/+/config")
|
|
|
|
self.discovered_devices = {}
|
|
|
|
# MQTT Loop starten
|
|
self.client.loop_start()
|
|
|
|
# Warten auf Nachrichten - mit Fortschrittsanzeige
|
|
for i in range(timeout):
|
|
time.sleep(1)
|
|
if (i + 1) % 5 == 0 or i == timeout - 1:
|
|
logger.info(f"Discovery läuft... {len(self.discovered_devices)} Geräte gefunden ({i+1}/{timeout}s)")
|
|
|
|
# Loop stoppen
|
|
self.client.loop_stop()
|
|
|
|
logger.info(f"✓ {len(self.discovered_devices)} MQTT-Geräte gefunden")
|
|
|
|
# Debug: Zeige einige gefundene Topics
|
|
if self.discovered_devices:
|
|
logger.debug("Gefundene Geräte (Auswahl):")
|
|
for i, (key, device) in enumerate(list(self.discovered_devices.items())[:5]):
|
|
logger.debug(f" - {device['config'].get('name', key)} ({device['component']}) via {device['topic']}")
|
|
if len(self.discovered_devices) > 5:
|
|
logger.debug(f" ... und {len(self.discovered_devices) - 5} weitere")
|
|
|
|
return self.discovered_devices
|
|
|
|
def disconnect(self):
|
|
"""Trennt die MQTT-Verbindung"""
|
|
if self.client:
|
|
self.client.disconnect()
|
|
logger.info("MQTT-Verbindung getrennt")
|
|
|
|
|
|
class MQTTDeviceConverter:
|
|
"""Konvertiert MQTT Discovery Entities in Datenbank-Format, gruppiert nach Gerät"""
|
|
|
|
# Mapping von HA Komponenten zu Actor/Sensor
|
|
ACTOR_COMPONENTS = ['switch', 'light', 'cover', 'fan', 'lock', 'climate',
|
|
'vacuum', 'alarm_control_panel', ' ', 'number', 'select']
|
|
SENSOR_COMPONENTS = ['binary_sensor', 'sensor', 'device_tracker']
|
|
|
|
@staticmethod
|
|
def group_entities_by_device(discovered_devices: Dict) -> Dict[str, List]:
|
|
"""
|
|
Gruppiert Discovery-Entities nach Gerät (node_id)
|
|
|
|
Args:
|
|
discovered_devices: Dictionary mit allen gefundenen Entities
|
|
|
|
Returns:
|
|
Dictionary: {device_id: [entity1, entity2, ...]}
|
|
"""
|
|
devices = {}
|
|
|
|
for entity_key, entity in discovered_devices.items():
|
|
# Device Identifier aus Config extrahieren
|
|
config = entity.get('config', {})
|
|
device_info = config.get('device') or config.get('dev') or {}
|
|
# Node ID als Geräte-Identifier verwenden
|
|
node_id = entity.get('node_id', 'unknown')
|
|
|
|
# Zusätzlich Device Identifiers prüfen falls vorhanden
|
|
if device_info and 'identifiers' in device_info:
|
|
identifiers = device_info['identifiers']
|
|
if isinstance(identifiers, list) and identifiers:
|
|
node_id = identifiers[0]
|
|
|
|
if node_id not in devices:
|
|
devices[node_id] = {
|
|
'entities': [],
|
|
'device_info': device_info,
|
|
'node_id': node_id
|
|
}
|
|
|
|
devices[node_id]['entities'].append(entity)
|
|
|
|
return devices
|
|
|
|
@staticmethod
|
|
def is_actor_entity(component: str) -> bool:
|
|
"""Prüft ob Entity-Komponente ein Aktor ist"""
|
|
if(component == "climate"):
|
|
logger.info("Climate device gefunden");
|
|
return component in MQTTDeviceConverter.ACTOR_COMPONENTS
|
|
|
|
@staticmethod
|
|
def is_sensor_entity(component: str) -> bool:
|
|
"""Prüft ob Entity-Komponente ein Sensor ist"""
|
|
return component in MQTTDeviceConverter.SENSOR_COMPONENTS
|
|
|
|
@staticmethod
|
|
def convert_device_to_actors_and_sensors(device_id: str, device_data: Dict) -> tuple:
|
|
"""
|
|
Konvertiert ein Gerät mit allen seinen Entities in Actor/Sensor-Format
|
|
|
|
Args:
|
|
device_id: Geräte-ID (node_id)
|
|
device_data: Device-Daten mit Entities-Liste
|
|
|
|
Returns:
|
|
Tuple (actor_dict or None, sensor_dict or None)
|
|
"""
|
|
entities = device_data['entities']
|
|
device_info = device_data.get('device_info')
|
|
# Gerätename vom ersten Entity oder aus device_info
|
|
device_name = device_info.get('name', device_id)
|
|
device_mf = device_info.get('model', device_info.get('mdl', device_info.get('mf','MQTT')))
|
|
if(device_info.get('mf','MQTT') == "OpenDTU"):
|
|
device_mf = "Wechselrichter"
|
|
#if not device_name or device_name == device_id:
|
|
# Fallback: Name vom ersten Entity
|
|
# if entities:
|
|
# device_name = entities[0]['config'].get('name', device_id)
|
|
|
|
# Device URL
|
|
device_url = f"mqtt://{device_id}"
|
|
|
|
# Entities nach Actor/Sensor trennen
|
|
actor_entities = [e for e in entities if MQTTDeviceConverter.is_actor_entity(e['component'])]
|
|
sensor_entities = [e for e in entities if MQTTDeviceConverter.is_sensor_entity(e['component'])]
|
|
|
|
actor_result = None
|
|
sensor_result = None
|
|
|
|
# Actor erstellen falls Actor-Entities vorhanden
|
|
if actor_entities:
|
|
commands = []
|
|
states = []
|
|
|
|
for entity in actor_entities:
|
|
component = entity['component']
|
|
object_id = entity.get('object_id', 'unknown')
|
|
config = entity['config']
|
|
if(device_mf == "MQTT"):
|
|
device_mf = config.get('device', device_info.get('dev',{'model': 'MQTT'})).get('model', device_info.get('mdl', device_info.get('mf','MQTT')))
|
|
|
|
# Command aus der Entity erstellen
|
|
command_entry = MQTTDeviceConverter._entity_to_commands(component, object_id, config)
|
|
if command_entry:
|
|
commands.extend(command_entry)
|
|
|
|
# States aus der Entity extrahieren
|
|
entity_states = MQTTDeviceConverter._entity_to_states(component, object_id, config)
|
|
states.extend(entity_states)
|
|
|
|
if commands: # Nur Actor erstellen wenn Commands vorhanden
|
|
actor_result = {
|
|
'name': device_name,
|
|
'type': device_mf, # Allgemeiner Typ für Multi-Entity-Geräte
|
|
'url': device_url,
|
|
'commands': commands,
|
|
'states': states
|
|
}
|
|
|
|
# Sensor erstellen falls Sensor-Entities vorhanden
|
|
if sensor_entities:
|
|
states = []
|
|
|
|
for entity in sensor_entities:
|
|
component = entity['component']
|
|
object_id = entity.get('object_id', 'unknown')
|
|
config = entity['config']
|
|
if(device_mf == "MQTT"):
|
|
device_mf = device_info.get('model', device_info.get('mdl', device_info.get('mf','MQTT')))
|
|
# States aus der Entity extrahieren
|
|
entity_states = MQTTDeviceConverter._entity_to_states(component, object_id, config)
|
|
states.extend(entity_states)
|
|
|
|
if states: # Nur Sensor erstellen wenn States vorhanden
|
|
sensor_result = {
|
|
'name': device_name,
|
|
'type': device_mf,
|
|
'url': device_url,
|
|
'states': states
|
|
}
|
|
|
|
return actor_result, sensor_result
|
|
|
|
@staticmethod
|
|
def _entity_to_commands(component: str, object_id: str, config: Dict) -> Optional[Dict]:
|
|
"""
|
|
Konvertiert eine MQTT Entity in ein Command
|
|
|
|
Args:
|
|
component: Entity-Typ (number, button, switch, etc.)
|
|
object_id: Object ID (z.B. set_max_ampere_limit)
|
|
config: Entity-Konfiguration
|
|
|
|
Returns:
|
|
Command-Dictionary oder None
|
|
"""
|
|
# Command Topic - verschiedene mögliche Feldnamen
|
|
command_topic = config.get('command_topic') or config.get('cmd_t') or config.get('temperature_command_topic')
|
|
base_topic = config.get('~','')
|
|
command_topic = command_topic.replace("~",base_topic)
|
|
if not command_topic:
|
|
return None
|
|
|
|
# Command-Name aus object_id ableiten
|
|
#command_name =
|
|
# Oder aus dem Namen
|
|
command_name = config.get('name', object_id.replace('_', ' ').title().replace(' ', ''))
|
|
|
|
command_entry = [{
|
|
'command': command_name,
|
|
'parameters': [],
|
|
'url': command_topic
|
|
}]
|
|
|
|
# Parameter basierend auf Component-Typ
|
|
if component == 'number':
|
|
# Number hat einen Wert-Parameter
|
|
param = {
|
|
'name': 'value',
|
|
'type': 'number',
|
|
'url': command_topic
|
|
}
|
|
|
|
# Min/Max aus Config
|
|
if 'min' in config:
|
|
param['min'] = config['min']
|
|
if 'max' in config:
|
|
param['max'] = config['max']
|
|
|
|
# Unit hinzufügen - verschiedene mögliche Feldnamen
|
|
unit = (
|
|
config.get('unit_of_measurement') or
|
|
config.get('unit_of_meas') or
|
|
config.get('unit') or
|
|
config.get('u')
|
|
)
|
|
if unit:
|
|
param['unit'] = unit
|
|
|
|
command_entry[0]['parameters'].append(param)
|
|
|
|
elif component == 'select':
|
|
# Select hat Optionen
|
|
param = {
|
|
'name': 'option',
|
|
'type': 'string',
|
|
'url': command_topic
|
|
}
|
|
|
|
options = config.get('options') or config.get('ops')
|
|
if options:
|
|
param['values'] = options
|
|
|
|
command_entry[0]['parameters'].append(param)
|
|
|
|
elif component in ['switch', 'light']:
|
|
# Switch/Light haben on/off
|
|
param = {
|
|
'name': 'state',
|
|
'type': 'string',
|
|
'url': command_topic,
|
|
'values': [
|
|
config.get('payload_on', config.get('pl_on', 'ON')),
|
|
config.get('payload_off', config.get('pl_off', 'OFF'))
|
|
]
|
|
}
|
|
command_entry[0]['parameters'].append(param)
|
|
|
|
# Brightness für Light
|
|
brightness_cmd_topic = (
|
|
config.get('brightness_command_topic') or
|
|
config.get('bri_cmd_t')
|
|
)
|
|
if component == 'light' and brightness_cmd_topic:
|
|
command_entry[0]['parameters'].append({
|
|
'name': 'brightness',
|
|
'type': 'integer',
|
|
'min': 0,
|
|
'max': 255,
|
|
'url': brightness_cmd_topic
|
|
})
|
|
|
|
elif component == 'cover':
|
|
# Cover hat position
|
|
set_pos_topic = (
|
|
config.get('set_position_topic') or
|
|
config.get('pos_cmd_t')
|
|
)
|
|
if set_pos_topic:
|
|
param = {
|
|
'name': 'position',
|
|
'type': 'integer',
|
|
'min': 0,
|
|
'max': 100,
|
|
'url': set_pos_topic
|
|
}
|
|
command_entry[0]['parameters'].append(param)
|
|
else:
|
|
# Nur open/close/stop
|
|
param = {
|
|
'name': 'action',
|
|
'type': 'string',
|
|
'url': command_topic,
|
|
'values': ['OPEN', 'CLOSE', 'STOP']
|
|
}
|
|
command_entry[0]['parameters'].append(param)
|
|
|
|
elif component == 'button':
|
|
# Button hat normalerweise keinen Parameter, nur das Topic
|
|
param = {
|
|
'name': 'press',
|
|
'type': 'trigger',
|
|
'url': command_topic
|
|
}
|
|
command_entry[0]['parameters'].append(param)
|
|
|
|
elif component == 'climate':
|
|
# Climate hat Temperatur-Setpoint
|
|
temp_cmd_topic = (
|
|
config.get('temperature_command_topic') or
|
|
config.get('temp_cmd_t')
|
|
)
|
|
if temp_cmd_topic:
|
|
param = {
|
|
'name': 'temperature',
|
|
'type': 'number',
|
|
'url': temp_cmd_topic
|
|
}
|
|
|
|
if 'min_temp' in config:
|
|
param['min'] = config['min_temp']
|
|
if 'max_temp' in config:
|
|
param['max'] = config['max_temp']
|
|
command_entry[0]['command'] = "Solltemperatur"
|
|
command_entry[0]['parameters'].append(param)
|
|
|
|
mode_cmd_topic = (
|
|
config.get('mode_command_topic') or
|
|
config.get('mode_cmd_t')
|
|
)
|
|
if mode_cmd_topic:
|
|
param = {
|
|
'name': 'mode',
|
|
'type': 'string',
|
|
'url': mode_cmd_topic,
|
|
'values': config.get('modes', [])
|
|
}
|
|
command_entry.append({
|
|
'command': "Modus",
|
|
'parameters': [param]
|
|
})
|
|
else:
|
|
# Generischer Command mit dem Topic
|
|
param = {
|
|
'name': 'value',
|
|
'type': 'string',
|
|
'url': command_topic
|
|
}
|
|
command_entry['parameters'].append(param)
|
|
|
|
return command_entry
|
|
|
|
@staticmethod
|
|
def _value_path(config: Dict) -> Optional[str]:
|
|
"""
|
|
Liest aus dem value_template heraus, welcher Teil der Nutzlast gemeint
|
|
ist.
|
|
|
|
Mehrere Messwerte teilen sich oft ein Topic: der go-eCharger schickt
|
|
sechzehn Zahlen als JSON-Feld auf "nrg", und erst das value_template
|
|
sagt, dass "Strom L1" das vierte Element ist. Ohne diese Angabe
|
|
bekaemen alle sechzehn denselben Rohtext.
|
|
|
|
Zurueck kommt ein Pfad in derselben Schreibweise, die auch WLED
|
|
benutzt: "[3]", "ssid", "foo[0].bar". None heisst: die ganze Nutzlast.
|
|
|
|
Home Assistant erlaubt in value_template beliebiges Jinja. Hier wird
|
|
nur der einfache Fall gelesen - ein Zugriff auf value_json und was
|
|
danach an Punkten und Klammern folgt. Bei allem anderen (Werttabellen
|
|
wie ['Idle','Charging'][value_json|int], Bedingungen) bleibt es beim
|
|
Rohtext, also beim bisherigen Verhalten.
|
|
"""
|
|
vorlage = config.get('value_template') or config.get('val_tpl')
|
|
if not vorlage:
|
|
return None
|
|
treffer = re.search(
|
|
r"value_json((?:\s*(?:\.\w+|\[\s*'[^']*'\s*\]|\[\s*\"[^\"]*\"\s*\]|\[\s*\d+\s*\]))*)",
|
|
vorlage)
|
|
if not treffer or not treffer.group(1).strip():
|
|
return None
|
|
teile = []
|
|
for stueck in re.finditer(
|
|
r"\.(\w+)|\[\s*'([^']*)'\s*\]|\[\s*\"([^\"]*)\"\s*\]|\[\s*(\d+)\s*\]",
|
|
treffer.group(1)):
|
|
name = stueck.group(1) or stueck.group(2) or stueck.group(3)
|
|
teile.append(name if name else "[%s]" % stueck.group(4))
|
|
if not teile:
|
|
return None
|
|
# "[3]" haengt sich ohne Punkt an, Namen mit Punkt.
|
|
pfad = ""
|
|
for teil in teile:
|
|
pfad += teil if teil.startswith("[") else (("." + teil) if pfad else teil)
|
|
return pfad[:120]
|
|
|
|
@staticmethod
|
|
def _entity_to_states(component: str, object_id: str, config: Dict) -> List[Dict]:
|
|
"""
|
|
Extrahiert States aus einer MQTT Entity
|
|
|
|
Args:
|
|
component: Entity-Typ
|
|
object_id: Object ID
|
|
config: Entity-Konfiguration
|
|
|
|
Returns:
|
|
Liste von State-Dictionaries
|
|
"""
|
|
states = []
|
|
|
|
# State Topic - verschiedene mögliche Feldnamen prüfen
|
|
state_topic = (
|
|
config.get('state_topic') or
|
|
config.get('stat_t')
|
|
)
|
|
|
|
# Bei number/select: oft kein separates state_topic, dann command_topic verwenden
|
|
if not state_topic and component in ['number', 'select', 'button']:
|
|
# Bei diesen Komponenten kann der State über command_topic abgefragt werden
|
|
# oder es gibt ein explizites state_topic
|
|
state_topic = config.get('command_topic') or config.get('cmd_t')
|
|
|
|
if state_topic:
|
|
base_topic = config.get('~','')
|
|
state_topic = state_topic.replace("~",base_topic)
|
|
state_entry = {
|
|
'name': object_id,
|
|
'type': 'string',
|
|
'url': state_topic,
|
|
'value_path': MQTTDeviceConverter._value_path(config)
|
|
}
|
|
|
|
# Unit hinzufügen - verschiedene mögliche Feldnamen
|
|
unit = (
|
|
config.get('unit_of_measurement') or
|
|
config.get('unit_of_meas') or
|
|
config.get('unit') or
|
|
config.get('u') # Weitere Abkürzung
|
|
)
|
|
if unit:
|
|
state_entry['unit'] = unit
|
|
|
|
# Device Class als zusätzliche Info
|
|
if 'device_class' in config:
|
|
state_entry['device_class'] = config['device_class']
|
|
elif 'dev_cla' in config:
|
|
state_entry['device_class'] = config['dev_cla']
|
|
|
|
# Typ anpassen basierend auf Component
|
|
if component == 'number':
|
|
state_entry['type'] = 'number'
|
|
elif component == 'binary_sensor':
|
|
state_entry['type'] = 'boolean'
|
|
elif component == 'sensor':
|
|
# Bei Sensor den Typ aus value_template ableiten oder number annehmen
|
|
state_entry['type'] = 'number' # Default für Sensoren
|
|
|
|
states.append(state_entry)
|
|
|
|
# Zusätzliche State Topics (z.B. brightness bei Light)
|
|
if component == 'light':
|
|
brightness_topic = (
|
|
config.get('brightness_state_topic') or
|
|
config.get('bri_stat_t')
|
|
)
|
|
if brightness_topic:
|
|
states.append({
|
|
'name': f"{object_id}_brightness",
|
|
'type': 'integer',
|
|
'url': brightness_topic
|
|
})
|
|
|
|
if component == 'cover':
|
|
position_topic = (
|
|
config.get('position_topic') or
|
|
config.get('pos_t')
|
|
)
|
|
if position_topic:
|
|
states.append({
|
|
'name': f"{object_id}_position",
|
|
'type': 'integer',
|
|
'url': position_topic
|
|
})
|
|
|
|
if component == 'climate':
|
|
current_temp_topic = (
|
|
config.get('current_temperature_topic') or
|
|
config.get('curr_temp_t')
|
|
)
|
|
if current_temp_topic:
|
|
states.append({
|
|
'name': f"{object_id}_current_temp",
|
|
'type': 'number',
|
|
'unit': '°C',
|
|
'url': current_temp_topic
|
|
})
|
|
|
|
return states
|
|
|
|
|
|
# ============================================================================
|
|
# MODULE WRAPPER
|
|
# ============================================================================
|
|
|
|
from modules.base_module import BaseModule
|
|
|
|
|
|
class MQTTModule(BaseModule):
|
|
"""
|
|
MQTT Modul - Implementiert BaseModule Interface
|
|
Gibt Actors/Sensors zurück, KEINE DB-Operationen
|
|
"""
|
|
|
|
def is_enabled(self) -> bool:
|
|
"""Prüft ob MQTT aktiviert ist"""
|
|
return self.config.mqtt_enable
|
|
|
|
def discover(self):
|
|
"""
|
|
Führt MQTT Discovery durch
|
|
|
|
Returns:
|
|
Tuple (actors, sensors)
|
|
"""
|
|
logger.info("\n" + "=" * 60)
|
|
logger.info("MQTT/HOME ASSISTANT DISCOVERY")
|
|
logger.info("=" * 60)
|
|
|
|
actors = []
|
|
sensors = []
|
|
|
|
try:
|
|
mqtt_discovery = HomeAssistantDiscovery(
|
|
broker=self.config.mqtt_broker,
|
|
port=self.config.mqtt_port,
|
|
username=self.config.mqtt_username,
|
|
password=self.config.mqtt_password,
|
|
discovery_prefix=self.config.mqtt_discovery_prefix
|
|
)
|
|
|
|
if mqtt_discovery.connect():
|
|
mqtt_entities = mqtt_discovery.discover_devices(
|
|
timeout=self.config.mqtt_discovery_timeout
|
|
)
|
|
|
|
mqtt_devices = MQTTDeviceConverter.group_entities_by_device(mqtt_entities)
|
|
|
|
if not mqtt_devices:
|
|
logger.info("Keine MQTT-Geräte gefunden")
|
|
else:
|
|
logger.info(f"{len(mqtt_devices)} MQTT-Geräte gefunden (aus {len(mqtt_entities)} Entities)")
|
|
|
|
for device_id, device_data in mqtt_devices.items():
|
|
try:
|
|
actor_data, sensor_data = MQTTDeviceConverter.convert_device_to_actors_and_sensors(
|
|
device_id, device_data
|
|
)
|
|
if actor_data:
|
|
actors.append(actor_data)
|
|
|
|
if sensor_data:
|
|
sensors.append(sensor_data)
|
|
|
|
except Exception as e:
|
|
logger.error(f"✗ Fehler beim Verarbeiten von MQTT-Gerät {device_id}: {e}")
|
|
|
|
mqtt_discovery.disconnect()
|
|
else:
|
|
logger.error("MQTT-Verbindung fehlgeschlagen")
|
|
|
|
except Exception as e:
|
|
logger.error(f"✗ MQTT Discovery Fehler: {e}")
|
|
|
|
logger.info(f"MQTT: {len(actors)} Aktoren, {len(sensors)} Sensoren gefunden")
|
|
return actors, sensors |