Files
Smart-Dashboard/restricted/deviceDiscovery/device_discovery.py
T
adminandClaude Opus 5 38e6c3f96b Wetterstation erkennen, Einheiten nicht mehr ueberschreiben
Die Station haengt laengst am Websocket und wsMQTTbridge.py legt ihre
Felder unter weatherStation/# ab - nur wusste die Automatikdatenbank
nichts davon. Das neue Modul macht daraus ein Geraet mit elf
Messwerten: Aussentemperatur, Taupunkt, Feuchte, Druck, Wind, Boe und
die beiden Richtungen. Wie das Gartenwasser- und das
SolarManager-Modul sucht es nichts, sondern schreibt ein festes Geraet
hin; die Station meldet sich nicht per Home-Assistant-Discovery.

Gebraucht wird sie fuer den Hitzeschutz: dafuer ist die
Aussentemperatur die bessere Bedingung als die Raumtemperatur - ist es
drinnen schon warm, ist es zum Verschatten zu spaet. Und Wind gab es
hier bisher ueberhaupt nicht; ohne ihn laesst sich fuer Markise und
Sonnensegel kein Sturmschutz bauen.

Nicht dabei sind zwei Topics. IP ist keine Messung. Und QNH steht bei
uns gut hundert Hektopascal neben qff (1133 gegen 1023) und ist
offenbar nicht auf die Stationshoehe eingestellt - zwei Druecke
nebeneinander, von denen einer falsch ist, waeren im Editor eine Falle.

Dabei aufgefallen: der UPSERT schrieb unit = VALUES(unit)
bedingungslos. Ein Modul, das keine Einheit mitliefert, meint aber
"weiss ich nicht" und nicht "loesch die vorhandene" - am 10.09. hat ein
Suchlauf den drei Sonnensensoren ihr "Lux" genommen, weil das
Tahoma-Modul keine Einheiten kennt. Jetzt COALESCE, wie bei
current_value schon laenger.

config.ini liegt nicht im Git; der Abschnitt [wetterstation] muss dort
von Hand stehen, sonst bleibt das Modul aus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 20:08:10 +02:00

