From 17678da824756fcb432405dabd4507e3203f2368 Mon Sep 17 00:00:00 2001 From: Moirtz Wagner Date: Mon, 31 Aug 2026 15:44:39 +0200 Subject: [PATCH] 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 --- js/solar/autoActionFuncs.js | 6 +- restricted/autoActions/README.md | 33 ++++++-- restricted/autoActions/autoaction_runner.py | 17 +++- restricted/autoActions/transports.py | 49 ++++++++--- restricted/automations.php | 82 ++++++++++++++++--- .../deviceDiscovery/device_discovery.py | 10 ++- .../deviceDiscovery/modules/mqtt_module.py | 42 ++++++++++ 7 files changed, 203 insertions(+), 36 deletions(-) diff --git a/js/solar/autoActionFuncs.js b/js/solar/autoActionFuncs.js index 90acba0..6b9c212 100644 --- a/js/solar/autoActionFuncs.js +++ b/js/solar/autoActionFuncs.js @@ -84,9 +84,11 @@ function wertFeld(spec, wert) { optionListe(feld, [{ value: "true", text: "JA" }, { value: "false", text: "NEIN" }], wert === "" ? "true" : wert); } 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.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 { feld = document.createElement("input"); feld.className = "form-control"; @@ -104,7 +106,7 @@ function wertFeld(spec, wert) { /** Standardwert, wenn eine Zeile auf einen anderen Messwert umgestellt wird. */ function standardWert(spec) { 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 === "date") return new Date().toISOString().split("T")[0]; if (spec.input === "number") return "0"; diff --git a/restricted/autoActions/README.md b/restricted/autoActions/README.md index d6efaad..633ac77 100644 --- a/restricted/autoActions/README.md +++ b/restricted/autoActions/README.md @@ -140,11 +140,34 @@ 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. +`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 diff --git a/restricted/autoActions/autoaction_runner.py b/restricted/autoActions/autoaction_runner.py index 6d443cc..f7d6c31 100644 --- a/restricted/autoActions/autoaction_runner.py +++ b/restricted/autoActions/autoaction_runner.py @@ -44,6 +44,7 @@ Tabellen siehe homeMesh_automations.sql, Konfiguration siehe config.ini.example. import argparse import configparser +import json import logging import os import sys @@ -146,13 +147,24 @@ class Regelwerk: states = {} with db.cursor() as c: c.execute("""SELECT s.id, s.state_name, s.url AS state_url, s.value_path, - s.current_value, + s.current_value, s.possible_values, 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 LEFT JOIN state_types t ON s.state_type = t.id""") for row in c.fetchall(): 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 kommandos = {} @@ -418,7 +430,8 @@ class Runner: # 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"], "value_path": s["value_path"]} + "state_url": s["state_url"], "value_path": s["value_path"], + "wertetabelle": s["wertetabelle"]} 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 71afeb5..f615679 100644 --- a/restricted/autoActions/transports.py +++ b/restricted/autoActions/transports.py @@ -67,6 +67,29 @@ def wert_aus_pfad(daten, pfad): 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: """Gemeinsame Form. Wer nichts zu lesen hat, erbt die leeren Methoden.""" @@ -101,7 +124,7 @@ class MQTTTransport(Transport): def __init__(self, client, dry_run=False): self.client = client self.dry_run = dry_run - self.topics = {} # topic -> [(state_id, value_path), ...] + self.topics = {} # topic -> [(state_id, value_path, wertetabelle), ...] self.neu = {} # state_id -> wert def zustaende_anmelden(self, states): @@ -109,7 +132,8 @@ class MQTTTransport(Transport): for s in states: if not s["state_url"]: continue - self.topics.setdefault(s["state_url"], []).append((s["id"], s.get("value_path"))) + self.topics.setdefault(s["state_url"], []).append( + (s["id"], s.get("value_path"), s.get("wertetabelle") or {})) for topic in self.topics: self.client.subscribe(topic) mehrfach = sum(1 for e in self.topics.values() if len(e) > 1) @@ -126,13 +150,13 @@ class MQTTTransport(Transport): 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) + 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): + def _wert(text, daten, pfad, tabelle=None): """ Aus der Nutzlast den Wert eines einzelnen Messwerts machen. @@ -141,19 +165,24 @@ class MQTTTransport(Transport): 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 text + 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): - return "true" if wert else "false" - if isinstance(wert, (int, float, str)): - return str(wert) - return json.dumps(wert, ensure_ascii=False) + wert = "true" if wert else "false" + elif isinstance(wert, (int, float, str)): + wert = str(wert) + else: + return json.dumps(wert, ensure_ascii=False) + return uebersetze(wert, tabelle) def zustaende_lesen(self): werte, self.neu = self.neu, {} diff --git a/restricted/automations.php b/restricted/automations.php index 4a4171e..4c1a1ac 100644 --- a/restricted/automations.php +++ b/restricted/automations.php @@ -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. */ function inputForType($type, $hasOptions) { @@ -187,10 +224,7 @@ function deviceCatalog() if (!isset($devices[$id])) { continue; } - $options = json_decode($row["possible_values"], true); - if (!is_array($options)) { - $options = []; - } + $options = optionList($row["possible_values"], true); $type = $row["type"] ?: "string"; $devices[$id]["states"][] = [ "id" => intval($row["id"]), @@ -198,7 +232,7 @@ function deviceCatalog() "type" => $type, "input" => inputForType($type, count($options) > 0), "operators" => operatorChoices($type), - "options" => array_values($options), + "options" => $options, "unit" => $row["unit"], "value" => $row["current_value"], "url" => $row["url"], @@ -232,10 +266,7 @@ function deviceCatalog() if (!isset($commands[$cmdId])) { continue; } - $options = json_decode($row["possible_values"], true); - if (!is_array($options)) { - $options = []; - } + $options = optionList($row["possible_values"], false); $type = $row["type"] ?: "string"; // Das passende Kommando im Geraet suchen. Wenige Kommandos je // Geraet, deshalb reicht die lineare Suche. @@ -246,7 +277,7 @@ function deviceCatalog() "name" => $row["parameter_name"], "type" => $type, "input" => inputForType($type, count($options) > 0), - "options" => array_values($options), + "options" => $options, "min" => $row["min_value"] === null ? null : floatval($row["min_value"]), "max" => $row["max_value"] === null ? null : floatval($row["max_value"]), "url" => $row["url"], @@ -564,6 +595,7 @@ function listAutomations($floor) $namesState = stateNames(); $namesCommand = commandNames(); + $labelsParam = parameterLabels(); $list = []; foreach ($ids as $id) { @@ -572,7 +604,7 @@ function listAutomations($floor) continue; } $auto["conditionText"] = describeConditions($auto["conditions"], $namesState); - $auto["actionText"] = describeActions($auto["actions"], $namesCommand); + $auto["actionText"] = describeActions($auto["actions"], $namesCommand, $labelsParam); $list[] = $auto; } return $list; @@ -597,6 +629,25 @@ function stateNames() 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. */ function commandNames() { @@ -642,12 +693,17 @@ function describeConditions($conditions, $names) return implode(" oder ", $parts); } -function describeActions($actions, $names) +function describeActions($actions, $names, $labels = []) { $parts = []; foreach ($actions as $a) { $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) { $text .= " (" . implode(", ", $values) . ")"; } diff --git a/restricted/deviceDiscovery/device_discovery.py b/restricted/deviceDiscovery/device_discovery.py index f7cd4fe..93e079a 100644 --- a/restricted/deviceDiscovery/device_discovery.py +++ b/restricted/deviceDiscovery/device_discovery.py @@ -473,14 +473,15 @@ class DatabaseManager: for state in states: state_query = """ INSERT INTO actor_states - (actor_id, state_name, state_type, current_value, unit, url, value_path) - VALUES (%s, %s, %s, %s, %s, %s, %s) + (actor_id, state_name, state_type, current_value, unit, url, value_path, possible_values) + VALUES (%s, %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), - value_path = VALUES(value_path) + value_path = VALUES(value_path), + possible_values = VALUES(possible_values) """ state_name = state.get('name', '') state_type = self.PARAMETER_TYPES_MAPPING.get(state.get('type', 0), 4) @@ -488,8 +489,9 @@ class DatabaseManager: 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, (sensor_id, state_name, state_type, current_value, unit, state_url, value_path)) + cursor.execute(state_query, (sensor_id, state_name, state_type, current_value, unit, state_url, value_path, possible_vals)) self.connection.commit() cursor.close() diff --git a/restricted/deviceDiscovery/modules/mqtt_module.py b/restricted/deviceDiscovery/modules/mqtt_module.py index 6f132e1..255a416 100644 --- a/restricted/deviceDiscovery/modules/mqtt_module.py +++ b/restricted/deviceDiscovery/modules/mqtt_module.py @@ -549,6 +549,40 @@ class MQTTDeviceConverter: 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 def _entity_to_states(component: str, object_id: str, config: Dict) -> List[Dict]: """ @@ -585,6 +619,9 @@ class MQTTDeviceConverter: '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 = ( @@ -610,6 +647,11 @@ class MQTTDeviceConverter: elif component == 'sensor': # Bei Sensor den Typ aus value_template ableiten oder number annehmen 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)