NEWS
Zendure SolarFlow2400 AC (EVCC, Tibber und PV-Forecast)
-
HI,
da ich inzwischen öfters suchende nach einen Skript sehe und selber auch nichts "für mich passendes" gefunden habe...
Habe ich mich vor einer Woche (mit Hilfe der Ki) an die Arbeit gemacht und heute glaube ich ist der tag wo man es veröffentlichen kann
Um das script zu nutzen müsst Ihr mit eueren Solarflow, eine MQTT Verbindung zum iobroker aufbauen (ich gehe davon aus, das es jeder selber hinbekommt)
Nutzung von Tibber, EVCC und dem PV-Forecast kann deaktiviert werden
Von euch auszufüllen wäre die: "1. Konfiguration" und "2. Datenpunkte"
//------------------------------------------------------------------------------------- // ### ioBroker Skript: Batteriesteuerung (Zendure) mit PI-Regler ### // // v2.0 - 02.11.2025 // - ANPASSUNG: Die Steuerung von Tibber, EVCC und PV-Forecast erfolgt nun // ausschließlich über die internen "CONFIG" Variablen (ENABLE_TIBBER, etc.). // - ENTFERNT: Es werden keine externen Datenpunkte mehr zur Aktivierung/Deaktivierung // der Module benötigt oder abgefragt. // - Check: Timer-basierte Regelung (V1.9) bleibt erhalten. //------------------------------------------------------------------------------------- // --- 1. KONFIGURATION --- const CONFIG = { // --- Globale Schalter --- // Steuern Sie hier, welche Module aktiv sein sollen. // Es werden keine externen Datenpunkte mehr hierfür verwendet. ENABLE_TIBBER: true, ENABLE_EVCC: true, ENABLE_PV_FORECAST: true, // (Nur wirksam, wenn ENABLE_TIBBER auch true ist) DEBUG: false, // FALSE: Detaillierte Logs unterdrücken; TRUE: Logs ausgeben // --- Batterie & PV --- BATTERY_CAPACITY_KWH: 8.64, // Akkugröße in kWh (Nennkapazität) PV_FORECAST_SAFETY_FACTOR: 2.0, MAX_CHARGE_W: 2400, // Maximale Ladeleistung der Batterie MAX_DISCHARGE_W: 2400, // Maximale Entladeleistung der Batterie // --- PI-Regler Parameter --- KP: 0.7, KI: 0.08, DEADBAND_W: 15, TARGET_W: 0, // Anti-Windup Limits INTEGRAL_MAX: 30000, INTEGRAL_MIN: -30000, // --- Zeit-Parameter --- REGEL_INTERVALL_MS: 2000, // Intervall für die PI-Regelung (dt = 2.0) AC_MODE_COOLDOWN_MS: 5000, }; // --- 2. DATENPUNKTE (IDs) --- const IDs = { // --- Netz (Trigger) --- netz: "shelly.0.SHEM-3#8CAAB5619A05#1.Total.InstantPower", // Nur noch für Abfrage // --- Batterie (Zendure) --- acMode: "mqtt.2.Zendure.select.HOA1NPN3N210791.acMode", acModeSet: "mqtt.2.Zendure.select.HOA1NPN3N210791.acMode.set", currentInput: "mqtt.2.Zendure.number.HOA1NPN3N210791.inputLimit", inputSet: "mqtt.2.Zendure.number.HOA1NPN3N210791.inputLimit.set", currentOutput: "mqtt.2.Zendure.number.HOA1NPN3N210791.outputLimit", outputSet: "mqtt.2.Zendure.number.HOA1NPN3N210791.outputLimit.set", soc: "mqtt.2.Zendure.sensor.HOA1NPN3N210791.electricLevel", // --- Externe Steuerung --- evccModus: "0_userdata.0.zendure.EVCC_Modus", tibberLaden: "0_userdata.0.Tibber.01_Automatisierungs-Kanaele.Batterie_laden", // PV Prognose Restenergie des Tages pvForecastRest: "pvforecast.0.summary.energy.nowUntilEndOfDay" }; const AC_MODES = { INPUT: "Input mode", OUTPUT: "Output mode" }; // --- 3. GLOBALE VARIABLEN (Status) --- let integral = 0.0; let lastAcModeSwitch = 0; // --- 4. HILFSFUNKTIONEN FÜR LOGGING --- function logInfo(message) { log(`[Info] ${message}`, 'info'); } function logDebug(message) { if (CONFIG.DEBUG) { log(`[Debug] ${message}`, 'info'); } } // --- 5. INITIALISIERUNG & TIMER --- function initialize() { logInfo("--- Batteriesteuerung Skript gestartet (v2.0) ---"); logInfo(`Module: Tibber=${CONFIG.ENABLE_TIBBER}, EVCC=${CONFIG.ENABLE_EVCC}, PV-Forecast=${CONFIG.ENABLE_PV_FORECAST}`); integral = 0; lastAcModeSwitch = new Date().getTime(); // Startet den PI-Regler-Loop im festgelegten Intervall setInterval(mainControlLoop, CONFIG.REGEL_INTERVALL_MS); logInfo(`PI-Regelung läuft jetzt Timer-basiert alle ${CONFIG.REGEL_INTERVALL_MS}ms.`); // Führt initialen Check aus, falls Overrides schon aktiv sind checkOverridesAndAct(); } // --- 6. TRIGGER & OVERRIDES --- // KEIN Trigger auf IDs.netz mehr. // Trigger werden nur registriert, wenn das Modul in der CONFIG aktiviert ist. if (CONFIG.ENABLE_EVCC) { on({ id: IDs.evccModus, change: "any" }, (obj) => { logInfo(`EVCC Modus geändert auf: ${obj.state.val}. Prüfe Override.`); checkOverridesAndAct(); }); } if (CONFIG.ENABLE_TIBBER) { on({ id: IDs.tibberLaden, change: "any" }, (obj) => { logInfo(`Tibber Modus geändert auf: ${obj.state.val}. Prüfe Override.`); checkOverridesAndAct(); }); } if (CONFIG.ENABLE_TIBBER && CONFIG.ENABLE_PV_FORECAST) { on({ id: IDs.pvForecastRest, change: "any" }, (obj) => { logDebug(`PV Prognose (Rest) geändert auf: ${obj.state.val} Wh. Prüfe Tibber-Logik.`); checkOverridesAndAct(); }); } /** * Überprüft alle Overrides (Tibber, EVCC). Führt bei Aktivität sofort eine * Modusänderung durch und gibt 'true' zurück, wenn ein Override aktiv war. * @returns {boolean} - true, wenn ein Override aktiv war, sonst false. */ function checkOverridesAndAct() { // --- 6.1. Externe Overrides prüfen (Tibber) --- if (CONFIG.ENABLE_TIBBER && getState(IDs.tibberLaden).val === true) { let chargeAllowed = false; if (isNight()) { logInfo("Tibber-Laden aktiv (Nacht). Laden ERLAUBT."); chargeAllowed = true; } else { // PV-Forecast wird nur geprüft, wenn BEIDE Module aktiviert sind if (CONFIG.ENABLE_PV_FORECAST) { if (checkPvForecastAllowsDayChargeV1_4()) { logInfo("PV-Restenergie UNZUREICHEND. Tibber-Laden am Tag ERLAUBT."); chargeAllowed = true; } else { logInfo("PV-Restenergie AUSREICHEND. Laden am Tag BLOCKIERT (PV hat Priorität)."); } } else { // Falls PV-Forecast deaktiviert ist, gilt die Standardregel: Tibber am Tag blockiert. logInfo("Tibber am Tag blockiert (PV-Forecast-Modul ist deaktiviert)."); } } if (chargeAllowed) { forcePower(AC_MODES.INPUT, CONFIG.MAX_CHARGE_W); integral = CONFIG.INTEGRAL_MIN; return true; // Override war aktiv } } // --- 6.2. Externe Overrides prüfen (EVCC) --- if (CONFIG.ENABLE_EVCC) { const evccMode = getState(IDs.evccModus).val; if (evccMode === 2) { logInfo("EVCC Modus 2: Entladen blockiert."); forcePower(AC_MODES.OUTPUT, 0); integral = CONFIG.INTEGRAL_MAX; return true; // Override war aktiv } else if (evccMode === 3) { logInfo("EVCC Modus 3: Laden erzwungen."); forcePower(AC_MODES.INPUT, CONFIG.MAX_CHARGE_W); integral = CONFIG.INTEGRAL_MIN; return true; // Override war aktiv } } return false; // Kein Override war aktiv } // --- 7. HAUPTREGELUNGSLOOP (Durch Timer getriggert) --- function mainControlLoop() { // Prüfen, ob ein Override aktiv ist. const overrideActive = checkOverridesAndAct(); // Wenn ein Override aktiv war, bricht die Regelung hier ab. if (overrideActive) { return; } // --- PI-REGELUNG (Nur wenn kein Override aktiv ist) --- // Feste Zeitschritt-Basis (dt = 2.0 Sekunden) const dt = CONFIG.REGEL_INTERVALL_MS / 1000.0; const gridPower = getState(IDs.netz).val; let error = CONFIG.TARGET_W - gridPower; // --- 7.1. Deadband --- if (Math.abs(gridPower) <= CONFIG.DEADBAND_W) { error = 0.0; logDebug(`Netzleistung [${gridPower.toFixed(0)}W] in Deadband. Fehler auf 0 gesetzt.`); } // --- 7.2. PI-Berechnung --- integral = integral + error * dt; // Anti-Windup integral = Math.max(CONFIG.INTEGRAL_MIN, Math.min(CONFIG.INTEGRAL_MAX, integral)); const proportional = CONFIG.KP * error; const integralAnteil = CONFIG.KI * integral; let outputPower = proportional + integralAnteil; logDebug(`Regel-Loop: Netz=${gridPower.toFixed(0)}W | Fehler=${error.toFixed(0)} | P=${proportional.toFixed(1)} | I=${integralAnteil.toFixed(1)} | Integral=${integral.toFixed(1)} | Out(raw)=${outputPower.toFixed(0)}W`); // --- 7.3. Stellgröße auf Limits begrenzen (Clamping) --- if (outputPower > 0) { outputPower = Math.min(outputPower, CONFIG.MAX_CHARGE_W); } else if (outputPower < 0) { outputPower = Math.max(outputPower, -CONFIG.MAX_DISCHARGE_W); } // --- 7.4. Stellgröße an Batterie senden --- setBatteryPower(outputPower); } // --- 8. ZUSÄTZLICHE HELPER-FUNKTIONEN (Unverändert) --- function checkPvForecastAllowsDayChargeV1_4() { try { const socPercent = getState(IDs.soc).val; const forecastRestWh = getState(IDs.pvForecastRest).val; const missingCapacityKWh = CONFIG.BATTERY_CAPACITY_KWH * (100 - socPercent) / 100.0; const forecastRestKWh = forecastRestWh / 1000.0; const thresholdKWh = missingCapacityKWh * CONFIG.PV_FORECAST_SAFETY_FACTOR; logDebug(`PV-Check (Tag/Tibber): SOC=${socPercent}% | Ladebedarf=${missingCapacityKWh.toFixed(2)} kWh | PV-Rest=${forecastRestKWh.toFixed(2)} kWh | Schwelle=${thresholdKWh.toFixed(2)} kWh`); if (forecastRestKWh < thresholdKWh) { return true; } else { return false; } } catch (e) { logInfo("Fehler beim Lesen des SOC oder PV-Forecast-Datenpunkts. Tibber-Laden am Tag wird sicherheitshalber blockiert (PV-Priorität)."); return false; } } function setBatteryPower(power) { power = Math.round(power); const currentAcMode = getState(IDs.acMode).val; let targetAcMode = AC_MODES.OUTPUT; let targetPowerValue = Math.abs(power); if (power > 0) { targetAcMode = AC_MODES.INPUT; targetPowerValue = power; } if (currentAcMode !== targetAcMode) { const now = new Date().getTime(); if (now - lastAcModeSwitch < CONFIG.AC_MODE_COOLDOWN_MS) { // V1.8 BUGFIX: Modus-Wechsel blockiert -> Setze aktuellen Modus auf 0W (sicherer Stillstand) if (currentAcMode === AC_MODES.INPUT) { setState(IDs.inputSet, "0", false); } else { // AC_MODES.OUTPUT setState(IDs.outputSet, "0", false); } logDebug(`AC-Modus-Wechsel zu [${targetAcMode}] blockiert (Cooldown). Leistung des aktuellen Modus auf 0W gesetzt.`); return; } // --- Wechsel wird durchgeführt --- logInfo(`=== AC-Modus-Wechsel: [${currentAcMode}] -> [${targetAcMode}] ===`); lastAcModeSwitch = now; setState(IDs.acModeSet, targetAcMode, false); if (targetAcMode === AC_MODES.INPUT) { setState(IDs.outputSet, "0", false); setState(IDs.inputSet, String(targetPowerValue), false); logDebug(`Setze Input: ${targetPowerValue}W, Output: 0W`); } else { // AC_MODES.OUTPUT setState(IDs.inputSet, "0", false); setState(IDs.outputSet, String(targetPowerValue), false); logDebug(`Setze Input: 0W, Output: ${targetPowerValue}W`); } } else { // --- Modus ist korrekt, nur Leistung aktualisieren --- if (targetAcMode === AC_MODES.INPUT) { if (getState(IDs.currentInput).val !== targetPowerValue) { setState(IDs.inputSet, String(targetPowerValue), false); logDebug(`Update Input: ${targetPowerValue}W`); } } else { // AC_MODES.OUTPUT if (getState(IDs.currentOutput).val !== targetPowerValue) { setState(IDs.outputSet, String(targetPowerValue), false); logDebug(`Update Output: ${targetPowerValue}W`); } } } } function forcePower(mode, power) { power = Math.round(power); const powerStr = String(power); logDebug(`Force Mode: [${mode}]`); setState(IDs.acModeSet, mode, false); lastAcModeSwitch = new Date().getTime(); if (mode === AC_MODES.INPUT) { setState(IDs.outputSet, "0", false); setState(IDs.inputSet, powerStr, false); logDebug(`Force Set Input: ${powerStr}W, Output: 0W`); } else { // AC_MODES.OUTPUT setState(IDs.inputSet, "0", false); setState(IDs.outputSet, powerStr, false); logDebug(`Force Set Input: 0W, Output: ${powerStr}W`); } } function isNight() { try { const now = new Date(); const sunrise = getAstroDate('sunrise'); const sunset = getAstroDate('sunset'); if (!sunrise || !sunset) { logInfo("Astro-Daten (sunrise/sunset) nicht verfügbar. Prüfen Sie die Javascript-Instanz-Einstellungen."); return false; } if (now > sunset || now < sunrise) { return true; } return false; } catch (e) { logInfo("Astro-Funktion 'getAstroDate' nicht verfügbar. Prüfen Sie die Javascript-Instanz-Einstellungen."); return false; } } // --- Skriptstart --- initialize();Ich hoffe es hilft den einem oder anderen weiter...
Lässt sich auch gut mit dem Skript von @maxclaudi kombinieren (um Sicherheit wegen Smartmode zu haben)
https://forum.iobroker.net/topic/82263/zendure-smartmode-1-solarflow2400-ac-solarflow800-u-pro/150falls ich noch was optimiere oder Bugs finde, werde ich das hier im ersten Post, ändern
-
@Schimi: Du solltest bei Entladen aus dem Akku noch mit berücksichtigen, ob du viel mit PV oder Netz geladen hast. Wenn viel mit Netz sollte der Entladepreis mindestens 20% über deinem Ladepreis liegen. Soweit ich das sehe hast du das noch nicht mit drin. Oder wie wird das Entladen geregelt?
-
@lesiflo das stimmt, das wird über Tibber berücksichtigt und man kann es dort direkt konfigurieren... (bei mir z.b. muss sogar 25% Differenz sein)...
der Hintergedanke war, das die meisten die einen flexiblen Stromtarif haben, irgendwie sowas selber berechnen (kann der tibberlink Adapter direkt).
Will das Script nicht übertrieben aufblähen...Danke für deinen einwand