677 lines
24 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
from modules.gartenwasser_module import GartenwasserModule
from modules.solarmanager_module import SolarManagerModule
from modules.wetterstation_module import WetterstationModule
from modules.logic_module import LogicModule #nicht Vergessen neue Module auch unten in die Liste "modules" einzufügen!!
# 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)
# LOGIC
@property
def logic_enable(self) -> bool:
if not self.config.has_section('logic'):
return False
return self._get_bool('logic', 'enable', False)
# GARTENWASSER
@property
def gartenwasser_enable(self) -> bool:
if not self.config.has_section('gartenwasser'):
return False
return self._get_bool('gartenwasser', 'enable', False)
# SOLARMANAGER
@property
def solarmanager_enable(self) -> bool:
if not self.config.has_section('solarmanager'):
return False
return self._get_bool('solarmanager', 'enable', False)
# WETTERSTATION
@property
def wetterstation_enable(self) -> bool:
if not self.config.has_section('wetterstation'):
return False
return self._get_bool('wetterstation', 'enable', False)
# 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"""
PARAMETER_TYPES = {
0: "bool",
1: "integer", # Integer value
2: "float", # Floating point number
3: "string", # Text string
4: "undefined",
5: "time",
6: "array",
7: "deltatime",
8: "date",
9: "datetime",
}
PARAMETER_TYPES_MAPPING = {
"bool": 0,
"boolean" : 0,
"integer" : 1, # Integer value
"float" : 2, # Floating point number
"number" : 2, # Floating point number
"string" : 3, # Text string
"time" : 5,
"array": 6,
"deltatime" : 7,
"date" : 8,
"datetime" : 9,
}
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("TRUNCATE state_types")
cursor.execute("TRUNCATE command_parameters")
cursor.execute("TRUNCATE actor_commands")
cursor.execute("TRUNCATE actor_states")
cursor.execute("TRUNCATE actors")
cursor.execute("TRUNCATE sensor_states")
cursor.execute("TRUNCATE 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, url)
VALUES (%s, %s, %s)
ON DUPLICATE KEY UPDATE
type = VALUES(type),
name = VALUES(name)
"""
cursor.execute(query, (device_type, name, url))
query = """
SELECT ID FROM actors where url = %s
"""
cursor.execute(query, (url))
actor_id = cursor.fetchone()[0]
# Commands einfügen
for cmd in commands:
command_name = cmd.get('command', '')
command_url = cmd.get('url', '')
cmd_query = """
INSERT INTO actor_commands (actor_id, command_name, command_url)
VALUES (%s, %s, %s)
ON DUPLICATE KEY UPDATE
command_url = VALUES(command_url)
"""
cursor.execute(cmd_query, (actor_id, command_name, command_url))
query = """
SELECT ID FROM actor_commands where actor_id = %s and command_name = %s
"""
cursor.execute(query, (actor_id, command_name))
command_id = cursor.fetchone()[0]
# 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)
ON DUPLICATE KEY UPDATE
parameter_type = VALUES(parameter_type),
min_value = VALUES(min_value),
max_value = VALUES(max_value),
possible_values = VALUES(possible_values),
url = VALUES(url)
"""
param_name = param.get('name', '')
param_type = self.PARAMETER_TYPES_MAPPING.get(param.get('type', ''), 4)
min_val = param.get('min')
max_val = param.get('max')
possible_vals = json.dumps(param.get('values')) if 'values' in param else ""
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, possible_values, value_path)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
state_type = VALUES(state_type),
-- COALESCE und nicht VALUES() allein: ein Modul, das
-- keinen Startwert kennt, liefert hier NULL, und das
-- loeschte bisher den laufenden Messwert. Der Runner
-- schrieb ihn auch nicht zurueck - er merkt sich, was er
-- zuletzt in die Datenbank geschrieben hat, und der
-- naechste gleiche Wert galt damit als unveraendert. Im
-- Editor stand danach "Regen heute (= )", bis sich der
-- Wert zufaellig einmal aenderte.
current_value = COALESCE(VALUES(current_value), current_value),
possible_values = VALUES(possible_values),
-- Dieselbe Ueberlegung wie bei current_value: ein Modul,
-- das keine Einheit mitliefert, meint "weiss ich nicht"
-- und nicht "loesch die vorhandene". Ohne COALESCE hat
-- ein Suchlauf am 10.09.2026 den Sonnensensoren ihr
-- "Lux" genommen, weil das Tahoma-Modul keine Einheiten
-- kennt - im Automatik-Editor stand danach "877" ohne
-- Angabe, wovon.
unit = COALESCE(VALUES(unit), unit),
url = VALUES(url),
value_path = VALUES(value_path)
"""
state_name = state.get('name', '')
state_type = self.PARAMETER_TYPES_MAPPING.get(state.get('type', 0), 4)
current_value = str(state.get('current_value', '')) if 'current_value' in state else None
unit = state.get('unit')
state_url = state.get('url')
value_path = state.get('value_path')
possible_vals = json.dumps(state.get('values')) if 'values' in state else ""
cursor.execute(state_query, (actor_id, state_name, state_type, current_value, unit, state_url, possible_vals, value_path))
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 fillVariableTypes(self):
cursor = self.connection.cursor()
query = """
INSERT INTO state_types (id, type)
VALUES (%s, %s)
ON DUPLICATE KEY UPDATE
type = VALUES(type)
"""
for id, parameter_type in self.PARAMETER_TYPES.items():
cursor.execute(query, (id, parameter_type))
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 actors (type, name, url)
VALUES (%s, %s, %s)
ON DUPLICATE KEY UPDATE
type = VALUES(type),
name = VALUES(name)
"""
cursor.execute(query, (device_type, name, url))
sensor_id = cursor.lastrowid
query = """
SELECT ID FROM actors where url = %s
"""
cursor.execute(query, (url))
sensor_id = cursor.fetchone()[0]
# States einfügen
for state in states:
state_query = """
INSERT INTO actor_states
(actor_id, state_name, state_type, current_value, unit, url, value_path, possible_values)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
state_type = VALUES(state_type),
-- Siehe insert_actor: NULL heisst "kein Startwert",
-- nicht "Messwert loeschen".
current_value = COALESCE(VALUES(current_value), current_value),
-- Dieselbe Ueberlegung wie bei current_value: ein Modul,
-- das keine Einheit mitliefert, meint "weiss ich nicht"
-- und nicht "loesch die vorhandene". Ohne COALESCE hat
-- ein Suchlauf am 10.09.2026 den Sonnensensoren ihr
-- "Lux" genommen, weil das Tahoma-Modul keine Einheiten
-- kennt - im Automatik-Editor stand danach "877" ohne
-- Angabe, wovon.
unit = COALESCE(VALUES(unit), unit),
url = VALUES(url),
value_path = VALUES(value_path),
possible_values = VALUES(possible_values)
"""
state_name = state.get('name', '')
state_type = self.PARAMETER_TYPES_MAPPING.get(state.get('type', 0), 4)
current_value = str(state.get('current_value', '')) if 'current_value' in state else None
unit = state.get('unit')
state_url = state.get('url')
value_path = state.get('value_path')
possible_vals = json.dumps(state.get('values')) if 'values' in state else ""
cursor.execute(state_query, (sensor_id, state_name, state_type, current_value, unit, state_url, value_path, possible_vals))
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
db.fillVariableTypes()
# Module initialisieren
modules = [
TahomaModule(config),
WLEDModule(config),
MQTTModule(config),
ShellyModule(config),
LogicModule(config),
GartenwasserModule(config),
SolarManagerModule(config),
WetterstationModule(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()