Compare commits

...
3 Commits
Author SHA1 Message Date
adminandClaude Opus 5 17678da824 Zahlencodes als Klartext
Manche Geraete schicken eine Zahl und meinen einen Zustand - der
go-eCharger etwa 2 fuer "Charging". Welche Zahl welchen Namen hat, steht im
value_template der Home-Assistant-Discovery als Werttabelle:

    {{ ['Unknown','Idle','Charging','WaitCar','Complete','Error'][value_json|int] }}

Das ist kein Pfad in die Nutzlast, sondern eine Uebersetzung, und sie wurde
bisher verworfen. Jetzt landet sie in possible_values - in derselben
Schreibweise, die WLED fuer seine Effektliste schon benutzt, naemlich einer
Liste aus {Wert: Bezeichnung}. Damit versteht der Editor sie ohne
Zusatzarbeit und macht eine Auswahlliste daraus. Ein Versatz im Ausdruck
wandert in die Schluessel: aus [value_json|int-3] wird {"3":"Default"}.

Der Runner uebersetzt beim Lesen, eine Bedingung vergleicht also den
Klartext. Steht die Zahl nicht in der Tabelle, bleibt sie stehen - ein
erfundener Name waere schlimmer als ein roher Wert.

Dabei ist ein Folgefehler aufgefallen: Auswahlwerte in dieser Schreibweise
kamen im Editor als "[object Object]" an. Die alte addOptions kannte die
Form, der neue Modell-Aufbau nicht - aufgefallen ist es nie, weil WLED beim
Umbau abgeschaltet war. Der Katalog liefert Auswahlwerte jetzt einheitlich
als {value, label}, und zwar seitenrichtig:

    Messwert   angezeigt Charging, gespeichert Charging
               (der Runner hat schon uebersetzt)
    Parameter  angezeigt Blink,    gespeichert 1
               (das Geraet will die Zahl)

Die Uebersicht loest die Bezeichnung ebenfalls auf: "Effekt (Blink)" statt
"Effekt (1)".

Nachgemessen: alle fuenf Werttabellen auf dem Broker richtig erkannt,
einschliesslich des Versatzes, kein Fehltreffer unter den 35 uebrigen
Vorlagen. Live liefert der go-eCharger jetzt Idle, Neutral, Auto, Eco und
None statt 1, 0, 0, 4, 0. Ein weiterer Discovery-Lauf aendert keine Zeile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 15:44:39 +02:00
adminandClaude Opus 5 56b2ab1648 Mehrere Messwerte auf einem Topic auseinanderhalten
Der go-eCharger schickt sechzehn Zahlen als JSON-Feld auf einem einzigen
Topic, die Wechselrichter und die WLAN-Felder machen es aehnlich. Welcher
Teil der Nutzlast gemeint ist, steht im value_template der
Home-Assistant-Discovery - das Modul las es bisher nur, um den Datentyp zu
raten, und warf es dann weg. Alle sechzehn Messwerte bekamen denselben
Rohtext.

actor_states hat jetzt eine Spalte value_path: ein Pfad in die Nutzlast, in
derselben Schreibweise, die WLED schon benutzt ("[4]", "ssid",
"seg[0].col[0]"). NULL heisst weiterhin: die ganze Nutzlast.

Gelesen wird aus dem Template nur der einfache Fall - ein Zugriff auf
value_json und was danach an Punkten und Klammern folgt. Von den 40
Vorlagen, die hier auf dem Broker liegen, sind 28 genau das. Die uebrigen
zwoelf sind entweder die ganze Nutzlast (dann ist NULL richtig) oder
Werttabellen wie ['Idle','Charging'][value_json|int] - das ist kein Pfad,
sondern eine Uebersetzung von Zahl nach Text, und dort bleibt es beim
bisherigen Verhalten. Kein einziger Fehltreffer: die Werttabellen liefern
sauber None statt eines erfundenen Pfades.

Die Pfad-Auswertung stand schon im WLED-Transport; sie ist jetzt eine
gemeinsame Funktion, die sich beide teilen. MQTT wertet die Nutzlast einmal
je Nachricht aus und verteilt sie danach an alle Messwerte des Topics.

