Files
Smart-Dashboard/restricted/deviceDiscovery/modules/mqtt_module.py
T
adminandClaude Opus 5 56b2ab1648 Mehrere Messwerte auf einem Topic auseinanderhalten
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>
2026-08-31 15:23:08 +02:00

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