Initial commit

This commit is contained in:
2026-02-14 19:47:21 +01:00
parent fd2d3cee8b
commit 0e78302640
446 changed files with 114460 additions and 1024 deletions
File diff suppressed because it is too large Load Diff
+596
View File
@@ -0,0 +1,596 @@
#!/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('name', config.get('unique_id', object_id or 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': temp_cmd_topic,
'values': config.get('modes', [])
}
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
+756
View File
@@ -0,0 +1,756 @@
#!/usr/bin/env python3
"""
Shelly Device Discovery Script
Findet alle Shelly-Geräte im lokalen Netzwerk und speichert Sensoren und Aktoren
in der Datenbank gemäß dem EnergyFlow Schema.
"""
import json
import requests
import socket
import mysql.connector
from mysql.connector import Error
from typing import List, Dict, Optional
import argparse
import logging
from datetime import datetime
# 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 = 1, end_ip: int = 254, timeout: float = 0.5) -> 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
"""
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'):
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
class ShellyDatabaseWriter:
"""Klasse zum Schreiben der Shelly-Daten in die Datenbank"""
def __init__(self, host: str, user: str, password: str, database: str):
self.host = host
self.user = user
self.password = password
self.database = database
self.connection = None
def connect(self):
"""Stellt Verbindung zur Datenbank her"""
try:
self.connection = mysql.connector.connect(
host=self.host,
user=self.user,
password=self.password,
database=self.database,
charset='utf8mb4',
collation='utf8mb4_bin'
)
logger.info("Datenbankverbindung hergestellt")
except Error as e:
logger.error(f"Fehler bei Datenbankverbindung: {e}")
raise
def disconnect(self):
"""Schließt Datenbankverbindung"""
if self.connection and self.connection.is_connected():
self.connection.close()
logger.info("Datenbankverbindung geschlossen")
def parse_gen2_device(self, device: Dict) -> tuple:
"""
Parst Gen2 Shelly-Gerät und extrahiert Aktoren/Sensoren
Returns:
(actors, sensors) Tuple mit Listen
"""
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
if 'switch:0' in status or 'switch' in status:
switch_count = 0
for key in status.keys():
if key.startswith('switch:'):
switch_count += 1
for i in range(switch_count):
switch_data = status.get(f'switch:{i}', {})
actors.append({
'type': f'ShellySwitch_{device_model}',
'name': f"{device_name}_Switch_{i}",
'url': f"http://{ip}/rpc/Switch.Set?id={i}",
'parameters': json.dumps({
'device_id': info.get('id'),
'switch_id': i,
'model': device_model,
'generation': 2
}),
'commands': [
{'command_name': 'turn_on', 'params': []},
{'command_name': 'turn_off', 'params': []},
{'command_name': 'toggle', 'params': []}
],
'states': [
{
'state_name': 'output',
'current_value': str(switch_data.get('output', False)),
'unit': None
}
]
})
# Cover/Roller als Aktoren
if 'cover:0' in status:
cover_count = 0
for key in status.keys():
if key.startswith('cover:'):
cover_count += 1
for i in range(cover_count):
cover_data = status.get(f'cover:{i}', {})
actors.append({
'type': f'ShellyCover_{device_model}',
'name': f"{device_name}_Cover_{i}",
'url': f"http://{ip}/rpc/Cover.GoToPosition?id={i}",
'parameters': json.dumps({
'device_id': info.get('id'),
'cover_id': i,
'model': device_model,
'generation': 2
}),
'commands': [
{'command_name': 'open', 'params': []},
{'command_name': 'close', 'params': []},
{'command_name': 'stop', 'params': []},
{'command_name': 'set_position', 'params': [
{
'parameter_name': 'position',
'parameter_type': 'integer',
'min_value': 0,
'max_value': 100
}
]}
],
'states': [
{
'state_name': 'current_pos',
'current_value': str(cover_data.get('current_pos', 0)),
'unit': '%'
},
{
'state_name': 'state',
'current_value': cover_data.get('state', 'unknown'),
'unit': None
}
]
})
# Temperatur-Sensoren
for key in status.keys():
if key.startswith('temperature:'):
temp_id = key.split(':')[1]
temp_data = status[key]
sensors.append({
'type': 'ShellyTemperatureSensor',
'name': f"{device_name}_Temperature_{temp_id}",
'url': f"http://{ip}/rpc/Temperature.GetStatus?id={temp_id}",
'parameters': json.dumps({
'device_id': info.get('id'),
'sensor_id': temp_id,
'model': device_model
}),
'states': [
{
'state_name': 'temperature',
'current_value': str(temp_data.get('tC', 0)),
'unit': '°C'
}
]
})
# Humidity-Sensoren
for key in status.keys():
if key.startswith('humidity:'):
hum_id = key.split(':')[1]
hum_data = status[key]
sensors.append({
'type': 'ShellyHumiditySensor',
'name': f"{device_name}_Humidity_{hum_id}",
'url': f"http://{ip}/rpc/Humidity.GetStatus?id={hum_id}",
'parameters': json.dumps({
'device_id': info.get('id'),
'sensor_id': hum_id,
'model': device_model
}),
'states': [
{
'state_name': 'humidity',
'current_value': str(hum_data.get('rh', 0)),
'unit': '%'
}
]
})
# Energie-Sensoren (Power Meter)
for i in range(10): # Max 10 switches/covers prüfen
switch_key = f'switch:{i}'
if switch_key in status:
switch_data = status[switch_key]
if 'apower' in switch_data: # Aktuelle Leistung
sensors.append({
'type': 'ShellyPowerMeter',
'name': f"{device_name}_Power_{i}",
'url': f"http://{ip}/rpc/Switch.GetStatus?id={i}",
'parameters': json.dumps({
'device_id': info.get('id'),
'switch_id': i,
'model': device_model
}),
'states': [
{
'state_name': 'active_power',
'current_value': str(switch_data.get('apower', 0)),
'unit': 'W'
},
{
'state_name': 'voltage',
'current_value': str(switch_data.get('voltage', 0)),
'unit': 'V'
},
{
'state_name': 'current',
'current_value': str(switch_data.get('current', 0)),
'unit': 'A'
}
]
})
return actors, sensors
def parse_gen1_device(self, device: Dict) -> tuple:
"""
Parst Gen1 Shelly-Gerät und extrahiert Aktoren/Sensoren
Returns:
(actors, sensors) Tuple mit Listen
"""
actors = []
sensors = []
info = device.get('info', {})
status = device.get('status', {})
ip = device['ip']
device_type = info.get('type', 'Unknown')
device_name = info.get('name', f"Shelly_{device_type}_{ip}")
# Relays als Aktoren
if 'relays' in status:
for i, relay in enumerate(status['relays']):
actors.append({
'type': f'ShellyRelay_{device_type}',
'name': f"{device_name}_Relay_{i}",
'url': f"http://{ip}/relay/{i}",
'parameters': json.dumps({
'device_type': device_type,
'relay_id': i,
'generation': 1
}),
'commands': [
{'command_name': 'turn_on', 'params': []},
{'command_name': 'turn_off', 'params': []},
{'command_name': 'toggle', 'params': []}
],
'states': [
{
'state_name': 'ison',
'current_value': str(relay.get('ison', False)),
'unit': None
}
]
})
# Rollers als Aktoren
if 'rollers' in status:
for i, roller in enumerate(status['rollers']):
actors.append({
'type': f'ShellyRoller_{device_type}',
'name': f"{device_name}_Roller_{i}",
'url': f"http://{ip}/roller/{i}",
'parameters': json.dumps({
'device_type': device_type,
'roller_id': i,
'generation': 1
}),
'commands': [
{'command_name': 'open', 'params': []},
{'command_name': 'close', 'params': []},
{'command_name': 'stop', 'params': []},
{'command_name': 'go_to_position', 'params': [
{
'parameter_name': 'position',
'parameter_type': 'integer',
'min_value': 0,
'max_value': 100
}
]}
],
'states': [
{
'state_name': 'current_pos',
'current_value': str(roller.get('current_pos', 0)),
'unit': '%'
},
{
'state_name': 'state',
'current_value': roller.get('state', 'stop'),
'unit': None
}
]
})
# Temperatur-Sensoren
if 'tmp' in status:
temp_data = status['tmp']
if 'tC' in temp_data:
sensors.append({
'type': 'ShellyTemperatureSensor',
'name': f"{device_name}_Temperature",
'url': f"http://{ip}/status",
'parameters': json.dumps({
'device_type': device_type,
'generation': 1
}),
'states': [
{
'state_name': 'temperature',
'current_value': str(temp_data.get('tC', 0)),
'unit': '°C'
}
]
})
# Energie-Sensoren (Meters)
if 'meters' in status:
for i, meter in enumerate(status['meters']):
sensors.append({
'type': 'ShellyPowerMeter',
'name': f"{device_name}_Power_{i}",
'url': f"http://{ip}/status",
'parameters': json.dumps({
'device_type': device_type,
'meter_id': i,
'generation': 1
}),
'states': [
{
'state_name': 'power',
'current_value': str(meter.get('power', 0)),
'unit': 'W'
},
{
'state_name': 'total',
'current_value': str(meter.get('total', 0)),
'unit': 'Wh'
}
]
})
return actors, sensors
def insert_actor(self, actor: Dict) -> Optional[int]:
"""
Fügt einen Aktor in die Datenbank ein
Returns:
Actor ID oder None bei Fehler
"""
try:
cursor = self.connection.cursor()
# Prüfe ob Aktor bereits existiert
cursor.execute(
"SELECT id FROM actors WHERE url = %s",
(actor['url'],)
)
result = cursor.fetchone()
if result:
actor_id = result[0]
# Update bestehender Aktor
cursor.execute(
"""UPDATE actors
SET type = %s, name = %s, parameters = %s
WHERE id = %s""",
(actor['type'], actor['name'], actor['parameters'], actor_id)
)
logger.info(f"Aktor aktualisiert: {actor['name']}")
else:
# Neuer Aktor
cursor.execute(
"""INSERT INTO actors (type, name, parameters, url)
VALUES (%s, %s, %s, %s)""",
(actor['type'], actor['name'], actor['parameters'], actor['url'])
)
actor_id = cursor.lastrowid
logger.info(f"Neuer Aktor eingefügt: {actor['name']}")
# Commands einfügen
for command in actor.get('commands', []):
cursor.execute(
"""INSERT INTO actor_commands (actor_id, command_name)
VALUES (%s, %s)
ON DUPLICATE KEY UPDATE command_name = command_name""",
(actor_id, command['command_name'])
)
command_id = cursor.lastrowid
# Command Parameters einfügen
for param in command.get('params', []):
cursor.execute(
"""INSERT INTO command_parameters
(command_id, parameter_name, parameter_type, min_value, max_value)
VALUES (%s, %s, %s, %s, %s)""",
(command_id, param['parameter_name'], param['parameter_type'],
param.get('min_value'), param.get('max_value'))
)
# States einfügen
for state in actor.get('states', []):
cursor.execute(
"""INSERT INTO actor_states
(actor_id, state_name, current_value, unit)
VALUES (%s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
current_value = VALUES(current_value),
last_updated = CURRENT_TIMESTAMP""",
(actor_id, state['state_name'], state['current_value'], state['unit'])
)
self.connection.commit()
cursor.close()
return actor_id
except Error as e:
logger.error(f"Fehler beim Einfügen des Aktors: {e}")
self.connection.rollback()
return None
def insert_sensor(self, sensor: Dict) -> Optional[int]:
"""
Fügt einen Sensor in die Datenbank ein
Returns:
Sensor ID oder None bei Fehler
"""
try:
cursor = self.connection.cursor()
# Prüfe ob Sensor bereits existiert
cursor.execute(
"SELECT id FROM sensors WHERE url = %s",
(sensor['url'],)
)
result = cursor.fetchone()
if result:
sensor_id = result[0]
# Update bestehender Sensor
cursor.execute(
"""UPDATE sensors
SET type = %s, name = %s, parameters = %s
WHERE id = %s""",
(sensor['type'], sensor['name'], sensor['parameters'], sensor_id)
)
logger.info(f"Sensor aktualisiert: {sensor['name']}")
else:
# Neuer Sensor
cursor.execute(
"""INSERT INTO sensors (type, name, parameters, url)
VALUES (%s, %s, %s, %s)""",
(sensor['type'], sensor['name'], sensor['parameters'], sensor['url'])
)
sensor_id = cursor.lastrowid
logger.info(f"Neuer Sensor eingefügt: {sensor['name']}")
# States einfügen
for state in sensor.get('states', []):
cursor.execute(
"""INSERT INTO sensor_states
(sensor_id, state_name, current_value, unit)
VALUES (%s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
current_value = VALUES(current_value),
last_updated = CURRENT_TIMESTAMP""",
(sensor_id, state['state_name'], state['current_value'], state['unit'])
)
self.connection.commit()
cursor.close()
return sensor_id
except Error as e:
logger.error(f"Fehler beim Einfügen des Sensors: {e}")
self.connection.rollback()
return None
def process_devices(self, devices: List[Dict]):
"""
Verarbeitet alle entdeckten Geräte und schreibt sie in die DB
"""
total_actors = 0
total_sensors = 0
for device in devices:
logger.info(f"Verarbeite Gerät: {device['ip']}")
if device['generation'] == 2:
actors, sensors = self.parse_gen2_device(device)
else:
actors, sensors = self.parse_gen1_device(device)
# Aktoren einfügen
for actor in actors:
if self.insert_actor(actor):
total_actors += 1
# Sensoren einfügen
for sensor in sensors:
if self.insert_sensor(sensor):
total_sensors += 1
logger.info(f"Verarbeitung abgeschlossen: {total_actors} Aktoren, {total_sensors} Sensoren")
def main():
"""Hauptfunktion"""
parser = argparse.ArgumentParser(
description='Findet Shelly-Geräte im Netzwerk und schreibt sie in die Datenbank'
)
parser.add_argument(
'--network',
default='192.168.1',
help='Netzwerk-Präfix (Standard: 192.168.1)'
)
parser.add_argument(
'--start-ip',
type=int,
default=1,
help='Start IP (letztes Oktett, Standard: 1)'
)
parser.add_argument(
'--end-ip',
type=int,
default=254,
help='End IP (letztes Oktett, Standard: 254)'
)
parser.add_argument(
'--db-host',
default='localhost',
help='Datenbank Host (Standard: localhost)'
)
parser.add_argument(
'--db-user',
required=True,
help='Datenbank Benutzer'
)
parser.add_argument(
'--db-password',
required=True,
help='Datenbank Passwort'
)
parser.add_argument(
'--db-name',
default='EnergyFlow',
help='Datenbank Name (Standard: EnergyFlow)'
)
parser.add_argument(
'--debug',
action='store_true',
help='Debug-Modus aktivieren'
)
args = parser.parse_args()
if args.debug:
logger.setLevel(logging.DEBUG)
# Discovery
logger.info("Starte Shelly Device Discovery...")
discovery = ShellyDiscovery(network_range=args.network)
devices = discovery.discover_devices(start_ip=args.start_ip, end_ip=args.end_ip)
if not devices:
logger.warning("Keine Shelly-Geräte gefunden!")
return
# Datenbank-Schreibvorgang
logger.info("Schreibe Geräte in Datenbank...")
db_writer = ShellyDatabaseWriter(
host=args.db_host,
user=args.db_user,
password=args.db_password,
database=args.db_name
)
try:
db_writer.connect()
db_writer.process_devices(devices)
finally:
db_writer.disconnect()
logger.info("Fertig!")
if __name__ == '__main__':
main()