diff --git a/homeMesh_DB-layout.sql b/homeMesh_DB-layout.sql index bf7b1d7..82c304e 100644 --- a/homeMesh_DB-layout.sql +++ b/homeMesh_DB-layout.sql @@ -261,7 +261,11 @@ ALTER TABLE `actor_commands` -- ALTER TABLE `actor_states` ADD PRIMARY KEY (`id`), - ADD UNIQUE KEY `actor_id_2` (`actor_id`,`url`), + -- Auf den Namen und nicht auf die URL, aus demselben Grund wie bei + -- command_parameters: korrigiert ein Modul die URL eines Messwerts, soll + -- die vorhandene Zeile geaendert werden. Am URL-Schluessel entstand + -- stattdessen eine zweite Zeile, und die alte blieb ohne URL zurueck. + ADD UNIQUE KEY `actor_id_2` (`actor_id`,`state_name`), ADD KEY `actor_id` (`actor_id`), ADD KEY `idx_actor_states_name` (`state_name`), ADD KEY `state_type` (`state_type`); @@ -271,7 +275,11 @@ ALTER TABLE `actor_states` -- ALTER TABLE `command_parameters` ADD PRIMARY KEY (`id`), - ADD UNIQUE KEY `command_id_2` (`command_id`,`url`), + -- Auf den Namen und nicht auf die URL: Parameter ohne URL (WLED traegt + -- alles in der Kommando-Vorlage) haetten dort NULL, und eine NULL + -- kollidiert in MySQL nie - ON DUPLICATE KEY UPDATE griff nicht und jeder + -- Discovery-Lauf legte die Parameter erneut an. + ADD UNIQUE KEY `command_id_2` (`command_id`,`parameter_name`), ADD KEY `command_id` (`command_id`); -- diff --git a/restricted/autoActions/README.md b/restricted/autoActions/README.md index 24a1eba..437dfa3 100644 --- a/restricted/autoActions/README.md +++ b/restricted/autoActions/README.md @@ -114,19 +114,27 @@ Fenster gar nichts gelaufen ist — dann ist auch keine Sperre aktiv. Welcher Weg zum Gerät führt, entscheidet die URL des Aktors in `actors`: -| URL | Messwert (`actor_states.url`) | Kommando | -|-------------|-------------------------------|-------------------------------------| -| `mqtt://…` | vollständiges Topic, abonniert | publiziert auf das Parameter-Topic | -| `http://…` | Feldname in der JSON-Antwort, gepollt | Parameter als Abfrageargumente | -| `io://…` | Tahoma-Statusname, gepollt | `exec/apply` an die Tahoma-Box | -| `Logic` | gerechnet: Uhrzeit, Datum, Sonne | – | +| URL | Messwert (`actor_states.url`) | Kommando (`actor_commands.command_url`) | +|---|---|---| +| `mqtt://…` | vollständiges Topic, abonniert | Nutzlast auf das Parameter-Topic | +| `http://…` | Feldname in der JSON-Antwort, gepollt | Abfrageargumente an die Geräte-URL (`turn=on`) | +| `wled://…` | Pfad in `/json/state` (`seg[0].col[0]`), gepollt | JSON-Vorlage mit Platzhaltern, als Ganzes gesendet | +| Tahoma | Statusname (`core:ClosureState`), gepollt | `exec/apply` an die Box | +| `Logic` | gerechnet: Uhrzeit, Datum, Sonne | – | -Alle vier stehen in `transports.py`. Eine fünfte Geräteart kommt als weitere +Alle fünf stehen in `transports.py`. Eine sechste Geräteart kommt als weitere Klasse dazu; sie braucht `passt()`, `zustaende_lesen()` und `senden()`. -Bei HTTP heißen die Schaltbefehle nicht so wie in der Datenbank — dafür gibt es -in `HTTPTransport` eine kleine Übersetzungstabelle (`turn_on` → `on=true`). -Das ist die Stelle, die beim Anschluss neuer HTTP-Geräte wächst. +Tahoma ist der einzige, der nicht am URL-Schema erkannt wird, sondern an der +**Box-Kennung** in der URL. Das Schema beschreibt dort die Funkart, und +dieselbe Box liefert `io://` für die Jalousien, `rts://` für die Dachfenster +und `internal://` für die Alarmanlage. Ohne `pin` in der `config.ini` ist +niemand zuständig — dann meldet der Runner beim Auslösen „kein Transport", +statt still nichts zu tun. + +Bei WLED trägt die Kommando-Vorlage alles: `{"seg":[{"col":[[%red%,%green%,%blue%]]}]}` +wird mit den Parameterwerten gefüllt und am Stück geschickt. Deshalb haben die +Parameter dort keine eigene URL — ihr Name *ist* der Platzhalter. ## Voraussetzungen diff --git a/restricted/autoActions/autoaction_runner.py b/restricted/autoActions/autoaction_runner.py index 186282d..546ce5b 100644 --- a/restricted/autoActions/autoaction_runner.py +++ b/restricted/autoActions/autoaction_runner.py @@ -55,7 +55,7 @@ import requests import paho.mqtt.client as mqtt from transports import (HTTPTransport, LogicTransport, MQTTTransport, - TahomaTransport) + TahomaTransport, WLEDTransport) logger = logging.getLogger("autoaction") @@ -377,6 +377,7 @@ class Runner: self.mqtt = self._mqtt_verbinden() self.transporte = [ MQTTTransport(self.mqtt, self.dry_run), + WLEDTransport(requests, dry_run=self.dry_run), HTTPTransport(requests, dry_run=self.dry_run), TahomaTransport(requests, config.text("tahoma", "pin"), config.text("tahoma", "token"), diff --git a/restricted/autoActions/transports.py b/restricted/autoActions/transports.py index 17d9de8..a7a0505 100644 --- a/restricted/autoActions/transports.py +++ b/restricted/autoActions/transports.py @@ -8,11 +8,14 @@ URL des Aktors in der Tabelle `actors`: mqtt://... MQTT-Geraete (Home-Assistant-Discovery) http://... Shelly und andere HTTP-Geraete - io://... Tahoma + wled://... WLED-Lampen + io://, rts://, internal://, ogp:// Tahoma - das Schema haengt an der + Funkart des Geraets, deshalb wird dort nicht danach + entschieden, sondern an der Box-Kennung in der URL Logic das gerechnete Geraet "Zeitpunkt" (Uhrzeit, Datum, Sonne) Alle liegen in einer Datei statt in einem Paket wie bei deviceDiscovery: es -sind vier kurze Klassen, und wer eine fuenfte Geraeteart anschliesst, sieht +sind fuenf kurze Klassen, und wer eine sechste Geraeteart anschliesst, sieht hier auf einen Blick, was dafuer zu tun ist. Jeder Transport hat zwei Haelften: @@ -28,11 +31,19 @@ Jeder Transport hat zwei Haelften: import json import logging +import re +import urllib3 from datetime import datetime from urllib.parse import quote logger = logging.getLogger("autoaction.transport") +# Die Tahoma-Box hat ein selbst ausgestelltes Zertifikat auf einen Namen, den +# nur das Heimnetz kennt. Die Pruefung ist dort bewusst aus (wie in +# ajax/tahoma.php); ohne diese Zeile warnt urllib3 bei jeder einzelnen +# Abfrage und uebertoent das eigentliche Protokoll. +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + class Transport: """Gemeinsame Form. Wer nichts zu lesen hat, erbt die leeren Methoden.""" @@ -137,20 +148,15 @@ class HTTPTransport(Transport): JSON-Antwort ("tC", "a_voltage"). Solche Geraete melden sich nicht von selbst, sie werden im Poll-Takt gefragt. - Beim Senden haengen die Parameter als Abfrageargumente an die actor_url. - Die Schaltbefehle von Shelly heissen aber nicht so, wie sie in der - Datenbank stehen - dafuer steht unten eine kleine Uebersetzungstabelle. - Wer ein anderes HTTP-Geraet anschliesst, erweitert genau die. + Beim Senden wird die command_url als Abfrageargument an die actor_url + gehaengt ("turn=on") und die Parameter mit ihrem eigenen Namen dazu. + Frueher stand hier eine Uebersetzungstabelle, weil die Shelly-Kommandos + ohne URL in der Datenbank landeten - das ist im Discovery behoben, die + Zuordnung gehoert dorthin und nicht in den Runner. """ schema = "http" - SHELLY_BEFEHLE = { - "turn_on": {"on": "true"}, - "turn_off": {"on": "false"}, - "toggle": {"toggle": "true"}, - } - def __init__(self, requests_modul, timeout=5, dry_run=False): self.requests = requests_modul self.timeout = timeout @@ -181,14 +187,17 @@ class HTTPTransport(Transport): return werte def senden(self, aktion): - argumente = dict(self.SHELLY_BEFEHLE.get(aktion["command_url"], {})) + argumente = {} + for teil in (aktion["command_url"] or "").split("&"): + if "=" in teil: + schluessel, wert = teil.split("=", 1) + argumente[schluessel] = wert for p in aktion["params"]: if p["url"]: argumente[p["url"]] = p["wert"] - if not argumente and aktion["command_url"]: - # Kein bekannter Schaltbefehl und keine Parameter: das Kommando - # als Argument anhaengen, mehr laesst sich hier nicht ableiten. - argumente[aktion["command_url"]] = "true" + if not argumente: + raise RuntimeError("Kommando ohne URL und ohne Parameter - im " + "Geraetemodell fehlt die Angabe, was zu schicken ist") if self.dry_run: logger.info("[dry-run] HTTP %s %s", aktion["actor_url"], argumente) return @@ -197,16 +206,119 @@ class HTTPTransport(Transport): raise RuntimeError("HTTP %d von %s" % (antwort.status_code, aktion["actor_url"])) +# --------------------------------------------------------------------------- +# WLED +# --------------------------------------------------------------------------- + +class WLEDTransport(Transport): + """ + WLED-Lampen sprechen ueber eine einzige JSON-Schnittstelle: + GET http://IP/json/state liefert den Zustand, POST dorthin setzt ihn. + + Das Geraetemodell nutzt das elegant aus - die command_url ist eine + JSON-Vorlage mit Platzhaltern: + + {"bri":%brightness%} + {"seg":[{"col":[[%red%,%green%,%blue%]]}]} + + Gesendet wird also nicht Argument fuer Argument, sondern die ausgefuellte + Vorlage am Stueck. Deshalb haben die Parameter hier auch keine eigene URL: + ihr Name ist der Platzhalter. + + Die state_url ist ein Pfad in die Antwort ("on", "bri", + "seg[0].col[0]") - dieselbe Schreibweise, die auch in current_value steht. + """ + + schema = "wled://" + + def __init__(self, requests_modul, timeout=5, dry_run=False): + self.requests = requests_modul + self.timeout = timeout + self.dry_run = dry_run + self.states = [] + + @staticmethod + def _adresse(actor_url): + return "http://" + actor_url[len("wled://"):].rstrip("/") + + def zustaende_anmelden(self, states): + self.states = [s for s in states if s["state_url"]] + logger.info("WLED: %d Messwerte an %d Lampen", + len(self.states), len({s["actor_url"] for s in self.states})) + + @staticmethod + def _pfad(daten, pfad): + """"seg[0].col[0]" in der Antwort nachschlagen.""" + for teil in pfad.split("."): + treffer = re.match(r"^([^\[]*)((?:\[\d+\])*)$", teil) + if not treffer: + return None + if treffer.group(1): + daten = daten[treffer.group(1)] + for index in re.findall(r"\[(\d+)\]", treffer.group(2)): + daten = daten[int(index)] + return daten + + def zustaende_lesen(self): + werte = {} + nach_lampe = {} + for s in self.states: + nach_lampe.setdefault(s["actor_url"], []).append(s) + for actor_url, states in nach_lampe.items(): + try: + antwort = self.requests.get(self._adresse(actor_url) + "/json/state", + timeout=self.timeout) + daten = antwort.json() + except Exception as fehler: + logger.debug("WLED %s nicht erreichbar: %s", actor_url, fehler) + continue + for s in states: + try: + werte[s["id"]] = str(self._pfad(daten, s["state_url"])) + except (KeyError, IndexError, TypeError): + logger.debug("WLED %s: Pfad %s nicht gefunden", actor_url, s["state_url"]) + return werte + + def senden(self, aktion): + vorlage = aktion["command_url"] + if not vorlage: + raise RuntimeError("WLED-Kommando ohne Vorlage") + for p in aktion["params"]: + vorlage = vorlage.replace("%" + p["name"] + "%", str(p["wert"])) + try: + rumpf = json.loads(vorlage) + except ValueError: + # Ein nicht ersetzter Platzhalter oder ein Textwert an einer + # Stelle, wo eine Zahl stehen muss. Lieber hier abbrechen als der + # Lampe etwas Unverstaendliches schicken. + raise RuntimeError("WLED-Vorlage ergibt kein gueltiges JSON: " + vorlage[:120]) + if self.dry_run: + logger.info("[dry-run] WLED %s <- %s", aktion["actor_url"], + json.dumps(rumpf, ensure_ascii=False)) + return + antwort = self.requests.post(self._adresse(aktion["actor_url"]) + "/json/state", + json=rumpf, timeout=self.timeout) + if antwort.status_code >= 400: + raise RuntimeError("WLED antwortete mit %d" % antwort.status_code) + + # --------------------------------------------------------------------------- # Tahoma # --------------------------------------------------------------------------- class TahomaTransport(Transport): """ - Die actor_url ist die deviceURL (io://...), die state_url ein Statusname + Die actor_url ist die deviceURL, die state_url ein Statusname ("core:ClosureState"), die command_url ein Kommandoname ("setClosure"). Geschickt wird ueber exec/apply - genauso wie in ajax/tahoma.php, nur ohne die dortige Sonderbehandlung fuer "faehrt gerade". + + Zustaendig ist dieser Transport fuer alles, was die Kennung der eigenen + Box in der URL traegt. Am Schema laesst sich das nicht festmachen: es + beschreibt die Funkart, und dieselbe Box liefert io:// fuer die + Jalousien, rts:// fuer die Dachfenster und internal:// fuer die Alarm- + anlage. Ohne PIN in der config.ini ist niemand zustaendig - dann meldet + der Runner beim Ausloesen "kein Transport", statt still nichts zu tun. """ schema = "io://" @@ -219,6 +331,9 @@ class TahomaTransport(Transport): self.dry_run = dry_run self.states = [] + def passt(self, actor_url): + return bool(self.pin) and ("://" + self.pin + "/") in actor_url + @property def basis(self): return "https://gateway-%s:8443/enduser-mobile-web/1/enduserAPI" % self.pin diff --git a/restricted/deviceDiscovery/device_discovery.py b/restricted/deviceDiscovery/device_discovery.py index 6c8580e..0ba4b76 100644 --- a/restricted/deviceDiscovery/device_discovery.py +++ b/restricted/deviceDiscovery/device_discovery.py @@ -365,13 +365,13 @@ class DatabaseManager: INSERT INTO actor_commands (actor_id, command_name, command_url) VALUES (%s, %s, %s) ON DUPLICATE KEY UPDATE - command_name = VALUES(command_name) + command_url = VALUES(command_url) """ cursor.execute(cmd_query, (actor_id, command_name, command_url)) query = """ - SELECT ID FROM actor_commands where actor_id = %s and command_url = %s + SELECT ID FROM actor_commands where actor_id = %s and command_name = %s """ - cursor.execute(query, (actor_id, command_url)) + cursor.execute(query, (actor_id, command_name)) command_id = cursor.fetchone()[0] # Parameter einfügen @@ -381,14 +381,14 @@ class DatabaseManager: (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) + possible_values = VALUES(possible_values), + url = VALUES(url) """ param_name = param.get('name', '') - param_type = param.get('type', '') + param_type = self.PARAMETER_TYPES_MAPPING.get(param.get('type', ''), 4) min_val = param.get('min') max_val = param.get('max') possible_vals = json.dumps(param.get('values')) if 'values' in param else "" @@ -404,11 +404,11 @@ class DatabaseManager: (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) + unit = VALUES(unit), + url = VALUES(url) """ state_name = state.get('name', '') state_type = self.PARAMETER_TYPES_MAPPING.get(state.get('type', 0), 4) @@ -474,10 +474,10 @@ class DatabaseManager: (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) + unit = VALUES(unit), + url = VALUES(url) """ state_name = state.get('name', '') state_type = self.PARAMETER_TYPES_MAPPING.get(state.get('type', 0), 4) diff --git a/restricted/deviceDiscovery/modules/shelly_module.py b/restricted/deviceDiscovery/modules/shelly_module.py index 537de4c..cef8b2c 100644 --- a/restricted/deviceDiscovery/modules/shelly_module.py +++ b/restricted/deviceDiscovery/modules/shelly_module.py @@ -267,14 +267,15 @@ class ShellyModule(BaseModule): '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': 'turn_on', 'url': 'on=true', 'parameters': []}, + {'command': 'turn_off', 'url': 'on=false', 'parameters': []}, {'command': 'toggle', 'parameters': []} ], 'states': [ { 'name': 'output', 'type': 'boolean', + 'url': 'output', 'current_value': switch_data.get('output', False) } ] @@ -444,14 +445,15 @@ class ShellyModule(BaseModule): 'name': f"{device_name}_Relay_{i}", 'url': f"http://{ip}/relay/{i}", 'commands': [ - {'command': 'turn_on', 'parameters': []}, - {'command': 'turn_off', 'parameters': []}, - {'command': 'toggle', 'parameters': []} + {'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) } ]