Compare commits
3
Commits
0cb1fc635e
...
17678da824
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17678da824 | ||
|
|
56b2ab1648 | ||
|
|
631baa7333 |
+11
-2
@@ -68,6 +68,7 @@ CREATE TABLE `actor_states` (
|
|||||||
`current_value` varchar(255) DEFAULT NULL COMMENT 'Aktueller Wert des State',
|
`current_value` varchar(255) DEFAULT NULL COMMENT 'Aktueller Wert des State',
|
||||||
`unit` varchar(20) DEFAULT NULL COMMENT 'Einheit falls vorhanden',
|
`unit` varchar(20) DEFAULT NULL COMMENT 'Einheit falls vorhanden',
|
||||||
`url` varchar(255) DEFAULT NULL COMMENT 'MQTT Topic oder URL zum State',
|
`url` varchar(255) DEFAULT NULL COMMENT 'MQTT Topic oder URL zum State',
|
||||||
|
`value_path` varchar(120) DEFAULT NULL COMMENT 'Pfad in die Nutzlast, wenn sich mehrere Messwerte ein Topic teilen: der go-eCharger schickt sechzehn Zahlen als JSON-Feld auf einem Topic. Schreibweise [3], ssid, foo[0].bar. NULL = ganze Nutzlast',
|
||||||
`last_updated` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
|
`last_updated` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
|
||||||
`possible_values` text NOT NULL COMMENT 'JSON array with possible enum values.'
|
`possible_values` text NOT NULL COMMENT 'JSON array with possible enum values.'
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||||
@@ -261,7 +262,11 @@ ALTER TABLE `actor_commands`
|
|||||||
--
|
--
|
||||||
ALTER TABLE `actor_states`
|
ALTER TABLE `actor_states`
|
||||||
ADD PRIMARY KEY (`id`),
|
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 `actor_id` (`actor_id`),
|
||||||
ADD KEY `idx_actor_states_name` (`state_name`),
|
ADD KEY `idx_actor_states_name` (`state_name`),
|
||||||
ADD KEY `state_type` (`state_type`);
|
ADD KEY `state_type` (`state_type`);
|
||||||
@@ -271,7 +276,11 @@ ALTER TABLE `actor_states`
|
|||||||
--
|
--
|
||||||
ALTER TABLE `command_parameters`
|
ALTER TABLE `command_parameters`
|
||||||
ADD PRIMARY KEY (`id`),
|
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`);
|
ADD KEY `command_id` (`command_id`);
|
||||||
|
|
||||||
--
|
--
|
||||||
|
|||||||
@@ -84,9 +84,11 @@ function wertFeld(spec, wert) {
|
|||||||
optionListe(feld, [{ value: "true", text: "JA" }, { value: "false", text: "NEIN" }],
|
optionListe(feld, [{ value: "true", text: "JA" }, { value: "false", text: "NEIN" }],
|
||||||
wert === "" ? "true" : wert);
|
wert === "" ? "true" : wert);
|
||||||
} else if (spec.input === "select") {
|
} else if (spec.input === "select") {
|
||||||
|
// Die Auswahl kommt fertig aus dem Katalog: bei Messwerten steht der
|
||||||
|
// Klartext auch als Wert, bei Geraeteparametern die Zahl dahinter.
|
||||||
feld = document.createElement("select");
|
feld = document.createElement("select");
|
||||||
feld.className = "form-select";
|
feld.className = "form-select";
|
||||||
optionListe(feld, spec.options.map(o => ({ value: o, text: o })), wert);
|
optionListe(feld, spec.options.map(o => ({ value: o.value, text: o.label })), wert);
|
||||||
} else {
|
} else {
|
||||||
feld = document.createElement("input");
|
feld = document.createElement("input");
|
||||||
feld.className = "form-control";
|
feld.className = "form-control";
|
||||||
@@ -104,7 +106,7 @@ function wertFeld(spec, wert) {
|
|||||||
/** Standardwert, wenn eine Zeile auf einen anderen Messwert umgestellt wird. */
|
/** Standardwert, wenn eine Zeile auf einen anderen Messwert umgestellt wird. */
|
||||||
function standardWert(spec) {
|
function standardWert(spec) {
|
||||||
if (spec.input === "bool") return "true";
|
if (spec.input === "bool") return "true";
|
||||||
if (spec.input === "select") return spec.options.length ? spec.options[0] : "";
|
if (spec.input === "select") return spec.options.length ? spec.options[0].value : "";
|
||||||
if (spec.input === "time") return "00:00";
|
if (spec.input === "time") return "00:00";
|
||||||
if (spec.input === "date") return new Date().toISOString().split("T")[0];
|
if (spec.input === "date") return new Date().toISOString().split("T")[0];
|
||||||
if (spec.input === "number") return "0";
|
if (spec.input === "number") return "0";
|
||||||
|
|||||||
@@ -114,19 +114,64 @@ 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`:
|
Welcher Weg zum Gerät führt, entscheidet die URL des Aktors in `actors`:
|
||||||
|
|
||||||
| URL | Messwert (`actor_states.url`) | Kommando |
|
| URL | Messwert (`actor_states.url`) | Kommando (`actor_commands.command_url`) |
|
||||||
|-------------|-------------------------------|-------------------------------------|
|
|---|---|---|
|
||||||
| `mqtt://…` | vollständiges Topic, abonniert | publiziert auf das Parameter-Topic |
|
| `mqtt://…` | vollständiges Topic, abonniert; bei mehreren Messwerten je Topic zusätzlich `value_path` | Nutzlast auf das Parameter-Topic |
|
||||||
| `http://…` | Feldname in der JSON-Antwort, gepollt | Parameter als Abfrageargumente |
|
| `http://…` | Feldname in der JSON-Antwort, gepollt | Abfrageargumente an die Geräte-URL (`turn=on`) |
|
||||||
| `io://…` | Tahoma-Statusname, gepollt | `exec/apply` an die Tahoma-Box |
|
| `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 | – |
|
| `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()`.
|
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
|
Tahoma ist der einzige, der nicht am URL-Schema erkannt wird, sondern an der
|
||||||
in `HTTPTransport` eine kleine Übersetzungstabelle (`turn_on` → `on=true`).
|
**Box-Kennung** in der URL. Das Schema beschreibt dort die Funkart, und
|
||||||
Das ist die Stelle, die beim Anschluss neuer HTTP-Geräte wächst.
|
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.
|
||||||
|
|
||||||
|
Mehrere Messwerte teilen sich oft **ein Topic**: der go-eCharger schickt
|
||||||
|
sechzehn Zahlen als JSON-Feld auf `…/nrg`, und erst das `value_template` der
|
||||||
|
Home-Assistant-Discovery sagt, dass „Strom L1" das fünfte Element ist. Diese
|
||||||
|
Angabe steht in `actor_states.value_path` — in derselben Schreibweise, die
|
||||||
|
auch WLED benutzt: `[4]`, `ssid`, `seg[0].col[0]`. Ohne Pfad gilt die ganze
|
||||||
|
Nutzlast.
|
||||||
|
|
||||||
|
Gelesen wird nur der einfache Fall aus dem Template: ein Zugriff auf
|
||||||
|
`value_json` und was danach an Punkten und Klammern folgt.
|
||||||
|
|
||||||
|
### Werttabellen
|
||||||
|
|
||||||
|
Manche Geräte schicken eine Zahl und meinen einen Zustand:
|
||||||
|
|
||||||
|
```
|
||||||
|
{{ ['Unknown','Idle','Charging','WaitCar','Complete','Error'][value_json|int] }}
|
||||||
|
{{ ['Default','Eco','NextTrip'][value_json|int-3] }}
|
||||||
|
```
|
||||||
|
|
||||||
|
Das ist kein Pfad, sondern eine Übersetzung von Zahl nach Text. Sie landet in
|
||||||
|
`possible_values` — in der Schreibweise, die WLED für seine Effektliste schon
|
||||||
|
benutzt: eine Liste aus `{Wert: Bezeichnung}`. Ein Versatz im Ausdruck wandert
|
||||||
|
dabei in die Schlüssel, aus `[value_json|int-3]` wird also `{"3":"Default"}`.
|
||||||
|
|
||||||
|
Der Runner übersetzt beim Lesen: aus der gesendeten `2` wird `Charging`. Eine
|
||||||
|
Bedingung vergleicht damit genau den Klartext, den der Editor zur Auswahl
|
||||||
|
stellt. Steht die Zahl nicht in der Tabelle, bleibt sie stehen — ein
|
||||||
|
erfundener Name wäre schlimmer als ein roher Wert.
|
||||||
|
|
||||||
|
Auf beiden Seiten des Editors steckt dieselbe Tabelle, aber der gespeicherte
|
||||||
|
Wert ist ein anderer:
|
||||||
|
|
||||||
|
| | angezeigt | gespeichert |
|
||||||
|
|---|---|---|
|
||||||
|
| **Messwert** (Bedingung) | `Charging` | `Charging` — der Runner hat schon übersetzt |
|
||||||
|
| **Parameter** (Aktion) | `Blink` | `1` — das Gerät will die Zahl |
|
||||||
|
|
||||||
|
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
|
## Voraussetzungen
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ Tabellen siehe homeMesh_automations.sql, Konfiguration siehe config.ini.example.
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import configparser
|
import configparser
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
@@ -55,7 +56,7 @@ import requests
|
|||||||
import paho.mqtt.client as mqtt
|
import paho.mqtt.client as mqtt
|
||||||
|
|
||||||
from transports import (HTTPTransport, LogicTransport, MQTTTransport,
|
from transports import (HTTPTransport, LogicTransport, MQTTTransport,
|
||||||
TahomaTransport)
|
TahomaTransport, WLEDTransport)
|
||||||
|
|
||||||
logger = logging.getLogger("autoaction")
|
logger = logging.getLogger("autoaction")
|
||||||
|
|
||||||
@@ -145,13 +146,25 @@ class Regelwerk:
|
|||||||
|
|
||||||
states = {}
|
states = {}
|
||||||
with db.cursor() as c:
|
with db.cursor() as c:
|
||||||
c.execute("""SELECT s.id, s.state_name, s.url AS state_url, s.current_value,
|
c.execute("""SELECT s.id, s.state_name, s.url AS state_url, s.value_path,
|
||||||
|
s.current_value, s.possible_values,
|
||||||
a.url AS actor_url, a.name AS actor_name, t.type
|
a.url AS actor_url, a.name AS actor_name, t.type
|
||||||
FROM actor_states s
|
FROM actor_states s
|
||||||
JOIN actors a ON a.id = s.actor_id
|
JOIN actors a ON a.id = s.actor_id
|
||||||
LEFT JOIN state_types t ON s.state_type = t.id""")
|
LEFT JOIN state_types t ON s.state_type = t.id""")
|
||||||
for row in c.fetchall():
|
for row in c.fetchall():
|
||||||
row["type"] = row["type"] or "string"
|
row["type"] = row["type"] or "string"
|
||||||
|
# Werttabelle als flaches {gesendeter Wert: Bezeichnung}. In
|
||||||
|
# der Datenbank steht sie als Liste aus Ein-Schluessel-
|
||||||
|
# Objekten, weil der Editor sie so schon versteht.
|
||||||
|
row["wertetabelle"] = {}
|
||||||
|
try:
|
||||||
|
for eintrag in json.loads(row["possible_values"] or "[]"):
|
||||||
|
if isinstance(eintrag, dict):
|
||||||
|
for wert, name in eintrag.items():
|
||||||
|
row["wertetabelle"][str(wert)] = name
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
states[row["id"]] = row
|
states[row["id"]] = row
|
||||||
|
|
||||||
kommandos = {}
|
kommandos = {}
|
||||||
@@ -377,6 +390,7 @@ class Runner:
|
|||||||
self.mqtt = self._mqtt_verbinden()
|
self.mqtt = self._mqtt_verbinden()
|
||||||
self.transporte = [
|
self.transporte = [
|
||||||
MQTTTransport(self.mqtt, self.dry_run),
|
MQTTTransport(self.mqtt, self.dry_run),
|
||||||
|
WLEDTransport(requests, dry_run=self.dry_run),
|
||||||
HTTPTransport(requests, dry_run=self.dry_run),
|
HTTPTransport(requests, dry_run=self.dry_run),
|
||||||
TahomaTransport(requests, config.text("tahoma", "pin"),
|
TahomaTransport(requests, config.text("tahoma", "pin"),
|
||||||
config.text("tahoma", "token"),
|
config.text("tahoma", "token"),
|
||||||
@@ -415,7 +429,9 @@ class Runner:
|
|||||||
self.regelwerk = Regelwerk.laden(self.db)
|
self.regelwerk = Regelwerk.laden(self.db)
|
||||||
# Jeder Transport bekommt die Messwerte, fuer die er zustaendig ist.
|
# Jeder Transport bekommt die Messwerte, fuer die er zustaendig ist.
|
||||||
for transport in self.transporte:
|
for transport in self.transporte:
|
||||||
passende = [{"id": s["id"], "actor_url": s["actor_url"], "state_url": s["state_url"]}
|
passende = [{"id": s["id"], "actor_url": s["actor_url"],
|
||||||
|
"state_url": s["state_url"], "value_path": s["value_path"],
|
||||||
|
"wertetabelle": s["wertetabelle"]}
|
||||||
for s in self.regelwerk.states.values()
|
for s in self.regelwerk.states.values()
|
||||||
if transport.passt(s["actor_url"])]
|
if transport.passt(s["actor_url"])]
|
||||||
transport.zustaende_anmelden(passende)
|
transport.zustaende_anmelden(passende)
|
||||||
|
|||||||
@@ -8,11 +8,14 @@ URL des Aktors in der Tabelle `actors`:
|
|||||||
|
|
||||||
mqtt://... MQTT-Geraete (Home-Assistant-Discovery)
|
mqtt://... MQTT-Geraete (Home-Assistant-Discovery)
|
||||||
http://... Shelly und andere HTTP-Geraete
|
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)
|
Logic das gerechnete Geraet "Zeitpunkt" (Uhrzeit, Datum, Sonne)
|
||||||
|
|
||||||
Alle liegen in einer Datei statt in einem Paket wie bei deviceDiscovery: es
|
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.
|
hier auf einen Blick, was dafuer zu tun ist.
|
||||||
|
|
||||||
Jeder Transport hat zwei Haelften:
|
Jeder Transport hat zwei Haelften:
|
||||||
@@ -21,18 +24,71 @@ Jeder Transport hat zwei Haelften:
|
|||||||
zustaende_lesen() liefert {state_id: wert} - nur was neu ist
|
zustaende_lesen() liefert {state_id: wert} - nur was neu ist
|
||||||
senden(aktion) fuehrt ein Kommando aus
|
senden(aktion) fuehrt ein Kommando aus
|
||||||
|
|
||||||
`states` ist eine Liste von Dicts mit actor_url, state_url und id,
|
`states` ist eine Liste von Dicts mit actor_url, state_url, value_path und
|
||||||
`aktion` ein Dict mit actor_url, command_url und params (Liste aus
|
id, `aktion` ein Dict mit actor_url, command_url und params (Liste aus
|
||||||
{url, name, wert}).
|
{url, name, wert}).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
|
import urllib3
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
logger = logging.getLogger("autoaction.transport")
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
def wert_aus_pfad(daten, pfad):
|
||||||
|
"""
|
||||||
|
Einen Teilwert aus einer Nutzlast holen: "[3]", "ssid", "seg[0].col[0]".
|
||||||
|
Ein leerer Pfad heisst: die Nutzlast selbst.
|
||||||
|
|
||||||
|
Gebraucht wird das an zwei Stellen. MQTT-Geraete legen mehrere Messwerte
|
||||||
|
auf ein Topic - der go-eCharger schickt sechzehn Zahlen als JSON-Feld -,
|
||||||
|
und WLED liefert seinen gesamten Zustand als ein Dokument.
|
||||||
|
"""
|
||||||
|
if not pfad:
|
||||||
|
return daten
|
||||||
|
for teil in pfad.split("."):
|
||||||
|
treffer = re.match(r"^([^\[]*)((?:\[\d+\])*)$", teil)
|
||||||
|
if not treffer:
|
||||||
|
raise KeyError(pfad)
|
||||||
|
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 uebersetze(wert, tabelle):
|
||||||
|
"""
|
||||||
|
Aus der gesendeten Zahl den Zustandsnamen machen: aus "2" wird "Charging".
|
||||||
|
|
||||||
|
Manche Geraete schicken einen Zahlencode und meinen einen Zustand. Welche
|
||||||
|
Zahl welchen Namen hat, steht in possible_values - derselben Spalte, aus
|
||||||
|
der auch der Editor seine Auswahlliste baut. Eine Bedingung vergleicht
|
||||||
|
damit genau den Klartext, den man dort ausgewaehlt hat.
|
||||||
|
|
||||||
|
Steht die Zahl nicht in der Tabelle, bleibt sie stehen: ein erfundener
|
||||||
|
Name waere schlimmer als ein roher Wert.
|
||||||
|
"""
|
||||||
|
if not tabelle:
|
||||||
|
return wert
|
||||||
|
if wert in tabelle:
|
||||||
|
return tabelle[wert]
|
||||||
|
try: # "2.0" und "2" meinen dieselbe Stufe
|
||||||
|
ganz = str(int(float(wert)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return wert
|
||||||
|
return tabelle.get(ganz, wert)
|
||||||
|
|
||||||
|
|
||||||
class Transport:
|
class Transport:
|
||||||
"""Gemeinsame Form. Wer nichts zu lesen hat, erbt die leeren Methoden."""
|
"""Gemeinsame Form. Wer nichts zu lesen hat, erbt die leeren Methoden."""
|
||||||
@@ -68,7 +124,7 @@ class MQTTTransport(Transport):
|
|||||||
def __init__(self, client, dry_run=False):
|
def __init__(self, client, dry_run=False):
|
||||||
self.client = client
|
self.client = client
|
||||||
self.dry_run = dry_run
|
self.dry_run = dry_run
|
||||||
self.topics = {} # topic -> [state_id, ...]
|
self.topics = {} # topic -> [(state_id, value_path, wertetabelle), ...]
|
||||||
self.neu = {} # state_id -> wert
|
self.neu = {} # state_id -> wert
|
||||||
|
|
||||||
def zustaende_anmelden(self, states):
|
def zustaende_anmelden(self, states):
|
||||||
@@ -76,34 +132,57 @@ class MQTTTransport(Transport):
|
|||||||
for s in states:
|
for s in states:
|
||||||
if not s["state_url"]:
|
if not s["state_url"]:
|
||||||
continue
|
continue
|
||||||
self.topics.setdefault(s["state_url"], []).append(s["id"])
|
self.topics.setdefault(s["state_url"], []).append(
|
||||||
|
(s["id"], s.get("value_path"), s.get("wertetabelle") or {}))
|
||||||
for topic in self.topics:
|
for topic in self.topics:
|
||||||
self.client.subscribe(topic)
|
self.client.subscribe(topic)
|
||||||
logger.info("MQTT: %d Topics abonniert", len(self.topics))
|
mehrfach = sum(1 for e in self.topics.values() if len(e) > 1)
|
||||||
|
logger.info("MQTT: %d Topics abonniert, %d davon mit mehreren Messwerten",
|
||||||
|
len(self.topics), mehrfach)
|
||||||
|
|
||||||
def nachricht(self, topic, payload):
|
def nachricht(self, topic, payload):
|
||||||
"""Wird vom Runner aus dem on_message-Rueckruf gerufen."""
|
"""Wird vom Runner aus dem on_message-Rueckruf gerufen."""
|
||||||
for state_id in self.topics.get(topic, []):
|
eintraege = self.topics.get(topic, [])
|
||||||
self.neu[state_id] = self._wert(payload)
|
if not eintraege:
|
||||||
|
return
|
||||||
@staticmethod
|
|
||||||
def _wert(payload):
|
|
||||||
"""
|
|
||||||
Rohtext, aber JSON-Skalare werden ausgepackt: manche Geraete schicken
|
|
||||||
"21.4", andere 21.4 mit Anfuehrungszeichen, wieder andere true statt
|
|
||||||
ON. Objekte bleiben wie sie sind - welches Feld gemeint ist, weiss die
|
|
||||||
Datenbank nicht, und Raten waere schlimmer als Nichtstun.
|
|
||||||
"""
|
|
||||||
text = payload.decode("utf-8", "replace").strip() if isinstance(payload, bytes) else str(payload)
|
text = payload.decode("utf-8", "replace").strip() if isinstance(payload, bytes) else str(payload)
|
||||||
try:
|
try:
|
||||||
wert = json.loads(text)
|
daten = json.loads(text)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return text
|
daten = None # kein JSON - dann gilt der Rohtext
|
||||||
|
for state_id, pfad, tabelle in eintraege:
|
||||||
|
wert = self._wert(text, daten, pfad, tabelle)
|
||||||
|
if wert is not None:
|
||||||
|
self.neu[state_id] = wert
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _wert(text, daten, pfad, tabelle=None):
|
||||||
|
"""
|
||||||
|
Aus der Nutzlast den Wert eines einzelnen Messwerts machen.
|
||||||
|
|
||||||
|
Mehrere Messwerte teilen sich oft ein Topic; welcher Teil gemeint ist,
|
||||||
|
steht in value_path - gelesen aus dem value_template der
|
||||||
|
Home-Assistant-Discovery. Ohne Pfad gilt die ganze Nutzlast, und
|
||||||
|
JSON-Skalare werden ausgepackt: manche Geraete schicken 21.4 mit
|
||||||
|
Anfuehrungszeichen, andere true statt ON.
|
||||||
|
|
||||||
|
Steht eine Werttabelle dabei, wird aus der gesendeten Zahl der
|
||||||
|
Zustandsname: aus 2 wird "Charging".
|
||||||
|
"""
|
||||||
|
if daten is None:
|
||||||
|
return uebersetze(text, tabelle)
|
||||||
|
try:
|
||||||
|
wert = wert_aus_pfad(daten, pfad)
|
||||||
|
except (KeyError, IndexError, TypeError):
|
||||||
|
logger.debug("Pfad %s nicht in der Nutzlast: %s", pfad, text[:80])
|
||||||
|
return None
|
||||||
if isinstance(wert, bool):
|
if isinstance(wert, bool):
|
||||||
return "true" if wert else "false"
|
wert = "true" if wert else "false"
|
||||||
if isinstance(wert, (int, float, str)):
|
elif isinstance(wert, (int, float, str)):
|
||||||
return str(wert)
|
wert = str(wert)
|
||||||
return text
|
else:
|
||||||
|
return json.dumps(wert, ensure_ascii=False)
|
||||||
|
return uebersetze(wert, tabelle)
|
||||||
|
|
||||||
def zustaende_lesen(self):
|
def zustaende_lesen(self):
|
||||||
werte, self.neu = self.neu, {}
|
werte, self.neu = self.neu, {}
|
||||||
@@ -137,20 +216,15 @@ class HTTPTransport(Transport):
|
|||||||
JSON-Antwort ("tC", "a_voltage"). Solche Geraete melden sich nicht von
|
JSON-Antwort ("tC", "a_voltage"). Solche Geraete melden sich nicht von
|
||||||
selbst, sie werden im Poll-Takt gefragt.
|
selbst, sie werden im Poll-Takt gefragt.
|
||||||
|
|
||||||
Beim Senden haengen die Parameter als Abfrageargumente an die actor_url.
|
Beim Senden wird die command_url als Abfrageargument an die actor_url
|
||||||
Die Schaltbefehle von Shelly heissen aber nicht so, wie sie in der
|
gehaengt ("turn=on") und die Parameter mit ihrem eigenen Namen dazu.
|
||||||
Datenbank stehen - dafuer steht unten eine kleine Uebersetzungstabelle.
|
Frueher stand hier eine Uebersetzungstabelle, weil die Shelly-Kommandos
|
||||||
Wer ein anderes HTTP-Geraet anschliesst, erweitert genau die.
|
ohne URL in der Datenbank landeten - das ist im Discovery behoben, die
|
||||||
|
Zuordnung gehoert dorthin und nicht in den Runner.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
schema = "http"
|
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):
|
def __init__(self, requests_modul, timeout=5, dry_run=False):
|
||||||
self.requests = requests_modul
|
self.requests = requests_modul
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
@@ -181,14 +255,17 @@ class HTTPTransport(Transport):
|
|||||||
return werte
|
return werte
|
||||||
|
|
||||||
def senden(self, aktion):
|
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"]:
|
for p in aktion["params"]:
|
||||||
if p["url"]:
|
if p["url"]:
|
||||||
argumente[p["url"]] = p["wert"]
|
argumente[p["url"]] = p["wert"]
|
||||||
if not argumente and aktion["command_url"]:
|
if not argumente:
|
||||||
# Kein bekannter Schaltbefehl und keine Parameter: das Kommando
|
raise RuntimeError("Kommando ohne URL und ohne Parameter - im "
|
||||||
# als Argument anhaengen, mehr laesst sich hier nicht ableiten.
|
"Geraetemodell fehlt die Angabe, was zu schicken ist")
|
||||||
argumente[aktion["command_url"]] = "true"
|
|
||||||
if self.dry_run:
|
if self.dry_run:
|
||||||
logger.info("[dry-run] HTTP %s %s", aktion["actor_url"], argumente)
|
logger.info("[dry-run] HTTP %s %s", aktion["actor_url"], argumente)
|
||||||
return
|
return
|
||||||
@@ -197,16 +274,110 @@ class HTTPTransport(Transport):
|
|||||||
raise RuntimeError("HTTP %d von %s" % (antwort.status_code, aktion["actor_url"]))
|
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}))
|
||||||
|
|
||||||
|
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:
|
||||||
|
# Bei WLED ist die state_url selbst schon der Pfad. Ein
|
||||||
|
# gesetzter value_path hat trotzdem Vorrang, falls das
|
||||||
|
# Geraetemodell spaeter darauf umgestellt wird.
|
||||||
|
pfad = s.get("value_path") or s["state_url"]
|
||||||
|
try:
|
||||||
|
werte[s["id"]] = str(wert_aus_pfad(daten, pfad))
|
||||||
|
except (KeyError, IndexError, TypeError):
|
||||||
|
logger.debug("WLED %s: Pfad %s nicht gefunden", actor_url, pfad)
|
||||||
|
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
|
# Tahoma
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TahomaTransport(Transport):
|
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").
|
("core:ClosureState"), die command_url ein Kommandoname ("setClosure").
|
||||||
Geschickt wird ueber exec/apply - genauso wie in ajax/tahoma.php, nur ohne
|
Geschickt wird ueber exec/apply - genauso wie in ajax/tahoma.php, nur ohne
|
||||||
die dortige Sonderbehandlung fuer "faehrt gerade".
|
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://"
|
schema = "io://"
|
||||||
@@ -219,6 +390,9 @@ class TahomaTransport(Transport):
|
|||||||
self.dry_run = dry_run
|
self.dry_run = dry_run
|
||||||
self.states = []
|
self.states = []
|
||||||
|
|
||||||
|
def passt(self, actor_url):
|
||||||
|
return bool(self.pin) and ("://" + self.pin + "/") in actor_url
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def basis(self):
|
def basis(self):
|
||||||
return "https://gateway-%s:8443/enduser-mobile-web/1/enduserAPI" % self.pin
|
return "https://gateway-%s:8443/enduser-mobile-web/1/enduserAPI" % self.pin
|
||||||
|
|||||||
+69
-13
@@ -100,6 +100,43 @@ function lockoutChoices()
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auswahlwerte aus possible_values in eine einheitliche Form bringen.
|
||||||
|
*
|
||||||
|
* In der Datenbank stehen zwei Schreibweisen. Eine schlichte Liste
|
||||||
|
* (["open","close"]) und eine Liste aus Ein-Schluessel-Objekten
|
||||||
|
* ([{"0":"Unknown"},{"1":"Idle"}]) - letztere ueberall dort, wo das Geraet
|
||||||
|
* eine Zahl schickt und einen Zustand meint.
|
||||||
|
*
|
||||||
|
* Was davon der gespeicherte Wert ist, haengt an der Seite:
|
||||||
|
*
|
||||||
|
* Messwert: der Runner uebersetzt die Zahl schon beim Lesen in den
|
||||||
|
* Klartext, eine Bedingung vergleicht also "Charging".
|
||||||
|
* Parameter: das Geraet will die Zahl - gespeichert wird der Schluessel,
|
||||||
|
* angezeigt der Klartext.
|
||||||
|
*/
|
||||||
|
function optionList($json, $klartextAlsWert)
|
||||||
|
{
|
||||||
|
$roh = json_decode($json, true);
|
||||||
|
if (!is_array($roh)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$liste = [];
|
||||||
|
foreach ($roh as $eintrag) {
|
||||||
|
if (is_array($eintrag)) {
|
||||||
|
foreach ($eintrag as $wert => $bezeichnung) {
|
||||||
|
$liste[] = [
|
||||||
|
"value" => $klartextAlsWert ? strval($bezeichnung) : strval($wert),
|
||||||
|
"label" => strval($bezeichnung),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$liste[] = ["value" => strval($eintrag), "label" => strval($eintrag)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $liste;
|
||||||
|
}
|
||||||
|
|
||||||
/** Wie das Wertfeld im Editor aussieht. */
|
/** Wie das Wertfeld im Editor aussieht. */
|
||||||
function inputForType($type, $hasOptions)
|
function inputForType($type, $hasOptions)
|
||||||
{
|
{
|
||||||
@@ -187,10 +224,7 @@ function deviceCatalog()
|
|||||||
if (!isset($devices[$id])) {
|
if (!isset($devices[$id])) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$options = json_decode($row["possible_values"], true);
|
$options = optionList($row["possible_values"], true);
|
||||||
if (!is_array($options)) {
|
|
||||||
$options = [];
|
|
||||||
}
|
|
||||||
$type = $row["type"] ?: "string";
|
$type = $row["type"] ?: "string";
|
||||||
$devices[$id]["states"][] = [
|
$devices[$id]["states"][] = [
|
||||||
"id" => intval($row["id"]),
|
"id" => intval($row["id"]),
|
||||||
@@ -198,7 +232,7 @@ function deviceCatalog()
|
|||||||
"type" => $type,
|
"type" => $type,
|
||||||
"input" => inputForType($type, count($options) > 0),
|
"input" => inputForType($type, count($options) > 0),
|
||||||
"operators" => operatorChoices($type),
|
"operators" => operatorChoices($type),
|
||||||
"options" => array_values($options),
|
"options" => $options,
|
||||||
"unit" => $row["unit"],
|
"unit" => $row["unit"],
|
||||||
"value" => $row["current_value"],
|
"value" => $row["current_value"],
|
||||||
"url" => $row["url"],
|
"url" => $row["url"],
|
||||||
@@ -232,10 +266,7 @@ function deviceCatalog()
|
|||||||
if (!isset($commands[$cmdId])) {
|
if (!isset($commands[$cmdId])) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$options = json_decode($row["possible_values"], true);
|
$options = optionList($row["possible_values"], false);
|
||||||
if (!is_array($options)) {
|
|
||||||
$options = [];
|
|
||||||
}
|
|
||||||
$type = $row["type"] ?: "string";
|
$type = $row["type"] ?: "string";
|
||||||
// Das passende Kommando im Geraet suchen. Wenige Kommandos je
|
// Das passende Kommando im Geraet suchen. Wenige Kommandos je
|
||||||
// Geraet, deshalb reicht die lineare Suche.
|
// Geraet, deshalb reicht die lineare Suche.
|
||||||
@@ -246,7 +277,7 @@ function deviceCatalog()
|
|||||||
"name" => $row["parameter_name"],
|
"name" => $row["parameter_name"],
|
||||||
"type" => $type,
|
"type" => $type,
|
||||||
"input" => inputForType($type, count($options) > 0),
|
"input" => inputForType($type, count($options) > 0),
|
||||||
"options" => array_values($options),
|
"options" => $options,
|
||||||
"min" => $row["min_value"] === null ? null : floatval($row["min_value"]),
|
"min" => $row["min_value"] === null ? null : floatval($row["min_value"]),
|
||||||
"max" => $row["max_value"] === null ? null : floatval($row["max_value"]),
|
"max" => $row["max_value"] === null ? null : floatval($row["max_value"]),
|
||||||
"url" => $row["url"],
|
"url" => $row["url"],
|
||||||
@@ -564,6 +595,7 @@ function listAutomations($floor)
|
|||||||
|
|
||||||
$namesState = stateNames();
|
$namesState = stateNames();
|
||||||
$namesCommand = commandNames();
|
$namesCommand = commandNames();
|
||||||
|
$labelsParam = parameterLabels();
|
||||||
|
|
||||||
$list = [];
|
$list = [];
|
||||||
foreach ($ids as $id) {
|
foreach ($ids as $id) {
|
||||||
@@ -572,7 +604,7 @@ function listAutomations($floor)
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$auto["conditionText"] = describeConditions($auto["conditions"], $namesState);
|
$auto["conditionText"] = describeConditions($auto["conditions"], $namesState);
|
||||||
$auto["actionText"] = describeActions($auto["actions"], $namesCommand);
|
$auto["actionText"] = describeActions($auto["actions"], $namesCommand, $labelsParam);
|
||||||
$list[] = $auto;
|
$list[] = $auto;
|
||||||
}
|
}
|
||||||
return $list;
|
return $list;
|
||||||
@@ -597,6 +629,25 @@ function stateNames()
|
|||||||
return $names;
|
return $names;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* id => [gespeicherter Wert => Bezeichnung] fuer alle Kommando-Parameter,
|
||||||
|
* die eine Auswahl haben. Gespeichert wird bei Parametern die Zahl, die das
|
||||||
|
* Geraet erwartet - in der Uebersicht soll aber "Blink" stehen und nicht "1".
|
||||||
|
*/
|
||||||
|
function parameterLabels()
|
||||||
|
{
|
||||||
|
$db = meshDb();
|
||||||
|
$res = $db->query("SELECT id, possible_values FROM command_parameters
|
||||||
|
WHERE possible_values <> '' AND possible_values <> '[]'");
|
||||||
|
$labels = [];
|
||||||
|
while ($row = $res->fetch_assoc()) {
|
||||||
|
foreach (optionList($row["possible_values"], false) as $option) {
|
||||||
|
$labels[intval($row["id"])][$option["value"]] = $option["label"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $labels;
|
||||||
|
}
|
||||||
|
|
||||||
/** id => "Geraet: Kommando" fuer alle Kommandos. */
|
/** id => "Geraet: Kommando" fuer alle Kommandos. */
|
||||||
function commandNames()
|
function commandNames()
|
||||||
{
|
{
|
||||||
@@ -642,12 +693,17 @@ function describeConditions($conditions, $names)
|
|||||||
return implode(" oder ", $parts);
|
return implode(" oder ", $parts);
|
||||||
}
|
}
|
||||||
|
|
||||||
function describeActions($actions, $names)
|
function describeActions($actions, $names, $labels = [])
|
||||||
{
|
{
|
||||||
$parts = [];
|
$parts = [];
|
||||||
foreach ($actions as $a) {
|
foreach ($actions as $a) {
|
||||||
$text = $names[$a["command_id"]] ?? ("Kommando " . $a["command_id"]);
|
$text = $names[$a["command_id"]] ?? ("Kommando " . $a["command_id"]);
|
||||||
$values = array_values($a["params"]);
|
$values = [];
|
||||||
|
foreach ($a["params"] as $parameterId => $wert) {
|
||||||
|
// Wo es eine Auswahl gibt, die Bezeichnung nennen: "Blink" sagt
|
||||||
|
// mehr als die 1, die das Geraet tatsaechlich bekommt.
|
||||||
|
$values[] = $labels[intval($parameterId)][$wert] ?? $wert;
|
||||||
|
}
|
||||||
if ($values) {
|
if ($values) {
|
||||||
$text .= " (" . implode(", ", $values) . ")";
|
$text .= " (" . implode(", ", $values) . ")";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -365,13 +365,13 @@ class DatabaseManager:
|
|||||||
INSERT INTO actor_commands (actor_id, command_name, command_url)
|
INSERT INTO actor_commands (actor_id, command_name, command_url)
|
||||||
VALUES (%s, %s, %s)
|
VALUES (%s, %s, %s)
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
command_name = VALUES(command_name)
|
command_url = VALUES(command_url)
|
||||||
"""
|
"""
|
||||||
cursor.execute(cmd_query, (actor_id, command_name, command_url))
|
cursor.execute(cmd_query, (actor_id, command_name, command_url))
|
||||||
query = """
|
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]
|
command_id = cursor.fetchone()[0]
|
||||||
|
|
||||||
# Parameter einfügen
|
# Parameter einfügen
|
||||||
@@ -381,14 +381,14 @@ class DatabaseManager:
|
|||||||
(command_id, parameter_name, parameter_type, min_value, max_value, possible_values, url)
|
(command_id, parameter_name, parameter_type, min_value, max_value, possible_values, url)
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
parameter_name = VALUES(parameter_name),
|
|
||||||
parameter_type = VALUES(parameter_type),
|
parameter_type = VALUES(parameter_type),
|
||||||
min_value = VALUES(min_value),
|
min_value = VALUES(min_value),
|
||||||
max_value = VALUES(max_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_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')
|
min_val = param.get('min')
|
||||||
max_val = param.get('max')
|
max_val = param.get('max')
|
||||||
possible_vals = json.dumps(param.get('values')) if 'values' in param else ""
|
possible_vals = json.dumps(param.get('values')) if 'values' in param else ""
|
||||||
@@ -401,22 +401,24 @@ class DatabaseManager:
|
|||||||
for state in states:
|
for state in states:
|
||||||
state_query = """
|
state_query = """
|
||||||
INSERT INTO actor_states
|
INSERT INTO actor_states
|
||||||
(actor_id, state_name, state_type, current_value, unit, url, possible_values)
|
(actor_id, state_name, state_type, current_value, unit, url, possible_values, value_path)
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
state_name = VALUES(state_name),
|
|
||||||
state_type = VALUES(state_type),
|
state_type = VALUES(state_type),
|
||||||
current_value = VALUES(current_value),
|
current_value = VALUES(current_value),
|
||||||
possible_values = VALUES(possible_values),
|
possible_values = VALUES(possible_values),
|
||||||
unit = VALUES(unit)
|
unit = VALUES(unit),
|
||||||
|
url = VALUES(url),
|
||||||
|
value_path = VALUES(value_path)
|
||||||
"""
|
"""
|
||||||
state_name = state.get('name', '')
|
state_name = state.get('name', '')
|
||||||
state_type = self.PARAMETER_TYPES_MAPPING.get(state.get('type', 0), 4)
|
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
|
current_value = str(state.get('current_value', '')) if 'current_value' in state else None
|
||||||
unit = state.get('unit')
|
unit = state.get('unit')
|
||||||
state_url = state.get('url')
|
state_url = state.get('url')
|
||||||
|
value_path = state.get('value_path')
|
||||||
possible_vals = json.dumps(state.get('values')) if 'values' in state else ""
|
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))
|
cursor.execute(state_query, (actor_id, state_name, state_type, current_value, unit, state_url, possible_vals, value_path))
|
||||||
|
|
||||||
self.connection.commit()
|
self.connection.commit()
|
||||||
cursor.close()
|
cursor.close()
|
||||||
@@ -471,21 +473,25 @@ class DatabaseManager:
|
|||||||
for state in states:
|
for state in states:
|
||||||
state_query = """
|
state_query = """
|
||||||
INSERT INTO actor_states
|
INSERT INTO actor_states
|
||||||
(actor_id, state_name, state_type, current_value, unit, url)
|
(actor_id, state_name, state_type, current_value, unit, url, value_path, possible_values)
|
||||||
VALUES (%s, %s, %s, %s, %s, %s)
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
state_name = VALUES(state_name),
|
|
||||||
state_type = VALUES(state_type),
|
state_type = VALUES(state_type),
|
||||||
current_value = VALUES(current_value),
|
current_value = VALUES(current_value),
|
||||||
unit = VALUES(unit)
|
unit = VALUES(unit),
|
||||||
|
url = VALUES(url),
|
||||||
|
value_path = VALUES(value_path),
|
||||||
|
possible_values = VALUES(possible_values)
|
||||||
"""
|
"""
|
||||||
state_name = state.get('name', '')
|
state_name = state.get('name', '')
|
||||||
state_type = self.PARAMETER_TYPES_MAPPING.get(state.get('type', 0), 4)
|
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
|
current_value = str(state.get('current_value', '')) if 'current_value' in state else None
|
||||||
unit = state.get('unit')
|
unit = state.get('unit')
|
||||||
state_url = state.get('url')
|
state_url = state.get('url')
|
||||||
|
value_path = state.get('value_path')
|
||||||
|
possible_vals = json.dumps(state.get('values')) if 'values' in state else ""
|
||||||
|
|
||||||
cursor.execute(state_query, (sensor_id, state_name, state_type, current_value, unit, state_url))
|
cursor.execute(state_query, (sensor_id, state_name, state_type, current_value, unit, state_url, value_path, possible_vals))
|
||||||
|
|
||||||
self.connection.commit()
|
self.connection.commit()
|
||||||
cursor.close()
|
cursor.close()
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ Erweitert das Tahoma Script um MQTT-Geräte via Home Assistant Discovery
|
|||||||
|
|
||||||
import paho.mqtt.client as mqtt
|
import paho.mqtt.client as mqtt
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
import logging
|
import logging
|
||||||
@@ -506,6 +507,82 @@ class MQTTDeviceConverter:
|
|||||||
|
|
||||||
return command_entry
|
return command_entry
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _value_path(config: Dict) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Liest aus dem value_template heraus, welcher Teil der Nutzlast gemeint
|
||||||
|
ist.
|
||||||
|
|
||||||
|
Mehrere Messwerte teilen sich oft ein Topic: der go-eCharger schickt
|
||||||
|
sechzehn Zahlen als JSON-Feld auf "nrg", und erst das value_template
|
||||||
|
sagt, dass "Strom L1" das vierte Element ist. Ohne diese Angabe
|
||||||
|
bekaemen alle sechzehn denselben Rohtext.
|
||||||
|
|
||||||
|
Zurueck kommt ein Pfad in derselben Schreibweise, die auch WLED
|
||||||
|
benutzt: "[3]", "ssid", "foo[0].bar". None heisst: die ganze Nutzlast.
|
||||||
|
|
||||||
|
Home Assistant erlaubt in value_template beliebiges Jinja. Hier wird
|
||||||
|
nur der einfache Fall gelesen - ein Zugriff auf value_json und was
|
||||||
|
danach an Punkten und Klammern folgt. Bei allem anderen (Werttabellen
|
||||||
|
wie ['Idle','Charging'][value_json|int], Bedingungen) bleibt es beim
|
||||||
|
Rohtext, also beim bisherigen Verhalten.
|
||||||
|
"""
|
||||||
|
vorlage = config.get('value_template') or config.get('val_tpl')
|
||||||
|
if not vorlage:
|
||||||
|
return None
|
||||||
|
treffer = re.search(
|
||||||
|
r"value_json((?:\s*(?:\.\w+|\[\s*'[^']*'\s*\]|\[\s*\"[^\"]*\"\s*\]|\[\s*\d+\s*\]))*)",
|
||||||
|
vorlage)
|
||||||
|
if not treffer or not treffer.group(1).strip():
|
||||||
|
return None
|
||||||
|
teile = []
|
||||||
|
for stueck in re.finditer(
|
||||||
|
r"\.(\w+)|\[\s*'([^']*)'\s*\]|\[\s*\"([^\"]*)\"\s*\]|\[\s*(\d+)\s*\]",
|
||||||
|
treffer.group(1)):
|
||||||
|
name = stueck.group(1) or stueck.group(2) or stueck.group(3)
|
||||||
|
teile.append(name if name else "[%s]" % stueck.group(4))
|
||||||
|
if not teile:
|
||||||
|
return None
|
||||||
|
# "[3]" haengt sich ohne Punkt an, Namen mit Punkt.
|
||||||
|
pfad = ""
|
||||||
|
for teil in teile:
|
||||||
|
pfad += teil if teil.startswith("[") else (("." + teil) if pfad else teil)
|
||||||
|
return pfad[:120]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _value_map(config: Dict) -> Optional[List[Dict]]:
|
||||||
|
"""
|
||||||
|
Liest eine Werttabelle aus dem value_template.
|
||||||
|
|
||||||
|
Manche Geraete schicken eine Zahl und meinen einen Zustand:
|
||||||
|
|
||||||
|
{{ ['Unknown','Idle','Charging','WaitCar','Complete','Error'][value_json|int ]}}
|
||||||
|
{{ ['Default','Eco','NextTrip'][value_json|int-3]}}
|
||||||
|
|
||||||
|
Das ist kein Pfad in die Nutzlast, sondern eine Uebersetzung von Zahl
|
||||||
|
nach Text. Zurueck kommt sie in der Schreibweise, die WLED fuer seine
|
||||||
|
Effektliste schon benutzt - eine Liste aus {Wert: Bezeichnung} -,
|
||||||
|
denn die versteht der Editor bereits und macht eine Auswahlliste
|
||||||
|
daraus.
|
||||||
|
|
||||||
|
Der Versatz im Ausdruck wandert dabei in die Schluessel: bei
|
||||||
|
"value_json|int-3" gehoert der erste Eintrag zur gesendeten 3.
|
||||||
|
"""
|
||||||
|
vorlage = config.get('value_template') or config.get('val_tpl')
|
||||||
|
if not vorlage:
|
||||||
|
return None
|
||||||
|
treffer = re.search(
|
||||||
|
r"\[\s*((?:'[^']*'|\"[^\"]*\")(?:\s*,\s*(?:'[^']*'|\"[^\"]*\"))*)\s*\]"
|
||||||
|
r"\s*\[\s*value_json\s*\|\s*int\s*([+-]\s*\d+)?\s*\]",
|
||||||
|
vorlage)
|
||||||
|
if not treffer:
|
||||||
|
return None
|
||||||
|
bezeichnungen = [t[1:-1] for t in
|
||||||
|
re.findall(r"'[^']*'|\"[^\"]*\"", treffer.group(1))]
|
||||||
|
versatz = int(treffer.group(2).replace(' ', '')) if treffer.group(2) else 0
|
||||||
|
# index = wert + versatz -> wert = index - versatz
|
||||||
|
return [{str(i - versatz): name} for i, name in enumerate(bezeichnungen)]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _entity_to_states(component: str, object_id: str, config: Dict) -> List[Dict]:
|
def _entity_to_states(component: str, object_id: str, config: Dict) -> List[Dict]:
|
||||||
"""
|
"""
|
||||||
@@ -539,8 +616,12 @@ class MQTTDeviceConverter:
|
|||||||
state_entry = {
|
state_entry = {
|
||||||
'name': object_id,
|
'name': object_id,
|
||||||
'type': 'string',
|
'type': 'string',
|
||||||
'url': state_topic
|
'url': state_topic,
|
||||||
|
'value_path': MQTTDeviceConverter._value_path(config)
|
||||||
}
|
}
|
||||||
|
wertetabelle = MQTTDeviceConverter._value_map(config)
|
||||||
|
if wertetabelle:
|
||||||
|
state_entry['values'] = wertetabelle
|
||||||
|
|
||||||
# Unit hinzufügen - verschiedene mögliche Feldnamen
|
# Unit hinzufügen - verschiedene mögliche Feldnamen
|
||||||
unit = (
|
unit = (
|
||||||
@@ -567,6 +648,11 @@ class MQTTDeviceConverter:
|
|||||||
# Bei Sensor den Typ aus value_template ableiten oder number annehmen
|
# Bei Sensor den Typ aus value_template ableiten oder number annehmen
|
||||||
state_entry['type'] = 'number' # Default für Sensoren
|
state_entry['type'] = 'number' # Default für Sensoren
|
||||||
|
|
||||||
|
# Eine Werttabelle schlaegt den geratenen Typ: was beim Geraet als
|
||||||
|
# Zahl ankommt, ist fuer den Benutzer ein Zustandsname.
|
||||||
|
if 'values' in state_entry:
|
||||||
|
state_entry['type'] = 'string'
|
||||||
|
|
||||||
states.append(state_entry)
|
states.append(state_entry)
|
||||||
|
|
||||||
# Zusätzliche State Topics (z.B. brightness bei Light)
|
# Zusätzliche State Topics (z.B. brightness bei Light)
|
||||||
|
|||||||
@@ -267,14 +267,15 @@ class ShellyModule(BaseModule):
|
|||||||
'name': f"{device_name}_Switch_{i}",
|
'name': f"{device_name}_Switch_{i}",
|
||||||
'url': f"http://{ip}/rpc/Switch.Set?id={i}",
|
'url': f"http://{ip}/rpc/Switch.Set?id={i}",
|
||||||
'commands': [
|
'commands': [
|
||||||
{'command': 'turn_on', 'parameters': []},
|
{'command': 'turn_on', 'url': 'on=true', 'parameters': []},
|
||||||
{'command': 'turn_off', 'parameters': []},
|
{'command': 'turn_off', 'url': 'on=false', 'parameters': []},
|
||||||
{'command': 'toggle', 'parameters': []}
|
{'command': 'toggle', 'parameters': []}
|
||||||
],
|
],
|
||||||
'states': [
|
'states': [
|
||||||
{
|
{
|
||||||
'name': 'output',
|
'name': 'output',
|
||||||
'type': 'boolean',
|
'type': 'boolean',
|
||||||
|
'url': 'output',
|
||||||
'current_value': switch_data.get('output', False)
|
'current_value': switch_data.get('output', False)
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -444,14 +445,15 @@ class ShellyModule(BaseModule):
|
|||||||
'name': f"{device_name}_Relay_{i}",
|
'name': f"{device_name}_Relay_{i}",
|
||||||
'url': f"http://{ip}/relay/{i}",
|
'url': f"http://{ip}/relay/{i}",
|
||||||
'commands': [
|
'commands': [
|
||||||
{'command': 'turn_on', 'parameters': []},
|
{'command': 'turn_on', 'url': 'turn=on', 'parameters': []},
|
||||||
{'command': 'turn_off', 'parameters': []},
|
{'command': 'turn_off', 'url': 'turn=off', 'parameters': []},
|
||||||
{'command': 'toggle', 'parameters': []}
|
{'command': 'toggle', 'url': 'turn=toggle', 'parameters': []}
|
||||||
],
|
],
|
||||||
'states': [
|
'states': [
|
||||||
{
|
{
|
||||||
'name': 'ison',
|
'name': 'ison',
|
||||||
'type': 'boolean',
|
'type': 'boolean',
|
||||||
|
'url': 'ison',
|
||||||
'current_value': relay.get('ison', False)
|
'current_value': relay.get('ison', False)
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user