Initial commit
This commit is contained in:
@@ -0,0 +1,530 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Device Discovery - Refactored with Unified Architecture
|
||||
=======================================================
|
||||
Zentrale Datenbank-Logik im Hauptscript
|
||||
Einheitliche Schnittstelle für alle Module
|
||||
"""
|
||||
|
||||
import pymysql
|
||||
from pymysql import Error
|
||||
import json
|
||||
import logging
|
||||
import configparser
|
||||
import os
|
||||
from typing import Optional, List, Dict
|
||||
|
||||
# Import Module
|
||||
from modules.tahoma_module import TahomaModule
|
||||
from modules.wled_module import WLEDModule
|
||||
from modules.mqtt_module import MQTTModule
|
||||
from modules.shelly_module import ShellyModule
|
||||
|
||||
# Logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# CONFIG CLASS (Original)
|
||||
# ============================================================================
|
||||
|
||||
class Config:
|
||||
"""Lädt und verwaltet die Konfiguration"""
|
||||
|
||||
def __init__(self, config_file: str = 'config.ini'):
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
config_path = os.path.join(script_dir, config_file)
|
||||
|
||||
if not os.path.exists(config_path):
|
||||
raise FileNotFoundError(
|
||||
f"Konfigurationsdatei '{config_file}' nicht gefunden in {script_dir}"
|
||||
)
|
||||
|
||||
self.config = configparser.ConfigParser()
|
||||
self.config.read(config_path, encoding='utf-8')
|
||||
logger.info(f"Konfiguration geladen von: {config_path}")
|
||||
self._validate()
|
||||
|
||||
def _validate(self):
|
||||
required_sections = ['database', 'options']
|
||||
for section in required_sections:
|
||||
if not self.config.has_section(section):
|
||||
raise ValueError(f"Erforderliche Sektion '[{section}]' fehlt")
|
||||
|
||||
def _get_bool(self, section: str, key: str, fallback: bool = False) -> bool:
|
||||
value = self.config.get(section, key, fallback=str(fallback)).strip().lower()
|
||||
return value in ('true', '1', 'yes', 'on')
|
||||
|
||||
def _get_int(self, section: str, key: str, fallback: int = 0) -> int:
|
||||
try:
|
||||
return self.config.getint(section, key, fallback=fallback)
|
||||
except ValueError:
|
||||
return fallback
|
||||
|
||||
def _get_list(self, section: str, key: str) -> List[str]:
|
||||
value = self.config.get(section, key, fallback='').strip()
|
||||
if not value:
|
||||
return []
|
||||
return [item.strip() for item in value.split(',') if item.strip()]
|
||||
|
||||
# Database
|
||||
@property
|
||||
def db_host(self) -> str:
|
||||
return self.config.get('database', 'host').strip()
|
||||
|
||||
@property
|
||||
def db_port(self) -> int:
|
||||
return self._get_int('database', 'port', 3306)
|
||||
|
||||
@property
|
||||
def db_name(self) -> str:
|
||||
return self.config.get('database', 'database').strip()
|
||||
|
||||
@property
|
||||
def db_user(self) -> str:
|
||||
return self.config.get('database', 'user').strip()
|
||||
|
||||
@property
|
||||
def db_password(self) -> str:
|
||||
return self.config.get('database', 'password').strip()
|
||||
|
||||
# Tahoma
|
||||
@property
|
||||
def tahoma_enable(self) -> bool:
|
||||
if not self.config.has_section('tahoma'):
|
||||
return False
|
||||
return self._get_bool('tahoma', 'enable', False)
|
||||
|
||||
@property
|
||||
def tahoma_ip(self) -> str:
|
||||
if not self.config.has_section('tahoma'):
|
||||
return ''
|
||||
return self.config.get('tahoma', 'ip', fallback='').strip()
|
||||
|
||||
@property
|
||||
def tahoma_token(self) -> str:
|
||||
if not self.config.has_section('tahoma'):
|
||||
return ''
|
||||
return self.config.get('tahoma', 'token', fallback='').strip()
|
||||
|
||||
@property
|
||||
def tahoma_timeout(self) -> int:
|
||||
if not self.config.has_section('tahoma'):
|
||||
return 10
|
||||
return self._get_int('tahoma', 'timeout', 10)
|
||||
|
||||
# WLED
|
||||
@property
|
||||
def wled_enable(self) -> bool:
|
||||
if not self.config.has_section('wled'):
|
||||
return False
|
||||
return self._get_bool('wled', 'enable', False)
|
||||
|
||||
@property
|
||||
def wled_discovery_timeout(self) -> int:
|
||||
if not self.config.has_section('wled'):
|
||||
return 5
|
||||
return self._get_int('wled', 'discovery_timeout', 5)
|
||||
|
||||
@property
|
||||
def wled_manual_ips(self) -> List[str]:
|
||||
if not self.config.has_section('wled'):
|
||||
return []
|
||||
return self._get_list('wled', 'manual_ips')
|
||||
|
||||
@property
|
||||
def wled_timeout(self) -> int:
|
||||
if not self.config.has_section('wled'):
|
||||
return 2
|
||||
return self._get_int('wled', 'timeout', 2)
|
||||
|
||||
# MQTT
|
||||
@property
|
||||
def mqtt_enable(self) -> bool:
|
||||
if not self.config.has_section('mqtt'):
|
||||
return False
|
||||
return self._get_bool('mqtt', 'enable', False)
|
||||
|
||||
@property
|
||||
def mqtt_broker(self) -> str:
|
||||
if not self.config.has_section('mqtt'):
|
||||
return 'localhost'
|
||||
return self.config.get('mqtt', 'broker', fallback='localhost').strip()
|
||||
|
||||
@property
|
||||
def mqtt_port(self) -> int:
|
||||
if not self.config.has_section('mqtt'):
|
||||
return 1883
|
||||
return self._get_int('mqtt', 'port', 1883)
|
||||
|
||||
@property
|
||||
def mqtt_username(self) -> Optional[str]:
|
||||
if not self.config.has_section('mqtt'):
|
||||
return None
|
||||
value = self.config.get('mqtt', 'username', fallback='').strip()
|
||||
return value if value else None
|
||||
|
||||
@property
|
||||
def mqtt_password(self) -> Optional[str]:
|
||||
if not self.config.has_section('mqtt'):
|
||||
return None
|
||||
value = self.config.get('mqtt', 'password', fallback='').strip()
|
||||
return value if value else None
|
||||
|
||||
@property
|
||||
def mqtt_discovery_prefix(self) -> str:
|
||||
if not self.config.has_section('mqtt'):
|
||||
return 'homeassistant'
|
||||
return self.config.get('mqtt', 'discovery_prefix', fallback='homeassistant').strip()
|
||||
|
||||
@property
|
||||
def mqtt_discovery_timeout(self) -> int:
|
||||
if not self.config.has_section('mqtt'):
|
||||
return 10
|
||||
return self._get_int('mqtt', 'discovery_timeout', 10)
|
||||
|
||||
# Shelly
|
||||
@property
|
||||
def shelly_enable(self) -> bool:
|
||||
if not self.config.has_section('shelly'):
|
||||
return False
|
||||
return self._get_bool('shelly', 'enable', False)
|
||||
|
||||
@property
|
||||
def shelly_network_range(self) -> str:
|
||||
if not self.config.has_section('shelly'):
|
||||
return '192.168.1'
|
||||
return self.config.get('shelly', 'network_range', fallback='192.168.1').strip()
|
||||
|
||||
@property
|
||||
def shelly_start_ip(self) -> int:
|
||||
if not self.config.has_section('shelly'):
|
||||
return 1
|
||||
return self._get_int('shelly', 'start_ip', 1)
|
||||
|
||||
@property
|
||||
def shelly_end_ip(self) -> int:
|
||||
if not self.config.has_section('shelly'):
|
||||
return 254
|
||||
return self._get_int('shelly', 'end_ip', 254)
|
||||
|
||||
# Options
|
||||
@property
|
||||
def clear_tables(self) -> bool:
|
||||
return self._get_bool('options', 'clear_tables', True)
|
||||
|
||||
@property
|
||||
def log_level(self) -> str:
|
||||
level = self.config.get('options', 'log_level', fallback='INFO').strip().upper()
|
||||
valid_levels = ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']
|
||||
return level if level in valid_levels else 'INFO'
|
||||
|
||||
@property
|
||||
def log_file(self) -> Optional[str]:
|
||||
value = self.config.get('options', 'log_file', fallback='').strip()
|
||||
return value if value else None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# DATABASE MANAGER (Zentral im Hauptscript)
|
||||
# ============================================================================
|
||||
|
||||
class DatabaseManager:
|
||||
"""Zentrale Datenbank-Verwaltung - ALLE DB-Operationen hier"""
|
||||
|
||||
def __init__(self, host: str, database: str, user: str, password: str, port: int = 3306):
|
||||
self.host = host
|
||||
self.database = database
|
||||
self.user = user
|
||||
self.password = password
|
||||
self.port = port
|
||||
self.connection = None
|
||||
|
||||
def connect(self) -> bool:
|
||||
"""Stellt Verbindung zur Datenbank her"""
|
||||
try:
|
||||
self.connection = pymysql.connect(
|
||||
host=self.host,
|
||||
database=self.database,
|
||||
user=self.user,
|
||||
password=self.password,
|
||||
port=self.port,
|
||||
charset='utf8mb4'
|
||||
)
|
||||
logger.info("✓ Erfolgreich mit MySQL-Datenbank verbunden")
|
||||
return True
|
||||
except Error as e:
|
||||
logger.error(f"✗ Datenbankverbindung fehlgeschlagen: {e}")
|
||||
return False
|
||||
|
||||
def disconnect(self):
|
||||
"""Schließt Datenbankverbindung"""
|
||||
if self.connection:
|
||||
self.connection.close()
|
||||
logger.info("Datenbankverbindung geschlossen")
|
||||
|
||||
def clear_tables(self):
|
||||
"""Löscht alle Einträge aus allen Tabellen"""
|
||||
try:
|
||||
cursor = self.connection.cursor()
|
||||
cursor.execute("SET FOREIGN_KEY_CHECKS=0")
|
||||
|
||||
cursor.execute("DELETE FROM command_parameters")
|
||||
cursor.execute("DELETE FROM actor_commands")
|
||||
cursor.execute("DELETE FROM actor_states")
|
||||
cursor.execute("DELETE FROM actors")
|
||||
cursor.execute("DELETE FROM sensor_states")
|
||||
cursor.execute("DELETE FROM sensors")
|
||||
|
||||
cursor.execute("SET FOREIGN_KEY_CHECKS=1")
|
||||
|
||||
self.connection.commit()
|
||||
logger.info("✓ Alle Tabellen geleert")
|
||||
cursor.close()
|
||||
except Error as e:
|
||||
logger.error(f"✗ Fehler beim Leeren der Tabellen: {e}")
|
||||
self.connection.rollback()
|
||||
|
||||
def insert_actor(self, device_type: str, name: str, url: str,
|
||||
commands: list, states: list) -> bool:
|
||||
"""
|
||||
Fügt einen Aktor in die Datenbank ein
|
||||
|
||||
Args:
|
||||
device_type: Typ des Geräts
|
||||
name: Name des Geräts
|
||||
url: Eindeutige URL/ID
|
||||
commands: Liste von Command-Dicts
|
||||
states: Liste von State-Dicts
|
||||
"""
|
||||
try:
|
||||
cursor = self.connection.cursor()
|
||||
|
||||
# Actor einfügen
|
||||
query = """
|
||||
INSERT INTO actors (type, name, parameters, url)
|
||||
VALUES (%s, %s, NULL, %s)
|
||||
"""
|
||||
cursor.execute(query, (device_type, name, url))
|
||||
actor_id = cursor.lastrowid
|
||||
|
||||
# Commands einfügen
|
||||
for cmd in commands:
|
||||
command_name = cmd.get('command', '')
|
||||
|
||||
cmd_query = """
|
||||
INSERT INTO actor_commands (actor_id, command_name)
|
||||
VALUES (%s, %s)
|
||||
"""
|
||||
cursor.execute(cmd_query, (actor_id, command_name))
|
||||
command_id = cursor.lastrowid
|
||||
|
||||
# Parameter einfügen
|
||||
for param in cmd.get('parameters', []):
|
||||
param_query = """
|
||||
INSERT INTO command_parameters
|
||||
(command_id, parameter_name, parameter_type, min_value, max_value, possible_values, url)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
"""
|
||||
param_name = param.get('name', '')
|
||||
param_type = param.get('type', '')
|
||||
min_val = param.get('min')
|
||||
max_val = param.get('max')
|
||||
possible_vals = json.dumps(param.get('values')) if 'values' in param else None
|
||||
param_url = param.get('url')
|
||||
|
||||
cursor.execute(param_query,
|
||||
(command_id, param_name, param_type, min_val, max_val, possible_vals, param_url))
|
||||
|
||||
# States einfügen
|
||||
for state in states:
|
||||
state_query = """
|
||||
INSERT INTO actor_states
|
||||
(actor_id, state_name, state_type, current_value, unit, url)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
"""
|
||||
state_name = state.get('name', '')
|
||||
state_type = state.get('type', 0)
|
||||
current_value = str(state.get('current_value', '')) if 'current_value' in state else None
|
||||
unit = state.get('unit')
|
||||
state_url = state.get('url')
|
||||
|
||||
cursor.execute(state_query, (actor_id, state_name, state_type, current_value, unit, state_url))
|
||||
|
||||
self.connection.commit()
|
||||
cursor.close()
|
||||
return True
|
||||
|
||||
except Error as e:
|
||||
logger.error(f"✗ Fehler beim Einfügen des Aktors {name}: {e}")
|
||||
self.connection.rollback()
|
||||
return False
|
||||
|
||||
def insert_sensor(self, device_type: str, name: str, url: str, states: list) -> bool:
|
||||
"""
|
||||
Fügt einen Sensor in die Datenbank ein
|
||||
|
||||
Args:
|
||||
device_type: Typ des Sensors
|
||||
name: Name des Sensors
|
||||
url: Eindeutige URL/ID
|
||||
states: Liste von State-Dicts
|
||||
"""
|
||||
try:
|
||||
cursor = self.connection.cursor()
|
||||
|
||||
# Sensor einfügen
|
||||
query = """
|
||||
INSERT INTO sensors (type, name, parameters, url)
|
||||
VALUES (%s, %s, NULL, %s)
|
||||
"""
|
||||
cursor.execute(query, (device_type, name, url))
|
||||
sensor_id = cursor.lastrowid
|
||||
|
||||
# States einfügen
|
||||
for state in states:
|
||||
state_query = """
|
||||
INSERT INTO sensor_states
|
||||
(sensor_id, state_name, state_type, current_value, unit, url)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
"""
|
||||
state_name = state.get('name', '')
|
||||
state_type = state.get('type', 0)
|
||||
current_value = str(state.get('current_value', '')) if 'current_value' in state else None
|
||||
unit = state.get('unit')
|
||||
state_url = state.get('url')
|
||||
|
||||
cursor.execute(state_query, (sensor_id, state_name, state_type, current_value, unit, state_url))
|
||||
|
||||
self.connection.commit()
|
||||
cursor.close()
|
||||
return True
|
||||
|
||||
except Error as e:
|
||||
logger.error(f"✗ Fehler beim Einfügen des Sensors {name}: {e}")
|
||||
self.connection.rollback()
|
||||
return False
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# MAIN FUNCTION
|
||||
# ============================================================================
|
||||
|
||||
def main():
|
||||
"""Hauptfunktion - orchestriert alle Module mit einheitlicher Schnittstelle"""
|
||||
|
||||
# Konfiguration laden
|
||||
try:
|
||||
config = Config('config.ini')
|
||||
logger.info("✓ Konfiguration erfolgreich geladen")
|
||||
except FileNotFoundError as e:
|
||||
print(f"FEHLER: {e}")
|
||||
return
|
||||
except Exception as e:
|
||||
print(f"FEHLER: {e}")
|
||||
return
|
||||
|
||||
# Logging anpassen
|
||||
log_level = getattr(logging, config.log_level)
|
||||
logger.setLevel(log_level)
|
||||
|
||||
if config.log_file:
|
||||
file_handler = logging.FileHandler(config.log_file, encoding='utf-8')
|
||||
file_handler.setLevel(log_level)
|
||||
file_handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s'))
|
||||
logger.addHandler(file_handler)
|
||||
logger.info(f"Logging in Datei: {config.log_file}")
|
||||
|
||||
# Datenbank initialisieren
|
||||
logger.info("Verbinde mit MySQL-Datenbank...")
|
||||
db = DatabaseManager(
|
||||
config.db_host,
|
||||
config.db_name,
|
||||
config.db_user,
|
||||
config.db_password,
|
||||
config.db_port
|
||||
)
|
||||
|
||||
if not db.connect():
|
||||
logger.error("Datenbankverbindung fehlgeschlagen. Abbruch.")
|
||||
return
|
||||
|
||||
try:
|
||||
# Tabellen leeren
|
||||
if config.clear_tables:
|
||||
db.clear_tables()
|
||||
|
||||
total_actors = 0
|
||||
total_sensors = 0
|
||||
|
||||
# Module initialisieren
|
||||
modules = [
|
||||
TahomaModule(config),
|
||||
WLEDModule(config),
|
||||
MQTTModule(config),
|
||||
ShellyModule(config)
|
||||
]
|
||||
|
||||
# Jedes Modul durchlaufen
|
||||
for module in modules:
|
||||
if not module.is_enabled():
|
||||
logger.info(f"Modul {module.get_name()} ist deaktiviert")
|
||||
continue
|
||||
|
||||
try:
|
||||
# Discovery durchführen (einheitliche Schnittstelle!)
|
||||
actors, sensors = module.discover()
|
||||
|
||||
# Actors in DB speichern
|
||||
for actor in actors:
|
||||
if db.insert_actor(
|
||||
actor['type'],
|
||||
actor['name'],
|
||||
actor['url'],
|
||||
actor.get('commands', []),
|
||||
actor.get('states', [])
|
||||
):
|
||||
total_actors += 1
|
||||
logger.info(f" ✓ Actor: {actor['name']} ({actor['type']})")
|
||||
|
||||
# Sensors in DB speichern
|
||||
for sensor in sensors:
|
||||
if db.insert_sensor(
|
||||
sensor['type'],
|
||||
sensor['name'],
|
||||
sensor['url'],
|
||||
sensor.get('states', [])
|
||||
):
|
||||
total_sensors += 1
|
||||
logger.info(f" ✓ Sensor: {sensor['name']} ({sensor['type']})")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"✗ Fehler bei Modul {module.get_name()}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
# Zusammenfassung
|
||||
logger.info("\n" + "=" * 60)
|
||||
logger.info("ZUSAMMENFASSUNG")
|
||||
logger.info("=" * 60)
|
||||
logger.info(f"Aktoren gespeichert: {total_actors}")
|
||||
logger.info(f"Sensoren gespeichert: {total_sensors}")
|
||||
logger.info("=" * 60)
|
||||
|
||||
logger.info("\n✓ Import erfolgreich abgeschlossen!")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"✗ Fehler: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
finally:
|
||||
db.disconnect()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/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),
|
||||
'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,
|
||||
'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
|
||||
@@ -0,0 +1,675 @@
|
||||
#!/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
|
||||
@@ -0,0 +1,456 @@
|
||||
|
||||
|
||||
|
||||
#!/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 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, network_range: str = "192.168.1"):
|
||||
self.network_range = network_range
|
||||
self.devices = []
|
||||
|
||||
def scan_network(self, start_ip: int = 2, end_ip: int = 254, timeout: float = 0.3) -> List[str]:
|
||||
"""
|
||||
Scannt das Netzwerk nach aktiven Hosts
|
||||
|
||||
Args:
|
||||
start_ip: Start IP (letztes Oktett)
|
||||
end_ip: End IP (letztes Oktett)
|
||||
timeout: Timeout für Socket-Verbindung
|
||||
|
||||
Returns:
|
||||
Liste von erreichbaren IP-Adressen
|
||||
"""
|
||||
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(self, 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}/shelly",
|
||||
timeout=2
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if 'type' in data and (data['type'].startswith('SHELLY') or data['type'].startswith('SHSW')):
|
||||
logger.info(f"Shelly Gen1 Gerät gefunden: {ip} - {data.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, start_ip: int = 1, end_ip: int = 254) -> List[Dict]:
|
||||
"""
|
||||
Entdeckt alle Shelly-Geräte im Netzwerk
|
||||
|
||||
Returns:
|
||||
Liste von Shelly-Geräten mit Status
|
||||
"""
|
||||
active_hosts = self.scan_network(start_ip, end_ip)
|
||||
|
||||
for ip in active_hosts:
|
||||
device = self.is_shelly_device(ip)
|
||||
if device:
|
||||
status = self.get_device_status(device)
|
||||
device['status'] = status
|
||||
self.devices.append(device)
|
||||
|
||||
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(network_range=self.config.shelly_network_range)
|
||||
devices = discovery.discover_devices(
|
||||
start_ip=self.config.shelly_start_ip,
|
||||
end_ip=self.config.shelly_end_ip
|
||||
)
|
||||
|
||||
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', 'parameters': []},
|
||||
{'command': 'turn_off', 'parameters': []},
|
||||
{'command': 'toggle', 'parameters': []}
|
||||
],
|
||||
'states': [
|
||||
{
|
||||
'name': 'output',
|
||||
'type': 'boolean',
|
||||
'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': 'ShellyTemperatureSensor',
|
||||
'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'),
|
||||
'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': 'ShellyEnergyMeter',
|
||||
'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.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', 'parameters': []},
|
||||
{'command': 'turn_off', 'parameters': []},
|
||||
{'command': 'toggle', 'parameters': []}
|
||||
],
|
||||
'states': [
|
||||
{
|
||||
'name': 'ison',
|
||||
'type': 'boolean',
|
||||
'current_value': relay.get('ison', False)
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
# Temperatursensoren
|
||||
temp_data = status.get('tmp', {})
|
||||
if temp_data and 'tC' in temp_data:
|
||||
sensors.append({
|
||||
'type': 'ShellyTemperatureSensor',
|
||||
'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
|
||||
@@ -0,0 +1,310 @@
|
||||
#!/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', 'ExteriorScreen', 'Awning', 'Blind',
|
||||
'GarageDoor', 'Window', 'Light', 'OnOff', 'DimmableLight',
|
||||
'HeatingSystem', 'Valve', 'Switch', 'Door', 'Curtain',
|
||||
'VenetianBlind', 'PergolaScreen'
|
||||
}
|
||||
|
||||
SENSOR_TYPES = {
|
||||
'TemperatureSensor', 'LightSensor', 'HumiditySensor',
|
||||
'ContactSensor', 'OccupancySensor', 'SmokeSensor',
|
||||
'WaterDetectionSensor', 'WindowHandle', 'MotionSensor',
|
||||
'SunSensor', 'WindSensor', 'RainSensor', 'ConsumptionSensor'
|
||||
}
|
||||
|
||||
# Tahoma Commands mit Parametern
|
||||
TAHOMA_COMMANDS = {
|
||||
"setClosure": [{"name": "position", "type": "integer", "min": 0, "max": 100}],
|
||||
"setClosureAndOrientation": [
|
||||
{"name": "position", "type": "integer", "min": 0, "max": 100},
|
||||
{"name": "neigung", "type": "integer", "min": 0, "max": 100}
|
||||
],
|
||||
"setOrientation": [{"name": "neigung", "type": "integer", "min": 0, "max": 100}],
|
||||
"up": [], "down": [], "my": [], "stop": [], "refresh": [], "wink":[],
|
||||
"setMyPosition": [{"name": "position", "type": "integer", "min": 0, "max": 100}],
|
||||
"on": [], "off": [], "toggle": [],
|
||||
"setIntensity": [{"name": "helligkeit", "type": "integer", "min": 0, "max": 100}],
|
||||
"setColor": [
|
||||
{"name": "farbton", "type": "integer", "min": 0, "max": 360},
|
||||
{"name": "sättigung", "type": "integer", "min": 0, "max": 100}
|
||||
],
|
||||
"setColorTemperature": [{"name": "farbtemperatur", "type": "integer", "min": 2000, "max": 6500}],
|
||||
"setTargetTemperature": [{"name": "temperatur", "type": "float", "min": 5.0, "max": 30.0}],
|
||||
"setMode": [{"name": "betriebsart", "type": "string"}],
|
||||
"pulse": [{"name": "impuls_dauer", "type": "integer", "min": 1, "max": 3600}],
|
||||
"setLevel": [{"name": "ausgangs_level", "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('controllableName', device.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('controllableName', device.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_name = cmd.get('commandName', '')
|
||||
cmd_params = cls.TAHOMA_COMMANDS.get(command_name, "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,
|
||||
'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 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_name = state.get('name', '')
|
||||
if state_name:
|
||||
state_entry = {
|
||||
'name': state_name,
|
||||
'type': state.get('type', 0)
|
||||
}
|
||||
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_name = state.get('name', '')
|
||||
if state_name:
|
||||
state_entry = {
|
||||
'name': state_name,
|
||||
'type': state.get('type', 0)
|
||||
}
|
||||
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:
|
||||
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('controllableName', device.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)
|
||||
actor = {
|
||||
'type': device_type,
|
||||
'name': device_name,
|
||||
'url': device_url,
|
||||
'commands': commands,
|
||||
'states': states
|
||||
}
|
||||
|
||||
elif is_sensor:
|
||||
states = DeviceClassifier.extract_sensor_data(device)
|
||||
sensor = {
|
||||
'type': device_type,
|
||||
'name': device_name,
|
||||
'url': device_url,
|
||||
'states': states
|
||||
}
|
||||
|
||||
return actor, sensor
|
||||
@@ -0,0 +1,313 @@
|
||||
#!/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 = 1.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': 'on', 'parameters': []},
|
||||
{'command': 'off', 'parameters': []},
|
||||
{
|
||||
'command': 'setBrightness',
|
||||
'parameters': [
|
||||
{'name': 'brightness', 'type': 'integer', 'min': 0, 'max': 255}
|
||||
]
|
||||
},
|
||||
{
|
||||
'command': 'setColor',
|
||||
'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': 'setEffect',
|
||||
'parameters': [
|
||||
{'name': 'effect', 'type': 'integer', 'min': 0, 'max': 255, 'values': eff_values}
|
||||
]
|
||||
},
|
||||
{
|
||||
'command': 'setPreset',
|
||||
'parameters': [
|
||||
{'name': 'preset', 'type': 'integer', 'min': 1, 'max': 250, 'values': preset_values}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
states = []
|
||||
if state:
|
||||
states.append({
|
||||
'name': 'power',
|
||||
'type': 'boolean',
|
||||
'current_value': state.get('on', False)
|
||||
})
|
||||
states.append({
|
||||
'name': 'brightness',
|
||||
'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': 'color_rgb',
|
||||
'type': 'array',
|
||||
'current_value': colors[0]
|
||||
})
|
||||
|
||||
return {
|
||||
'type': 'WLED',
|
||||
'name': name,
|
||||
'url': f"wled://{self.ip}",
|
||||
'commands': commands,
|
||||
'states': states
|
||||
}
|
||||
|
||||
|
||||
class WLEDModule(BaseModule):
|
||||
"""
|
||||
WLED Modul - Implementiert BaseModule Interface
|
||||
Gibt nur Actors zurück, KEINE DB-Operationen
|
||||
"""
|
||||
|
||||
def is_enabled(self) -> bool:
|
||||
"""Prüft ob WLED aktiviert ist"""
|
||||
return self.config.wled_enable
|
||||
|
||||
def discover(self) -> Tuple[List[Dict], List[Dict]]:
|
||||
"""
|
||||
Führt WLED Discovery durch
|
||||
|
||||
Returns:
|
||||
Tuple (actors, sensors) - WLED sind immer Actors
|
||||
"""
|
||||
logger.info("\n" + "=" * 60)
|
||||
logger.info("WLED-GERÄTE WERDEN GESUCHT")
|
||||
logger.info("=" * 60)
|
||||
|
||||
actors = []
|
||||
sensors = []
|
||||
|
||||
# Discovery
|
||||
wled_ips = WLEDDiscovery.discover_devices(timeout=self.config.wled_discovery_timeout)
|
||||
|
||||
# Manuelle IPs hinzufügen
|
||||
if self.config.wled_manual_ips:
|
||||
logger.info(f"Füge {len(self.config.wled_manual_ips)} manuelle WLED-IPs hinzu...")
|
||||
for manual_ip in self.config.wled_manual_ips:
|
||||
if manual_ip not in wled_ips:
|
||||
if WLEDDiscovery.is_wled_device(manual_ip):
|
||||
wled_ips.append(manual_ip)
|
||||
logger.info(f"✓ Manuelles WLED-Gerät: {manual_ip}")
|
||||
else:
|
||||
logger.warning(f"⚠ {manual_ip} ist kein WLED-Gerät")
|
||||
|
||||
if not wled_ips:
|
||||
logger.info("Keine WLED-Geräte gefunden")
|
||||
return actors, sensors
|
||||
|
||||
logger.info(f"{len(wled_ips)} WLED-Geräte gefunden")
|
||||
|
||||
# Gerätedaten abrufen
|
||||
for ip in wled_ips:
|
||||
try:
|
||||
wled = WLEDAPI(ip)
|
||||
device_data = wled.get_device_data()
|
||||
|
||||
if device_data:
|
||||
actors.append(device_data)
|
||||
else:
|
||||
logger.warning(f"⚠ Konnte keine Daten von WLED {ip} abrufen")
|
||||
except Exception as e:
|
||||
logger.error(f"✗ Fehler beim Verarbeiten von WLED {ip}: {e}")
|
||||
|
||||
logger.info(f"WLED: {len(actors)} Aktoren gefunden")
|
||||
return actors, sensors
|
||||
Reference in New Issue
Block a user