# Node-RED Topic Mapper für Zendure SolarFlow

> Ein aufgeräumter Node-RED-Flow, der die MQTT-Topics des Zendure SolarFlow in ein sauberes Schema für Home Assistant überführt - robust und ohne Extra-Container.

_Quelle: https://tbsch.de/post/2026-06-03-node-red-topic-mapper-fur-zendure-solarflow/ · Stand: 2026-06-03_


<div class="tb-lead">Ein kleiner Node-RED-Flow, der die Zendure-MQTT-Topics in ein verwertbares Schema für <em>Home Assistant</em> überführt. Robust und ohne weiteren Docker-Container.</div>


Im [Beitrag zur lokalen Anbindung](/post/2025-06-15-zendure-solarflow-lokal-uber-mqtt-steuern/) 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 :grin:.

> [!info]
> Alle Beiträge rund um Zendure SolarFlow lokal findest du in der [Zendure-Übersicht](/tags/zendure/).

## Warum der Mapper überhaupt nötig ist

Wenn dein [Zendure SolarFlow](https://amzlink.to/az001L0a62zYY"!sponsored") lokal über MQTT spricht (das hardcoded Passwort errechnest du [hier](/post/2025-06-30-zendure-solarflow-mqtt-passwort-knacken/)), 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.

> [!info]
> Für genau dieses Mapping gibt es ein Python-Skript im Original-Repo des **SolarFlow Bluetooth Managers**: [`solarflow-topic-mapper.py`](https://github.com/reinhard-brandstaedter/solarflow-bt-manager/blob/master/src/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

<pre class="mermaid">
flowchart TD
    sf[&#34;Zendure&lt;br/&gt;SolarFlow&#34;]
    broker[&#34;MQTT-Broker&#34;]
    parse[&#34;parse telemetry&#34;]
    settopic[&#34;set topic&#34;]
    rewrite[&#34;rewrite topic&#34;]
    ha[&#34;Home Assistant&#34;]

    sf --&gt;|&#34;/+/+/properties/report&lt;br&gt;/+/+/properties/write/reply&#34;| parse
    sf --&gt;|&#34;/+/+/log&lt;br&gt;/+/+/event&#34;| settopic
    parse --&gt;|&#34;zendure/__id__/...&#34;| broker
    settopic --&gt;|&#34;zendure/__id__/status&#34;| broker
    broker --&gt; ha
    ha --&gt;|&#34;zendure/cmd/...&#34;| rewrite
    rewrite --&gt;|&#34;iot/...&#34;| sf
</pre>


## Function Node

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

```javascript
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.

```json
[{"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](https://github.com/Zendure/developer-device-data-report?tab=readme-ov-file#solarflow).

## 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 :smile:.

Viel Spaß beim Importieren :sunglasses:!

---

Das Titel-/Hintergrundbild stammt von [Claudio Schwarz](https://unsplash.com/de/@purzlbaum?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash) auf [Unsplash](https://unsplash.com/de/fotos/nahaufnahme-eines-fensters-mit-einem-gebaude-im-hintergrund-fyeOxvYvIyY?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash).

