Files
SolarManager/wattpilot_bruecke.py
T
adminandClaude Opus 5 5990a8feb2 Bruecke nimmt jetzt auch Kommandos entgegen
Der Schreibweg wattpilot/properties/<schluessel>/set war unbenutzbar. Die
Bibliothek reichte Zahlen als Zeichenkette an die Wallbox weiter
("value must be uint8_t"), griff bei unbekannten Namen und bei
Eigenschaften ohne rw-Angabe ungeprueft zu - ftt und fte haben keine, ein
Klick auf die Ladeplanung haette den paho-Callback getoetet - und meldete
nichts zurueck.

Jetzt bekommt der Wert den Typ aus der API-Beschreibung, so dass die
Wallbox 'lmo = 4' genauso nimmt wie 'lmo = Awattar'. Unbekanntes und
Schreibgeschuetztes wird abgelehnt statt zu werfen, und das Ergebnis geht
auf <schluessel>/result - genau wie es der go-e tut. Damit laesst sich das
Dashboard auf einen Weg fuer beide Wallboxen umstellen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 08:36:00 +02:00

275 lines
10 KiB
Python
Executable File

#!/usr/local/bin/python3.10
"""Startet die wattpilotshell, aber mit einer Reparatur an der MQTT-Bruecke.
Das Problem
-----------
Die Wallbox schickt in ihren Statusnachrichten Felder, die die
API-Beschreibung der Bibliothek (wattpilot 0.2, Stand 2022) nicht kennt -
gemessen 34 Stueck, darunter 'clea', 'cle', 'opad' und die ganze
'ocpp*'-Familie. Die Bibliothek schlaegt jedes Feld ungeprueft in ihrem
Verzeichnis nach:
for prop_name, value in msg_dict["status"].items():
pd = wpdef["properties"][prop_name] # KeyError bei 'clea'
mqtt_publish_property(wp, mqtt_client, pd, value)
Der KeyError reisst die restliche Schleife mit. Alles, was in derselben
Nachricht *hinter* dem unbekannten Feld steht, wird nie veroeffentlicht -
beim Verbindungsaufbau trifft das unter anderem 'amp', 'car', 'ust', 'err',
'cbl', 'pha' und 'wh'. Sichtbar wurde das nur als Fehlerpaar im Log, etwa
alle 16 Sekunden:
ERROR:wattpilot:Error received from WebSocketApp: 'clea'
ERROR:websocket:error from callback ... Wattpilot.__on_message ...: 'clea'
Der zweite Fehler
-----------------
Dieselbe Ursache aus der anderen Richtung: 'lot' steht in der
API-Beschreibung als jsonType 'integer', die Firmware schickt aber ein
Objekt. Die Bibliothek reicht es dann unveraendert an paho weiter, und das
lehnt ab:
ERROR:wattpilot:Error received from WebSocketApp:
payload must be a string, bytearray, int, float or None
Auch dieser Fehler riss die Schleife mit. Im alten Log machte er gut vier von
zehn Zeilen aus.
Die Reparatur
-------------
Unbekannte Felder werden uebersprungen statt zu werfen, jedes einmal mit
einer Warnung im Log. Werte, die trotz Kodierung kein Skalar sind, gehen als
JSON hinaus statt als Python-Objekt. Alle uebrigen Felder derselben Nachricht
kommen dadurch durch.
Die Schreibrichtung
-------------------
Dieselbe Bruecke nimmt Kommandos auf wattpilot/properties/<schluessel>/set
entgegen - das Gegenstueck zu api/set?<schluessel>=... beim go-e. Auch dieser
Weg war unbenutzbar: die Bibliothek reicht Zahlen als Zeichenkette weiter
("value must be uint8_t"), greift bei unbekannten Namen und bei
Eigenschaften ohne rw-Angabe ungeprueft zu, und meldet nichts zurueck. Hier
wird der Wert in den Typ gebracht, den die API-Beschreibung nennt, unsauberes
verworfen statt den paho-Callback zu toeten, und das Ergebnis auf
<schluessel>/result veroeffentlicht - genau wie es der go-e tut.
Warum hier und nicht in der Bibliothek
--------------------------------------
Die Bibliothek liegt unter ~/.local/lib/python3.10/site-packages und damit
ausserhalb dieses Repositorys; eine Neuinstallation wuerde eine Aenderung
dort spurlos zurueckdrehen. wattpilot 0.2 ist seit Mai 2022 die letzte
Veroeffentlichung, ein Update behebt es also nicht. Deshalb steht die
Reparatur hier, unter Versionskontrolle, und ersetzt die Funktion zur
Laufzeit. mqtt_setup() schlaegt den Namen erst beim Registrieren nach, so
dass unsere Fassung greift.
"""
import json
import logging
import re
import sys
from wattpilot import wattpilotshell as shell
_LOG = logging.getLogger("wattpilot.bruecke")
# Jedes unbekannte Feld soll genau einmal auffallen, nicht bei jeder Nachricht.
_gemeldet = set()
_original_kodieren = shell.mqtt_get_encoded_property
def mqtt_get_encoded_property(pd, value):
"""Ersatz fuer wattpilotshell.mqtt_get_encoded_property.
Die Bibliothek verpackt nur dann in JSON, wenn die API-Beschreibung den
Wert als Objekt, Liste oder Wahrheitswert fuehrt. Stimmt die Beschreibung
nicht mit dem ueberein, was die Firmware wirklich schickt, kommt ein
dict oder eine list bei paho an - und paho nimmt nur Skalare.
"""
kodiert = _original_kodieren(pd, value)
if kodiert is None or isinstance(kodiert, (str, bytes, bytearray, int, float)):
return kodiert
name = pd.get("key", "?")
if name not in _gemeldet:
_gemeldet.add(name)
_LOG.warning(
"Feld '%s' ist als jsonType %r beschrieben, kommt aber als %s an "
"- wird als JSON veroeffentlicht", name, pd.get("jsonType"),
type(kodiert).__name__)
return json.dumps(kodiert, cls=shell.JSONNamespaceEncoder)
def mqtt_publish_message(event, message):
"""Ersatz fuer wattpilotshell.mqtt_publish_message - siehe Modulkopf."""
wp = event["wp"]
if shell.mqtt_client is None:
return
nachricht = json.loads(message)
typ = nachricht["type"]
if shell.MQTT_PUBLISH_MESSAGES == "true" and (
shell.MQTT_MESSAGES in ([], [""]) or typ in shell.MQTT_MESSAGES):
thema = shell.mqtt_subst_topic(shell.MQTT_TOPIC_MESSAGES, {
"baseTopic": shell.MQTT_TOPIC_BASE,
"serialNumber": wp.serial,
"messageType": typ,
})
shell.mqtt_client.publish(thema, message)
if shell.MQTT_PUBLISH_PROPERTIES != "true" or typ not in ("fullStatus", "deltaStatus"):
return
eigenschaften = shell.wpdef["properties"]
for name, wert in nachricht["status"].items():
beschreibung = eigenschaften.get(name)
if beschreibung is None:
if name not in _gemeldet:
_gemeldet.add(name)
_LOG.warning(
"Feld '%s' steht nicht in der API-Beschreibung der "
"Bibliothek - wird uebersprungen (Wert: %.80s)", name, repr(wert))
continue
shell.mqtt_publish_property(wp, shell.mqtt_client, beschreibung, wert)
# --- Schreibrichtung: was aus MQTT hereinkommt ------------------------------
_original_dekodieren = shell.mqtt_get_decoded_property
# requestId -> Eigenschaftsname, damit die Antwort der Wallbox ihr Topic
# wiederfindet. Bleibt eine Antwort aus, verfaellt der Eintrag beim Aufraeumen.
_offene_anfragen = {}
_antwort_haengt = [False]
_KEIN_WERT = object()
def _als_typ(text, typ):
"""Bringt eine Zeichenkette in den Typ, den die API-Beschreibung nennt.
Gibt _KEIN_WERT zurueck, wenn das nicht geht - dann ist der Wert keine
Zahlenform, sondern vermutlich ein Name aus der valueMap ("Awattar"), um
den sich die Bibliothek selbst kuemmert.
"""
if text.lower() in ("null", "none"):
return None
try:
if typ == "integer":
return int(float(text))
if typ == "number":
return float(text)
if typ == "boolean":
if text.lower() in ("true", "1"):
return True
if text.lower() in ("false", "0"):
return False
except ValueError:
pass
return _KEIN_WERT
def mqtt_get_decoded_property(pd, value):
"""Ersatz fuer wattpilotshell.mqtt_get_decoded_property.
Die Bibliothek holt nur Listen und Objekte aus dem JSON; alles andere
reicht sie als Zeichenkette an die Wallbox weiter, und die lehnt ab:
ERROR:wattpilot:Error Sending Request 1. Message: value must be uint8_t
Hier bekommt der Wert zuerst den Typ, den die API-Beschreibung nennt.
Damit nimmt die Wallbox 'lmo = 4' genauso wie 'lmo = Awattar' - der go-e
kennt nur die Zahlenform, und beide sollen gleich bedienbar sein. Nur was
sich nicht als Zahl lesen laesst, geht den alten Weg ueber die valueMap.
"""
if isinstance(value, str):
gewandelt = _als_typ(value.strip(), pd.get("jsonType"))
if gewandelt is not _KEIN_WERT:
return gewandelt
return _original_dekodieren(pd, value)
def _antwort_veroeffentlichen(event, nachricht):
"""Meldet zurueck, ob die Wallbox den Wert genommen hat.
Der go-e antwortet auf <praefix><schluessel>/result mit 'success' oder
einer Fehlermeldung. Die Bibliothek kennt das nicht, also bauen wir es
nach - sonst bleibt ein abgelehnter Wert fuer die Weboberflaeche
unsichtbar.
"""
if shell.mqtt_client is None:
return
# Die Wallbox schickt die Kennung als Zeichenkette zurueck ("1"),
# gezaehlt wird sie als Zahl. Beide Seiten als Text vergleichen.
name = _offene_anfragen.pop(str(getattr(nachricht, "requestId", "")), None)
if name is None:
return
erfolg = bool(getattr(nachricht, "success", False))
antwort = "success" if erfolg else str(getattr(nachricht, "message", "error"))
thema = shell.mqtt_subst_topic("~/result", {
"baseTopic": shell.MQTT_TOPIC_BASE,
"serialNumber": event["wp"].serial,
"propName": name,
})
shell.mqtt_client.publish(thema, antwort)
def mqtt_set_value(client, userdata, message):
"""Ersatz fuer wattpilotshell.mqtt_set_value.
Das Original greift zweimal ungeprueft zu: wpdef["properties"][name] wirft
bei einem unbekannten Namen, pd['rw'] bei jeder Eigenschaft ohne
Schreibangabe - ftt und fte zum Beispiel haben keine. Beides toetet den
paho-Callback, und danach nimmt die Bruecke keine Kommandos mehr an.
"""
muster = shell.mqtt_subst_topic(shell.MQTT_TOPIC_PROPERTY_SET,
{"propName": "([^/]+)"})
treffer = re.fullmatch(muster, message.topic)
if treffer is None:
_LOG.warning("Unerwartetes Set-Topic %s - ignoriert", message.topic)
return
name = treffer.group(1)
beschreibung = shell.wpdef["properties"].get(name)
if beschreibung is None:
_LOG.warning("Schreibversuch auf unbekannte Eigenschaft '%s' - "
"ignoriert (Topic %s)", name, message.topic)
return
if beschreibung.get("rw") == "R":
_LOG.warning("Eigenschaft '%s' ist schreibgeschuetzt - ignoriert", name)
return
wert = mqtt_get_decoded_property(beschreibung, message.payload.decode("utf-8"))
if not _antwort_haengt[0]:
shell.wp.add_event_handler("wp_response", _antwort_veroeffentlichen)
_antwort_haengt[0] = True
if len(_offene_anfragen) > 50:
_offene_anfragen.clear()
# Erst vormerken, dann senden. Die Wallbox antwortet binnen Millisekunden
# und aus einem anderen Thread; wer die Zuordnung hinterher eintraegt,
# findet die Antwort nicht mehr vor. send_update() zaehlt die Kennung
# hoch und benutzt sie sofort, die naechste ist also vorhersagbar.
naechste = getattr(shell.wp, "_Wattpilot__requestid", 0) + 1
_offene_anfragen[str(naechste)] = name
shell.wp.send_update(name, wert)
_LOG.info("Gesetzt: %s = %r (%s)", name, wert, type(wert).__name__)
shell.mqtt_get_encoded_property = mqtt_get_encoded_property
shell.mqtt_get_decoded_property = mqtt_get_decoded_property
shell.mqtt_publish_message = mqtt_publish_message
shell.mqtt_set_value = mqtt_set_value
if __name__ == "__main__":
try:
sys.exit(shell.main())
except KeyboardInterrupt:
sys.exit(0)