diff --git a/homeMesh_DB-layout.sql b/homeMesh_DB-layout.sql index 82c304e..d2e6329 100644 --- a/homeMesh_DB-layout.sql +++ b/homeMesh_DB-layout.sql @@ -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; diff --git a/restricted/autoActions/README.md b/restricted/autoActions/README.md index 437dfa3..d6efaad 100644 --- a/restricted/autoActions/README.md +++ b/restricted/autoActions/README.md @@ -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. diff --git a/restricted/autoActions/autoaction_runner.py b/restricted/autoActions/autoaction_runner.py index 546ce5b..6d443cc 100644 --- a/restricted/autoActions/autoaction_runner.py +++ b/restricted/autoActions/autoaction_runner.py @@ -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) diff --git a/restricted/autoActions/transports.py b/restricted/autoActions/transports.py index a7a0505..71afeb5 100644 --- a/restricted/autoActions/transports.py +++ b/restricted/autoActions/transports.py @@ -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): diff --git a/restricted/deviceDiscovery/device_discovery.py b/restricted/deviceDiscovery/device_discovery.py index 0ba4b76..f7cd4fe 100644 --- a/restricted/deviceDiscovery/device_discovery.py +++ b/restricted/deviceDiscovery/device_discovery.py @@ -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() diff --git a/restricted/deviceDiscovery/modules/mqtt_module.py b/restricted/deviceDiscovery/modules/mqtt_module.py index b23ddd3..6f132e1 100644 --- a/restricted/deviceDiscovery/modules/mqtt_module.py +++ b/restricted/deviceDiscovery/modules/mqtt_module.py @@ -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