Zum Hauptinhalt springen

Node-RED Topic Mapper für Zendure SolarFlow

Tobias Schulz
Autor
Tobias Schulz
고생 끝에 낙이 온다 · immer, weiter
Inhaltsverzeichnis
Ein kleiner Node-RED-Flow, der die Zendure-MQTT-Topics in ein verwertbares Schema für Home Assistant überführt. Robust und ohne weiteren Docker-Container.

Im Beitrag zur lokalen Anbindung hatte ich den Topic-Mapper-Flow noch nicht teilen können, weil er zu sehr auf meine Installation zugeschnitten war. Inzwischen ist er aufgeräumt, hostnamenfrei und seit Monaten produktiv. Höchste Zeit, den Flow rauszugeben 😁.

Warum der Mapper überhaupt nötig ist
#

Wenn dein SolarFlow lokal über MQTT spricht (das hardcoded Passwort errechnest du hier), bekommst du Topics in dieser Form:

TopicInhalt
/+/+/properties/reportProperties des Hubs und packData der Akkus.
/+/+/properties/write/replyAntworten auf Schreibbefehle, gleicher Payload-Stil.
/+/+/log und /+/+/eventGeräte-Logs und sonstige Events.

Die eigentlichen Messwerte stecken alle in einem einzigen properties-Objekt und kommen nur als Delta: ändert sich solarInputPower, kommt eine Nachricht mit ausschließlich diesem Schlüssel. Für saubere MQTT-Sensoren in Home Assistant ist das ungeeignet, denn dort braucht jeder Sensor genau ein Topic mit einem stabilen, retained Wert.

Genau diese Lücke schließt der Mapper. Er zerlegt jedes properties-Objekt in einzelne Topics, baut für packData einen Akku-Baum, hält den Online-Status nach, und bietet als Bonus einen sauberen Befehls-Eingang.

Info

Für genau dieses Mapping gibt es ein Python-Skript im Original-Repo des SolarFlow Bluetooth Managers: solarflow-topic-mapper.py . Sehr ordentliche Vorlage, die letzte Code-Änderung liegt allerdings rund zwei Jahre zurück. Vor allem aber wollte ich keinen weiteren Docker-Container für eine Aufgabe, die Node-RED einfach direkt miterledigen kann.

Zielschema
#

Aus dem Wust an Zendure-Topics soll am Ende dieses Schema rauskommen, alles retained:

TopicBedeutung
zendure/<device_id>/telemetry/<key>ein Topic pro Property
zendure/<device_id>/batteries/<sn>/<prop>ein Topic pro Pack-Property pro Akku
zendure/<device_id>/status"online" als Lebenszeichen
zendure/cmd/<product>/<id>/properties/writesauberer Befehls-Eingang

Der letzte Punkt ist der Komfort-Bonus: Befehle auf zendure/cmd/... werden vom Mapper transparent auf das Zendure-interne iot/... umgeschrieben. Aus Sicht der Automations-Schicht gibt es nur noch einen einzigen Namespace zendure/.

Architektur
#

