#!/usr/bin/env python3 """ Shelly Module Enthält NUR Shelly-spezifische Geräte-Discovery Logik KEINE Datenbank-Operationen! """ import json import requests import socket import logging from typing import List, Dict, Optional, Tuple from modules.base_module import BaseModule # 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 = 2, end_ip: int = 254, timeout: float = 0.3) -> 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 """ logger.info(f"Suche Shelly Getät unter: {ip}") 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') or data['type'].startswith('SHSW')): 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 # ============================================================================ # MODULE WRAPPER # ============================================================================ class ShellyModule(BaseModule): """ Shelly Modul - Implementiert BaseModule Interface Gibt Actors/Sensors zurück, KEINE DB-Operationen """ def is_enabled(self) -> bool: """Prüft ob Shelly aktiviert ist""" return self.config.shelly_enable def discover(self) -> Tuple[List[Dict], List[Dict]]: """ Führt Shelly Discovery durch Returns: Tuple (actors, sensors) """ logger.info("\n" + "=" * 60) logger.info("SHELLY-GERÄTE WERDEN GESUCHT") logger.info("=" * 60) actors = [] sensors = [] try: discovery = ShellyDiscovery(network_range=self.config.shelly_network_range) devices = discovery.discover_devices( start_ip=self.config.shelly_start_ip, end_ip=self.config.shelly_end_ip ) if not devices: logger.info("Keine Shelly-Geräte gefunden") return actors, sensors logger.info(f"{len(devices)} Shelly-Geräte gefunden") # Verarbeite jedes Gerät for device in devices: if device['generation'] == 2: device_actors, device_sensors = self._parse_gen2_device(device) else: device_actors, device_sensors = self._parse_gen1_device(device) actors.extend(device_actors) sensors.extend(device_sensors) except Exception as e: logger.error(f"✗ Shelly Discovery Fehler: {e}") logger.info(f"Shelly: {len(actors)} Aktoren, {len(sensors)} Sensoren gefunden") return actors, sensors def _parse_gen2_device(self, device: Dict) -> Tuple[List[Dict], List[Dict]]: """Parst Gen2 Shelly-Gerät""" 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 switch_count = sum(1 for key in status.keys() if key.startswith('switch:')) for i in range(switch_count): switch_data = status.get(f'switch:{i}', {}) actors.append({ 'type': f'ShellySwitch_{device_model}'.replace(' ', '_'), 'name': f"{device_name}_Switch_{i}", 'url': f"http://{ip}/rpc/Switch.Set?id={i}", 'commands': [ {'command': 'turn_on', 'parameters': []}, {'command': 'turn_off', 'parameters': []}, {'command': 'toggle', 'parameters': []} ], 'states': [ { 'name': 'output', 'type': 'boolean', 'current_value': switch_data.get('output', False) } ] }) # Temperatursensoren temp_count = sum(1 for key in status.keys() if key.startswith('temperature:')) for i in range(temp_count): temp_data = status.get(f'temperature:{i}', {}) sensors.append({ 'type': 'ShellyTemperatureSensor', 'name': f"{device_name}_Temp_{i}", 'url': f"http://{ip}/rpc/Temperature.GetStatus?id={i}", 'states': [ { 'name': 'temperature', 'type': 'number', 'current_value': temp_data.get('tC'), 'unit': '°C' } ] }) # Energy-Meter em_count = sum(1 for key in status.keys() if key.startswith('em:')) for i in range(em_count): em_data = status.get(f'em:{i}', {}) sensors.append({ 'type': 'ShellyEnergyMeter', 'name': f"{device_name}_EM_{i}", 'url': f"http://{ip}/rpc/em.GetStatus?id={i}", 'states': [] }) if(em_data.get('a_voltage') is not None): sensors[len(sensors)-1]['states'].append({ 'name': 'Spannung Phase A', 'type': 'number', 'url': 'a_voltage', 'current_value': em_data.get('a_voltage'), 'unit': 'V'}) if(em_data.get('b_voltage') is not None): sensors[len(sensors)-1]['states'].append({ 'name': 'Spannung Phase B', 'type': 'number', 'url': 'b_voltage', 'current_value': em_data.get('b_voltage'), 'unit': 'V'}) if(em_data.get('c_voltage') is not None): sensors[len(sensors)-1]['states'].append({ 'name': 'Spannung Phase C', 'type': 'number', 'url': 'c_voltage', 'current_value': em_data.get('c_voltage'), 'unit': 'V'}) if(em_data.get('a_current') is not None): sensors[len(sensors)-1]['states'].append({ 'name': 'Strom Phase A', 'type': 'number', 'url': 'a_current', 'current_value': em_data.get('a_current'), 'unit': 'A'}) if(em_data.get('b_current') is not None): sensors[len(sensors)-1]['states'].append({ 'name': 'Strom Phase B', 'type': 'number', 'url': 'b_current', 'current_value': em_data.get('b_current'), 'unit': 'A'}) if(em_data.get('c_current') is not None): sensors[len(sensors)-1]['states'].append({ 'name': 'Strom Phase C', 'type': 'number', 'url': 'c_current', 'current_value': em_data.get('c_current'), 'unit': 'A'}) if(em_data.get('a_act_power') is not None): sensors[len(sensors)-1]['states'].append({ 'name': 'Wirkleistung Phase A', 'type': 'number', 'url': 'a_act_power', 'current_value': em_data.get('a_act_power'), 'unit': 'W'}) if(em_data.get('b_act_power') is not None): sensors[len(sensors)-1]['states'].append({ 'name': 'Wirkleistung Phase B', 'type': 'number', 'url': 'b_act_power', 'current_value': em_data.get('b_act_power'), 'unit': 'W'}) if(em_data.get('c_act_power') is not None): sensors[len(sensors)-1]['states'].append({ 'name': 'Wirkleistung Phase C', 'type': 'number', 'url': 'c_act_power', 'current_value': em_data.get('c_act_power'), 'unit': 'W'}) if(em_data.get('a_aprt_power') is not None): sensors[len(sensors)-1]['states'].append({ 'name': 'Scheinleistung Phase A', 'type': 'number', 'url': 'a_aprt_power', 'current_value': em_data.get('a_aprt_power'), 'unit': 'VA'}) if(em_data.get('b_aprt_power') is not None): sensors[len(sensors)-1]['states'].append({ 'name': 'Scheinleistung Phase B', 'type': 'number', 'url': 'b_aprt_power', 'current_value': em_data.get('b_aprt_power'), 'unit': 'VA'}) if(em_data.get('c_aprt_power') is not None): sensors[len(sensors)-1]['states'].append({ 'name': 'Scheinleistung Phase C', 'type': 'number', 'url': 'c_aprt_power', 'current_value': em_data.get('c_aprt_power'), 'unit': 'VA'}) if(em_data.get('a_freq') is not None): sensors[len(sensors)-1]['states'].append({ 'name': 'Frequenz Phase A', 'type': 'number', 'url': 'a_freq', 'current_value': em_data.get('a_freq'), 'unit': 'Hz'}) if(em_data.get('b_freq') is not None): sensors[len(sensors)-1]['states'].append({ 'name': 'Frequenz Phase B', 'type': 'number', 'url': 'b_freq', 'current_value': em_data.get('b_freq'), 'unit': 'Hz'}) if(em_data.get('c_freq') is not None): sensors[len(sensors)-1]['states'].append({ 'name': 'Frequenz Phase C', 'type': 'number', 'url': 'c_freq', 'current_value': em_data.get('c_freq'), 'unit': 'Hz'}) if(em_data.get('total_act_power') is not None): sensors[len(sensors)-1]['states'].append({ 'name': 'Wirkleistung gesamt', 'type': 'number', 'url': 'total_act_power', 'current_value': em_data.get('total_act_power'), 'unit': 'W'}) return actors, sensors def _parse_gen1_device(self, device: Dict) -> Tuple[List[Dict], List[Dict]]: """Parst Gen1 Shelly-Gerät""" actors = [] sensors = [] info = device.get('info', {}) status = device.get('status', {}) ip = device['ip'] device_name = info.get('name', f"Shelly_{info.get('type', ip)}") device_type = info.get('type', 'Unknown') # Relays als Aktoren relays = status.get('relays', []) for i, relay in enumerate(relays): actors.append({ 'type': f'ShellyRelay_{device_type}'.replace(' ', '_'), 'name': f"{device_name}_Relay_{i}", 'url': f"http://{ip}/relay/{i}", 'commands': [ {'command': 'turn_on', 'parameters': []}, {'command': 'turn_off', 'parameters': []}, {'command': 'toggle', 'parameters': []} ], 'states': [ { 'name': 'ison', 'type': 'boolean', 'current_value': relay.get('ison', False) } ] }) # Temperatursensoren temp_data = status.get('tmp', {}) if temp_data and 'tC' in temp_data: sensors.append({ 'type': 'ShellyTemperatureSensor', 'name': f"{device_name}_Temp", 'url': f"http://{ip}/status", 'states': [ { 'name': 'temperature', 'type': 'number', 'current_value': temp_data.get('tC'), 'unit': '°C' } ] }) return actors, sensors