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:
| Topic | Inhalt |
|---|---|
/+/+/properties/report | Properties des Hubs und packData der Akkus. |
/+/+/properties/write/reply | Antworten auf Schreibbefehle, gleicher Payload-Stil. |
/+/+/log und /+/+/event | Gerä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.
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:
| Topic | Bedeutung |
|---|---|
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/write | sauberer 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:
- MQTT-Broker. Im Knoten
Home Assistant MQTTHostname, Port, Zugangsdaten und TLS-Optionen auf deine Umgebung umstellen. Im Beispiel steht ein Platzhaltermqtt-broker.localmit Port8883und aktivem TLS. - TLS-Zertifikate. Wenn dein Broker keine Client-Zertifikate verlangt, die TLS-Config leeren oder den TLS-Block entfernen.
verifyservercert: falseist 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":""}]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 .




