#!/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 concurrent.futures 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): self.devices = [] def scan_network(self, network: str = None, max_threads: int = 50) -> List[str]: """Scannt das Netzwerk nach Shelly-Geräten""" if network is None: try: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.connect(("8.8.8.8", 80)) local_ip = s.getsockname()[0] s.close() network_prefix = '.'.join(local_ip.split('.')[:-1]) except: logger.warning("Konnte lokale IP nicht ermitteln, verwende 192.168.1.x") network_prefix = "192.168.1" else: network_prefix = '.'.join(network.split('.')[:3]) logger.info(f"Scanne Netzwerk {network_prefix}.0/24 nach Shelly-Geräten...") def check_ip(ip): device = ShellyDiscovery.is_shelly_device(ip) if device: return device return None shelly_devices = [] with concurrent.futures.ThreadPoolExecutor(max_workers=max_threads) as executor: futures = [executor.submit(check_ip, f"{network_prefix}.{i}") for i in range(1, 255)] for future in concurrent.futures.as_completed(futures): result = future.result() if result: shelly_devices.append(result) #logger.info(f"Shelly-Gerät gefunden: {result}") return shelly_devices """def scan_network(self, start_ip: int = 2, end_ip: int = 254, timeout: float = 0.3) -> List[str]: 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(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}/settings", timeout=2 ) if response.status_code == 200: data = response.json() if 'device' in data and (data['device']['type'].startswith('SHELLY') or data['device']['type'].startswith('SHSW')): logger.info(f"Shelly Gen1 Gerät gefunden: {ip} - {data['device'].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 get_mqtt_config(self, device: Dict) -> Optional[Dict]: """ Holt die MQTT-Einstellung eines Gen2-Geräts. Nur Gen2 kann MQTT; die alten Geräte werden weiterhin abgefragt. Wer sendet, muss nicht gepollt werden - deshalb interessiert hier nicht nur, ob MQTT eingeschaltet ist, sondern auch das Präfix: daraus ergibt sich das Topic, unter dem die Werte ankommen. Returns: Die Antwort von Mqtt.GetConfig oder None (Gen1, altes Firmware, Gerät nicht erreichbar). """ if device.get('generation') != 2: return None try: response = requests.get( f"http://{device['ip']}/rpc/Mqtt.GetConfig", timeout=2 ) if response.status_code == 200: return response.json() except Exception as e: logger.debug(f"Keine MQTT-Einstellung von {device['ip']}: {e}") return None def discover_devices(self) -> List[Dict]: """ Entdeckt alle Shelly-Geräte im Netzwerk Returns: Liste von Shelly-Geräten mit Status """ self.devices = self.scan_network() for idx, device in enumerate(self.devices): status = self.get_device_status(device) self.devices[idx]['status'] = status self.devices[idx]['mqtt'] = self.get_mqtt_config(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 # Welche Komponenten ihren Zustand von selbst an den Broker schicken. # # "status_ntf" am Geraet heisst nicht, dass jede Komponente etwas sendet - # es heisst nur, dass gesendet werden darf. Nachgemessen am Pro 3EM: unter # Power_EG/# kommen ausschliesslich em:0, emdata:0 und online. Die # Temperatur der Endstufe (temperature:0) steht zwar im RPC-Status, wird # aber nie veroeffentlicht - ein Messwert, der auf dieses Topic zeigte, # bliebe fuer immer auf seinem letzten Wert stehen. # # Deshalb eine ausdrueckliche Liste statt "alles, was MQTT kann". Wer eine # Komponente ergaenzen will, hoert vorher nach: # mosquitto_sub -t '/#' -v # und traegt sie hier ein, wenn sie tatsaechlich sendet. Alles andere # bleibt beim Abfragen - langsamer, aber richtig. MQTT_KOMPONENTEN = {"em", "switch"} @staticmethod def _mqtt_praefix(device: Dict) -> Optional[str]: """ Das Topic-Präfix, unter dem ein Gerät seine Zustände sendet - oder None, wenn es das nicht tut. Verlangt beides: MQTT eingeschaltet UND die Statusmeldungen eingeschaltet. Ohne status_ntf hält das Gerät die Verbindung, schickt aber nichts von selbst - dann bliebe ein Messwert auf dem Broker ewig aus, und Abfragen ist die richtige Antwort. """ mqtt = device.get('mqtt') or {} if mqtt.get('enable') and mqtt.get('status_ntf') and mqtt.get('topic_prefix'): return mqtt['topic_prefix'] return None @staticmethod def _adressiere(states: List[Dict], praefix: Optional[str], komponente: str, index: int) -> List[Dict]: """ Die Messwerte einer Komponente adressieren. Zwei Wege, und der Unterschied ist nicht Geschmack, sondern Technik: ohne MQTT Das Gerät wird abgefragt. Die Adresse steht am Gerät (actors.url = der RPC-Aufruf), der Messwert nennt nur das Feld der Antwort: url = 'total_act_power'. mit MQTT Es gibt nichts abzufragen, die Nachricht kommt von selbst. Dann muss die Adresse am Messwert stehen: url = 'Power_EG/status/em:0', und das Feld in der JSON-Nachricht wandert nach value_path - genau so, wie es das MQTT-Modul für alle anderen Geräte hält. Damit fällt für die neuen Geräte das Abfragen im Automatik-Runner weg; die alten Shellys ohne MQTT bleiben unverändert beim HTTP-Weg - und ebenso jede Komponente, die zwar am Gerät hängt, aber nichts sendet (siehe MQTT_KOMPONENTEN). """ if not praefix or komponente not in ShellyModule.MQTT_KOMPONENTEN: return states topic = f"{praefix}/status/{komponente}:{index}" for state in states: # Was bisher die Adresse war, ist jetzt der Schlüssel darin. state['value_path'] = state.get('url') state['url'] = topic return states 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() devices = discovery.discover_devices() 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') # Sendet das Geraet seine Zustaende selbst? Dann bekommen die # Messwerte Topics statt Feldnamen - siehe _adressiere(). praefix = self._mqtt_praefix(device) if praefix: logger.info(f" {device_name}: {', '.join(sorted(self.MQTT_KOMPONENTEN))}" f" ueber MQTT ({praefix}/status/...), der Rest ueber HTTP") # 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', 'url': 'on=true', 'parameters': []}, {'command': 'turn_off', 'url': 'on=false', 'parameters': []}, {'command': 'toggle', 'parameters': []} ], 'states': self._adressiere([ { 'name': 'output', 'type': 'boolean', 'url': 'output', 'current_value': switch_data.get('output', False) } ], praefix, 'switch', i) }) # 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': 'Temperatur', 'name': f"{device_name}_Temp_{i}", 'url': f"http://{ip}/rpc/Temperature.GetStatus?id={i}", 'states': self._adressiere([ { 'name': 'temperature', 'type': 'number', 'current_value': temp_data.get('tC'), 'url': f"tC", 'unit': '°C' } ], praefix, 'temperature', i) }) # 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': 'Stromzähler', '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'}) # Erst hier, weil die Messwerte oben einzeln angehaengt werden. self._adressiere(sensors[len(sensors)-1]['states'], praefix, 'em', i) 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['device'].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', 'url': 'turn=on', 'parameters': []}, {'command': 'turn_off', 'url': 'turn=off', 'parameters': []}, {'command': 'toggle', 'url': 'turn=toggle', 'parameters': []} ], 'states': [ { 'name': 'ison', 'type': 'boolean', 'url': 'ison', 'current_value': relay.get('ison', False) } ] }) # Temperatursensoren temp_data = status.get('tmp', {}) if temp_data and 'tC' in temp_data: sensors.append({ 'type': 'Temperatur', '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