Smart battery with Zendure, Tibber and NodeRed

Patrick D. RuppPatrick D. Rupp
3 min read

I have an 820W balcony solar system, feeding into a Zendure SolarFlow with two 960Wh batteries and then going through a Hoymiles HM-800 into my home.

My goal was to store the solar energy in the batteries, and if the energy prices are higher, discharge into my home.

The Problem

There is no solution to control it locally, only with the app. Zendure only has a public MQTT in the internet that is not locally available, an one can only read data from it. It looked like I needed to wait until Zendure would give me a method to control the device locally.

Man, I already have the energy prices, the current energy usage of my home, even the current solar energy and the battery percentage of my Zendure system... there must be a way. And there is!

You remember my HM-800? Instead of the Hoymiles DTU, I am using a AhoyDTU. With it, I can send a limit to the Inverter. I did already set a max output of 300W in the Zendure App (because thats a little bit under the base energy usage of my home) and now I set the Inverter limit to 0%. Now the Zendure system moves all solar energy into the battery. If the limit is reverted to 100%, it moves energy into the home. Nice!

Because the AhoyDTU has MQTT, and one can set a limit, my plan was almost complete. If there only was a way to automate this...

The Solution

My current setup uses a NodeRed instance in HomeAssistant, an AhoyDTU, the Tibber API and the public Zendure MQTT.

Every hour, I get the current prices from Tibber, store them, wait 5 seconds, read the stored prices, calculate the average price and store it in an HomeAssistant Sensor named "elecricity_price_average".

Then, every time the Tibber Integration changes the price, or the battery percentage changes (I get the value from the Zendure MQTT and store it in its own sensor), I get the values with a handy "ValueGetter"-Subflow, check if it should discharge into my home (which it should when the current price is higher than the average price of the day), store the limit in a sensor and send the new limit to the inverter with MQTT.

This is my final flow:

Code

Average price calculation:

function calculateAverage(values) {
    // Check if the values array is not empty
    if (values.length === 0) {
        return 0;
    }

    // Calculate the sum of all values
    var sum = values.reduce(function(a, b) {
        return a + b;
    });

    // Calculate the average
    var average = sum / values.length;

    return average;
}

let prices = [];
// Push all prices into an array
msg.payload.forEach(function(price) {
    prices.push(price.total);
});

// the 0.00 is for an offset that can be added to the average
let result = Math.round((calculateAverage(prices) + 0.00) * 1000) / 1000

return msg = {
    "payload": result
};

Discharge condition:

// Prepare values
let battery = msg.payload.battery;
let price = msg.payload.price;
let average = msg.payload.averagePrice;
let solar = msg.payload.solarEnergy;

// Charge battery
msg.payload = "0"

// Price high -> discharge battery
if (price > average) {
    msg.payload = "100"
}

if (
    (battery <= 20 && solar > 0) ||
    (battery <= 12 && solar == 0)
) {
    // Charge battery
    msg.payload = "0"
}

// Battery full -> excess solar into home
if (battery >= 100 && solar > 0) {
    msg.payload = "100"
}

// Send new limit to inverter
return msg;

MQTT topic:

ahoydtu/ctrl/limit/0

ValueGetter Subflow:

[{"id":"f193ca922ddcefac","type":"subflow","name":"ValueGetter","info":"","category":"Vratny","in":[{"x":120,"y":260,"wires":[{"id":"c56d509f669595a3"}]}],"out":[{"x":1180,"y":260,"wires":[{"id":"8e25b00de8b09d79","port":0}]}],"env":[{"name":"entities","type":"json","value":"{}","ui":{"icon":"font-awesome/fa-bars","label":{"de":"Entitäten"}}},{"name":"topic","type":"str","value":""}],"meta":{},"color":"#3FADB5","icon":"node-red/join.svg"},{"id":"b4a9ab518ebd88d2","type":"api-current-state","z":"f193ca922ddcefac","name":"","server":"4db50fc5.1329b","version":3,"outputs":1,"halt_if":"","halt_if_type":"str","halt_if_compare":"is","entity_id":"","state_type":"str","blockInputOverrides":false,"outputProperties":[{"property":"payload","propertyType":"msg","value":"","valueType":"entityState"},{"property":"data","propertyType":"msg","value":"","valueType":"entity"}],"for":"0","forType":"num","forUnits":"minutes","override_topic":false,"state_location":"payload","override_payload":"msg","entity_location":"data","override_data":"msg","x":720,"y":260,"wires":[["b1f94791af70a145"]]},{"id":"b1f94791af70a145","type":"join","z":"f193ca922ddcefac","name":"Zusammenführen","mode":"auto","build":"object","property":"payload","propertyType":"msg","key":"topic","joiner":"\\n","joinerType":"str","accumulate":false,"timeout":"","count":"2","reduceRight":false,"reduceExp":"","reduceInit":"","reduceInitType":"","reduceFixup":"","x":910,"y":260,"wires":[["8e25b00de8b09d79"]]},{"id":"8e25b00de8b09d79","type":"function","z":"f193ca922ddcefac","name":"Filter","func":"let newMsg={};\nnewMsg.payload=msg.payload;\nnewMsg.topic = env.get(\"topic\");\nreturn newMsg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":1070,"y":260,"wires":[[]]},{"id":"989cce5990120f96","type":"split","z":"f193ca922ddcefac","name":"","splt":"\\n","spltType":"str","arraySplt":1,"arraySpltType":"len","stream":false,"addname":"topic","x":390,"y":260,"wires":[["90c052159aac0c60"]]},{"id":"90c052159aac0c60","type":"function","z":"f193ca922ddcefac","name":"Convert","func":"msg.payload = { 'entityId': msg.payload};\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":540,"y":260,"wires":[["b4a9ab518ebd88d2"]]},{"id":"c56d509f669595a3","type":"function","z":"f193ca922ddcefac","name":"Get Variable","func":"msg.payload=env.get(\"entities\");\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":230,"y":260,"wires":[["989cce5990120f96"]]},{"id":"4db50fc5.1329b","type":"server","name":"Home Assistant","addon":true,"rejectUnauthorizedCerts":true,"ha_boolean":"","connectionDelay":false,"cacheJson":false,"heartbeat":false,"heartbeatInterval":"","statusSeparator":"","enableGlobalContextStore":false}]

ValueGetter Entities:

{
    "solarEnergy": "sensor.solarenergie",
    "battery": "sensor.batterie_ladung",
    "price": "sensor.electricity_price_your_address",
    "averagePrice": "sensor.electricity_price_average"
}
0
Subscribe to my newsletter

Read articles from Patrick D. Rupp directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Patrick D. Rupp
Patrick D. Rupp