2026-02-14 20:08:34 +01:00

675 lines
25 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 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)
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']
# Command aus der Entity erstellen
command_entry = MQTTDeviceConverter._entity_to_command(component, object_id, config)
if command_entry:
commands.append(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': f"mqtt_device", # 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']
# 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': f"mqtt_device",
'url': device_url,
'states': states
}
return actor_result, sensor_result
@staticmethod
def _entity_to_command(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')
if not command_topic:
return None
# Command-Name aus object_id ableiten
command_name = object_id.replace('_', ' ').title().replace(' ', '')
# Oder aus dem Namen
entity_name = config.get('name', object_id)
command_entry = {
'command': command_name,
'parameters': []
}
# 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['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['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['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['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['parameters'].append(param)
else:
# Nur open/close/stop
param = {
'name': 'action',
'type': 'string',
'url': command_topic,
'values': ['OPEN', 'CLOSE', 'STOP']
}
command_entry['parameters'].append(param)
elif component == 'button':
# Button hat normalerweise keinen Parameter, nur das Topic
param = {
'name': 'press',
'type': 'trigger',
'url': command_topic
}
command_entry['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['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['parameters'].append(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 _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') or # Abkürzung
config.get('~') and config.get('stat_t') # Mit Base Topic
)
# 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:
state_entry = {
'name': object_id,
'type': 'string',
'url': state_topic
}
# 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