Smart-Dashboard/restricted/history/getTahomaDevices.py
2026-02-14 20:08:34 +01:00

1573 lines
56 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
Somfy Tahoma Local API to MySQL Database Script
Liest alle Aktoren und Sensoren aus der Tahoma Box und speichert sie in MySQL
"""
import requests
import pymysql
from pymysql import Error
import json
import logging
from typing import List, Dict, Optional
import urllib3
import socket
import configparser
import os
from mqtt_discovery import HomeAssistantDiscovery, MQTTDeviceConverter
# SSL-Warnungen deaktivieren (Tahoma verwendet selbst-signierte Zertifikate)
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
class Config:
"""Lädt und verwaltet die Konfiguration aus config.ini"""
def __init__(self, config_file: str = 'config.ini'):
"""
Lädt die Konfiguration
Args:
config_file: Name der Konfigurationsdatei (wird im Script-Verzeichnis gesucht)
"""
# Verzeichnis des Scripts ermitteln
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!\n"
f"Erwartet in: {script_dir}\n"
f"Bitte erstellen Sie die Datei anhand der Vorlage config.ini.example"
)
self.config = configparser.ConfigParser()
self.config.read(config_path, encoding='utf-8')
logger.info(f"Konfiguration geladen von: {config_path}")
# Konfiguration validieren
self._validate()
def _validate(self):
"""Validiert die Konfiguration"""
required_sections = ['tahoma', 'database', 'wled', 'options']
for section in required_sections:
if not self.config.has_section(section):
raise ValueError(f"Erforderliche Sektion '[{section}]' fehlt in config.ini")
def _get_bool(self, section: str, key: str, fallback: bool = False) -> bool:
"""Hilfsmethode zum Lesen von Boolean-Werten"""
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:
"""Hilfsmethode zum Lesen von Integer-Werten"""
try:
return self.config.getint(section, key, fallback=fallback)
except ValueError:
return fallback
def _get_list(self, section: str, key: str) -> List[str]:
"""Hilfsmethode zum Lesen von Listen (komma-getrennt)"""
value = self.config.get(section, key, fallback='').strip()
if not value:
return []
return [item.strip() for item in value.split(',') if item.strip()]
# Tahoma Konfiguration
@property
def tahoma_ip(self) -> str:
return self.config.get('tahoma', 'ip').strip()
@property
def tahoma_token(self) -> str:
return self.config.get('tahoma', 'token').strip()
@property
def tahoma_timeout(self) -> int:
if self.config.has_section('advanced'):
return self._get_int('advanced', 'tahoma_timeout', 10)
return 10
# Datenbank Konfiguration
@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()
# WLED Konfiguration
@property
def wled_enable(self) -> bool:
return self._get_bool('wled', 'enable', True)
@property
def wled_discovery_timeout(self) -> int:
return self._get_int('wled', 'discovery_timeout', 5)
@property
def wled_manual_ips(self) -> List[str]:
return self._get_list('wled', 'manual_ips')
@property
def wled_scan_network(self) -> Optional[str]:
value = self.config.get('wled', 'scan_network', fallback='').strip()
return value if value else None
@property
def wled_timeout(self) -> int:
if self.config.has_section('advanced'):
return self._get_int('advanced', 'wled_timeout', 2)
return 2
# MQTT Konfiguration
@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)
# Optionen
@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
# Advanced
@property
def scan_threads(self) -> int:
if self.config.has_section('advanced'):
return self._get_int('advanced', 'scan_threads', 50)
return 50
# Konfiguration laden (global, wird in main() initialisiert)
config = None
# Logging konfigurieren (wird nach Config-Laden angepasst)
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
class TahomaAPI:
"""Klasse für die Kommunikation mit der Tahoma Local API"""
def __init__(self, gateway_ip: str, api_token: str):
"""
Initialisiert die Tahoma API Verbindung
Args:
gateway_ip: IP-Adresse der Tahoma Box
api_token: API Token (Bearer Token)
"""
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]:
"""
Ruft die komplette Setup-Konfiguration ab
Returns:
Dictionary mit allen Geräten oder None bei Fehler
"""
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]:
"""
Extrahiert alle Geräte aus dem Setup
Returns:
Liste aller Geräte
"""
setup = self.get_setup()
if not setup:
return []
devices = setup.get('devices', [])
logger.info(f"{len(devices)} Geräte gefunden")
return devices
def get_device_definition(self, device_url: str) -> Optional[Dict]:
"""
Ruft die detaillierte Definition eines Geräts ab
Args:
device_url: URL des Geräts
Returns:
Dictionary mit Gerätedefinition oder None bei Fehler
"""
try:
# Device URL encodieren
from urllib.parse import quote
encoded_url = quote(device_url, safe='')
url = f"{self.base_url}/setup/devices/{encoded_url}"
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.debug(f"Fehler beim Abrufen der Device-Definition für {device_url}: {e}")
return None
def get_device_states(self, device_url: str) -> List[Dict]:
"""
Ruft die aktuellen States eines Geräts ab
Args:
device_url: URL des Geräts
Returns:
Liste der States
"""
try:
from urllib.parse import quote
encoded_url = quote(device_url, safe='')
url = f"{self.base_url}/setup/devices/{encoded_url}/states"
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.debug(f"Fehler beim Abrufen der Device-States für {device_url}: {e}")
return []
class DatabaseManager:
"""Klasse für die MySQL-Datenbankoperationen"""
def __init__(self, host: str, database: str, user: str, password: str, port: int = 3306):
"""
Initialisiert die Datenbankverbindung
Args:
host: MySQL Host
database: Datenbankname
user: Benutzername
password: Passwort
port: Port (Standard: 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
Returns:
True bei Erfolg, False bei Fehler
"""
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 MariaDB/MySQL-Datenbank verbunden")
return True
except Error as e:
logger.error(f"Fehler bei der Datenbankverbindung: {e}")
return False
def disconnect(self):
"""Schließt die 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()
# Foreign Key Constraints temporär deaktivieren
cursor.execute("SET FOREIGN_KEY_CHECKS=0")
# Alle Tabellen leeren
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")
# Foreign Key Constraints wieder aktivieren
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 mit Commands und States in die Datenbank ein
(AKTUALISIERT für MQTT URL-Support)
"""
try:
cursor = self.connection.cursor()
# 1. Aktor 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
# 2. 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 mit URL einfügen
cmd_params = cmd.get('parameters', [])
for param in cmd_params:
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') # NEU: MQTT Topic
cursor.execute(param_query,
(command_id, param_name, param_type, min_val, max_val, possible_vals, param_url))
# 3. States mit URL 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') # NEU: MQTT Topic
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 mit States in die Datenbank ein
(AKTUALISIERT für MQTT URL-Support)
"""
try:
cursor = self.connection.cursor()
# 1. 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
# 2. States mit URL 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') # NEU: MQTT Topic
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
class WLEDDiscovery:
"""Klasse für die automatische WLED-Geräteerkennung im Netzwerk"""
@staticmethod
def discover_devices(timeout: int = 5) -> List[str]:
"""
Sucht nach WLED-Geräten im lokalen Netzwerk mittels mDNS
Args:
timeout: Timeout in Sekunden für die Suche
Returns:
Liste mit IP-Adressen gefundener WLED-Geräte
"""
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:
# IP-Adresse extrahieren
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()
# Filtern: Nur WLED-Geräte
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-Bibliothek 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 (Fallback-Methode)
Args:
network: Netzwerk im Format "192.168.1.0/24" (None = automatisch)
max_threads: Maximale Anzahl paralleler Threads
Returns:
Liste mit IP-Adressen gefundener WLED-Geräte
"""
import socket
import concurrent.futures
if network is None:
# Eigene IP ermitteln und Netzwerk ableiten
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
local_ip = s.getsockname()[0]
s.close()
# Netzwerk ableiten (angenommen /24)
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 eine IP-Adresse ein WLED-Gerät ist
Args:
ip: IP-Adresse
timeout: Timeout für die Anfrage
Returns:
True wenn WLED-Gerät, False sonst
"""
try:
response = requests.get(
f"http://{ip}/json/info",
timeout=timeout,
headers={'User-Agent': 'TahomaSync/1.0'}
)
if response.status_code == 200:
data = response.json()
# WLED antwortet mit spezifischen Feldern
return 'ver' in data or 'name' in data
except:
pass
return False
class WLEDAPI:
"""Klasse für die Kommunikation mit WLED-Geräten"""
def __init__(self, ip: str):
"""
Initialisiert die WLED-API Verbindung
Args:
ip: IP-Adresse des WLED-Geräts
"""
self.ip = ip
self.base_url = f"http://{ip}"
def get_info(self) -> Optional[Dict]:
"""
Ruft Geräteinformationen ab
Returns:
Dictionary mit Geräteinformationen oder None
"""
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[Dict]:
"""
Ruft die Presets ab
Returns:
Dictionary mit Presets oder None
"""
try:
response = requests.get(f"{self.base_url}/presets.json", timeout=2)
response.raise_for_status()
presets_data = response.json()
# Nur die relevanten Daten extrahieren
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[Dict]:
"""
Ruft die Effects ab
Returns:
Dictionary mit effects oder None
"""
try:
response = requests.get(f"{self.base_url}/json/eff", timeout=2)
response.raise_for_status()
eff_data = response.json()
# Nur die relevanten Daten extrahieren
eff_list = []
for eff_id, eff_data in enumerate(eff_data):
if(eff_data):
eff_name = eff_data
else:
eff_name = "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]:
"""
Ruft aktuellen Zustand ab
Returns:
Dictionary mit aktuellem Zustand oder None
"""
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 ein Geräte-Dictionary im Tahoma-ähnlichen Format
Returns:
Dictionary mit Gerätedaten
"""
info = self.get_info()
state = self.get_state()
if not info:
return None
# Name des Geräts
name = info.get('name', f"WLED {self.ip}")
preset_values = self.get_presets()
eff_values = self.get_effects()
# Commands für WLED erstellen
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 erstellen
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)
})
# Farbe (erstes Segment)
segments = state.get('seg', [])
if segments and len(segments) > 0:
seg = segments[0]
colors = seg.get('col', [[0,0,0]])
if colors and len(colors) > 0:
rgb = colors[0]
states.append({
'name': 'color_rgb',
'type': 'array',
'current_value': rgb
})
return {
'name': name,
'type': 'WLED',
'url': f"wled://{self.ip}",
'commands': commands,
'states': states,
'info': {
'version': info.get('ver', 'unknown'),
'ip': self.ip,
'mac': info.get('mac', 'unknown')
}
}
class DeviceClassifier:
"""Klassifiziert Geräte als Aktoren oder Sensoren"""
# Bekannte Aktor-Typen (können erweitert werden)
ACTOR_TYPES = {
'RollerShutter', 'ExteriorScreen', 'Awning', 'Blind',
'GarageDoor', 'Window', 'Light', 'OnOff', 'DimmableLight',
'HeatingSystem', 'Valve', 'Switch', 'Door', 'Curtain',
'VenetianBlind', 'PergolaScreen'
}
# Bekannte Sensor-Typen (können erweitert werden)
SENSOR_TYPES = {
'TemperatureSensor', 'LightSensor', 'HumiditySensor',
'ContactSensor', 'OccupancySensor', 'SmokeSensor',
'WaterDetectionSensor', 'WindowHandle', 'MotionSensor',
'SunSensor', 'WindSensor', 'RainSensor', 'ConsumptionSensor'
}
# Bekannte Kommandos (können erweitert werden)
TAHOMA_COMMANDS = {
# ---------- Jalousien & Rollos ----------
"setClosure": [
{
"name": "position",
"type": "integer",
"min": 0,
"max": 100,
"unit": "%",
"description": "0% = offen, 100% = zu"
}
],
"setClosureAndOrientation": [
{
"name": "position",
"type": "integer",
"min": 0,
"max": 100,
"unit": "%",
"description": "0% = offen, 100% = zu"
},
{
"name": "neigung",
"type": "integer",
"min": 0,
"max": 100,
"unit": "%",
"description": "0% = offen, 100% = zu (Neigungswinkel bei Lamellen)"
},
],
"setOrientation": [
{
"name": "neigung",
"type": "integer",
"min": 0,
"max": 100,
"unit": "%",
"description": "0% = offen, 100% = zu (Nur Neigungswinkel)"
}
],
"up": [], # öffnet vollständig → kein Parameter
"down": [], # schließt vollständig → kein Parameter
"my": [], # fährt zur „MeinePosition“
"setMyPosition": [
{
"name": "position",
"type": "integer",
"min": 0,
"max": 100,
"unit": "%",
"description": "Speichert die aktuelle bzw. angegebene Position (0% = offen, 100% = zu)"
}
],
"stop": [], # sofort anhalten
"refresh": [], # Status neu abfragen
# ---------- Licht / Schalter ----------
"on": [],
"off": [],
"toggle": [],
"setIntensity": [
{
"name": "helligkeit",
"type": "integer",
"min": 0,
"max": 100,
"unit": "%",
"description": "Helligkeit des Lichts"
}
],
"setColor": [
{
"name": "farbton",
"type": "integer",
"min": 0,
"max": 360,
"unit": "°",
"description": "Farbton (0360°)"
},
{
"name": "sättigung",
"type": "integer",
"min": 0,
"max": 100,
"unit": "%",
"description": "Sättigung des Farbtons"
},
],
"setColorTemperature": [
{
"name": "farbtemperatur",
"type": "integer",
"min": 2000,
"max": 6500,
"unit": "K",
"description": "Farbtemperatur in Kelvin"
}
],
"setTransition": [
{
"name": "dauer",
"type": "integer",
"min": 0,
"max": 3600,
"unit": "s",
"description": "Übergangszeit für nachfolgende Befehle"
}
],
# ---------- Thermostat ----------
"setTargetTemperature": [
{
"name": "temperatur",
"type": "float",
"min": 5.0,
"max": 30.0,
"unit": "°C",
"description": "SollTemperatur"
}
],
"setMode": [
{
"name": "betriebsart",
"type": "string",
"enum": ["off", "heating", "cooling", "auto"],
"unit": None,
"description": "Betriebsmodus des Thermostats"
}
],
"setBoost": [
{
"name": "boost_dauer",
"type": "integer",
"min": 1,
"max": 180,
"unit": "min",
"description": "KurzBoostDauer"
}
],
# ---------- Schalt / SzenenGeräte ----------
"pulse": [
{
"name": "impuls_dauer",
"type": "integer",
"min": 1,
"max": 3600,
"unit": "s",
"description": "KurzimpulsDauer"
}
],
"setLevel": [
{
"name": "ausgangs_level",
"type": "integer",
"min": 0,
"max": 100,
"unit": "%",
"description": "AusgangsLevel (dimmbare Relais)"
}
],
"trigger": [], # Szene ausführen → kein Parameter
# ---------- MetaBefehle (für alle Geräte) ----------
"configureReporting": [
{
"name": "intervall",
"type": "integer",
"min": 30,
"max": 86400,
"unit": "s",
"description": "Meldeintervall für das Gerät"
}
],
"setBatteryThreshold": [
{
"name": "warnschwelle",
"type": "integer",
"min": 0,
"max": 100,
"unit": "%",
"description": "Batteriewarnschwelle"
}
],
}
@classmethod
def is_actor(cls, device: Dict) -> bool:
"""
Prüft, ob ein Gerät ein Aktor ist
Args:
device: Geräte-Dictionary
Returns:
True wenn Aktor, False sonst
"""
device_type = device.get('definition', '').get('uiClass', '')
# Prüfung nach bekannten Typen
if device_type in cls.ACTOR_TYPES:
return True
# Prüfung nach Commandos (Aktoren haben typischerweise Commands)
commands = device.get('definition', {}).get('commands', [])
if commands and len(commands) > 0:
# Wenn Commands wie open, close, on, off existieren
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 ein Gerät ein Sensor ist
Args:
device: Geräte-Dictionary
Returns:
True wenn Sensor, False sonst
"""
device_type = device.get('definition', '').get('uiClass', '')
controllable_name = device.get('controllableName', '')
# Prüfung nach bekannten Typen
if device_type in cls.SENSOR_TYPES:
return True
if controllable_name in cls.SENSOR_TYPES:
return True
else:
return False
# Prüfung nach States (Sensoren haben typischerweise nur States, keine Commands)
states = device.get('states', [])
commands = device.get('definition', {}).get('commands', [])
# Sensor hat States aber keine oder nur wenige 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 einem Aktor
Args:
device: Geräte-Dictionary von der Tahoma API
Returns:
Tuple (commands_list, states_list)
"""
commands = []
states = []
# Commands aus der Definition extrahieren
cmd_definitions = device.get('definition', {}).get('commands', [])
for cmd in cmd_definitions:
command_name = cmd.get('commandName', '')
command_entry = {
'command': command_name,
'parameters': []
}
# HINWEIS: Tahoma API liefert oft keine detaillierten Parameter-Infos
# Daher werden Commands erstmal ohne Parameter-Details gespeichert
#cmd_params = cmd.get('parameters', [])
cmd_params = cls.TAHOMA_COMMANDS.get(command_name,[])
for cmd_param in cmd_params:
param_detail = {
'name': cmd_param.get('name', '')
}
# Datentyp (falls vorhanden)
param_type = cmd_param.get('type')
if param_type:
param_detail['type'] = param_type
# Min/Max Werte (meist nicht in Tahoma API vorhanden)
if 'min' in cmd_param:
param_detail['min'] = cmd_param['min']
if 'max' in cmd_param:
param_detail['max'] = cmd_param['max']
# Mögliche Werte (enum) (meist nicht vorhanden)
if 'values' in cmd_param:
param_detail['values'] = cmd_param['values']
if 'unit' in cmd_param:
param_detail['unit'] = cmd_param['unit']
if 'description' in cmd_param:
param_detail['description'] = cmd_param['description']
# Nur hinzufügen wenn Name vorhanden
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 einem Sensor
Args:
device: Geräte-Dictionary von der Tahoma API
Returns:
Liste der States
"""
states = []
# 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)
}
# Aktueller Wert falls vorhanden
if 'value' in state:
state_entry['current_value'] = state['value']
states.append(state_entry)
return states
def process_devices(tahoma: TahomaAPI, db: DatabaseManager,
enable_wled: bool = True, wled_timeout: int = 5,
enable_mqtt: bool = True, # NEU
clear_before_insert: bool = True):
"""
Verarbeitet alle Geräte und speichert sie in der Datenbank
Args:
tahoma: TahomaAPI Instanz
db: DatabaseManager Instanz
enable_wled: WLED-Geräte suchen und hinzufügen
wled_timeout: Timeout für WLED-Discovery in Sekunden
clear_before_insert: Tabellen vor dem Einfügen leeren (Standard: True)
"""
# Optional: Tabellen leeren
if clear_before_insert:
db.clear_tables()
actor_count = 0
sensor_count = 0
unknown_count = 0
# ========== TAHOMA-GERÄTE VERARBEITEN ==========
logger.info("=" * 60)
logger.info("TAHOMA-GERÄTE WERDEN ABGERUFEN")
logger.info("=" * 60)
devices = tahoma.get_devices()
if not devices:
logger.warning("Keine Tahoma-Geräte gefunden")
else:
# Gruppierte Geräte identifizieren (#1, #2, etc.)
device_groups = {} # {base_url: [devices]}
standalone_devices = []
for device in devices:
device_url = device.get('deviceURL', '')
# Prüfen ob URL mit #1, #2, etc. endet
import re
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():
# Hauptgerät finden (ohne #-Endung oder mit #1)
main_device = None
sub_devices = []
for dev in group_devices:
url = dev.get('deviceURL', '')
if url.endswith('#1'):
main_device = dev
else:
sub_devices.append(dev)
# Falls kein #1, nehme das erste Gerät als Hauptgerät
if not main_device and group_devices:
main_device = group_devices[0]
sub_devices = group_devices[1:]
# Name vom Hauptgerät für alle übernehmen
main_name = main_device.get('label', 'Unbekannt') if main_device else 'Unbekannt'
# Alle Geräte der Gruppe verarbeiten mit gemeinsamem Namen
for device in group_devices:
device_url = device.get('deviceURL', '')
# controllableName als Typ verwenden
device_type = device.get('controllableName', device.get('uiClass', 'Unknown'))
# Gerät klassifizieren
is_actor = DeviceClassifier.is_actor(device)
is_sensor = DeviceClassifier.is_sensor(device)
if is_actor:
commands, states = DeviceClassifier.extract_actor_data(device)
if db.insert_actor(device_type, main_name, device_url, commands, states):
actor_count += 1
logger.info(f"✓ Aktor [Gruppe]: {main_name} ({device_type}) - "
f"{len(commands)} Commands, {len(states)} States")
elif is_sensor:
states = DeviceClassifier.extract_sensor_data(device)
if db.insert_sensor(device_type, main_name, device_url, states):
sensor_count += 1
logger.info(f"✓ Sensor [Gruppe]: {main_name} ({device_type}) - "
f"{len(states)} States")
else:
unknown_count += 1
logger.warning(f"⚠ Unbekannt [Gruppe]: {main_name} ({device_type}-{device_url})")
# Standalone-Geräte verarbeiten
for device in standalone_devices:
device_url = device.get('deviceURL', '')
device_name = device.get('label', 'Unbekannt')
# controllableName als Typ verwenden
device_type = device.get('controllableName', device.get('uiClass', 'Unknown'))
# Gerät klassifizieren
is_actor = DeviceClassifier.is_actor(device)
is_sensor = DeviceClassifier.is_sensor(device)
if is_actor:
# Daten extrahieren
commands, states = DeviceClassifier.extract_actor_data(device)
# In Datenbank speichern
if db.insert_actor(device_type, device_name, device_url, commands, states):
actor_count += 1
logger.info(f"✓ Aktor: {device_name} ({device_type}) - "
f"{len(commands)} Commands, {len(states)} States")
elif is_sensor:
# Daten extrahieren
states = DeviceClassifier.extract_sensor_data(device)
# In Datenbank speichern
if db.insert_sensor(device_type, device_name, device_url, states):
sensor_count += 1
logger.info(f"✓ Sensor: {device_name} ({device_type}) - "
f"{len(states)} States")
else:
unknown_count += 1
logger.warning(f"⚠ Unbekannt: {device_name} ({device_type})")
# ========== WLED-GERÄTE SUCHEN UND VERARBEITEN ==========
if enable_wled:
logger.info("\n" + "=" * 60)
logger.info("WLED-GERÄTE WERDEN GESUCHT")
logger.info("=" * 60)
wled_ips = WLEDDiscovery.discover_devices(timeout=wled_timeout)
# Manuelle IPs aus Config hinzufügen
if config and config.wled_manual_ips:
logger.info(f"Füge {len(config.wled_manual_ips)} manuelle WLED-IPs hinzu...")
for manual_ip in 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")
else:
logger.info(f"\n{len(wled_ips)} WLED-Geräte gefunden, füge sie hinzu...")
for ip in wled_ips:
try:
wled = WLEDAPI(ip)
device_data = wled.get_device_data()
if device_data:
name = device_data['name']
device_type = device_data['type']
url = device_data['url']
commands = device_data['commands']
states = device_data['states']
# WLED immer als Aktor hinzufügen
if db.insert_actor(device_type, name, url, commands, states):
actor_count += 1
logger.info(f"✓ WLED: {name} ({ip}) - "
f"{len(commands)} Commands, {len(states)} States")
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}")
# ========== MQTT-GERÄTE SUCHEN UND VERARBEITEN ==========
# ========== MQTT-GERÄTE SUCHEN UND VERARBEITEN ==========
if enable_mqtt:
logger.info("\n" + "=" * 60)
logger.info("MQTT/HOME ASSISTANT DISCOVERY")
logger.info("=" * 60)
try:
# MQTT Discovery initialisieren
mqtt_discovery = HomeAssistantDiscovery(
broker=config.mqtt_broker,
port=config.mqtt_port,
username=config.mqtt_username,
password=config.mqtt_password,
discovery_prefix=config.mqtt_discovery_prefix
)
# Verbinden
if mqtt_discovery.connect():
# Discovery durchführen
mqtt_entities = mqtt_discovery.discover_devices(
timeout=config.mqtt_discovery_timeout
)
# Entities nach Geräten gruppieren
mqtt_devices = MQTTDeviceConverter.group_entities_by_device(mqtt_entities)
# Geräte verarbeiten
if not mqtt_devices:
logger.info("Keine MQTT-Geräte gefunden")
else:
logger.info(f"\n{len(mqtt_devices)} MQTT-Geräte gefunden (aus {len(mqtt_entities)} Entities)")
for device_id, device_data in mqtt_devices.items():
try:
# Gerät in Actor/Sensor konvertieren
actor_data, sensor_data = MQTTDeviceConverter.convert_device_to_actors_and_sensors(
device_id, device_data
)
# Actor speichern falls vorhanden
if actor_data:
if db.insert_actor(
actor_data['type'],
actor_data['name'],
actor_data['url'],
actor_data['commands'],
actor_data['states']
):
actor_count += 1
logger.info(f"✓ MQTT Actor: {actor_data['name']} - "
f"{len(actor_data['commands'])} Commands, "
f"{len(actor_data['states'])} States")
# Sensor speichern falls vorhanden
if sensor_data:
if db.insert_sensor(
sensor_data['type'],
sensor_data['name'],
sensor_data['url'],
sensor_data['states']
):
sensor_count += 1
logger.info(f"✓ MQTT Sensor: {sensor_data['name']} - "
f"{len(sensor_data['states'])} States")
except Exception as e:
logger.error(f"✗ Fehler beim Verarbeiten von MQTT-Gerät {device_id}: {e}")
import traceback
traceback.print_exc()
# Verbindung trennen
mqtt_discovery.disconnect()
else:
logger.error("MQTT-Verbindung fehlgeschlagen")
except Exception as e:
logger.error(f"✗ MQTT Discovery Fehler: {e}")
import traceback
traceback.print_exc()
# ========== ZUSAMMENFASSUNG ==========
logger.info("\n" + "=" * 60)
logger.info("ZUSAMMENFASSUNG")
logger.info("=" * 60)
logger.info(f"Aktoren gespeichert: {actor_count}")
logger.info(f"Sensoren gespeichert: {sensor_count}")
logger.info(f"Unbekannte Geräte: {unknown_count}")
logger.info("=" * 60)
def main():
"""Hauptfunktion"""
global config
# Konfigurationsdatei laden
try:
config = Config('config.ini')
logger.info("Konfiguration erfolgreich geladen")
except FileNotFoundError as e:
print(f"FEHLER: {e}")
print("\nBitte erstellen Sie eine config.ini Datei mit Ihren Einstellungen.")
print("Siehe config.ini Vorlage für Details.")
return
except Exception as e:
print(f"FEHLER beim Laden der Konfiguration: {e}")
return
# Logging-Level anpassen
log_level = getattr(logging, config.log_level)
logger.setLevel(log_level)
# Optional: Log-Datei einrichten
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}")
# Tahoma API initialisieren
logger.info("Verbinde mit Tahoma Box...")
tahoma = TahomaAPI(config.tahoma_ip, config.tahoma_token)
# 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:
# Geräte verarbeiten und in Datenbank speichern
process_devices(
tahoma,
db,
enable_wled=config.wled_enable,
wled_timeout=config.wled_discovery_timeout,
enable_mqtt=config.mqtt_enable, # NEU
clear_before_insert=config.clear_tables
)
logger.info("\n✓ Import erfolgreich abgeschlossen!")
except Exception as e:
logger.error(f"✗ Fehler während der Verarbeitung: {e}")
import traceback
traceback.print_exc()
finally:
# Datenbankverbindung schließen
db.disconnect()
if __name__ == "__main__":
main()