I use MQTT to read out all the values I can get and send the values via the flows to charge and discharge my 3 Zendure Hyper 2000’s (and 1 Ace 1500).
Here is the script I’m using:
/**
* TITAN_MQTT_PARSER.JS V3.44 - BYPASS DISPLAY FIX
* - FIX: Überschreibt Werte NIE wieder mit 0, wenn sie im JSON fehlen (Stateful).
* - NEW: Bat_Net (Netto-Batterieleistung) berechnet für virtuelle Geräte.
* - PURE: Stabiles Dashboard mit echten, validierten Werten.
* - DISPLAY: Erzwingt "PV-Bypass" bei SoC >= 99% und aktiver Sonne.
*/
const TITAN = {
DEVICES: {
HYPER_1: { displayName: "HY1", varPrefix: "Hyper_1", sn: "EE1HYMCFM200629" },
HYPER_2: { displayName: "HY2", varPrefix: "Hyper_2", sn: "EE1LYMGJM320918" },
HYPER_3: { displayName: "HY3", varPrefix: "Hyper_3", sn: "EE1LHMGHM312110" },
ACE1500: { displayName: "ACE", varPrefix: "ACE1500", sn: "FE1HTMG4M350376" }
}
};
async function updateVariable(name, value) {
const vars = await Homey.logic.getVariables();
const cleanName = name.replace(/[-_ ]/g, '').toLowerCase();
const variable = Object.values(vars).find(v => v.name.replace(/[-_ ]/g, '').toLowerCase() === cleanName);
if (variable && variable.value !== value) {
await Homey.logic.updateVariable({ id: variable.id, variable: { value } });
}
}
function getViennaTimeString(date = new Date()) {
return new Intl.DateTimeFormat('de-AT', {
timeZone: 'Europe/Vienna',
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: false
}).format(date).replace(',', ' -');
}
const rawPayload = args[0];
const isManualTest = (!rawPayload || typeof rawPayload !== 'string');
// ==========================================
// 🌟 DIAGNOSE-DASHBOARD (Manueller Test)
// ==========================================
if (isManualTest) {
console.log(" ");
console.log("=".repeat(120));
console.log(` 🛠️ TITAN DIAGNOSE DASHBOARD | Alle Homey-Werte | ${getViennaTimeString()} `);
console.log("=".repeat(120));
const currentVars = await Homey.logic.getVariables();
const getVal = (prefix, suffix) => {
const v = Object.values(currentVars).find(varObj => varObj.name === `${prefix}_${suffix}`);
return (v && v.value !== null) ? v.value : "---";
};
const allDevices = [TITAN.DEVICES.HYPER_1, TITAN.DEVICES.HYPER_2, TITAN.DEVICES.HYPER_3, TITAN.DEVICES.ACE1500];
const metrics = [
{ key: "State", icon: "📊", name: "Status ", unit: "" },
{ key: "SoC", icon: "🔋", name: "SoC ", unit: "%" },
{ key: "Temp", icon: "🌡️", name: "Temp ", unit: "°C" },
{ key: "Bat_Net", icon: "⚖️", name: "Bat_Net ", unit: "W" },
{ key: "Bat_In", icon: "⬇️", name: "Bat_In ", unit: "W" },
{ key: "Bat_Out", icon: "⬆️", name: "Bat_Out ", unit: "W" },
{ key: "AC_Out", icon: "🏠", name: "AC_Out ", unit: "W" },
{ key: "Grid_In", icon: "🔌", name: "Grid_In ", unit: "W" },
{ key: "Power_PV", icon: "☀️", name: "PV_Power ", unit: "W" },
{ key: "Time_Left", icon: "⏱️", name: "Time_Left ", unit: "" },
{ key: "AC_Mode", icon: "⚙️", name: "AC_Mode ", unit: "" }
];
let headerRow = " 🎚️ Parameter |".padEnd(19);
for (const d of allDevices) {
headerRow += ` ${d.displayName} (${d.sn.substring(10)}) |`.padStart(25);
}
console.log(headerRow);
console.log("-".repeat(120));
for (const m of metrics) {
let row = ` ${m.icon} ${m.name}|`;
for (const d of allDevices) {
let val = getVal(d.varPrefix, m.key);
let unitDisp = m.unit;
if (m.key === "Temp" && val !== "---") {
let tVal = Number(val);
val = tVal > 1000 ? (tVal / 100).toFixed(1) : (tVal > 100 ? (tVal / 10).toFixed(1) : tVal.toFixed(1));
}
if (m.key === "Time_Left" && val !== "---") {
let mins = parseInt(val);
if (isNaN(mins) || mins === 0 || mins >= 59000) val = ">99h";
else val = `${Math.floor(mins / 60)}h ${mins % 60}m`;
unitDisp = "";
}
if (m.key === "AC_Mode" && val !== "---") {
const modeNum = parseInt(val);
if (modeNum === 1) val = "1 (Inverter)";
else if (modeNum === 2) val = "2 (AC Charge)";
else val = `Standby (${modeNum})`;
}
let displayVal = `${val} ${unitDisp}`.trim();
row += displayVal.padStart(23) + " |";
}
console.log(row);
}
console.log("=".repeat(120));
return true;
}
// ==========================================
// 🚀 REGULÄRER MQTT PARSER (STATEFUL)
// ==========================================
if (rawPayload.includes('"unique_id":') || rawPayload.includes('"device_class":')) return false;
let payload;
try { payload = JSON.parse(rawPayload); } catch (err) { return false; }
const device = Object.values(TITAN.DEVICES).find(d => payload.sn === d.sn);
if (!device) return false;
const prefix = device.varPrefix;
const timeStr = getViennaTimeString();
// Watchdog Update (gekürzt, um die DB nicht zu fluten)
await updateVariable('MQTT_Last_Raw_JSON', `${timeStr} | ${device.displayName} | Payload empfangen`);
// --- 1. SICHERES AUSLESEN DER WERTE (NUR WENN VORHANDEN) ---
// Bat_In (Laden)
let newBatIn = undefined;
if (payload.outputPackPower !== undefined) {
newBatIn = Number(payload.outputPackPower);
await updateVariable(`${prefix}_Bat_In`, newBatIn);
}
// Bat_Out (Entladen)
let newBatOut = undefined;
if (payload.packInputPower !== undefined) {
newBatOut = Number(payload.packInputPower);
await updateVariable(`${prefix}_Bat_Out`, newBatOut);
}
// AC_Out (Haus)
if (payload.outputHomePower !== undefined) {
await updateVariable(`${prefix}_AC_Out`, Number(payload.outputHomePower));
} else if (payload.acOutputPower !== undefined) {
await updateVariable(`${prefix}_AC_Out`, Number(payload.acOutputPower));
}
// Grid_In (Netzbezug am Hub)
if (payload.gridInputPower !== undefined) {
await updateVariable(`${prefix}_Grid_In`, Number(payload.gridInputPower));
}
// PV Power (Nur updaten, wenn wirklich PV-Daten im Paket sind!)
let hasPvData = false;
let pvPower = 0;
if (payload.solarInputPower !== undefined) {
pvPower = Number(payload.solarInputPower);
hasPvData = true;
} else if (payload.solarPower1 !== undefined || payload.solarPower2 !== undefined) {
pvPower = Number(payload.solarPower1 || 0) + Number(payload.solarPower2 || 0);
hasPvData = true;
}
if (hasPvData) {
await updateVariable(`${prefix}_Power_PV`, pvPower);
}
// SoC
let soc = null;
if (payload.electricLevel !== undefined) {
soc = Number(payload.electricLevel);
} else if (payload.packData && Array.isArray(payload.packData)) {
let sum = 0, count = 0;
payload.packData.forEach(p => {
if (p.socLevel != null) { sum += Number(p.socLevel); count++; }
});
if (count > 0) soc = sum / count;
}
if (soc !== null) await updateVariable(`${prefix}_SoC`, Math.round(soc * 10) / 10);
// Temp
if (payload.hyperTmp !== undefined) {
await updateVariable(`${prefix}_Temp`, Number(payload.hyperTmp));
} else if (payload.packData && Array.isArray(payload.packData)) {
const packWithTemp = payload.packData.find(p => p.maxTemp !== undefined);
if (packWithTemp) await updateVariable(`${prefix}_Temp`, Number(packWithTemp.maxTemp));
}
// Time Left
if (payload.remainOutTime != null || payload.remainInputTime != null) {
let rt = (payload.remainOutTime || payload.remainInputTime || 0);
await updateVariable(`${prefix}_Time_Left`, rt >= 59000 ? 0 : rt);
}
// AC-Mode
if (payload.acMode !== undefined) {
await updateVariable(`${prefix}_AC_Mode`, Number(payload.acMode));
}
// ==========================================
// 🧠 LOGIK: LIVE-STATUS BERECHNUNG & BAT_NET
// ==========================================
const currentVars = await Homey.logic.getVariables();
const getValSafe = (suffix) => {
const v = Object.values(currentVars).find(varObj => varObj.name === `${prefix}_${suffix}`);
return (v && v.value !== null) ? Number(v.value) : 0;
};
// Wir holen uns die frisch geschriebenen oder die alten Variablen zur Berechnung
let curBatOut = (newBatOut !== undefined) ? newBatOut : getValSafe('Bat_Out');
let curBatIn = (newBatIn !== undefined) ? newBatIn : getValSafe('Bat_In');
let curPv = hasPvData ? pvPower : getValSafe('Power_PV');
let curAcOut = getValSafe('AC_Out');
let curAcMode = getValSafe('AC_Mode');
let curSoc = (soc !== null) ? soc : getValSafe('SoC');
// --- NEU: Bat_Net (Netto-Leistung) ---
// Positiv = Laden (Bat_In), Negativ = Entladen (Bat_Out)
let curBatNet = curBatIn - curBatOut;
await updateVariable(`${prefix}_Bat_Net`, curBatNet);
// -------------------------------------
let devState = "Standby";
if (curBatOut > 30) {
devState = (curPv > 20) ? "Entladen (PV+Akku)" : "Entladen (Akku)";
} else if (curSoc >= 99 && curPv > 20) {
// ERZWUNGENER BYPASS-STATUS ab 99% (Ignoriert Balancing-BatIn)
devState = "PV-Bypass";
} else if (curAcMode === 2 && curBatIn > 30) {
devState = "Netzladung";
} else if (curBatIn > 30 && curPv > 20) {
devState = "PV-Laden";
} else if (curAcOut > 30 && curPv > 20 && curBatIn < 30 && curBatOut < 30) {
devState = "PV-Bypass";
} else if (curPv > 20) {
devState = "PV-Standby";
} else {
devState = "Standby";
}
await updateVariable(`${prefix}_State`, devState);
return true;
Find out your serial numbers and credentials, then replace mine with them!
Ask Google how to get the cloud credentials for MQTT login.
The script even generates a nice LOG when you “TEST“ the script:
You’ll need the MQTT Client to connect to the Zendure Cloud like that:
And you need two flows to read from and write to Zendure Hyper 2000:
Read:
Write (is a bit more complicated
)
Sorry, it’s a mix of german and english… just translate it into your language.
And of course you need to setup alle the variables (at least those in the flows)…
Maybe I have forgotten something.
I’ve created a complete BMS based on Homey with Flows/Scripts/MQTT in the last months with Gemini (mostly the PRO version, the others suck!).
I have several more scripts for optimizing charging/discharging based on my other smart home devices like PV, Heatpump and so on, but that would be breaking the mold here.
If you have questions for the Zendure MQTT Integration, I’ll try to help you.
If you want quicker help, ask Gemini Pro! 
Good luck and have fun!