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>
This commit is contained in:
2026-08-31 15:44:39 +02:00
co-authored by Claude Opus 5
parent 56b2ab1648
commit 17678da824
7 changed files with 203 additions and 36 deletions
+4 -2
View File
@@ -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";
+28 -5
View File
@@ -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
+15 -2
View File
@@ -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)
+39 -10
View File
@@ -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, {}
+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. */
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) . ")";
}
@@ -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()
@@ -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 = (
@@ -611,6 +648,11 @@ class MQTTDeviceConverter:
# 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)
# Zusätzliche State Topics (z.B. brightness bei Light)