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>
This commit is contained in:
@@ -68,6 +68,7 @@ CREATE TABLE `actor_states` (
|
||||
`current_value` varchar(255) DEFAULT NULL COMMENT 'Aktueller Wert des State',
|
||||
`unit` varchar(20) DEFAULT NULL COMMENT 'Einheit falls vorhanden',
|
||||
`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(),
|
||||
`possible_values` text NOT NULL COMMENT 'JSON array with possible enum values.'
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||
|
||||
@@ -116,7 +116,7 @@ Welcher Weg zum Gerät führt, entscheidet die URL des Aktors in `actors`:
|
||||
|
||||
| URL | Messwert (`actor_states.url`) | Kommando (`actor_commands.command_url`) |
|
||||
|---|---|---|
|
||||
| `mqtt://…` | vollständiges Topic, abonniert | Nutzlast 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 | Abfrageargumente an die Geräte-URL (`turn=on`) |
|
||||
| `wled://…` | Pfad in `/json/state` (`seg[0].col[0]`), gepollt | JSON-Vorlage mit Platzhaltern, als Ganzes gesendet |
|
||||
| Tahoma | Statusname (`core:ClosureState`), gepollt | `exec/apply` an die Box |
|
||||
@@ -132,6 +132,20 @@ 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 wie
|
||||
`{{ ['Idle','Charging'][value_json|int] }}` sind keine Pfade, sondern eine
|
||||
Übersetzung von Zahl nach Text — dort bleibt es beim Rohwert. Das betrifft
|
||||
hier acht Messwerte (Ladezustand, Fehlercode und Ähnliches am go-eCharger);
|
||||
sie sind als Zahl vergleichbar, nur nicht als Klartext.
|
||||
|
||||
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.
|
||||
|
||||
@@ -145,7 +145,8 @@ class Regelwerk:
|
||||
|
||||
states = {}
|
||||
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,
|
||||
a.url AS actor_url, a.name AS actor_name, t.type
|
||||
FROM actor_states s
|
||||
JOIN actors a ON a.id = s.actor_id
|
||||
@@ -416,7 +417,8 @@ class Runner:
|
||||
self.regelwerk = Regelwerk.laden(self.db)
|
||||
# Jeder Transport bekommt die Messwerte, fuer die er zustaendig ist.
|
||||
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"]}
|
||||
for s in self.regelwerk.states.values()
|
||||
if transport.passt(s["actor_url"])]
|
||||
transport.zustaende_anmelden(passende)
|
||||
|
||||
@@ -24,8 +24,8 @@ Jeder Transport hat zwei Haelften:
|
||||
zustaende_lesen() liefert {state_id: wert} - nur was neu ist
|
||||
senden(aktion) fuehrt ein Kommando aus
|
||||
|
||||
`states` ist eine Liste von Dicts mit actor_url, state_url und id,
|
||||
`aktion` ein Dict mit actor_url, command_url und params (Liste aus
|
||||
`states` ist eine Liste von Dicts mit actor_url, state_url, value_path und
|
||||
id, `aktion` ein Dict mit actor_url, command_url und params (Liste aus
|
||||
{url, name, wert}).
|
||||
"""
|
||||
|
||||
@@ -45,6 +45,28 @@ logger = logging.getLogger("autoaction.transport")
|
||||
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
|
||||
|
||||
|
||||
class Transport:
|
||||
"""Gemeinsame Form. Wer nichts zu lesen hat, erbt die leeren Methoden."""
|
||||
|
||||
@@ -79,7 +101,7 @@ class MQTTTransport(Transport):
|
||||
def __init__(self, client, dry_run=False):
|
||||
self.client = client
|
||||
self.dry_run = dry_run
|
||||
self.topics = {} # topic -> [state_id, ...]
|
||||
self.topics = {} # topic -> [(state_id, value_path), ...]
|
||||
self.neu = {} # state_id -> wert
|
||||
|
||||
def zustaende_anmelden(self, states):
|
||||
@@ -87,34 +109,51 @@ class MQTTTransport(Transport):
|
||||
for s in states:
|
||||
if not s["state_url"]:
|
||||
continue
|
||||
self.topics.setdefault(s["state_url"], []).append(s["id"])
|
||||
self.topics.setdefault(s["state_url"], []).append((s["id"], s.get("value_path")))
|
||||
for topic in self.topics:
|
||||
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):
|
||||
"""Wird vom Runner aus dem on_message-Rueckruf gerufen."""
|
||||
for state_id in self.topics.get(topic, []):
|
||||
self.neu[state_id] = self._wert(payload)
|
||||
|
||||
@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.
|
||||
"""
|
||||
eintraege = self.topics.get(topic, [])
|
||||
if not eintraege:
|
||||
return
|
||||
text = payload.decode("utf-8", "replace").strip() if isinstance(payload, bytes) else str(payload)
|
||||
try:
|
||||
wert = json.loads(text)
|
||||
daten = json.loads(text)
|
||||
except ValueError:
|
||||
daten = None # kein JSON - dann gilt der Rohtext
|
||||
for state_id, pfad in eintraege:
|
||||
wert = self._wert(text, daten, pfad)
|
||||
if wert is not None:
|
||||
self.neu[state_id] = wert
|
||||
|
||||
@staticmethod
|
||||
def _wert(text, daten, pfad):
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
if daten is None:
|
||||
return text
|
||||
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):
|
||||
return "true" if wert else "false"
|
||||
if isinstance(wert, (int, float, str)):
|
||||
return str(wert)
|
||||
return text
|
||||
return json.dumps(wert, ensure_ascii=False)
|
||||
|
||||
def zustaende_lesen(self):
|
||||
werte, self.neu = self.neu, {}
|
||||
@@ -246,19 +285,6 @@ class WLEDTransport(Transport):
|
||||
logger.info("WLED: %d Messwerte an %d Lampen",
|
||||
len(self.states), len({s["actor_url"] for s in self.states}))
|
||||
|
||||
@staticmethod
|
||||
def _pfad(daten, pfad):
|
||||
""""seg[0].col[0]" in der Antwort nachschlagen."""
|
||||
for teil in pfad.split("."):
|
||||
treffer = re.match(r"^([^\[]*)((?:\[\d+\])*)$", teil)
|
||||
if not treffer:
|
||||
return None
|
||||
if treffer.group(1):
|
||||
daten = daten[treffer.group(1)]
|
||||
for index in re.findall(r"\[(\d+)\]", treffer.group(2)):
|
||||
daten = daten[int(index)]
|
||||
return daten
|
||||
|
||||
def zustaende_lesen(self):
|
||||
werte = {}
|
||||
nach_lampe = {}
|
||||
@@ -273,10 +299,14 @@ class WLEDTransport(Transport):
|
||||
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(self._pfad(daten, s["state_url"]))
|
||||
werte[s["id"]] = str(wert_aus_pfad(daten, pfad))
|
||||
except (KeyError, IndexError, TypeError):
|
||||
logger.debug("WLED %s: Pfad %s nicht gefunden", actor_url, s["state_url"])
|
||||
logger.debug("WLED %s: Pfad %s nicht gefunden", actor_url, pfad)
|
||||
return werte
|
||||
|
||||
def senden(self, aktion):
|
||||
|
||||
@@ -401,22 +401,24 @@ class DatabaseManager:
|
||||
for state in states:
|
||||
state_query = """
|
||||
INSERT INTO actor_states
|
||||
(actor_id, state_name, state_type, current_value, unit, url, possible_values)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
(actor_id, state_name, state_type, current_value, unit, url, possible_values, value_path)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
state_type = VALUES(state_type),
|
||||
current_value = VALUES(current_value),
|
||||
possible_values = VALUES(possible_values),
|
||||
unit = VALUES(unit),
|
||||
url = VALUES(url)
|
||||
url = VALUES(url),
|
||||
value_path = VALUES(value_path)
|
||||
"""
|
||||
state_name = state.get('name', '')
|
||||
state_type = self.PARAMETER_TYPES_MAPPING.get(state.get('type', 0), 4)
|
||||
current_value = str(state.get('current_value', '')) if 'current_value' in state else None
|
||||
unit = state.get('unit')
|
||||
state_url = state.get('url')
|
||||
value_path = state.get('value_path')
|
||||
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()
|
||||
cursor.close()
|
||||
@@ -471,21 +473,23 @@ class DatabaseManager:
|
||||
for state in states:
|
||||
state_query = """
|
||||
INSERT INTO actor_states
|
||||
(actor_id, state_name, state_type, current_value, unit, url)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
(actor_id, state_name, state_type, current_value, unit, url, value_path)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
state_type = VALUES(state_type),
|
||||
current_value = VALUES(current_value),
|
||||
unit = VALUES(unit),
|
||||
url = VALUES(url)
|
||||
url = VALUES(url),
|
||||
value_path = VALUES(value_path)
|
||||
"""
|
||||
state_name = state.get('name', '')
|
||||
state_type = self.PARAMETER_TYPES_MAPPING.get(state.get('type', 0), 4)
|
||||
current_value = str(state.get('current_value', '')) if 'current_value' in state else None
|
||||
unit = state.get('unit')
|
||||
state_url = state.get('url')
|
||||
value_path = state.get('value_path')
|
||||
|
||||
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))
|
||||
|
||||
self.connection.commit()
|
||||
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 json
|
||||
import re
|
||||
import time
|
||||
from typing import Dict, List, Optional
|
||||
import logging
|
||||
@@ -506,6 +507,48 @@ class MQTTDeviceConverter:
|
||||
|
||||
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 _entity_to_states(component: str, object_id: str, config: Dict) -> List[Dict]:
|
||||
"""
|
||||
@@ -539,7 +582,8 @@ class MQTTDeviceConverter:
|
||||
state_entry = {
|
||||
'name': object_id,
|
||||
'type': 'string',
|
||||
'url': state_topic
|
||||
'url': state_topic,
|
||||
'value_path': MQTTDeviceConverter._value_path(config)
|
||||
}
|
||||
|
||||
# Unit hinzufügen - verschiedene mögliche Feldnamen
|
||||
|
||||
Reference in New Issue
Block a user