2026-02-14 19:47:21 +01:00

531 lines
18 KiB
Python

#!/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()