flowchart TD
    sf["Zendure
SolarFlow"] broker["MQTT-Broker"] parse["parse telemetry"] settopic["set topic"] rewrite["rewrite topic"] ha["Home Assistant"] sf -->|"/+/+/properties/report
/+/+/properties/write/reply"| parse sf -->|"/+/+/log
/+/+/event"| settopic parse -->|"zendure/__id__/..."| broker settopic -->|"zendure/__id__/status"| broker broker --> ha ha -->|"zendure/cmd/..."| rewrite rewrite -->|"iot/..."| sf

Function Node
#

Die wichtigste Function. Sie zerlegt properties und packData und setzt nebenbei den Online-Status:

const topic_parts = msg.topic.split("/");
const device_id = topic_parts[2];

const is_write_reply = ["write", "reply"]
    .every(part => topic_parts.includes(part));

let has_properties = false;
let has_pack_data = false;

if ("properties" in msg.payload && !is_write_reply) {
    has_properties = true;
    for (let key in msg.payload.properties) {
        node.send([{
            topic: `zendure/${device_id}/telemetry/${key}`,
            payload: msg.payload.properties[key],
            retain: true,
        }, null, null]);
    }
}

if ("packData" in msg.payload && Array.isArray(msg.payload.packData)) {
    has_pack_data = true;
    msg.payload.packData.forEach(pack => {
        const sn = pack.sn;
        delete pack.sn;
        for (let prop in pack) {
            node.send([{
                topic: `zendure/${device_id}/batteries/${sn}/${prop}`,
                payload: pack[prop],
                retain: true,
            }, null, null]);
        }
    });
}

if (!has_properties && !has_pack_data) node.send([null, msg, null]);

node.send([null, null, {
    topic: `zendure/${device_id}/status`,
    payload: "online",
    retain: true,
}]);

Drei Outputs: telemetry (verbunden mit MQTT-Out), others (für Debug-Zwecke offen) und status. Die write/reply-Antworten werden bewusst nicht als Telemetrie ausgewertet, weil ihr Payload nur das wiedergibt, was ich selbst gerade geschrieben habe. Sie zählen aber als Lebenszeichen. Das Topic-Ziel enthält absichtlich nur die device_id, nicht die product_id, was die Sensor-Konfiguration in Home Assistant einfacher hält.

Die beiden anderen Functions sind Einzeiler. set topic setzt für log und event nur den Status "online" auf zendure/<device_id>/status. rewrite topic ersetzt für den Befehls-Kanal das Präfix zendure/cmd/ durch iot/.

Den Flow importieren
#

In Node-RED über Menü → Import in einen leeren Tab einfügen. Vor dem Deploy zwei Dinge anpassen:

  1. MQTT-Broker. Im Knoten Home Assistant MQTT Hostname, Port, Zugangsdaten und TLS-Optionen auf deine Umgebung umstellen. Im Beispiel steht ein Platzhalter mqtt-broker.local mit Port 8883 und aktivem TLS.
  2. TLS-Zertifikate. Wenn dein Broker keine Client-Zertifikate verlangt, die TLS-Config leeren oder den TLS-Block entfernen. verifyservercert: false ist auf meine eigene CA gemünzt, prüfe das bei dir.
[{"id":"661870a4a8dc9578","type":"tab","label":"Zendure SolarFlow Topic Mapper","disabled":false,"info":"","env":[]},{"id":"dbef3a2a6ac13d38","type":"group","z":"661870a4a8dc9578","name":"Zendure Telemetry Topics verarbeiten","style":{"label":true,"color":"#999999","fill":"#ffefbf","fill-opacity":"0.2"},"nodes":["70239655f64ff049","c4294b73f2df7591","3ed191421ad7509e","990c5574211cdd6a","b9f7cc71f1ea571f","ab5f182a4c5df576","48153921e53c4182"],"x":14,"y":119,"w":612,"h":202},{"id":"db3fb2bbd94f8068","type":"group","z":"661870a4a8dc9578","name":"MQTT Befehle an Zendure Broker","style":{"label":true,"fill":"#ffbfbf","fill-opacity":"0.2"},"nodes":["3065a73c7cb7203b","a08d74f99a34b6c9","c065eba5601fb19a"],"x":54,"y":379,"w":492,"h":82},{"id":"70239655f64ff049","type":"function","z":"661870a4a8dc9578","g":"dbef3a2a6ac13d38","name":"parse telemetry","func":"const topic_parts = msg.topic.split(\"/\");\nconst product_id = topic_parts[1];\nconst device_id = topic_parts[2];\nconst is_write_reply = [\"write\", \"reply\"].every(part => topic_parts.includes(part));\nlet has_properties = false;\nlet has_pack_data = false;\nif (\"properties\" in msg.payload && !is_write_reply) {\n    has_properties = true;\n    for (let key in msg.payload.properties) {\n        node.send([{ topic: `zendure/${device_id}/telemetry/${key}`, payload: msg.payload.properties[key], retain: true }, null, null]);\n    }\n}\nif (\"packData\" in msg.payload && Array.isArray(msg.payload.packData)) {\n    has_pack_data = true;\n    msg.payload.packData.forEach(pack => {\n        const sn = pack.sn;\n        delete pack.sn;\n        for (let prop in pack) {\n            node.send([{ topic: `zendure/${device_id}/batteries/${sn}/${prop}`, payload: pack[prop], retain: true }, null, null]);\n        }\n    });\n}\nif (!has_properties && !has_pack_data) node.send([null, msg, null]);\nnode.send([null, null, { topic: `zendure/${device_id}/status`, payload: \"online\", retain: true }]);","outputs":3,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":360,"y":180,"wires":[["c4294b73f2df7591"],[],[]],"outputLabels":["telemetry","others","status"]},{"id":"c4294b73f2df7591","type":"mqtt out","z":"661870a4a8dc9578","g":"dbef3a2a6ac13d38","name":"mqtt out","topic":"","qos":"","retain":"","respTopic":"","contentType":"","userProps":"","correl":"","expiry":"","broker":"666dbb21de861090","x":540,"y":180,"wires":[]},{"id":"3ed191421ad7509e","type":"mqtt in","z":"661870a4a8dc9578","g":"dbef3a2a6ac13d38","name":"sf properties/report","topic":"/+/+/properties/report","qos":"0","datatype":"auto-detect","broker":"666dbb21de861090","nl":false,"rap":true,"rh":0,"inputs":0,"x":130,"y":160,"wires":[["70239655f64ff049"]]},{"id":"990c5574211cdd6a","type":"mqtt in","z":"661870a4a8dc9578","g":"dbef3a2a6ac13d38","name":"sf log","topic":"/+/+/log","qos":"0","datatype":"auto-detect","broker":"666dbb21de861090","nl":false,"rap":true,"rh":0,"inputs":0,"x":90,"y":240,"wires":[["ab5f182a4c5df576"]]},{"id":"b9f7cc71f1ea571f","type":"mqtt in","z":"661870a4a8dc9578","g":"dbef3a2a6ac13d38","name":"sf event","topic":"/+/+/event","qos":"0","datatype":"auto-detect","broker":"666dbb21de861090","nl":false,"rap":true,"rh":0,"inputs":0,"x":90,"y":280,"wires":[["ab5f182a4c5df576"]]},{"id":"ab5f182a4c5df576","type":"function","z":"661870a4a8dc9578","g":"dbef3a2a6ac13d38","name":"set topic","func":"const topic_parts = msg.topic.split(\"/\");\nconst device_id = topic_parts[2];\nmsg.topic = `zendure/${device_id}/status`;\nmsg.payload = \"online\";\nmsg.retain = true;\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":360,"y":240,"wires":[[]]},{"id":"48153921e53c4182","type":"mqtt in","z":"661870a4a8dc9578","g":"dbef3a2a6ac13d38","name":"sf properties/write/reply","topic":"/+/+/properties/write/reply","qos":"0","datatype":"auto-detect","broker":"666dbb21de861090","nl":false,"rap":true,"rh":0,"inputs":0,"x":140,"y":200,"wires":[["70239655f64ff049"]]},{"id":"3065a73c7cb7203b","type":"mqtt in","z":"661870a4a8dc9578","g":"db3fb2bbd94f8068","name":"mqtt in","topic":"zendure/cmd/#","qos":"0","datatype":"auto-detect","broker":"666dbb21de861090","nl":false,"rap":true,"rh":"2","inputs":0,"x":130,"y":420,"wires":[["a08d74f99a34b6c9"]]},{"id":"a08d74f99a34b6c9","type":"function","z":"661870a4a8dc9578","g":"db3fb2bbd94f8068","name":"rewrite topic","func":"msg.topic = msg.topic.replace(\"zendure/cmd/\", \"iot/\");\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":290,"y":420,"wires":[["c065eba5601fb19a"]]},{"id":"c065eba5601fb19a","type":"mqtt out","z":"661870a4a8dc9578","g":"db3fb2bbd94f8068","name":"mqtt out","topic":"","qos":"","retain":"","respTopic":"","contentType":"","userProps":"","correl":"","expiry":"","broker":"666dbb21de861090","x":460,"y":420,"wires":[]},{"id":"666dbb21de861090","type":"mqtt-broker","name":"Home Assistant MQTT","broker":"mqtt-broker.local","port":"8883","tls":"1c39c6626d1e78ba","clientid":"node-red","autoConnect":true,"usetls":true,"protocolVersion":"5","keepalive":"60","cleansession":true,"autoUnsubscribe":true,"birthTopic":"","birthQos":"0","birthRetain":"false","birthPayload":"","birthMsg":{},"closeTopic":"","closeQos":"0","closeRetain":"false","closePayload":"","closeMsg":{},"willTopic":"","willQos":"0","willRetain":"false","willPayload":"","willMsg":{},"userProps":"","sessionExpiry":""},{"id":"1c39c6626d1e78ba","type":"tls-config","name":"mqtt CA","cert":"","key":"","ca":"","certname":"client.crt","keyname":"client.key","caname":"ca.crt","servername":"mqtt-broker.local","verifyservercert":false,"alpnprotocol":""}]
Tip

Property-Namen wandern 1:1 ins Topic (solarInputPower, electricLevel, outputPackPower, …). Eine vollständige Auflistung pflegt Zendure selbst im Developer-Repo .

Fazit
#

Der Mapper läuft bei mir seit Monaten unauffällig. Ein Stück Infrastruktur, das du einmal baust und dann vergisst - genau so soll es sein 😄.

Viel Spaß beim Importieren 😎!


Das Titel-/Hintergrundbild stammt von Claudio Schwarz auf Unsplash .

Verwandte Artikel