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