Regenbasierte automatische Gartenbewässerung + homeMesh-Anbindung für AutoAction-Regeleditor

- Neues stündliches Python-Script (restricted/gartenbewaesserung/auto_watering.py): holt Niederschlags-
  und Sonnenuntergangsdaten von Open-Meteo, wertet je Zone (Hochbeete/Tröge/Garten vorn) individuelle
  Regen-Schwellenwerte und Mindest-Bewässerungsdauern aus und startet bei Bedarf per MQTT den passenden
  Automatik-Modus der Ventilsteuerung im Zeitraum vor Sonnenuntergang; veröffentlicht den Regen-Status
  zusätzlich stündlich als retained MQTT-Nachricht je Zone.
- Web-UI: neue Bewässerungs-Steuerung/-Anzeige (ajax/watering.php, js/solar/solarMQTT.js,
  assets/img/realtime.svg) inkl. Live-Status-Icons (aus/geplant/pausiert/aktiv per Farbe und Symbol).
- AutoAction-Regeleditor (ajax/actorDetails.php, sensorDetails.php, fillActorDD.php, fillSensorDD.php,
  tahoma.php, AutoAction.php) liest Actor-/Sensor-Parameter jetzt live aus der homeMesh-Datenbank statt
  aus stark vereinfachten Altfeldern, inkl. zugehöriger Anpassungen am Device-Discovery-Tooling
  (restricted/deviceDiscovery/*, neues logic_module.py).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-26 20:19:27 +02:00
co-authored by Claude Sonnet 5
parent 4de95c1a29
commit 29f752eafe
22 changed files with 1693 additions and 234 deletions
+117 -28
View File
@@ -19,6 +19,7 @@ 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.logic_module import LogicModule #nicht Vergessen neue Module auch in Zeile 546 einzufügen!!
# Logging
logging.basicConfig(
@@ -142,6 +143,14 @@ class Config:
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)
# MQTT
@property
def mqtt_enable(self) -> bool:
@@ -236,6 +245,33 @@ class Config:
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
@@ -272,13 +308,13 @@ class DatabaseManager:
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("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")
@@ -289,6 +325,8 @@ class DatabaseManager:
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:
"""
@@ -306,22 +344,35 @@ class DatabaseManager:
# Actor einfügen
query = """
INSERT INTO actors (type, name, parameters, url)
VALUES (%s, %s, NULL, %s)
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))
actor_id = cursor.lastrowid
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)
VALUES (%s, %s)
INSERT INTO actor_commands (actor_id, command_name, command_url)
VALUES (%s, %s, %s)
ON DUPLICATE KEY UPDATE
command_name = VALUES(command_name)
"""
cursor.execute(cmd_query, (actor_id, command_name))
command_id = cursor.lastrowid
cursor.execute(cmd_query, (actor_id, command_name, command_url))
query = """
SELECT ID FROM actor_commands where actor_id = %s and command_url = %s
"""
cursor.execute(query, (actor_id, command_url))
command_id = cursor.fetchone()[0]
# Parameter einfügen
for param in cmd.get('parameters', []):
@@ -329,12 +380,18 @@ class DatabaseManager:
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_name = VALUES(parameter_name),
parameter_type = VALUES(parameter_type),
min_value = VALUES(min_value),
max_value = VALUES(max_value),
possible_values = VALUES(possible_values)
"""
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
possible_vals = json.dumps(param.get('values')) if 'values' in param else ""
param_url = param.get('url')
cursor.execute(param_query,
@@ -344,16 +401,22 @@ class DatabaseManager:
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)
(actor_id, state_name, state_type, current_value, unit, url, possible_values)
VALUES (%s, %s, %s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
state_name = VALUES(state_name),
state_type = VALUES(state_type),
current_value = VALUES(current_value),
possible_values = VALUES(possible_values),
unit = VALUES(unit)
"""
state_name = state.get('name', '')
state_type = state.get('type', 0)
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')
cursor.execute(state_query, (actor_id, state_name, state_type, current_value, unit, state_url))
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))
self.connection.commit()
cursor.close()
@@ -363,6 +426,17 @@ class DatabaseManager:
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:
"""
@@ -379,21 +453,34 @@ class DatabaseManager:
# Sensor einfügen
query = """
INSERT INTO sensors (type, name, parameters, url)
VALUES (%s, %s, NULL, %s)
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 sensor_states
(sensor_id, state_name, state_type, current_value, unit, url)
INSERT INTO actor_states
(actor_id, state_name, state_type, current_value, unit, url)
VALUES (%s, %s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
state_name = VALUES(state_name),
state_type = VALUES(state_type),
current_value = VALUES(current_value),
unit = VALUES(unit)
"""
state_name = state.get('name', '')
state_type = state.get('type', 0)
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')
@@ -460,13 +547,15 @@ def main():
total_actors = 0
total_sensors = 0
db.fillVariableTypes()
# Module initialisieren
modules = [
TahomaModule(config),
WLEDModule(config),
MQTTModule(config),
ShellyModule(config)
ShellyModule(config),
LogicModule(config)
]
# Jedes Modul durchlaufen
@@ -52,6 +52,7 @@ class BaseModule(ABC):
'type': str,
'min': float (optional),
'max': float (optional),
'url': str, # Eindeutige ID/URL
'values': list (optional)
}
]
@@ -77,6 +78,7 @@ class BaseModule(ABC):
'name': str,
'type': int/str,
'current_value': any,
'url': str, # Eindeutige ID/URL
'unit': str (optional)
}
]
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""
WLED Module
Enthält NUR Logik-spezifische Geräte-Discovery Logik
KEINE Datenbank-Operationen!
"""
import requests
import socket
import concurrent.futures
import logging
from typing import List, Dict, Optional, Tuple
from modules.base_module import BaseModule
logger = logging.getLogger(__name__)
class LogicModule(BaseModule):
"""
Logic Modul - Implementiert BaseModule Interface
Gibt nur Actors zurück, KEINE DB-Operationen
"""
def is_enabled(self) -> bool:
"""Prüft ob Logic aktiviert ist"""
return self.config.logic_enable
def discover(self) -> Tuple[List[Dict], List[Dict]]:
"""
Erzeugt Einträge für Sensorunabhängige Funktionen (Timer, Sonnenauf/untergang, Uhrzeiten, etc.)
Returns:
Tuple (actors, sensors) - WLED sind immer Actors
"""
logger.info("\n" + "=" * 60)
logger.info("LOGIC-GERÄTE WERDEN ERZEUGT")
logger.info("=" * 60)
actors = []
sensors = []
states = []
states.append({
'name': 'Uhrzeit',
'url': 'time',
'type': 'time',
'current_value': '13:45'
})
states.append({
'name': 'Datum',
'url': 'date',
'type': 'date',
'current_value': '20.03.2026'
})
states.append({
'name': 'Sonnenaufgang',
'url': 'sunrise',
'type': 'deltatime',
'current_value': '00:00'
})
states.append({
'name': 'Sonnenuntergang',
'url': 'sunset',
'type': 'deltatime',
'current_value': '00:00'
})
sensors.append({'type': 'LOGIC',
'name': "Zeitpunkt",
'url': f"Logic",
'commands': [],
'states': states})
return actors, sensors
@@ -255,10 +255,13 @@ class MQTTDeviceConverter:
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:
device_mf = device_info.get('model', device_info.get('mdl', device_info.get('mf','MQTT')))
if(device_info.get('mf','MQTT') == "OpenDTU"):
device_mf = "Wechselrichter"
#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)
# if entities:
# device_name = entities[0]['config'].get('name', device_id)
# Device URL
device_url = f"mqtt://{device_id}"
@@ -279,11 +282,13 @@ class MQTTDeviceConverter:
component = entity['component']
object_id = entity.get('object_id', 'unknown')
config = entity['config']
if(device_mf == "MQTT"):
device_mf = config.get('device', device_info.get('dev',{'model': 'MQTT'})).get('model', device_info.get('mdl', device_info.get('mf','MQTT')))
# Command aus der Entity erstellen
command_entry = MQTTDeviceConverter._entity_to_command(component, object_id, config)
command_entry = MQTTDeviceConverter._entity_to_commands(component, object_id, config)
if command_entry:
commands.append(command_entry)
commands.extend(command_entry)
# States aus der Entity extrahieren
entity_states = MQTTDeviceConverter._entity_to_states(component, object_id, config)
@@ -292,7 +297,7 @@ class MQTTDeviceConverter:
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
'type': device_mf, # Allgemeiner Typ für Multi-Entity-Geräte
'url': device_url,
'commands': commands,
'states': states
@@ -306,7 +311,8 @@ class MQTTDeviceConverter:
component = entity['component']
object_id = entity.get('object_id', 'unknown')
config = entity['config']
if(device_mf == "MQTT"):
device_mf = device_info.get('model', device_info.get('mdl', device_info.get('mf','MQTT')))
# States aus der Entity extrahieren
entity_states = MQTTDeviceConverter._entity_to_states(component, object_id, config)
states.extend(entity_states)
@@ -314,7 +320,7 @@ class MQTTDeviceConverter:
if states: # Nur Sensor erstellen wenn States vorhanden
sensor_result = {
'name': device_name,
'type': f"mqtt_device",
'type': device_mf,
'url': device_url,
'states': states
}
@@ -322,7 +328,7 @@ class MQTTDeviceConverter:
return actor_result, sensor_result
@staticmethod
def _entity_to_command(component: str, object_id: str, config: Dict) -> Optional[Dict]:
def _entity_to_commands(component: str, object_id: str, config: Dict) -> Optional[Dict]:
"""
Konvertiert eine MQTT Entity in ein Command
@@ -336,19 +342,21 @@ class MQTTDeviceConverter:
"""
# Command Topic - verschiedene mögliche Feldnamen
command_topic = config.get('command_topic') or config.get('cmd_t') or config.get('temperature_command_topic')
base_topic = config.get('~','')
command_topic = command_topic.replace("~",base_topic)
if not command_topic:
return None
# Command-Name aus object_id ableiten
command_name = object_id.replace('_', ' ').title().replace(' ', '')
#command_name =
# Oder aus dem Namen
entity_name = config.get('name', object_id)
command_name = config.get('name', object_id.replace('_', ' ').title().replace(' ', ''))
command_entry = {
command_entry = [{
'command': command_name,
'parameters': []
}
'parameters': [],
'url': command_topic
}]
# Parameter basierend auf Component-Typ
if component == 'number':
@@ -375,7 +383,7 @@ class MQTTDeviceConverter:
if unit:
param['unit'] = unit
command_entry['parameters'].append(param)
command_entry[0]['parameters'].append(param)
elif component == 'select':
# Select hat Optionen
@@ -389,7 +397,7 @@ class MQTTDeviceConverter:
if options:
param['values'] = options
command_entry['parameters'].append(param)
command_entry[0]['parameters'].append(param)
elif component in ['switch', 'light']:
# Switch/Light haben on/off
@@ -402,7 +410,7 @@ class MQTTDeviceConverter:
config.get('payload_off', config.get('pl_off', 'OFF'))
]
}
command_entry['parameters'].append(param)
command_entry[0]['parameters'].append(param)
# Brightness für Light
brightness_cmd_topic = (
@@ -410,7 +418,7 @@ class MQTTDeviceConverter:
config.get('bri_cmd_t')
)
if component == 'light' and brightness_cmd_topic:
command_entry['parameters'].append({
command_entry[0]['parameters'].append({
'name': 'brightness',
'type': 'integer',
'min': 0,
@@ -432,7 +440,7 @@ class MQTTDeviceConverter:
'max': 100,
'url': set_pos_topic
}
command_entry['parameters'].append(param)
command_entry[0]['parameters'].append(param)
else:
# Nur open/close/stop
param = {
@@ -441,7 +449,7 @@ class MQTTDeviceConverter:
'url': command_topic,
'values': ['OPEN', 'CLOSE', 'STOP']
}
command_entry['parameters'].append(param)
command_entry[0]['parameters'].append(param)
elif component == 'button':
# Button hat normalerweise keinen Parameter, nur das Topic
@@ -450,7 +458,7 @@ class MQTTDeviceConverter:
'type': 'trigger',
'url': command_topic
}
command_entry['parameters'].append(param)
command_entry[0]['parameters'].append(param)
elif component == 'climate':
# Climate hat Temperatur-Setpoint
@@ -469,8 +477,9 @@ class MQTTDeviceConverter:
param['min'] = config['min_temp']
if 'max_temp' in config:
param['max'] = config['max_temp']
command_entry['parameters'].append(param)
command_entry[0]['command'] = "Solltemperatur"
command_entry[0]['parameters'].append(param)
mode_cmd_topic = (
config.get('mode_command_topic') or
config.get('mode_cmd_t')
@@ -482,7 +491,10 @@ class MQTTDeviceConverter:
'url': mode_cmd_topic,
'values': config.get('modes', [])
}
command_entry['parameters'].append(param)
command_entry.append({
'command': "Modus",
'parameters': [param]
})
else:
# Generischer Command mit dem Topic
param = {
@@ -512,8 +524,7 @@ class MQTTDeviceConverter:
# 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
config.get('stat_t')
)
# Bei number/select: oft kein separates state_topic, dann command_topic verwenden
@@ -523,6 +534,8 @@ class MQTTDeviceConverter:
state_topic = config.get('command_topic') or config.get('cmd_t')
if state_topic:
base_topic = config.get('~','')
state_topic = state_topic.replace("~",base_topic)
state_entry = {
'name': object_id,
'type': 'string',
@@ -654,7 +667,6 @@ class MQTTModule(BaseModule):
actor_data, sensor_data = MQTTDeviceConverter.convert_device_to_actors_and_sensors(
device_id, device_data
)
if actor_data:
actors.append(actor_data)
@@ -11,6 +11,7 @@ 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
@@ -28,22 +29,47 @@ class ShellyDiscovery:
SHELLY_MDNS_SERVICE = "_http._tcp.local."
COMMON_PORTS = [80]
def __init__(self, network_range: str = "192.168.1"):
self.network_range = network_range
def __init__(self):
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
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])
Args:
start_ip: Start IP (letztes Oktett)
end_ip: End IP (letztes Oktett)
timeout: Timeout für Socket-Verbindung
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)]
Returns:
Liste von erreichbaren IP-Adressen
"""
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}...")
@@ -64,8 +90,8 @@ class ShellyDiscovery:
logger.info(f"{len(active_hosts)} aktive Hosts gefunden")
return active_hosts
def is_shelly_device(self, ip: str) -> Optional[Dict]:
"""
def is_shelly_device(ip: str) -> Optional[Dict]:
"""
Prüft ob ein Host ein Shelly-Gerät ist
@@ -75,7 +101,7 @@ class ShellyDiscovery:
Returns:
Device Info Dict wenn Shelly, sonst None
"""
logger.info(f"Suche Shelly Getät unter: {ip}")
#logger.info(f"Suche Shelly Getät unter: {ip}")
try:
# Versuche Gen2 API (neuere Shelly-Geräte)
response = requests.get(
@@ -96,13 +122,13 @@ class ShellyDiscovery:
try:
# Versuche Gen1 API (ältere Shelly-Geräte)
response = requests.get(
f"http://{ip}/shelly",
f"http://{ip}/settings",
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')}")
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,
@@ -146,22 +172,19 @@ class ShellyDiscovery:
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]:
def discover_devices(self) -> 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)
self.devices = self.scan_network()
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)
for idx, device in enumerate(self.devices):
status = self.get_device_status(device)
self.devices[idx]['status'] = status
logger.info(f"Insgesamt {len(self.devices)} Shelly-Geräte entdeckt")
return self.devices
@@ -197,11 +220,8 @@ class ShellyModule(BaseModule):
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
)
discovery = ShellyDiscovery()
devices = discovery.discover_devices()
if not devices:
logger.info("Keine Shelly-Geräte gefunden")
@@ -266,7 +286,7 @@ class ShellyModule(BaseModule):
temp_data = status.get(f'temperature:{i}', {})
sensors.append({
'type': 'ShellyTemperatureSensor',
'type': 'Temperatur',
'name': f"{device_name}_Temp_{i}",
'url': f"http://{ip}/rpc/Temperature.GetStatus?id={i}",
'states': [
@@ -274,6 +294,7 @@ class ShellyModule(BaseModule):
'name': 'temperature',
'type': 'number',
'current_value': temp_data.get('tC'),
'url': f"tC",
'unit': '°C'
}
]
@@ -283,7 +304,7 @@ class ShellyModule(BaseModule):
for i in range(em_count):
em_data = status.get(f'em:{i}', {})
sensors.append({
'type': 'ShellyEnergyMeter',
'type': 'Stromzähler',
'name': f"{device_name}_EM_{i}",
'url': f"http://{ip}/rpc/em.GetStatus?id={i}",
'states': []
@@ -413,7 +434,7 @@ class ShellyModule(BaseModule):
ip = device['ip']
device_name = info.get('name', f"Shelly_{info.get('type', ip)}")
device_type = info.get('type', 'Unknown')
device_type = info['device'].get('type', 'Unknown')
# Relays als Aktoren
relays = status.get('relays', [])
@@ -440,7 +461,7 @@ class ShellyModule(BaseModule):
temp_data = status.get('tmp', {})
if temp_data and 'tC' in temp_data:
sensors.append({
'type': 'ShellyTemperatureSensor',
'type': 'Temperatur',
'name': f"{device_name}_Temp",
'url': f"http://{ip}/status",
'states': [
@@ -51,47 +51,166 @@ class DeviceClassifier:
"""Original DeviceClassifier - unverändert"""
ACTOR_TYPES = {
'RollerShutter', 'ExteriorScreen', 'Awning', 'Blind',
'GarageDoor', 'Window', 'Light', 'OnOff', 'DimmableLight',
'HeatingSystem', 'Valve', 'Switch', 'Door', 'Curtain',
'VenetianBlind', 'PergolaScreen'
'RollerShutter': 'Rollladen',
'ExteriorScreen': 'Außenrollo',
'Awning': 'Markise',
'ExteriorVenetianBlind':'Außenjalousie',
'Blind': 'Jalousie',
'GarageDoor': 'Garagentor',
'Window': 'Fenster',
'Light': 'Beleuchtung',
'OnOff': 'Schalter',
'DimmableLight': 'Dimmbare Beleuchtung',
'HeatingSystem': 'Heizungssystem',
'Valve': 'Ventil',
'Switch': 'Schalter',
'Door': 'Tür',
'Curtain': 'Vorhang',
'VenetianBlind': 'Jalousie',
'PergolaScreen': 'Pergola-Rollo'
}
SENSOR_TYPES = {
'TemperatureSensor', 'LightSensor', 'HumiditySensor',
'ContactSensor', 'OccupancySensor', 'SmokeSensor',
'WaterDetectionSensor', 'WindowHandle', 'MotionSensor',
'SunSensor', 'WindSensor', 'RainSensor', 'ConsumptionSensor'
'TemperatureSensor': 'Temperatur',
'LightSensor': 'Licht',
'HumiditySensor': 'Feuchtigkeit',
'ContactSensor': 'Kontakt',
'OccupancySensor': 'Anwesenheit',
'SmokeSensor': 'Rauch',
'WaterDetectionSensor': 'Wasser',
'WindowHandle': 'Fenstergriff',
'MotionSensor': 'Bewegung',
'SunSensor': 'Sonne',
'WindSensor': 'Wind',
'RainSensor': 'Regen',
'ConsumptionSensor': 'Verbrauch'
}
STATE_NAMES = {
"core:BatteryLevelState": 'Ladestand',
"core:BatteryState": 'Batteriezustand',
"core:ClosureState": 'Position',
"core:CommandLockLevelsState": 'Gesperrt',
"core:Memorized1OrientationState": 'Gespeicherte Neigung',
"core:Memorized1PositionState": 'Gespeicherte Position',
"core:MovingState": 'Fährt gerade',
"core:OpenClosedState": 'Geöffnet/Geschlossen',
"core:PriorityLockTimerState": 'Verriegelungstimer',
"core:DiscreteRSSILevelState": 'RSSI',
"core:SlateOrientationState": 'Lamellenausrichtung',
"core:StatusState": 'Status',
"core:LuminanceState": 'Helligkeit',
"core:SmokeState": 'Rauch',
"core:SunEnergyState": 'Sonnenenergie',
"core:TemperatureState": 'Temperatur',
"io:MaintenanceRadioPartBatteryState": 'Ladestand Funkmodul',
"io:MaintenanceSensorPartBatteryState": 'Ladestand Sensor',
"core:SensorDefectState": 'Sensor defekt'
}
# Tahoma Commands mit Parametern
STATE_ENUMS = {
"core:BatteryLevelState": ["full","normal","low","verylow"],
"core:BatteryState": ["full","normal","low","verylow"],
"core:ClosureState": '',
"core:CommandLockLevelsState": '',
"core:Memorized1OrientationState": '',
"core:Memorized1PositionState": '',
"core:MovingState": ["true","false"],
"core:OpenClosedState": ["open","close"],
"core:PriorityLockTimerState": '',
"core:DiscreteRSSILevelState": ["good","normal","low"],
"core:SlateOrientationState": '',
"core:StatusState": ["available","unavailable"],
"core:TargetClosureState": '',
"core:LuminanceState": 'Helligkeit',
"core:SmokeState": ["notDetected","detected"],
"core:SunEnergyState": '',
"core:TemperatureState": '',
"io:MaintenanceRadioPartBatteryState": ["full","normal","low","verylow"],
"io:MaintenanceSensorPartBatteryState": ["full","normal","low","verylow"],
"core:SensorDefectState": ["true","false"]
}
"""
"core:TargetClosureState": 'Zielposition',
"io:PriorityLockOriginatorState": 'Sperrendes Gerät',
"core:ManufacturerDiagnosticsState": 'Diagnose',
"core:OpenClosedUnknownState": 'Statusanzeige wenn unbekannt',
"core:ManufacturerSettingsState": 'Einstellungen',
"core:RSSILevelState": 'RSSI',
"core:NameState": 'Name',
"core:SecuredPositionState": 'Sicherer Zustand',
"""
@classmethod
def get_sensor_type_translation(cls, sensor):
return cls.SENSOR_TYPES.get(sensor, sensor)
@classmethod
def get_actor_type_translation(cls, actor_type):
return cls.ACTOR_TYPES.get(actor_type, actor_type)
PARAMETER_TYPES = {
0: "bool",
1: "integer", # Integer value
2: "float", # Floating point number
3: "string", # Text string
4: "undefined",
6: "bool",
11: "array"
}
TAHOMA_COMMANDS = {
"setClosure": [{"name": "position", "type": "integer", "min": 0, "max": 100}],
"setClosure": 'Position',
"setClosureAndOrientation": "Position+Neigung",
"setOrientation": "Neigung",
"up": "Auf",
"down": "Zu",
"my": "my-Position anfahren",
"stop": "Stop",
"refresh": "Aktualisieren",
"wink": "Winken",
"setMyPosition": "my-Position einstellen",
"on": "Anschalten",
"off": "Ausschalten",
"toggle": "Toggle",
"setIntensity": "Intensität",
"setColor": "Farbe",
"setColorTemperature": "Farbtemperatur",
"setTargetTemperature": "Solltemperatur",
"setMode": "Modus",
"pulse": "Impuls",
"setLevel": "Wert",
"trigger": "Trigger",
}
# Tahoma Commands mit Parametern
TAHOMA_COMMAND_PARAMS = {
"setClosure": [{"name": "Position","url":"0", "type": "integer", "min": 0, "max": 100}],
"setClosureAndOrientation": [
{"name": "position", "type": "integer", "min": 0, "max": 100},
{"name": "neigung", "type": "integer", "min": 0, "max": 100}
{"name": "Position", "url":"0", "type": "integer", "min": 0, "max": 100},
{"name": "Neigung", "url":"1", "type": "integer", "min": 0, "max": 100}
],
"setOrientation": [{"name": "neigung", "type": "integer", "min": 0, "max": 100}],
"setOrientation": [{"name": "Neigung", "url":"0", "type": "integer", "min": 0, "max": 100}],
"up": [], "down": [], "my": [], "stop": [], "refresh": [], "wink":[],
"setMyPosition": [{"name": "position", "type": "integer", "min": 0, "max": 100}],
"setMyPosition": [{"name": "Position", "url":"0", "type": "integer", "min": 0, "max": 100}],
"on": [], "off": [], "toggle": [],
"setIntensity": [{"name": "helligkeit", "type": "integer", "min": 0, "max": 100}],
"setIntensity": [{"name": "Helligkeit", "url":"0", "type": "integer", "min": 0, "max": 100}],
"setColor": [
{"name": "farbton", "type": "integer", "min": 0, "max": 360},
{"name": "sättigung", "type": "integer", "min": 0, "max": 100}
{"name": "Farbton", "url":"0", "type": "integer", "min": 0, "max": 360},
{"name": "Sättigung", "url":"1", "type": "integer", "min": 0, "max": 100}
],
"setColorTemperature": [{"name": "farbtemperatur", "type": "integer", "min": 2000, "max": 6500}],
"setTargetTemperature": [{"name": "temperatur", "type": "float", "min": 5.0, "max": 30.0}],
"setMode": [{"name": "betriebsart", "type": "string"}],
"pulse": [{"name": "impuls_dauer", "type": "integer", "min": 1, "max": 3600}],
"setLevel": [{"name": "ausgangs_level", "type": "integer", "min": 0, "max": 100}],
"setColorTemperature": [{"name": "Farbtemperatur", "url":"0", "type": "integer", "min": 2000, "max": 6500}],
"setTargetTemperature": [{"name": "Temperatur", "url":"0", "type": "float", "min": 5.0, "max": 30.0}],
"setMode": [{"name": "Betriebsart", "url":"0", "type": "string"}],
"pulse": [{"name": "Impulsdauer", "url":"0", "type": "integer", "min": 1, "max": 3600}],
"setLevel": [{"name": "Ausgangslevel", "url":"0", "type": "integer", "min": 0, "max": 100}],
"trigger": [],
}
@classmethod
def is_actor(cls, device: Dict) -> bool:
"""Prüft ob Gerät ein Aktor ist"""
device_type = device.get('controllableName', device.get('uiClass', ''))
device_type = device.get('definition').get('uiClass', '')
if device_type in cls.ACTOR_TYPES:
return True
@@ -108,7 +227,7 @@ class DeviceClassifier:
@classmethod
def is_sensor(cls, device: Dict) -> bool:
"""Prüft ob Gerät ein Sensor ist"""
device_type = device.get('controllableName', device.get('uiClass', ''))
device_type = device.get('definition').get('uiClass', '')
if device_type in cls.SENSOR_TYPES:
return True
@@ -130,11 +249,13 @@ class DeviceClassifier:
cmd_definitions = device.get('definition', {}).get('commands', [])
for cmd in cmd_definitions:
command_name = cmd.get('commandName', '')
cmd_params = cls.TAHOMA_COMMANDS.get(command_name, "Not in List")
command_url = cmd.get('commandName', '')
command_name = cls.TAHOMA_COMMANDS.get(command_url, command_url)
cmd_params = cls.TAHOMA_COMMAND_PARAMS.get(command_url, "Not in List")
if(cmd_params != "Not in List"): #only append command, if it is on of the listed commands, to prevent flooding the DB with bullshit.
command_entry = {
'command': command_name,
'url': command_url,
'parameters': []
}
@@ -149,7 +270,8 @@ class DeviceClassifier:
param_detail['max'] = cmd_param['max']
if 'values' in cmd_param:
param_detail['values'] = cmd_param['values']
if 'url' in cmd_param:
param_detail['url'] = cmd_param['url']
if param_detail['name']:
command_entry['parameters'].append(param_detail)
@@ -158,11 +280,15 @@ class DeviceClassifier:
# States extrahieren
state_definitions = device.get('states', [])
for state in state_definitions:
state_name = state.get('name', '')
state_url = state.get('name', '')
state_name = cls.STATE_NAMES.get(state_url, '')
state_enums = cls.STATE_ENUMS.get(state_url,'')
if state_name:
state_entry = {
'name': state_name,
'type': state.get('type', 0)
'url': state_url,
'type': cls.PARAMETER_TYPES.get(state.get('type', 4)),
'values': state_enums
}
if 'value' in state:
state_entry['current_value'] = state['value']
@@ -177,11 +303,15 @@ class DeviceClassifier:
state_definitions = device.get('states', [])
for state in state_definitions:
state_name = state.get('name', '')
state_url = state.get('name', '')
state_name = cls.STATE_NAMES.get(state_url, '')
state_enums = cls.STATE_ENUMS.get(state_url,'')
if state_name:
state_entry = {
'name': state_name,
'type': state.get('type', 0)
'url': state_url,
'type': cls.PARAMETER_TYPES.get(state.get('type', 4)),
'values': state_enums
}
if 'value' in state:
state_entry['current_value'] = state['value']
@@ -254,7 +384,10 @@ class TahomaModule(BaseModule):
main_name = main_device.get('label', 'Unbekannt') if main_device else 'Unbekannt'
for device in group_devices:
actor, sensor = self._process_device(device, main_name)
if(device.get('label','').startswith("IO (") == False):
actor, sensor = self._process_device(device, device.get('label',main_name))
else:
actor, sensor = self._process_device(device, main_name)
if actor:
actors.append(actor)
if sensor:
@@ -280,7 +413,7 @@ class TahomaModule(BaseModule):
Tuple (actor_dict or None, sensor_dict or None)
"""
device_url = device.get('deviceURL', '')
device_type = device.get('controllableName', device.get('uiClass', 'Unknown'))
device_type = device.get('definition').get('uiClass', 'Unknown')
is_actor = DeviceClassifier.is_actor(device)
is_sensor = DeviceClassifier.is_sensor(device)
@@ -290,6 +423,7 @@ class TahomaModule(BaseModule):
if is_actor:
commands, states = DeviceClassifier.extract_actor_data(device)
device_type = DeviceClassifier.get_actor_type_translation(device_type)
actor = {
'type': device_type,
'name': device_name,
@@ -300,6 +434,7 @@ class TahomaModule(BaseModule):
elif is_sensor:
states = DeviceClassifier.extract_sensor_data(device)
device_type = DeviceClassifier.get_sensor_type_translation(device_type)
sensor = {
'type': device_type,
'name': device_name,
@@ -106,7 +106,7 @@ class WLEDDiscovery:
return wled_devices
@staticmethod
def is_wled_device(ip: str, timeout: float = 1.0) -> bool:
def is_wled_device(ip: str, timeout: float = 2.0) -> bool:
"""Prüft ob IP ein WLED-Gerät ist"""
try:
response = requests.get(
@@ -190,16 +190,16 @@ class WLEDAPI:
eff_values = self.get_effects()
commands = [
{'command': 'on', 'parameters': []},
{'command': 'off', 'parameters': []},
{'command': 'An', 'url': '{"on":true}', 'parameters': []},
{'command': 'Aus', 'url': '{"on":false}', 'parameters': []},
{
'command': 'setBrightness',
'command': 'Helligkeit', 'url': '{"bri":%brightness%}',
'parameters': [
{'name': 'brightness', 'type': 'integer', 'min': 0, 'max': 255}
]
},
{
'command': 'setColor',
'command': 'Farbe', 'url': '{"seg":[{"col":[[%red%,%green%,%blue%]]}]}',
'parameters': [
{'name': 'red', 'type': 'integer', 'min': 0, 'max': 255},
{'name': 'green', 'type': 'integer', 'min': 0, 'max': 255},
@@ -207,13 +207,13 @@ class WLEDAPI:
]
},
{
'command': 'setEffect',
'command': 'Effekt', 'url': '{"seg":[{"fx":%effect%}]}',
'parameters': [
{'name': 'effect', 'type': 'integer', 'min': 0, 'max': 255, 'values': eff_values}
]
},
{
'command': 'setPreset',
'command': 'Preset', 'url': '{"ps":%preset%}',
'parameters': [
{'name': 'preset', 'type': 'integer', 'min': 1, 'max': 250, 'values': preset_values}
]
@@ -223,12 +223,14 @@ class WLEDAPI:
states = []
if state:
states.append({
'name': 'power',
'name': 'An',
'url': 'on',
'type': 'boolean',
'current_value': state.get('on', False)
})
states.append({
'name': 'brightness',
'name': 'Helligkeit',
'url': 'bri',
'type': 'integer',
'current_value': state.get('bri', 0)
})
@@ -238,7 +240,8 @@ class WLEDAPI:
colors = segments[0].get('col', [[0,0,0]])
if colors and len(colors) > 0:
states.append({
'name': 'color_rgb',
'name': 'Farbe',
'url': 'seg[0].col[0]',
'type': 'array',
'current_value': colors[0]
})