-
update
// v1.9 - 01.11.2025
// - OPTIMIERUNG: PI-Regelung ist nun Timer-basiert (alle 2000ms), um eine fixe
// Integrationszeit (dt) und damit eine höhere Stabilität zu gewährleisten.
// - VERBESSERUNG: Trennung der Log-Funktionen in logInfo (wichtig, immer an)
// und logDebug (detailliert, ausschaltbar).
// - NEU: Trigger auf Netzleistung entfernt. Die Regelung erfolgt nur noch über den Timer.* -
// v2.0 - 02.11.2025
// - ANPASSUNG: Die Steuerung von Tibber, EVCC und PV-Forecast erfolgt nun
// ausschließlich über die internen "CONFIG" Variablen (ENABLE_TIBBER, etc.).
// - ENTFERNT: Es werden keine externen Datenpunkte mehr zur Aktivierung/Deaktivierung
// der Module benötigt oder abgefragt.
// - Check: Timer-basierte Regelung (V1.9) bleibt erhalten.
//------------------------------------------------------------ -
Hi,
cooles java script, danke fürs bauen
Ich habe mal angefangen, Dein Javascript für Nerds wie mich in Blockly zu bauen:

Allerdings werde ich in meiner Version EVCC und Tibber weglassen, dafür wird Sie mit 2x Zendure AC2400 im Wechseltakt arbeiten. Entweder regel ich dann hoffentlich insgesamt schneller oder schone die Hardware etwas...wir werden sehen.
-
Habe fertig...alles nun schön in Funktionen gepackt (bis auf die Presets der Konstanten/Globals):