Nachgemessen: die sechzehn nrg-Werte liefern jetzt sechzehn eigene Zahlen
statt sechzehnmal dasselbe, die Leseabdeckung bleibt bei 440 von 441, und
ein weiterer Discovery-Lauf aendert keine Zeile.

Die restlichen geteilten Topics sind kein Fehler: bei den Wechselrichtern
lesen "Limit Persistent" und "Limit NonPersistent" wirklich denselben Wert
und unterscheiden sich nur im Kommando-Topic, und beim Thermostat liest die
Klima-Entity dieselbe Temperatur wie der Sensor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 15:23:08 +02:00
adminandClaude Opus 5 631baa7333 Discovery: alle Geraete finden und stabil ablegen
Ein Lauf mit allen Modulen brachte 54 Geraete und 441 Messwerte - und fuenf
Fehler ans Licht, die vorher niemand sehen konnte, weil clear_tables die
Tabellen bei jedem Lauf geleert hat.

Vier davon sind Schluessel- und Upsert-Fehler derselben Familie:

 * command_parameters war ueber (command_id, url) eindeutig. WLED-Parameter
   haben keine URL - ihr Name ist der Platzhalter in der Kommando-Vorlage -,
   und eine NULL kollidiert in MySQL nie. ON DUPLICATE KEY UPDATE griff
   also nicht, und jeder Lauf legte dieselben Parameter erneut an: aus 18
   wurden nach drei Laeufen 54. Schluessel jetzt (command_id,
   parameter_name).

 * actor_states war ueber (actor_id, url) eindeutig. Umgekehrtes Problem:
   Messwerte, die sich ein Topic teilen, ueberschrieben einander. Der
   go-eCharger schickt sechzehn Werte als JSON-Feld auf einem Topic - von
   denen kam genau einer in der Datenbank an. Schluessel jetzt (actor_id,
   state_name), das bringt 50 verlorene Messwerte zurueck.

 * Kommandos, Messwerte und Parameter aktualisierten ihre URL beim
   Wiederholungslauf nicht - sie stand nicht im UPDATE-Teil. Eine Korrektur
   in einem Modul kam damit nie in einer bestehenden Datenbank an.

 * Der Typ eines Kommando-Parameters wurde als Text in eine int-Spalte
   geschrieben. MariaDB macht daraus stillschweigend 0, und 0 ist "bool" -
   deshalb bot der Editor fuer die WLED-Helligkeit (0..255) ein Ja/Nein an.

Der fuenfte: die Shelly-Gen1-Relais hatten weder eine Kommando-URL noch eine
URL am Zustand. Ohne die weiss niemand, was zu schicken und wo nachzusehen
ist. Gen1 schaltet ueber ?turn=on|off|toggle und meldet sich in "ison".

Damit entfaellt auch die Uebersetzungstabelle im HTTPTransport: die
Zuordnung gehoert ins Geraetemodell, nicht in den Runner.

Zwei Luecken auf der Runner-Seite, die derselbe Lauf gezeigt hat:

 * WLED liess sich gar nicht ansteuern - es gab keinen Transport dafuer.
   Der neue nutzt die JSON-Vorlage aus command_url, fuellt die Platzhalter
   und schickt sie am Stueck an /json/state.

 * Tahoma wurde am Schema "io://" erkannt. Das Schema beschreibt aber die
   Funkart: dieselbe Box liefert rts:// fuer die Dachfenster und
   internal:// fuer die Alarmanlage. Drei von 22 Geraeten fielen durch.
   Erkannt wird jetzt an der Box-Kennung in der URL.

Nachgemessen: zwei Laeufe hintereinander aendern keine einzige Zeile mehr,
die Geraete-IDs bleiben ueber Laeufe stabil (Voraussetzung fuer die
Fremdschluessel der Automatiken), alle 54 Geraete finden genau einen
Transport, und 440 der 441 Messwerte sind tatsaechlich lesbar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 14:57:29 +02:00
9 changed files with 488 additions and 92 deletions
+11 -2
View File
@@ -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`);
-- --
+4 -2
View File
@@ -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";
+55 -10
View File
@@ -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 |
| `Logic` | gerechnet: Uhrzeit, Datum, Sonne | – | | 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()`. 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
+19 -3
View File
@@ -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)
+214 -40
View File
@@ -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
View File
@@ -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) . ")";
} }
+22 -16
View File
@@ -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)
} }
] ]