/**
* Batteriesteuerung Zendure CLUSTER (TypeScript)
* * CHANGELOG:
* - v8.0.12-TS: [26.01.2026] - DOCS: Detaillierte Kommentierung aller Config-Bereiche wiederhergestellt (SQL-Settings, Optional-Flags).
* - v8.0.11-TS: [26.01.2026] - OPTIMIZATION: Log-Spam reduziert (Status-Log vs. Live-Visu getrennt).
* - v8.0.10-TS: [26.01.2026] - FEATURE: Status-Logs bei Änderung + "Bis wann?"-Anzeige.
* - v8.0.0-TS: [26.01.2026] - MAJOR: Umbau auf Multi-Unit-Cluster.
* * KONTEXT:
* - Hardware: 1-3x Zendure SolarFlow 2400 AC + Shelly 3EM/Pro3EM
* - Architektur: Master-Controller regelt Netzbezug (PI), Dispatcher verteilt Last auf Units.
* * ZIELE:
* - Skalierbarkeit: 1 bis N Batterien ohne Code-Änderung (nur Config).
* - Effizienz: Bei <100W nur eine Unit aktiv ("Sticky Master").
* - Balancing: Bei >100W Lastverteilung nach nutzbarem SoC.
*/
// =====================================================================================
// === 1. USER KONFIGURATION ===
// =====================================================================================
// Globale Systemeinstellungen
const SYSTEM_CONFIG = {
// --- MQTT ---
// Der Basis-Pfad im ioBroker Objects-Baum, unter dem der Zendure-Adapter/MQTT liegt.
// WICHTIG: Ohne abschließenden Punkt angeben!
MQTT_BASE_PATH: "mqtt.2.Zendure",
// --- LOGGING & SYSTEM ---
INFO_LOGS: true, // Zeigt verständliche Statusmeldungen im Log (Empfohlen: true)
DEBUG: false, // Zeigt technische Details zur Fehleranalyse (Nur bei Problemen: true)
// Instanz-ID des SQL-Adapters für historische Abfragen.
// WICHTIG: Muss installiert und aktiv sein für KI-Funktionen!
SQL_INSTANCE_ID: 'sql.0',
// --- FEATURE FLAGS ---
ENABLE_TIBBER: true, // Aktiviert Preis-Optimierung (braucht Tibber-Datenpunkte)
ENABLE_EVCC: true, // Aktiviert externe Steuerung via EVCC-Datenpunkt
ENABLE_PREDICTIVE_CHARGE: true, // Aktiviert vorausschauendes Laden basierend auf History & Prognose
ENABLE_WEATHER_FORECAST: true, // Nutzt Wetterdaten zur Korrektur des Heizbedarfs
// --- PI-REGLER (Global für Netzbezug) ---
REGEL_INTERVALL_MS: 2000, // Wie oft wird geregelt? (2000ms = 2 Sekunden)
TARGET_W: 0, // Zielwert am Netzanschlusspunkt (0 = Nulleinspeisung)
DEADBAND_W: 30, // Totzone: Schwankungen +/- 30W werden ignoriert (schont Hardware)
HYSTERESIS_SWITCH_W: 200, // Mindest-Leistungsänderung, bevor der Regler reagiert (verhindert Zittern)
// Tuning (PID-Parameter) - Nur ändern, wenn man weiß, was man tut!
KP_N: 0.70, // Proportional-Anteil (Schnelligkeit)
KI_N: 0.08, // Integral-Anteil (Genauigkeit über Zeit)
KAW: 0.9, // Anti-Windup (Verhindert Übersteuern bei Limits)
LEAK: 0.998, // Integrator-Leak (Verhindert Wegdriften bei Inaktivität)
// --- WIRTSCHAFTLICHKEIT ---
MIN_PRICE_FLOOR_CT_KWH: 23, // [ct/kWh] Unter diesem Preis wird NICHT entladen (Akku sparen)
HISTORY_DAYS: 21, // [Tage] Zeitraum für die Analyse des Verbrauchsverhaltens (SQL-Datenbasis)
// --- HEIZUNG / KI ---
MAX_HEATING_RESERVE_KWH: 6.0, // [kWh] Wieviel Akku wird bei Kälte maximal für die Nacht reserviert?
HEATING_FACTOR: 0.5 // Wie stark beeinflusst Kälte den Reserve-Bedarf?
};
// Cluster-Konfiguration
const CLUSTER_CONFIG = {
// [Watt] Ab welcher Gesamtlast sollen mehrere Akkus parallel laufen?
// Unter diesem Wert läuft nur EIN Akku ("Sticky Master") für bessere Effizienz.
MIN_LOAD_PARALLEL_W: 100,
// [%] Wieviel Prozent muss ein anderer Akku "besser" gefüllt sein,
// damit er im Low-Load-Bereich neuer Master wird? (Verhindert ständiges Wechseln)
SOC_SWITCH_HYSTERESIS: 5,
// [ms] Wie lange muss ein Master mindestens laufen, bevor gewechselt werden darf?
MIN_MASTER_RUNTIME_MS: 300000, // 5 Minuten
// Definition der Einheiten (Batterien)
UNITS: [
{
id: "BAT_1", // Interne ID (frei wählbar, z.B. "Links", "Garage")
deviceId: "HOA1NPN3N210791", // PFLICHT: Die ID aus dem MQTT-Topic (z.B. zendure/HOA...)
enabled: true, // Schalter zum temporären Deaktivieren einer Unit
capacityKWh: 8.64, // Gesamtkapazität dieser Unit (AB1000/2000 Akkus summieren)
maxChargeW: 2400, // Hardware-Limit Laden (meist 1200W pro Hub, hier AC gekoppelt?)
maxDischargeW: 2400, // Hardware-Limit Entladen
acModeCooldownMs: 60000 // Zwangspause nach Moduswechsel (Input<->Output)
},
// BEISPIEL FÜR ZWEITEN AKKU (einfach auskommentieren/anpassen):
/*
{
id: "BAT_2",
deviceId: "HOA2XXXXXXXXXXX",
enabled: true,
capacityKWh: 3.84,
maxChargeW: 2400,
maxDischargeW: 2400,
acModeCooldownMs: 60000
}
*/// BEISPIEL FÜR DRITTEN AKKU (einfach auskommentieren/anpassen):
/*
{
id: "BAT_2",
deviceId: "HOA2XXXXXXXXXXX",
enabled: true,
capacityKWh: 3.84,
maxChargeW: 2400,
maxDischargeW: 2400,
acModeCooldownMs: 60000
}
*/
] as UnitConfig[]
};
// Zentrale Datenpunkte
// LEGENDE:
// [PFLICHT] = Muss existieren, sonst funktioniert das Skript nicht.
// [OPTIONAL] = Kann leer ("") gelassen werden, das entsprechende Feature wird dann deaktiviert.
// [SQL] = SQL-Logging im ioBroker aktivieren! (Zahnrad beim Datenpunkt -> SQL -> Aktivieren)
// Empfohlene SQL-Settings:
// - "Nur Änderungen aufzeichnen": JA
// - "Entprellzeit": 1000ms (optional)
// - "Aufbewahrungszeit": Mindestens SYSTEM_CONFIG.HISTORY_DAYS (z.B. 1 Monat)
const GLOBAL_IDS = {
// [PFLICHT] Aktuelle Leistung am Netzpunkt (Negativ = Einspeisung, Positiv = Bezug)
netz: "shelly.0.SHEM-3#8CAAB5619A05#1.Total.InstantPower",
// --- Wetter & KI ---
// [OPTIONAL] [SQL WICHTIG] Außentemperatur. Leer = 10°C Fallback.
// SQL: Zwingend erforderlich für Heizbedarf-Prognose.
temperature: "0_userdata.0.zendure.KI.Temperatur_Mittelwert",
// [OPTIONAL] [SQL WICHTIG] Zählerstände für Verbraucher, die NICHT aus dem Akku bedient werden sollen (rausrechnen).
// SQL: Zwingend erforderlich für Profilbildung.
heatLoadTotal: "0_userdata.0.zendure.KI.Heizung_Leistung_Tag", // Z.B. Wärmepumpe
wwLoadTotal: "0_userdata.0.zendure.KI.WW_Leistung_Tag", // Z.B. Heizstab
wbTotalKWh: "0_userdata.0.zendure.KI.Wallboxen_Verbrauch", // Z.B. Auto-Ladung
// [OPTIONAL] Wettervorhersage (Adapter: wetter.com oder daswetter). Leer = Deaktiviert.
forecastMinTempToday: "0_userdata.0.wetter_com.day_0.temp_min",
forecastMinTempTomorrow: "0_userdata.0.wetter_com.day_1.temp_min",
// [OPTIONAL] [SQL WICHTIG] PV-Ertrag Heute (kWh). Leer = Deaktiviert PV-Korrektur.
// SQL: Zwingend erforderlich für Genauigkeits-Berechnung.
pvTodayKWh: "0_userdata.0.PV-Anlage.Erzeugt_heute",
// --- Tibber (Dynamische Strompreise) ---
// [OPTIONAL] [SQL WICHTIG] Tibber Link / Pulse Daten (Zählerstände).
// Wenn keine Tibber-Daten da sind, alle 4 auf "" setzen -> Tibber-Logik deaktiviert.
// SQL: Zwingend erforderlich für Verbrauchsprofil (Import/Export Deltas).
netImportTotalKWh: "tibberlink.0.LocalPulse.0.Import_total",
netExportTotalKWh: "tibberlink.0.LocalPulse.0.Export_total",
tibberPricesToday: "tibberlink.0.Homes.f760a90a-56ba-45f2-b694-a7a565e72066.PricesToday.json",
tibberPricesTomorrow: "tibberlink.0.Homes.f760a90a-56ba-45f2-b694-a7a565e72066.PricesTomorrow.json",
// --- Steuerung ---
// [OPTIONAL] EVCC Modus (1=Normal, 2=Sperre, 3=Zwang). Leer = Immer Normal.
evccModus: "0_userdata.0.zendure.EVCC_Modus",
// [OPTIONAL] Urlaubsmodus (true/false). Leer = Inaktiv. Reduziert Grundlast-Annahme.
vacationMode: "0_userdata.0.Sonstiges.UrlaubsModus_An",
// --- Outputs (Werden vom Skript erstellt) ---
// Keine manuelle Konfiguration nötig.
lastPVAccuracy: "0_userdata.0.zendure.KI.lastPVAccuracy",
consumptionProfile: "0_userdata.0.zendure.KI.ConsumptionProfile",
clusterTotalSoC: "0_userdata.0.zendure.Cluster.TotalSoC",
clusterTotalPower: "0_userdata.0.zendure.Cluster.TotalPower",
clusterStatus: "0_userdata.0.zendure.Cluster.Status"
};
// --- PV FORECAST ADAPTER ---
// [OPTIONAL] Datenpunkte des "pvforecast" Adapters.
// Wenn nicht vorhanden, alle Felder auf "" setzen.
const PV_FC_IDS = {
now: "pvforecast.0.summary.energy.now",
rest: "pvforecast.0.summary.energy.nowUntilEndOfDay",
total: "pvforecast.0.summary.energy.today",
tomorrow: "pvforecast.0.summary.energy.tomorrow"
};
// =====================================================================================
// === 2. INTERFACES & TYPES ===
// =====================================================================================
interface UnitConfig {
id: string;
deviceId: string;
enabled: boolean;
capacityKWh: number;
maxChargeW: number;
maxDischargeW: number;
acModeCooldownMs: number;
}
interface UnitRuntimeState {
soc: number;
minSoc: number;
acMode: string;
usableSoC: number;
isAvailable: boolean;
canCharge: boolean;
lastSwitchTime: number;
currentTargetW: number;
ids: {
acMode: string; acModeSet: string; inputSet: string; outputSet: string;
soc: string; minSoc: string;
}
}
interface ClusterState {
totalSoC: number;
totalCapacityKWh: number;
totalUsableKWh: number;
virtualChargeLimit: number;
virtualDischargeLimit: number;
activeMasterId: string | null;
lastMasterSwitch: number;
}
interface ControlCache {
isTibberChargeNow: boolean;
isTibberDischargeNow: boolean;
isPriceBelowFloor: boolean;
currentPrice: number;
predictedChargeKWh: number;
globalReserveKWh: number;
}
interface TibberPriceRaw { startsAt: string; total: number; }
interface TibberPriceProcessed { start: Date; price: number; durationMs: number; }
interface TimeInterval { start: number; end: number; }
interface SqlEntry { val: number | null; ts: number; }
interface SqlResult { result: SqlEntry[]; error?: string; }
interface DeltaData { time: number; deltaKWh: number; }
interface DailyBucket { import: number; export: number; pv: number; wb: number; batOut: number; wd: number; }
interface ConsumptionProfile { [weekday: number]: { [time: string]: number[]; }; }
const AC_MODES = { INPUT: "Input mode", OUTPUT: "Output mode" };
// =====================================================================================
// === 3. GLOBALE STATES ===
// =====================================================================================
const units = new Map<string, UnitRuntimeState>();
const cluster: ClusterState = {
totalSoC: 0,
totalCapacityKWh: 0,
totalUsableKWh: 0,
virtualChargeLimit: 0,
virtualDischargeLimit: 0,
activeMasterId: null,
lastMasterSwitch: 0
};
let integral = 0.0;
const controlCache: ControlCache = {
isTibberChargeNow: false, isTibberDischargeNow: false, isPriceBelowFloor: false, currentPrice: 0,
predictedChargeKWh: 0, globalReserveKWh: 0
};
let tibberPriceData: TibberPriceProcessed[] = [];
let cheapestIntervals: TimeInterval[] = [];
let expensiveIntervals: TimeInterval[] = [];
let historicalConsumptionProfile: ConsumptionProfile = {};
let lastValidAccuracy: number | null = null;
let dynamicPriceReference = 0;
// @AUDIT-FIX: Status-Log Variable
let lastStatusLog = "";
// =====================================================================================
// === 4. HELPER FUNCTIONS ===
// =====================================================================================
function logInfo(msg: string) { if(SYSTEM_CONFIG.INFO_LOGS) log(`[Cluster] ${msg}`, 'info'); }
function logWarn(msg: string) { log(`[Cluster WARN] ${msg}`, 'warn'); }
function logDebug(msg: string) { if(SYSTEM_CONFIG.DEBUG) log(`[Cluster DEBUG] ${msg}`, 'debug'); }
function zendurePath(deviceId: string, type: string, endpoint: string): string {
return `${SYSTEM_CONFIG.MQTT_BASE_PATH}.${type}.${deviceId}.${endpoint}`;
}
function safeGetNumber(id: string, defaultVal: number): number {
try {
if (!id) return defaultVal;
const state = getState(id);
if (!state || state.val == null) return defaultVal;
const n = Number(state.val);
return isNaN(n) ? defaultVal : n;
} catch { return defaultVal; }
}
function safeGetString(id: string, defaultVal: string): string {
try {
if (!id) return defaultVal;
const state = getState(id);
return (state && state.val != null) ? String(state.val) : defaultVal;
} catch { return defaultVal; }
}
function normalizeAcMode(val: unknown): string {
if (!val) return AC_MODES.OUTPUT;
const s = String(val).toLowerCase();
if (s.includes('input')) return AC_MODES.INPUT;
return AC_MODES.OUTPUT;
}
function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
function sqlRequest(instance: string, command: string, message: any): Promise<SqlResult> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("SQL Request Timeout (5s)"));
}, 5000);
sendTo(instance, command, message, function (result: SqlResult) {
clearTimeout(timeout);
if (result.error) reject(new Error(result.error)); else resolve(result);
});
});
}
function logStrategyLists(limitCharge: number, limitDischarge: number) {
if (!SYSTEM_CONFIG.INFO_LOGS) return;
const format = (intervals: TimeInterval[], type: string, limit: number) => {
if (!intervals.length) return;
const lines = intervals.slice(0, 10).map(iv => {
const match = tibberPriceData.find(p => p.start.getTime() === iv.start);
if (!match) return null;
const d = match.start;
const dateStr = `${d.getDate()}.${d.getMonth()+1}.${d.getFullYear()}`;
const timeStr = `${d.getHours()}:${(d.getMinutes()<10?'0':'')+d.getMinutes()}:00`;
return `- ${dateStr}, ${timeStr} (${match.price.toFixed(2)} ct/kWh)`;
}).filter(l => l !== null);
if (lines.length) {
logInfo(`[Strategie] Geplante **${type}**-Zeiten (${intervals.length}x) [Limit: ${limit.toFixed(2)}ct]:\n${lines.join("\n")}`);
}
};
format(cheapestIntervals, "Lade", limitCharge);
format(expensiveIntervals, "Entlade", limitDischarge);
}
// =====================================================================================
// === 5. KI & DATA ANALYSIS ===
// =====================================================================================
async function getHistoryDeltas(id: string, scaleFactor: number = 1.0): Promise<DeltaData[]> {
if (!id || !existsState(id)) return [];
const end = Date.now();
const start = end - (SYSTEM_CONFIG.HISTORY_DAYS * 24 * 3600 * 1000);
const RECORD_LIMIT = Math.max(100000, SYSTEM_CONFIG.HISTORY_DAYS * 24 * 60 * 2);
try {
const result = await sqlRequest(SYSTEM_CONFIG.SQL_INSTANCE_ID, 'getHistory', {
id: id, options: { start, end, aggregate: 'none', limit: RECORD_LIMIT, returnNewestEntries: true, removeBorderValues: true, ignoreNull: true, ack: true }
});
const data = result.result;
if (!data || data.length < 2) return [];
data.sort((a, b) => a.ts - b.ts);
const res: DeltaData[] = [];
let lastVal: number | null = null;
const CHUNK_SIZE = 2000;
const MAX_REASONABLE_DELTA = 100;
for(let i=0; i<data.length; i++) {
if (i % CHUNK_SIZE === 0 && i > 0) await delay(1);
if (data[i].val === null) continue;
const curVal = data[i].val as number;
if (lastVal === null) { lastVal = curVal; continue; }
let delta = (curVal - lastVal) * scaleFactor;
if (delta < 0 || delta > MAX_REASONABLE_DELTA) {
lastVal = curVal;
continue;
}
lastVal = curVal;
res.push({ time: data[i].ts, deltaKWh: delta });
}
return res;
} catch(e) { logDebug(`Fehler SQL ${id}: ${e}`); return []; }
}
function calculatePVAccuracy(actualSoFar: number, forecastSoFar: number, minTemp: number, lastKnown: number | null): number {
if (actualSoFar < 0.2 && forecastSoFar < 0.5) return lastKnown ?? 0.8;
if (forecastSoFar < 0.5) {
if (minTemp < 0.0) return lastKnown ?? 0.5;
if (minTemp < 2.0) return lastKnown ?? 0.6;
return lastKnown ?? 1.0;
}
const deviation = Math.abs(actualSoFar - forecastSoFar);
const acc = 1.0 - (deviation / Math.max(forecastSoFar * 1.5, 1.0));
return Math.max(0.3, Math.min(1.0, acc));
}
async function loadTibberPriceData() {
if(!GLOBAL_IDS.tibberPricesToday) return;
let today: TibberPriceRaw[] = [], tomorrow: TibberPriceRaw[] = [];
try {
const tState = getState(GLOBAL_IDS.tibberPricesToday);
const mState = getState(GLOBAL_IDS.tibberPricesTomorrow);
if(tState && tState.val) today = JSON.parse(tState.val as string);
if(mState && mState.val) tomorrow = JSON.parse(mState.val as string);
} catch(e) {}
tibberPriceData = [];
[...today, ...tomorrow].forEach(i => {
tibberPriceData.push({ start: new Date(i.startsAt), price: i.total * 100, durationMs: 15 * 60 * 1000 });
});
}
function calculateDynamicPriceReference() {
if (tibberPriceData.length === 0) { dynamicPriceReference = SYSTEM_CONFIG.MIN_PRICE_FLOOR_CT_KWH; return; }
const now = Date.now();
const futurePrices = tibberPriceData.filter(i => (i.start.getTime() + i.durationMs) > now);
const listToUse = (futurePrices.length > 4) ? futurePrices : tibberPriceData;
if (listToUse.length < 4) { dynamicPriceReference = SYSTEM_CONFIG.MIN_PRICE_FLOOR_CT_KWH; return; }
const sortedPrices = [...listToUse].sort((a, b) => b.price - a.price);
const topCount = Math.max(1, Math.ceil(sortedPrices.length * 0.35));
const topSlice = sortedPrices.slice(0, topCount);
const avgTop = topSlice.reduce((sum, i) => sum + i.price, 0) / topSlice.length;
dynamicPriceReference = Math.max(avgTop, SYSTEM_CONFIG.MIN_PRICE_FLOOR_CT_KWH);
}
// Profil Bildung
async function updateConsumptionProfileFromSQL() {
logInfo("[KI] Starte SQL-Analyse für Verbrauchsprofil...");
historicalConsumptionProfile = {};
const [importD, exportD, pvDeltas, wbD] = await Promise.all([
getHistoryDeltas(GLOBAL_IDS.netImportTotalKWh, 1.0),
getHistoryDeltas(GLOBAL_IDS.netExportTotalKWh, 1.0),
getHistoryDeltas(GLOBAL_IDS.pvTodayKWh, 1.0),
getHistoryDeltas(GLOBAL_IDS.wbTotalKWh, 1.0)
]);
const dailyBuckets: { [date: string]: { [hm: string]: DailyBucket } } = {};
const addDeltas = async (deltas: DeltaData[], type: keyof DailyBucket) => {
if(!deltas) return;
for(let i=0; i < deltas.length; i++) {
if (i % 2000 === 0 && i > 0) await delay(1);
const d = deltas[i];
const dateObj = new Date(d.time);
const dateKey = dateObj.toISOString().split('T')[0];
const minutes = Math.floor(dateObj.getMinutes() / 15) * 15;
const hm = `${('0'+dateObj.getHours()).slice(-2)}:${('0'+minutes).slice(-2)}`;
if (!dailyBuckets[dateKey]) dailyBuckets[dateKey] = {};
if (!dailyBuckets[dateKey][hm]) dailyBuckets[dateKey][hm] = { import: 0, export: 0, pv: 0, wb: 0, batOut: 0, wd: dateObj.getDay() };
dailyBuckets[dateKey][hm][type] += d.deltaKWh;
}
};
await addDeltas(importD, 'import'); await addDeltas(exportD, 'export'); await addDeltas(pvDeltas, 'pv'); await addDeltas(wbD, 'wb');
for (const dateKey in dailyBuckets) {
const dayData = dailyBuckets[dateKey];
for (const hm in dayData) {
const b = dayData[hm];
const consumption = Math.max(0, (b.import + b.pv - b.export) - (b.wb || 0));
if (!historicalConsumptionProfile[b.wd]) historicalConsumptionProfile[b.wd] = {};
if (!historicalConsumptionProfile[b.wd][hm]) historicalConsumptionProfile[b.wd][hm] = [];
historicalConsumptionProfile[b.wd][hm].push(consumption);
}
}
try {
setState(GLOBAL_IDS.consumptionProfile, JSON.stringify(historicalConsumptionProfile), true);
} catch(e) {}
logInfo("[KI] Verbrauchsprofil aktualisiert.");
}
async function runPredictiveAnalysis() {
if (!SYSTEM_CONFIG.ENABLE_TIBBER) return;
await loadTibberPriceData();
calculateDynamicPriceReference();
const actualPV = safeGetNumber(GLOBAL_IDS.pvTodayKWh, 0);
const forecastSoFar = safeGetNumber(PV_FC_IDS.now, 0);
const minTempToday = parseFloat(safeGetString(GLOBAL_IDS.forecastMinTempToday, "99")) || 99;
logInfo(`[Diagnose PV] Ist (bis jetzt): ${actualPV.toFixed(2)} kWh | Soll (bis jetzt): ${forecastSoFar.toFixed(2)} kWh`);
const acc = calculatePVAccuracy(actualPV, forecastSoFar, minTempToday, lastValidAccuracy);
setState(GLOBAL_IDS.lastPVAccuracy, Math.round(acc * 100), true);
lastValidAccuracy = acc;
logInfo(`[Diagnose PV] Berechnete Genauigkeit: ${(acc*100).toFixed(0)}% (Delta: ${Math.abs(actualPV - forecastSoFar).toFixed(2)} kWh)`);
const soc = cluster.totalSoC;
const capacity = cluster.totalCapacityKWh;
const freeSpace = capacity * (1 - (soc/100));
controlCache.predictedChargeKWh = freeSpace;
const now = Date.now();
const bep = dynamicPriceReference * 0.9;
cheapestIntervals = tibberPriceData
.filter(i => (i.start.getTime() + i.durationMs) > now && i.price < bep)
.map(i => ({ start: i.start.getTime(), end: i.start.getTime() + i.durationMs }));
expensiveIntervals = tibberPriceData
.filter(i => (i.start.getTime() + i.durationMs) > now && i.price > dynamicPriceReference)
.map(i => ({ start: i.start.getTime(), end: i.start.getTime() + i.durationMs }));
const currentTemp = safeGetNumber(GLOBAL_IDS.temperature, 10);
const avgTemp = 10;
const tempDelta = avgTemp - currentTemp;
let heatingReserve = 0;
if (tempDelta > 0) heatingReserve = Math.min(SYSTEM_CONFIG.MAX_HEATING_RESERVE_KWH, tempDelta * SYSTEM_CONFIG.HEATING_FACTOR);
const pvTom = safeGetNumber(PV_FC_IDS.tomorrow, 0) * acc;
controlCache.globalReserveKWh = Math.max(0.5, heatingReserve - (pvTom * 0.3));
logInfo(`[KI-Analyse] Wärme-Reserve: ${heatingReserve.toFixed(2)}kWh (Kalkulations-Basis: ${currentTemp.toFixed(1)}°C)`);
logStrategyLists(bep, dynamicPriceReference);
logInfo(`[KI] PV-Genauigkeit: ${(acc*100).toFixed(0)}% | Wärme-Res: ${heatingReserve.toFixed(2)}kWh | Global-Res: ${controlCache.globalReserveKWh.toFixed(2)}kWh`);
updateControlFlags();
}
function updateControlFlags() {
if (!tibberPriceData.length) return;
const nowMs = Date.now();
controlCache.isTibberChargeNow = cheapestIntervals.some(i => nowMs >= i.start && nowMs < i.end);
controlCache.isTibberDischargeNow = expensiveIntervals.some(i => nowMs >= i.start && nowMs < i.end);
const currentPriceObj = tibberPriceData.find(i => nowMs >= i.start.getTime() && nowMs < (i.start.getTime() + i.durationMs));
if (currentPriceObj) {
controlCache.currentPrice = currentPriceObj.price;
controlCache.isPriceBelowFloor = currentPriceObj.price < SYSTEM_CONFIG.MIN_PRICE_FLOOR_CT_KWH;
}
}
// =====================================================================================
// === 6. DISPATCHER LOGIC (CORE) ===
// =====================================================================================
function updateClusterState() {
let sumStoredKWh = 0;
let sumCap = 0;
let sumUsableKWh = 0;
let sumChargeW = 0;
let sumDischargeW = 0;
CLUSTER_CONFIG.UNITS.forEach(cfg => {
if (!cfg.enabled) return;
let u = units.get(cfg.id);
if (!u) return;
u.soc = safeGetNumber(u.ids.soc, 0);
u.minSoc = safeGetNumber(u.ids.minSoc, 10);
u.acMode = normalizeAcMode(safeGetString(u.ids.acMode, AC_MODES.OUTPUT));
u.usableSoC = Math.max(0, u.soc - u.minSoc);
u.isAvailable = u.soc > (u.minSoc + 1);
u.canCharge = u.soc < 100;
sumStoredKWh += (u.soc / 100) * cfg.capacityKWh;
sumUsableKWh += (u.usableSoC / 100) * cfg.capacityKWh;
sumCap += cfg.capacityKWh;
if (u.canCharge) sumChargeW += cfg.maxChargeW;
if (u.isAvailable) sumDischargeW += cfg.maxDischargeW;
units.set(cfg.id, u);
});
cluster.totalSoC = sumCap > 0 ? ((sumStoredKWh / sumCap) * 100) : 0;
cluster.totalCapacityKWh = sumCap;
cluster.totalUsableKWh = sumUsableKWh;
cluster.virtualChargeLimit = sumChargeW;
cluster.virtualDischargeLimit = sumDischargeW;
setState(GLOBAL_IDS.clusterTotalSoC, parseFloat(cluster.totalSoC.toFixed(1)), true);
}
function distributePower(totalTargetW: number): Map<string, number> {
const distribution = new Map<string, number>();
const absTarget = Math.abs(totalTargetW);
const isCharging = totalTargetW > 0;
const candidates = CLUSTER_CONFIG.UNITS.filter(cfg => {
const u = units.get(cfg.id);
if (!u || !cfg.enabled) return false;
return isCharging ? u.canCharge : u.isAvailable;
});
if (candidates.length === 0) return distribution;
// STRATEGIE A: LOW LOAD -> STICKY MASTER
if (absTarget < CLUSTER_CONFIG.MIN_LOAD_PARALLEL_W) {
let masterId = cluster.activeMasterId;
const currentMasterCfg = candidates.find(c => c.id === masterId);
let masterNeedsSwitch = !currentMasterCfg;
if (!masterNeedsSwitch && currentMasterCfg) {
const currentMasterState = units.get(masterId!)!;
const runtime = Date.now() - cluster.lastMasterSwitch;
if (runtime > CLUSTER_CONFIG.MIN_MASTER_RUNTIME_MS) {
const betterCandidate = candidates.find(c => {
if (c.id === masterId) return false;
const u = units.get(c.id)!;
if (isCharging) {
return u.soc < (currentMasterState.soc - CLUSTER_CONFIG.SOC_SWITCH_HYSTERESIS);
} else {
return u.soc > (currentMasterState.soc + CLUSTER_CONFIG.SOC_SWITCH_HYSTERESIS);
}
});
if (betterCandidate) {
logInfo(`[Dispatcher] Master-Wechsel: ${masterId} -> ${betterCandidate.id}`);
masterId = betterCandidate.id;
cluster.lastMasterSwitch = Date.now();
}
}
}
if (!masterId || masterNeedsSwitch) {
candidates.sort((a, b) => {
const uA = units.get(a.id)!;
const uB = units.get(b.id)!;
if (isCharging) return uA.soc - uB.soc;
return uB.soc - uA.soc;
});
masterId = candidates[0].id;
cluster.lastMasterSwitch = Date.now();
}
cluster.activeMasterId = masterId;
distribution.set(masterId!, totalTargetW);
}
// STRATEGIE B: HIGH LOAD -> PARALLEL
else {
let totalWeight = 0;
const weightedUnits: { id: string, weight: number, cfg: UnitConfig }[] = [];
candidates.forEach(cfg => {
const u = units.get(cfg.id)!;
let weight = isCharging ? ((100 - u.soc) + 1) : (u.usableSoC + 1);
totalWeight += weight;
weightedUnits.push({ id: cfg.id, weight, cfg });
});
let remainingW = totalTargetW;
weightedUnits.forEach((item, index) => {
let targetW = 0;
if (index === weightedUnits.length - 1) {
targetW = remainingW;
} else {
const share = item.weight / totalWeight;
targetW = Math.round(totalTargetW * share);
}
if (isCharging) targetW = Math.min(targetW, item.cfg.maxChargeW);
else targetW = Math.max(targetW, -item.cfg.maxDischargeW);
distribution.set(item.id, targetW);
remainingW -= targetW;
});
}
return distribution;
}
function applyClusterPower(distribution: Map<string, number>) {
let totalP = 0;
for (const cfg of CLUSTER_CONFIG.UNITS) {
if (!cfg.enabled) continue;
const u = units.get(cfg.id);
if (!u) continue;
const targetW = distribution.get(cfg.id) || 0;
const absW = Math.abs(targetW);
const targetMode = targetW > 0 ? AC_MODES.INPUT : AC_MODES.OUTPUT;
const isSwitchingTimeout = (Date.now() - u.lastSwitchTime) > cfg.acModeCooldownMs;
const needsSwitch = normalizeAcMode(u.acMode) !== normalizeAcMode(targetMode);
if (needsSwitch && absW > 0) {
if (isSwitchingTimeout) {
setState(u.ids.acModeSet, targetMode);
u.lastSwitchTime = Date.now();
setState(u.ids.inputSet, "0");
setState(u.ids.outputSet, "0");
u.currentTargetW = targetW;
units.set(cfg.id, u);
logInfo(`[${cfg.id}] Switch zu ${targetMode}`);
continue;
}
} else {
const setID = (normalizeAcMode(u.acMode) === AC_MODES.INPUT) ? u.ids.inputSet : u.ids.outputSet;
const currentSet = safeGetNumber(setID, 0);
if (Math.abs(currentSet - absW) > 30 || (absW === 0 && currentSet !== 0)) {
setState(setID, String(absW));
}
}
u.currentTargetW = targetW;
units.set(cfg.id, u);
totalP += targetW;
}
setState(GLOBAL_IDS.clusterTotalPower, totalP, true);
}
// =====================================================================================
// === 7. PI CONTROLLER (GLOBAL) ===
// =====================================================================================
function mainControlLoop() {
updateClusterState();
const netzW = safeGetNumber(GLOBAL_IDS.netz, 0);
const evccMode = safeGetNumber(GLOBAL_IDS.evccModus, 1);
let maxCharge = cluster.virtualChargeLimit;
let maxDischarge = cluster.virtualDischargeLimit;
let overrideStatus = "";
const forceCharge = (controlCache.isTibberChargeNow || evccMode === 3);
if (forceCharge) {
maxDischarge = 0;
overrideStatus = "Force Charge";
} else {
if (evccMode === 2) {
maxDischarge = 0;
overrideStatus = "EVCC Sperre";
if (netzW > -SYSTEM_CONFIG.DEADBAND_W) maxCharge = 0;
}
if (SYSTEM_CONFIG.ENABLE_TIBBER && controlCache.isPriceBelowFloor) {
maxDischarge = 0;
overrideStatus = "Eco Price Stop";
// @FEATURE: "Bis wann?" für Eco Mode
const nextPriceRise = tibberPriceData.find(p => p.start.getTime() > Date.now() && p.price > SYSTEM_CONFIG.MIN_PRICE_FLOOR_CT_KWH);
if (nextPriceRise) {
const t = nextPriceRise.start;
const timeStr = `${t.getHours()}:${(t.getMinutes()<10?'0':'')+t.getMinutes()}`;
overrideStatus += ` (bis ${timeStr})`;
}
}
const totalUsableKWh = cluster.totalUsableKWh;
if (!controlCache.isTibberDischargeNow && totalUsableKWh < controlCache.globalReserveKWh) {
maxDischarge = 0;
overrideStatus = "Reserve Schutz";
// @FEATURE: "Bis wann?" für Reserve Schutz (Warten auf Hochpreis)
const nextHigh = expensiveIntervals.find(iv => iv.start > Date.now());
if (nextHigh) {
const t = new Date(nextHigh.start);
const timeStr = `${t.getHours()}:${(t.getMinutes()<10?'0':'')+t.getMinutes()}`;
overrideStatus += ` (bis ${timeStr})`;
}
}
}
let error = SYSTEM_CONFIG.TARGET_W - netzW;
if (Math.abs(netzW) < SYSTEM_CONFIG.DEADBAND_W) error = 0;
const canControl = (maxCharge > 0 || maxDischarge > 0);
if (!canControl) {
integral = 0;
error = 0;
overrideStatus = "Cluster nicht bereit";
}
if (forceCharge) {
integral = 0;
var output = maxCharge;
} else {
const NORM_P = 2400;
const eNorm = error / NORM_P;
const dt = SYSTEM_CONFIG.REGEL_INTERVALL_MS / 1000;
if (maxCharge === 0 && maxDischarge === 0) integral = 0;
else {
integral *= SYSTEM_CONFIG.LEAK;
integral += eNorm * dt;
}
const maxInt = 1.0 / SYSTEM_CONFIG.KI_N;
integral = Math.max(-maxInt, Math.min(maxInt, integral));
const p = SYSTEM_CONFIG.KP_N * eNorm;
const i = SYSTEM_CONFIG.KI_N * integral;
var output = (p + i) * NORM_P;
}
output = Math.max(-maxDischarge, Math.min(maxCharge, output));
if (Math.abs(output) < SYSTEM_CONFIG.HYSTERESIS_SWITCH_W && !forceCharge) {
if (Math.abs(output) < 50) output = 0;
}
const distribution = distributePower(output);
applyClusterPower(distribution);
// @AUDIT-FIX: Status-Logik getrennt (Logbuch vs. Anzeige)
let logState = "Standby"; // Stabil für Log
let textState = "Standby"; // Detailliert für Visu
if (overrideStatus) {
logState = overrideStatus;
textState = overrideStatus;
} else if (output > 0) {
logState = "Laden";
textState = `Laden (${Math.round(output)}W)`;
} else if (output < 0) {
logState = "Entladen";
textState = `Entladen (${Math.round(output)}W)`;
} else {
logState = "Standby";
textState = "Standby / Leerlauf";
}
// @FEATURE: Status-Änderung loggen (Nur bei Änderung des Basis-Zustands)
if (logState !== lastStatusLog) {
logInfo(`[Status] Wechsel zu: ${logState} (Aktuell: ${textState})`);
lastStatusLog = logState;
}
setState(GLOBAL_IDS.clusterStatus, textState, true);
}
// =====================================================================================
// === 8. INITIALISIERUNG ===
// =====================================================================================
async function createDataPoints() {
await createStateAsync(GLOBAL_IDS.clusterTotalSoC, { name: "Cluster SoC", type: "number", unit: "%", role: "value.battery" });
await createStateAsync(GLOBAL_IDS.clusterTotalPower, { name: "Cluster Power", type: "number", unit: "W", role: "value.power" });
await createStateAsync(GLOBAL_IDS.lastPVAccuracy, { name: "PV Genauigkeit", type: "number", unit: "%", role: "value" });
await createStateAsync(GLOBAL_IDS.consumptionProfile, { name: "Verbrauchsprofil", type: "string", role: "json" });
await createStateAsync(GLOBAL_IDS.clusterStatus, { name: "Cluster Status", type: "string", role: "text" });
}
async function initialize() {
logInfo("--- Zendure CLUSTER Controller v8.0.12 (DOCS & LOG-FIX) Start ---");
await createDataPoints();
// Load persisted data
try {
const accState = getState(GLOBAL_IDS.lastPVAccuracy);
if(accState && accState.val != null) lastValidAccuracy = Number(accState.val) / 100;
const profState = getState(GLOBAL_IDS.consumptionProfile);
if(profState && profState.val) historicalConsumptionProfile = JSON.parse(String(profState.val));
} catch(e) {}
// Init Units
for (const cfg of CLUSTER_CONFIG.UNITS) {
if (!cfg.enabled) continue;
const uState: UnitRuntimeState = {
soc: 0, minSoc: 10, acMode: AC_MODES.OUTPUT,
usableSoC: 0, isAvailable: false, canCharge: false,
lastSwitchTime: Date.now() - cfg.acModeCooldownMs,
currentTargetW: 0,
ids: {
acMode: zendurePath(cfg.deviceId, "select", "acMode"),
acModeSet: zendurePath(cfg.deviceId, "select", "acMode.set"),
inputSet: zendurePath(cfg.deviceId, "number", "inputLimit.set"),
outputSet: zendurePath(cfg.deviceId, "number", "outputLimit.set"),
soc: zendurePath(cfg.deviceId, "sensor", "electricLevel"),
minSoc: zendurePath(cfg.deviceId, "number", "minSoc")
}
};
units.set(cfg.id, uState);
logInfo(`Unit ${cfg.id} registriert.`);
}
// Start
await runPredictiveAnalysis();
updateControlFlags();
setInterval(mainControlLoop, SYSTEM_CONFIG.REGEL_INTERVALL_MS);
schedule('5 * * * *', runPredictiveAnalysis);
schedule('0 3 * * *', updateConsumptionProfileFromSQL);
schedule('*/3 * * * *', updateControlFlags);
}
initialize();