Ein bisschen stolz bin ich auf das hier:

im Zusammenspiel mit dem hier (Beispiel):

Volle Flexibilität bei den Datenpunkten (werden aus Bausteinen je nach Akku_Nr zusammengebaut und um die Object-Subfolder, Zielobjekte und evtl '.SET' (je nachdem ob man liest oder schreibt) erstellt.
So ist da hinzufügen eines weiteren Akkus einfach nur die Seriennummer und die MQTT.X Nummer in die Variable einzutragen und schon funzt es(hoffentlich).
Ist noch ein bisschen rough, muss noch 'sonst falls' bei der Akku-Ansteuerung bekommen, die Variaben für den 3. Akku fehlen noch und die Object_ID Funktion werde ich noch mit einem Rückgabewert versehen, aber das Prinzip wird glaube ich schon ersichtlich.
Heute leider keine Zeit mehr zum Testen...

-
cool!!
Ich könnte (wenn bedarf besteht) das JavaScript auch auf "mehrere Geräte" umbauen...
Das müsste aber einer testen oder mir min. ein Gerät spenden
edit
Ich glaube interessant wäre es auch wenn du das als Code zum importieren anbietest -
Kleine aber sinnvolle Fortschritte:
@Schimi:
Ich habe mich von Deinem Integral getrennt
Was aktuell im Blockly funktioniert:
Beide Zendure AC2400 takten und steuern im Wechsel
Hat mir viel Kopfzerbrechen gemacht die beiden Regelungen sinnvoll vom Aufschauckeln abzuhalten

bis hin zu einer lädt und der andere speist ein...

Das ist beides gelöst und ich habe nun ein sinnvolles Leistungs-Synchronisation (Loadbalancing) zwischen den beiden:

Sieht doch schon besser aus

-
Es gibt nun einen Nachtmodus (Sonnenuntergang bis Sonnenaufgang) der beide Zendure strikt im Output-Modus festhält, ich nutze ja kein Tibber o.ä.
-
SOC Balancing zwischen den beiden Akkus
Erstaunlicherweise Laden und Entladen die beiden nicht wirklich parallel (beide Akkus liefen da noch synchron in der Ansteuerung ohne Wechseltaktung):

Vielleicht ist SOC gar nicht so wichtig bei diesem Akkutyp, der blaue und der lila Graph sind die SOC-Werte.
Vielleicht laden und entladen die auch unterschiedlich, weil der eine aktuell im Technikraum und der andere bei mir im Büro steht... ergo unterschiedliche Temperaturn in der Nacht (wir reden hier von gerade mal 20° zu 16° Grad Unterschied) ?Aktuell steure ich die Akkus beim einem SOC Unterschied von mehr als 5% gezielter an um das auszugleichen.
Bin aber nicht sicher, ob ich den Part behalten werde.- Eine indirekte Unterstützung für meine Wallbox ist integriert.
Es gibt ein prozentuales Prio-System, bei gleicher Priorität zwischen Haus-Akku und EV 'sieht' die WB einen Teil der Akku-Ladeleistung als zus. virtuellen PV-Überschuss. Damit wird die WB früher motiviert das EV zu laden.
Über einem bestimmten SOC halten die Akkus die Wallbox virtuell (ja. hier kann es zu kurzem geringen Netzbezug kommen weil die Akkus nicht schnell genug schalten auf Entladen) am Leben. Funktioniert noch nicht ganz optimal, aktuell steuert meine go-E nur alle 90 Sekunden per Adapter. Und für genau den Adapter erzeuge ich eine teilweise gefakete PV-Einspeisungs-Leistungswerte.
Funktioniert aber schon ganz gut, hier war der Haus-Akku etwas höher in der Prio und hat der WB immer mehr Leistung geklaut, als der Akku fast voll war, bekam die WB wieder mehr Leistung freigeschaltet:

Lila oben ist die WB, grün unten die Ladeleistung beider Akkus kombiniert, man kann sogar die kurzen Einspeisungpeaks sehen, als die WB von 1- auf 3-phaisg geschaltet hat und ca. 20 Minuten später wieder zurück wegen insgesamt sinkendem Solarertag.
Und auch wenn die roten Netzbezugs-Peaks (ich nutze da MAX-Werte) im oberen Diagramm wild aussehen, das Haus hat heute insgesamt laut Smartmeter gerade mal 560 Wh verbraucht. Und das obwohl ich von 29,3 KWh Produktion nur 3,39KWh eingespeist habe, alles andere ist in Wärme, Haus-Akku für die Nacht und EV geflossen.
Langsam bekommt es Hand und Fuß.
Ich muss noch etwas an den scripten feilen und testen, dann würde ich die hier auch zu Verfügung stellen, solange @Schimi damit grün ist.Und nun der Wehrmutstropfen am Schluss:
Wenn ich die ZendureAC2400 einzeln schneller als 14 Sekunden in der Ansteuerung takte, dann sieht man zwar die mqtt Änderung in den SELECT/xxx-Limit Werten wie diese vom SET übernommen werden im ca. 1 Sekundentakt, die Werte im Sensor/ gridinput/homeoutput power frieren aber ein und mein Smartmeter bestätigt das auch.Ich komme einfach nicht dahinter, warum beide Zendure AC2400 sich bei mir so verhalten...

-