Skip to content
  • Home
  • Aktuell
  • Tags
  • 0 Ungelesen 0
  • Kategorien
  • Unreplied
  • Beliebt
  • GitHub
  • Docu
  • Hilfe
Skins
  • Light
  • Brite
  • Cerulean
  • Cosmo
  • Flatly
  • Journal
  • Litera
  • Lumen
  • Lux
  • Materia
  • Minty
  • Morph
  • Pulse
  • Sandstone
  • Simplex
  • Sketchy
  • Spacelab
  • United
  • Yeti
  • Zephyr
  • Dark
  • Cyborg
  • Darkly
  • Quartz
  • Slate
  • Solar
  • Superhero
  • Vapor

  • Standard: (Kein Skin)
  • Kein Skin
Einklappen
ioBroker Logo

Community Forum

donate donate
  1. ioBroker Community Home
  2. Deutsch
  3. Skripten / Logik
  4. JavaScript
  5. [TypeSkript] Zendure SolarFlow Steuerung: KI

NEWS

  • Jahresrückblick 2025 – unser neuer Blogbeitrag ist online! ✨
    BluefoxB
    Bluefox
    16
    1
    1.7k

  • Neuer Blogbeitrag: Monatsrückblick - Dezember 2025 🎄
    BluefoxB
    Bluefox
    13
    1
    867

  • Weihnachtsangebot 2025! 🎄
    BluefoxB
    Bluefox
    25
    1
    2.1k

[TypeSkript] Zendure SolarFlow Steuerung: KI

Geplant Angeheftet Gesperrt Verschoben JavaScript
3 Beiträge 2 Kommentatoren 28 Aufrufe 2 Watching
  • Älteste zuerst
  • Neuste zuerst
  • Meiste Stimmen
Antworten
  • In einem neuen Thema antworten
Anmelden zum Antworten
Dieses Thema wurde gelöscht. Nur Nutzer mit entsprechenden Rechten können es sehen.
  • S Online
    S Online
    Schimi
    schrieb am zuletzt editiert von Schimi
    #1

    [TypeSkript] Zendure SolarFlow Steuerung: PI-Regelung, Tibber & KI-Prognose (v7.3.4)

    Ich stelle hier mein TypeScript zur Steuerung des Zendure SolarFlow (2400 AC, etc.) zur Verfügung. Das Skript zielt auf maximale Wirtschaftlichkeit durch Nulleinspeisung, dynamische Strompreise (Tibber) und eine intelligente Ladeplanung ab.

    Es handelt sich um eine typsichere "Full-Control"-Lösung, die weit über einfache Blocklys hinausgeht und Hardware-Eigenheiten (z.B. AC-Mode Latenzen) abfängt.


    🚀 Features

    • PI-Regelung: Schnelle und präzise Nulleinspeisung (Target 0W) mit konfigurierbarer Hysterese und Deadband.
    • Tibber-Integration:
      • Eco-Stop: Entladesperre, wenn der Strompreis unter einer Schwelle (z.B. 23ct) liegt.
      • Strategische Ladung: Lädt den Akku nachts aus dem Netz, wenn der Preis günstig ist und die PV-Prognose für den nächsten Tag nicht ausreicht.
    • KI / Predictive Charging:
      • Lernt das Haus-Verbrauchsprofil der letzten 21 Tage (SQL-basiert).
      • Berechnet den Energiebedarf für die Nacht.
      • Bezieht PV-Forecast (Adapter pvforecast) mit ein.
      • Berücksichtigt Heizbedarf basierend auf Außentemperatur und Wettervorhersage.
    • EVCC-Integration:
      • Erkennt EVCC-Modi (Normal, Entladesperre, Zwangsladung).
      • Verhindert, dass der Haus-Akku das Auto lädt (Entladesperre bei Auto-Ladung).
    • Hardware-Schutz:
      • Beachtet Cooldowns beim Wechsel zwischen Input Mode (Laden) und Output Mode (Entladen).
      • Normalisiert inkonsistente MQTT-Statusmeldungen von Zendure.

    📋 Voraussetzungen

    1. Hardware: Zendure SolarFlow System & ein SmartMeter (z.B. Shelly 3EM/Pro3EM) am Hausanschluss.
    2. Adapter:
      • mqtt (Client-Modus).
      • javascript (TypeScript muss aktiviert sein).
      • sql (oder history) für die KI-Berechnungen.
      • (Optional) tibberlink für Preisdaten.
      • (Optional) pvforecast für Ertragsprognosen.
      • (Optional) wetter.com (oder ähnlich) für Temperaturdaten.

    ⚙️ Konfigurations-Anleitung

    Das Skript ist in zwei Bereiche unterteilt, die angepasst werden müssen: CONFIG und IDs.

    1. Systemeinstellungen (const CONFIG)

    Hier werden die physikalischen Limits und Strategien definiert. Suche im Skript nach const CONFIG.

    const CONFIG: SystemConfig = {
        // Hardware
        BATTERY_CAPACITY_KWH: 8.64,       // Kapazität eures Speichers
        MAX_CHARGE_W: 2400,               // Max Ladeleistung
        MAX_DISCHARGE_W: 2400,            // Max Entladeleistung
    
        // Wirtschaftlichkeit
        MIN_PRICE_FLOOR_CT_KWH: 23,       // Tibber: Unter diesem Preis nicht entladen
        HISTORY_DAYS: 21,                 // Analyse-Zeitraum für die KI
    
        // Regelung
        TARGET_W: 0,                      // Zielwert (0 = Nulleinspeisung)
        REGEL_INTERVALL_MS: 2000,         // Regelgeschwindigkeit (2s empfohlen)
        
        // ... weitere Parameter für Heizung, Tuning etc. sind im Skript kommentiert
    };
    
    

    2. Datenpunkte verknüpfen (const IDs)

    Das Skript benötigt eure spezifischen ioBroker-IDs (Objekt-Pfade). Ihr müsst die Pfade im Bereich const IDs anpassen.

    Wichtig: Damit die KI funktioniert, müssen bestimmte Datenpunkte per SQL-Adapter geloggt werden.

    Variable im Skript Beschreibung Benötigtes Format Logging (SQL)
    netz Aktuelle Leistung am Hausanschluss Watt (Number) Nein (Live)
    soc Akku-Stand Zendure % (0-100) Nein (Live)
    acMode, inputSet... Zendure MQTT Steuerpunkte - Nein
    KI-Datenpunkte:
    batTotalEnergy Gesamtenergie Akku (Lifetime) Wh JA (Nur Änderungen)
    batDischargeTotal Entladene Energie (Lifetime) Wh JA (Nur Änderungen)
    netImportTotalKWh Netzbezug Zählerstand kWh JA (Nur Änderungen)
    netExportTotalKWh Einspeisung Zählerstand kWh JA (Nur Änderungen)
    pvTodayKWh PV-Ertrag Heute (oder Total) kWh JA (Nur Änderungen)
    temperature Außentemperatur °C JA

    Hinweis zum SQL-Logging:
    Stellt im SQL-Adapter für die KI-Datenpunkte (Import, Export, PV, Akku-Total) folgendes ein:

    • Loggen von Änderungen: Aktiviert
    • Minimale Differenz: 0 (oder sehr klein, z.B. 0.001)
    • Vorhaltezeit: Mindestens 1 Monat (Skript nutzt standardmäßig 21 Tage Analyse)

    3. Installation

    1. Neues Skript im "Common"-Ordner des JavaScript-Adapters erstellen.
    2. Typ oben rechts auf TypeScript (TS) stellen.
    3. Code einfügen.
    4. Konfiguration (CONFIG und IDs) im oberen Teil des Skripts anpassen.
    5. Speichern und starten.

    ⚠️ Wichtige Hinweise

    • Zendure MQTT: Zendure erwartet die Limits (inputLimit.set / outputLimit.set) oft zwingend als String, obwohl die Datenpunkte im ioBroker oft als "Number" angelegt sind. Das Skript fängt dies ab Version 7.3.1 automatisch ab, um Warnungen im Log zu vermeiden.
    • Verbrauchsprofil: Nach dem ersten Start dauert es einige Sekunden, bis die KI die SQL-Daten analysiert hat. Wenn ihr noch keine 21 Tage Historie habt, nutzt das Skript Fallback-Werte, lernt aber mit der Zeit dazu.
    • AC-Mode: Das Skript normalisiert die Status-Meldungen von Zendure ("Input", "Input mode", "input"), da diese je nach Firmware variieren können.

    /**
    * Batteriesteuerung Zendure (TypeScript)
    * * CHANGELOG:
    * - v7.3.5-TS: [22.01.2026] - FEATURE: Umgang mit optionalen Datenpunkten verbessert. Leere IDs ("") deaktivieren die Funktion nun sauber.
    * - v7.3.4-TS: [21.01.2026] - CONFIG: Watchdog (SENSOR_TIMEOUT_MS) auf User-Wunsch entfernt.
    * - v7.3.3-TS: [21.01.2026] - DOCS: Detaillierte Kommentierung für Konfiguration & Datenpunkte.
    * * KONTEXT:
    * - Hardware: Zendure SolarFlow 2400 AC + Shelly 3EM/Pro3EM
    * - Schnittstellen: MQTT (Zendure), SQL (ioBroker History), Tibber (Dynamische Preise), EVCC (Ladesteuerung)
    * * ZIELE:
    * - Typsichere, absturzfreie Regelung (Null-Safety via safeGet Pattern).
    * - Maximierung der Wirtschaftlichkeit durch dynamische Tibber-Preise und KI-Prognosen.
    */
    
    // =====================================================================================
    // === 1. USER KONFIGURATION ===
    // =====================================================================================
    
    const CONFIG: SystemConfig = {
       // --- MQTT & GERÄTE ---
       MQTT_BASE_PATH: "mqtt.2.Zendure",  
       DEVICE_ID:      "HO..................", 
    
       // --- FEATURE FLAGS (AN/AUS) ---
       ENABLE_TIBBER: true,              
       ENABLE_EVCC: true,                
       ENABLE_PREDICTIVE_CHARGE: true,   // [Boolean] Aktiviert die vorausschauende Ladeplanung basierend auf PV-Prognose & Historie
       ENABLE_WEATHER_FORECAST: true,    
    
       // --- LOGGING & SYSTEM ---
       INFO_LOGS: true,                  
       DEBUG: false,                     
       SQL_INSTANCE_ID: 'sql.0',         // [String] Instanz-ID des SQL/History Adapters für historische Abfragen
    
       // --- SICHERHEIT ---
       SENSOR_TIMEOUT_MS: 0,             // [ms] 0 = Deaktiviert (Watchdog entfernt)
    
       // --- HARDWARE GRENZWERTE ---
       BATTERY_CAPACITY_KWH: 8.64,       // [kWh] Gesamtkapazität der Speicherbank
       MAX_CHARGE_W: 2400,               // [W] Maximale Ladeleistung (Hardware-Limit)
       MAX_DISCHARGE_W: 2400,            // [W] Maximale Entladeleistung (Hardware-Limit)
       
       // --- EFFIZIENZ ---
       CHARGE_EFFICIENCY: 0.92,          // [0.0-1.0] Lade-Effizienz (Verlustleistung beim Laden)
       DISCHARGE_EFFICIENCY: 0.92,       // [0.0-1.0] Entlade-Effizienz (Verlustleistung beim Entladen)
    
       // --- WIRTSCHAFTLICHKEIT ---
       MIN_PRICE_FLOOR_CT_KWH: 23,       // [ct/kWh] Preisuntergrenze, unter der NICHT entladen wird (Eco-Stop)
       HISTORY_DAYS: 21,                 // [Tage] Zeitraum für die Analyse historischer Daten (Verbrauchsprofil)
       
       // --- KI & HEIZUNG ---
       MAX_HEATING_RESERVE_KWH: 6.0,     // [kWh] Maximale Reserve für Heizbedarf bei Kälte
       HEATING_FACTOR: 0.5,              // [Faktor] Multiplikator für Temperaturdifferenz -> Zusatzbedarf
    
       // --- PI-REGLER ---
       REGEL_INTERVALL_MS: 2000,         // [ms] Taktung der Regelschleife
       AC_MODE_COOLDOWN_MS: 60000,       // [ms] Wartezeit nach Moduswechsel (Input<->Output)
       HYSTERESIS_SWITCH_W: 200,         // [W] Mindest-Leistungsänderung für Moduswechsel
       DEADBAND_W: 30,                   // [W] Totzone um den Nullpunkt (Regelhysterese)
       TARGET_W: 0,                      // [W] Zielwert am Netzanschlusspunkt (0 = Nulleinspeisung)
       
       // --- REGLER-TUNING ---
       KP_N: 0.70,        
       KI_N: 0.08,        
       KAW:  0.9,         
       LEAK: 0.998        
    };
    
    
    // =====================================================================================
    // === 2. DATENPUNKTE (IDs) ===
    // =====================================================================================
    
    // @KI_HINWEIS: Fehlende optionale Datenpunkte bitte einfach leer lassen ("").
    // Das Skript erkennt dies und deaktiviert die entsprechende Teilfunktion automatisch.
    
    const IDs: SystemIDs = {
       // --- HARDWARE LIVE-WERTE (ZWINGEND ERFORDERLICH) ---
       // [W] Aktuelle Leistung am Netzpunkt. Negativ = Einspeisung, Positiv = Bezug.
       netz: "shelly.0.SHEM-3#8CAAB5619A05#1.Total.InstantPower", 
       
       acMode: zendurePath("select", "acMode"),            
       acModeSet: zendurePath("select", "acMode.set"),     
       inputSet: zendurePath("number", "inputLimit.set"),  
       outputSet: zendurePath("number", "outputLimit.set"),
       
       // [%] Aktueller Ladestand (0-100)
       soc: zendurePath("sensor", "electricLevel"),        
       // [%] Mindest-Ladestand (Optional, "" wenn nicht vorhanden -> Default 10%)
       minSoc: zendurePath("number", "minSoc"),            
       
       // --- HISTORIE & STATISTIK (KI-Basis) ---
       // Wenn nicht vorhanden: "" eintragen. KI nutzt dann Standard-Annahmen.
       
       // [Wh] Gesamtenergie (Life-Time) aus dem Akku. 
       batTotalEnergy: "shelly.0.shellyplugsg3#d0cf13daf7f0#1.Relay0.Energy", 
       
       // [Wh] Eingespeiste Energie (Life-Time) ins Hausnetz.
       batDischargeTotal: "shelly.0.shellyplugsg3#d0cf13daf7f0#1.Relay0.ReturnedEnergy",
    
       // --- KI & UMWELT ---
       // [°C] Außentemperatur. Wenn "", wird Heizreserve deaktiviert.
       temperature: "0_userdata.0.zendure.KI.Temperatur_Mittelwert",
       
       // [kWh] Heizung. Wenn "", wird dieser Verbraucher ignoriert.
       heatLoadTotal: "0_userdata.0.zendure.KI.Heizung_Leistung_Tag",
       
       // [kWh] Warmwasser. Wenn "", wird dieser Verbraucher ignoriert.
       wwLoadTotal: "0_userdata.0.zendure.KI.WW_Leistung_Tag",
       
       // [kWh] Wallboxen. Wenn "", wird Auto-Ladung nicht aus Hausverbrauch herausgerechnet.
       wbTotalKWh: "0_userdata.0.zendure.KI.Wallboxen_Verbrauch",
       
       // --- PV & NETZ ---
       // [°C] Prognose Tiefsttemperatur. Wenn "", wird aktuelle Temperatur genutzt.
       forecastMinTempToday: "0_userdata.0.wetter_com.day_0.temp_min",
       forecastMinTempTomorrow: "0_userdata.0.wetter_com.day_1.temp_min",
       
       // [kWh] PV-Ertrag. Wenn "", wird PV-Einfluss auf Verbrauchsprofil geschätzt.
       pvTodayKWh: "0_userdata.0.PV-Anlage.Erzeugt_heute", 
       
       // Interne Speicher-Punkte (werden automatisch angelegt, Pfad ggf. anpassen)
       lastPVAccuracy: "0_userdata.0.zendure.KI.lastPVAccuracy", 
       consumptionProfile: "0_userdata.0.zendure.KI.ConsumptionProfile", 
       
       // [kWh] Netzbezug Gesamt. Wenn "", keine genaue Profilberechnung.
       netImportTotalKWh: "tibberlink.0.LocalPulse.0.Import_total",
       
       // [kWh] Netzeinspeisung Gesamt. Wenn "", keine genaue Profilberechnung.
       netExportTotalKWh: "tibberlink.0.LocalPulse.0.Export_total",
       
       // --- STEUERUNG ---
       // [JSON] Tibber Preisdaten. Wenn "", keine Preisoptimierung.
       tibberPricesToday: "tibberlink.0.Homes.................................PricesToday.json",
       tibberPricesTomorrow: "tibberlink.0.Homes....................PricesTomorrow.json",
       
       // [Number] EVCC Modus. Wenn "", keine EVCC-Integration.
       evccModus: "0_userdata.0.zendure.EVCC_Modus", 
       
       // [Boolean] Urlaubsmodus. Wenn "", Funktion inaktiv.
       vacationMode: "0_userdata.0.Sonstiges.UrlaubsModus_An", 
       
       // --- PV FORECAST ADAPTER ---
       // [kWh] Wenn "", keine PV-basierte Ladeplanung.
       pvForecastNow: "pvforecast.0.summary.energy.now",                   
       pvForecastRest: "pvforecast.0.summary.energy.nowUntilEndOfDay",     
       pvForecastTotal: "pvforecast.0.summary.energy.today",               
       pvForecastTomorrow: "pvforecast.0.summary.energy.tomorrow"          
    };
    
    
    // =====================================================================================
    // === 3. INTERFACES & TYPES ===
    // =====================================================================================
    
    interface SystemConfig {
       MQTT_BASE_PATH: string; DEVICE_ID: string; ENABLE_TIBBER: boolean; ENABLE_EVCC: boolean;
       ENABLE_PREDICTIVE_CHARGE: boolean; ENABLE_WEATHER_FORECAST: boolean; INFO_LOGS: boolean;
       DEBUG: boolean; SQL_INSTANCE_ID: string; BATTERY_CAPACITY_KWH: number; MAX_CHARGE_W: number;
       MAX_DISCHARGE_W: number; CHARGE_EFFICIENCY: number; DISCHARGE_EFFICIENCY: number;
       MIN_PRICE_FLOOR_CT_KWH: number; HISTORY_DAYS: number; MAX_HEATING_RESERVE_KWH: number;
       HEATING_FACTOR: number; REGEL_INTERVALL_MS: number; AC_MODE_COOLDOWN_MS: number;
       HYSTERESIS_SWITCH_W: number; DEADBAND_W: number; TARGET_W: number;
       KP_N: number; KI_N: number; KAW: number; LEAK: number; SENSOR_TIMEOUT_MS: number;
    }
    
    interface SystemIDs { [key: string]: string; }
    interface TibberPriceRaw { startsAt: string; total: number; }
    interface TibberPriceProcessed { start: Date; price: number; durationMs: 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[]; }; }
    interface ControlCache { isTibberChargeNow: boolean; isTibberDischargeNow: boolean; isPriceBelowFloor: boolean; currentPrice: number; }
    interface HardwareMirror { lastSync: number; acMode: string; inputSet: number; outputSet: number; }
    interface SystemState { netz: number; soc: number; minSoc: number; acMode: string; evccModus: number; netzTs: number; }
    
    // =====================================================================================
    // === 4. GLOBALE VARIABLEN ===
    // =====================================================================================
    
    const AC_MODES = { INPUT: "Input mode", OUTPUT: "Output mode" };
    
    let integral = 0.0;             
    let lastAcModeSwitch = 0;       
    let currentStatusText = "Initialisierung"; 
    let integralWarningCount = 0; 
    let lastValidAccuracy: number | null = null; 
    
    let predictedChargeKWh = 0;     
    let tibberPriceData: TibberPriceProcessed[] = [];       
    let cheapestIntervals: number[] = [];     
    let expensiveIntervals: number[] = [];    
    let historicalConsumptionProfile: ConsumptionProfile = {}; 
    let globalReserveKWh = 0;        
    let dynamicPriceReference = 0;  
    
    const controlCache: ControlCache = {
       isTibberChargeNow: false, isTibberDischargeNow: false, isPriceBelowFloor: false, currentPrice: 0
    };
    
    const hwMirror: HardwareMirror = {
       lastSync: 0, acMode: AC_MODES.OUTPUT, inputSet: 0, outputSet: 0
    };
    
    // =====================================================================================
    // === 5. UTILS & SAFETY GUARDRAILS ===
    // =====================================================================================
    
    /**
    * Konstruiert den MQTT-Pfad für Zendure-Datenpunkte dynamisch.
    */
    function zendurePath(type: string, endpoint: string): string {
       return `${CONFIG.MQTT_BASE_PATH}.${type}.${CONFIG.DEVICE_ID}.${endpoint}`;
    }
    
    function logInfo(message: string): void { if (CONFIG.INFO_LOGS) { log(`[Info] ${message}`, 'info'); } }
    function logDebug(message: string): void { if (CONFIG.DEBUG) { log(`[Debug] ${message}`, 'info'); } }
    function logWarn(message: string): void { log(`[Warn] ${message}`, 'warn'); }
    
    function setStatus(text: string): void {
       if (currentStatusText !== text) {
           currentStatusText = text;
           logInfo(`[Status] ${text}`);
       }
    }
    
    /**
    * SAFE GETTER: Liest eine Zahl aus einem State-Objekt sicher aus.
    * @KI_HINWEIS Wenn ID leer (""), wird der Default-Wert zurückgegeben.
    * Verhindert Crashes durch TOCTOU-Race-Conditions.
    */
    function safeGetNumber(id: string, defaultVal: number): number {
       if (!id) return defaultVal; // Feature disabled
       try {
           const state = getState(id);
           if (!state || state.val === null) return defaultVal;
           const num = Number(state.val);
           return isNaN(num) ? defaultVal : num;
       } catch (e) {
           return defaultVal;
       }
    }
    
    /**
    * SAFE GETTER: Liest einen Boolean aus einem State-Objekt sicher aus.
    */
    function safeGetBoolean(id: string, defaultVal: boolean): boolean {
       if (!id) return defaultVal; // Feature disabled
       try {
           const state = getState(id);
           if (!state || state.val === null) return defaultVal;
           return !!state.val;
       } catch (e) {
           return defaultVal;
       }
    }
    
    /**
    * SAFE GETTER: Liest einen String aus einem State-Objekt sicher aus.
    */
    function safeGetString(id: string, defaultVal: string): string {
       if (!id) return defaultVal; // Feature disabled
       try {
           const state = getState(id);
           if (!state || state.val === null) return defaultVal;
           return String(state.val);
       } catch (e) {
           return defaultVal;
       }
    }
    
    /**
    * Normalisiert den AC-Mode String auf die internen Konstanten.
    */
    function normalizeAcMode(val: any): string {
       if (!val) return AC_MODES.OUTPUT;
       const s = String(val).toLowerCase();
       if (s.includes('input')) return AC_MODES.INPUT;
       return AC_MODES.OUTPUT;
    }
    
    /**
    * Erstellt einen Datenpunkt, falls dieser noch nicht existiert.
    * @KI_HINWEIS Bricht ab, wenn ID leer ist (Feature disabled).
    */
    async function ensureState(id: string, name: string, type: 'number'|'string'|'boolean', unit: string, def: any): Promise<void> {
       if (!id) return; 
       if (!existsState(id)) {
           logInfo(`[Setup] Erstelle fehlenden Datenpunkt: ${id}`);
           await createStateAsync(id, {
               name: name,
               type: type,
               role: 'value',
               unit: unit,
               read: true,
               write: true,
               def: def
           });
           await setStateAsync(id, def, true); 
       }
    }
    
    async function setupSystemStates(): Promise<void> {
       await ensureState(IDs.lastPVAccuracy, "Letzte PV Genauigkeit", "number", "%", 100);
       await ensureState(IDs.consumptionProfile, "Verbrauchsprofil JSON", "string", "", "{}");
       await ensureState(IDs.temperature, "Temperatur Mittelwert", "number", "°C", 10);
       
       await ensureState(IDs.heatLoadTotal, "Heizung Leistung Tag", "number", "kWh", 0);
       await ensureState(IDs.wwLoadTotal, "WW Leistung Tag", "number", "kWh", 0);
       await ensureState(IDs.wbTotalKWh, "Wallboxen Verbrauch", "number", "kWh", 0);
    }
    
    /**
    * Wrapper für SQL-Abfragen mit Timeout-Schutz.
    */
    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);
           });
       });
    }
    
    /**
    * Berechnet die Genauigkeit der PV-Prognose im Vergleich zum Ist-Wert.
    */
    function calculatePVAccuracy(actualSoFar: number, forecastSoFar: number, minTemp: number, lastKnown: number | null): number {
       if (CONFIG.DEBUG || CONFIG.INFO_LOGS) {
           logInfo(`[Diagnose PV] Ist (bis jetzt): ${actualSoFar.toFixed(2)} | Soll (bis jetzt): ${forecastSoFar.toFixed(2)}`);
       }
    
       if (actualSoFar < 0.2 && forecastSoFar < 0.5) {
           if (CONFIG.INFO_LOGS) logInfo(`[Diagnose PV] Zu wenig Daten für validen Check (Morgen). Nutze Fallback.`);
           return lastKnown ?? 0.8; 
       }
    
       if (forecastSoFar < 0.5) {
           if (CONFIG.INFO_LOGS) logInfo(`[Diagnose PV] Zu wenig erwarteter Ertrag (${forecastSoFar.toFixed(2)} kWh).`);
           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)); 
       
       // Safety: Ensure result is strictly 0.0 - 1.0
       const result = Math.max(0.3, Math.min(1.0, acc));
       if (CONFIG.INFO_LOGS) logInfo(`[Diagnose PV] Berechnete Genauigkeit: ${(result*100).toFixed(0)}% (Delta: ${deviation.toFixed(2)} kWh)`);
       
       return result;
    }
    
    function delay(ms: number): Promise<void> {
       return new Promise(resolve => setTimeout(resolve, ms));
    }
    
    // =====================================================================================
    // === 6. KI-LOGIK & ANALYSE ===
    // =====================================================================================
    
    /**
    * Lädt historische Daten aus SQL und berechnet Deltas (Verbrauch/Ertrag).
    */
    async function getHistoryDeltas(id: string, scaleFactor: number = 1.0): Promise<DeltaData[]> {
       if (!id || !existsState(id)) return []; // Beendet Funktion sauber, wenn ID leer ist
       const end = Date.now();
       const start = end - (CONFIG.HISTORY_DAYS * 24 * 3600 * 1000);
       
       const RECORD_LIMIT = 100000;
    
       try {
           const result = await sqlRequest(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 [];
    
           if (data.length >= RECORD_LIMIT) {
               logWarn(`[SQL] Limit erreicht (${data.length} Einträge) für ${id}. Historie möglicherweise unvollständig!`);
           }
    
           data.sort((a, b) => a.ts - b.ts);
           const res: DeltaData[] = [];
           let lastVal: number | null = null;
           const CHUNK_SIZE = 2000;
           
           const MAX_REASONABLE_DELTA = Math.max(100, CONFIG.BATTERY_CAPACITY_KWH * 2);
    
           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;
               
               // @KI_HINWEIS: Reset-Erkennung.
               if (delta < 0) {
                    lastVal = curVal;
                    continue; 
               }
               
               lastVal = curVal;
               if (delta > MAX_REASONABLE_DELTA) continue; 
               
               res.push({ time: data[i].ts, deltaKWh: delta });
           }
           (result as any).result = null; 
           return res;
       } catch(e) { logDebug(`Fehler SQL ${id}: ${e}`); return []; }
    }
    
    async function getHistoryStats(id: string): Promise<number> {
       if (!id) return 0;
       const end = Date.now();
       const start = end - (CONFIG.HISTORY_DAYS * 24 * 3600 * 1000);
       try {
           const result = await sqlRequest(CONFIG.SQL_INSTANCE_ID, 'getHistory', {
               id: id, options: { start: start, end: end, aggregate: 'average', count: 1, ack: true }
           });
           if(result.result && result.result[0] && result.result[0].val !== null) return result.result[0].val;
           return 0;
       } catch(e) { return 0; }
    }
    
    function tryLoadConsumptionProfile(): boolean {
       if(!IDs.consumptionProfile) return false;
       try {
           const state = getState(IDs.consumptionProfile);
           if (state && state.val) {
               const data = JSON.parse(state.val as string);
               if (data && Object.keys(data).length > 0) {
                   historicalConsumptionProfile = data;
                   return true;
               }
           }
       } catch (e) { }
       return false;
    }
    
    function saveConsumptionProfile(): void {
       if(!IDs.consumptionProfile) return;
       try {
           const jsonStr = JSON.stringify(historicalConsumptionProfile);
           setState(IDs.consumptionProfile, jsonStr, true);
       } catch (e) { }
    }
    
    function getBucketKey(timestamp: number): { wd: number, hm: string } {
       const date = new Date(timestamp);
       const wd = date.getDay(); 
       const minutes = Math.floor(date.getMinutes() / 15) * 15;
       const hm = `${('0'+date.getHours()).slice(-2)}:${('0'+minutes).slice(-2)}`;
       return { wd, hm };
    }
    
    function getMedian(values: number[]): number {
       if (values.length === 0) return 0;
       values.sort((a, b) => a - b);
       const half = Math.floor(values.length / 2);
       return values.length % 2 ? values[half] : (values[half - 1] + values[half]) / 2.0;
    }
    
    /**
    * Erstellt ein Lastprofil basierend auf historischen Daten.
    */
    async function updateConsumptionProfileFromSQL(): Promise<void> {
       if(!IDs.netImportTotalKWh || !IDs.batDischargeTotal) return;
       logInfo("[KI] Starte SQL-Analyse für Verbrauchsprofil...");
       
       historicalConsumptionProfile = {}; 
       
       const [importD, exportD, pvDeltas, wbD, batDischargeD] = await Promise.all([
           getHistoryDeltas(IDs.netImportTotalKWh, 1.0),
           getHistoryDeltas(IDs.netExportTotalKWh, 1.0),
           getHistoryDeltas(IDs.pvTodayKWh, 1.0),
           getHistoryDeltas(IDs.wbTotalKWh, 1.0), 
           getHistoryDeltas(IDs.batDischargeTotal, 0.001)   
       ]);
       
       const dailyBuckets: { [date: string]: { [hm: string]: DailyBucket } } = {}; 
       const dailySums: { [date: string]: number } = {}; 
    
       const addDeltas = async (deltas: DeltaData[], type: keyof DailyBucket) => {
           if(!deltas) return;
           const CHUNK_SIZE = 2000;
           for(let i=0; i < deltas.length; i++) {
                if (i % CHUNK_SIZE === 0 && i > 0) await delay(1); 
               const d = deltas[i];
               const dateObj = new Date(d.time);
               const dateKey = dateObj.toISOString().split('T')[0];
               const { hm } = getBucketKey(d.time); 
               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'); await addDeltas(batDischargeD, 'batOut');
    
       const ROUNDTRIP_EFFICIENCY = CONFIG.CHARGE_EFFICIENCY * CONFIG.DISCHARGE_EFFICIENCY;
       const consumptionSums: number[] = [];
       
       for (const dateKey in dailyBuckets) {
           let daySum = 0;
           const dayData = dailyBuckets[dateKey];
           for (const hm in dayData) {
               const b = dayData[hm];
               const derivedBatIn = (b.batOut > 0) ? (b.batOut / ROUNDTRIP_EFFICIENCY) : 0;
               const consumption = Math.max(0, (b.import + b.pv - b.export) - (b.wb || 0) + (b.batOut - derivedBatIn));
               daySum += consumption;
           }
           dailySums[dateKey] = daySum;
           consumptionSums.push(daySum);
       }
       
       const medianConsumption = getMedian(consumptionSums);
       const outlierThreshold = medianConsumption * 0.4; 
    
       for (const dateKey in dailyBuckets) {
           if (dailySums[dateKey] < outlierThreshold) continue; 
           const dayData = dailyBuckets[dateKey];
           for (const hm in dayData) {
               const b = dayData[hm];
               const derivedBatIn = (b.batOut > 0) ? (b.batOut / ROUNDTRIP_EFFICIENCY) : 0;
               const consumption = Math.max(0, (b.import + b.pv - b.export) - (b.wb || 0) + (b.batOut - derivedBatIn));
               if (!historicalConsumptionProfile[b.wd]) historicalConsumptionProfile[b.wd] = {};
               if (!historicalConsumptionProfile[b.wd][hm]) historicalConsumptionProfile[b.wd][hm] = [];
               historicalConsumptionProfile[b.wd][hm].push(consumption);
           }
       }
       saveConsumptionProfile(); 
       logInfo("[KI] Verbrauchsprofil erfolgreich neu berechnet und gespeichert.");
    }
    
    function getSumAndDays(deltas: DeltaData[]): { sum: number, days: number } {
       if (!deltas || deltas.length === 0) return { sum: 0, days: 0 };
       const totalSum = deltas.reduce((sum, d) => sum + d.deltaKWh, 0);
       const uniqueDays = new Set(deltas.map(d => new Date(d.time).toISOString().split('T')[0])).size;
       return { sum: totalSum, days: Math.max(1, uniqueDays) };
    }
    
    function calculateDynamicPriceReference(): void {
       if (tibberPriceData.length === 0) { dynamicPriceReference = 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 > 0 ? futurePrices : tibberPriceData;
       const sortedPrices = [...listToUse].sort((a, b) => b.price - a.price);
       const topHalf = sortedPrices.slice(0, Math.ceil(sortedPrices.length / 2));
       const avgTop = topHalf.reduce((sum, i) => sum + i.price, 0) / topHalf.length;
       dynamicPriceReference = Math.max(avgTop, CONFIG.MIN_PRICE_FLOOR_CT_KWH);
    }
    
    function calculateBreakevenPrice(): number {
       return dynamicPriceReference * CONFIG.CHARGE_EFFICIENCY * CONFIG.DISCHARGE_EFFICIENCY;
    }
    
    async function loadTibberPriceData(): Promise<void> {
       if(!IDs.tibberPricesToday) return; 
       let today: TibberPriceRaw[] = [], tomorrow: TibberPriceRaw[] = [];
       try {
           const tState = getState(IDs.tibberPricesToday); 
           const mState = getState(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 logStrategyDetails(intervalTimestamps: number[], type: string, breakEvenPrice: number): void {
       if (!CONFIG.INFO_LOGS) return;
       
       if (intervalTimestamps.length === 0) {
           if (type === "Lade") {
               const now = Date.now();
               const lookaheadLimit = now + (15 * 60 * 60 * 1000);
               const future = tibberPriceData.filter(i => i.start.getTime() > now && i.start.getTime() < lookaheadLimit);
               const cheapest = future.sort((a,b) => a.price - b.price)[0];
               
               let msg = `[Strategie] Keine Ladeintervalle geplant.`;
               if (cheapest) {
                   const realCost = cheapest.price / CONFIG.CHARGE_EFFICIENCY;
                   msg += ` Günstigster Slot: ${cheapest.price.toFixed(2)}ct (Effektiv: ${realCost.toFixed(2)}ct). Limit (BreakEven): ${breakEvenPrice.toFixed(2)}ct. -> Zu teuer / Spanne zu klein.`;
               }
               logInfo(msg);
           }
           return;
       }
       
       let output = `[Strategie] Geplante **${type}**-Zeiten (${intervalTimestamps.length}x) [Limit: ${breakEvenPrice.toFixed(2)}ct]:\n`;
       const relevantIntervals = tibberPriceData.filter(i => intervalTimestamps.includes(i.start.getTime()));
       relevantIntervals.sort((a,b) => a.start.getTime() - b.start.getTime());
       relevantIntervals.forEach(i => { output += `- ${i.start.toLocaleString()} (${i.price.toFixed(2)} ct/kWh)\n`; });
       logInfo(output);
    }
    
    /**
    * Berechnet den benötigten Ladebedarf für die Nacht basierend auf Prognosen und Historie.
    */
    async function getPredictedChargeNeedKWh(accuracy: number): Promise<number> {
       if (!CONFIG.ENABLE_PREDICTIVE_CHARGE) return 0;
       try {
           const [importD, exportD, pvD, wbD, batOutD, heatD, wwD] = await Promise.all([
               getHistoryDeltas(IDs.netImportTotalKWh, 1.0), getHistoryDeltas(IDs.netExportTotalKWh, 1.0),
               getHistoryDeltas(IDs.pvTodayKWh, 1.0), getHistoryDeltas(IDs.wbTotalKWh, 1.0), 
               getHistoryDeltas(IDs.batDischargeTotal, 0.001), getHistoryDeltas(IDs.heatLoadTotal, 1.0), getHistoryDeltas(IDs.wwLoadTotal, 1.0)
           ]);
    
           const days = Math.max(1, getSumAndDays(importD).days);
           const avgPV = getSumAndDays(pvD).sum / days;
           const avgImp = getSumAndDays(importD).sum / days;
           const avgExp = getSumAndDays(exportD).sum / days;
           const avgWB = getSumAndDays(wbD).sum / days;
           const avgBatOut = getSumAndDays(batOutD).sum / days;
           
           const ROUNDTRIP_EFFICIENCY = CONFIG.CHARGE_EFFICIENCY * CONFIG.DISCHARGE_EFFICIENCY;
           const avgBatIn = avgBatOut / ROUNDTRIP_EFFICIENCY;
           
           const avgHeat = getSumAndDays(heatD).sum / days;
           const avgWW = getSumAndDays(wwD).sum / days;
    
           const totalCons = (avgPV + avgImp - avgExp) - avgWB + (avgBatOut - avgBatIn);
           let baseLoad = Math.max(0, totalCons - avgHeat - avgWW);
    
           let isVacation = false;
           // SAFE GETTER usage
           const vacModeVal = safeGetBoolean(IDs.vacationMode, false);
           isVacation = vacModeVal;
           if (isVacation) baseLoad = baseLoad * 0.5; 
    
           let calcTemp = 10, avgHistTemp = 10;
           let isUsingForecast = false;
           if (existsState(IDs.temperature)) avgHistTemp = await getHistoryStats(IDs.temperature) || 10;
           
           let minTempToday = 99;
           if (CONFIG.ENABLE_WEATHER_FORECAST && existsState(IDs.forecastMinTempToday)) {
               // SAFE GETTER
               const minTodayStr = safeGetString(IDs.forecastMinTempToday, "99");
               const minTomorrowStr = safeGetString(IDs.forecastMinTempTomorrow, "99");
               
               minTempToday = parseFloat(minTodayStr) || 99;
               const minTomorrow = parseFloat(minTomorrowStr) || 99;
               if (minTempToday < 90 && minTomorrow < 90) {
                   const currentT = safeGetNumber(IDs.temperature, 10);
                   calcTemp = Math.min(currentT, Math.min(minTempToday, minTomorrow));
                   isUsingForecast = true;
               }
           }
           if (!isUsingForecast && existsState(IDs.temperature)) {
                calcTemp = safeGetNumber(IDs.temperature, 10);
           }
    
           const tempDelta = avgHistTemp - calcTemp;
           let predHeat = avgHeat + avgWW;
           if (tempDelta > 0) predHeat += (tempDelta * CONFIG.HEATING_FACTOR);
           else predHeat = Math.max(avgWW, predHeat - (Math.abs(tempDelta) * CONFIG.HEATING_FACTOR));
           
           if (isVacation) predHeat = 0; else predHeat = Math.min(predHeat, CONFIG.MAX_HEATING_RESERVE_KWH);
           
           let tempInfo = `${calcTemp.toFixed(1)}°C`;
           if (isUsingForecast) tempInfo += " (Forecast)";
           if (isVacation) tempInfo += " [URLAUB: Wärme-Res deaktiviert]";
           
           logInfo(`[KI-Analyse] Base: ${baseLoad.toFixed(2)}kWh | Wärme: ${predHeat.toFixed(2)}kWh (Kalkulations-Basis: ${tempInfo})`);
    
           const actualPV = safeGetNumber(IDs.pvTodayKWh, 0);
           const forecastSoFar = safeGetNumber(IDs.pvForecastNow, 0);
           const fcRest = safeGetNumber(IDs.pvForecastRest, 0);
           
           const safeFcRest = Math.max(fcRest, forecastSoFar * 0.2);
           const effPVToday = actualPV + (safeFcRest * accuracy);
           
           const fcTomVal = safeGetNumber(IDs.pvForecastTomorrow, 0);
           const safePVMorgen = fcTomVal * accuracy;
           
           let need = (baseLoad + predHeat) / CONFIG.DISCHARGE_EFFICIENCY - (effPVToday + (safePVMorgen * 0.5));
           let charge = Math.max(0, need) / CONFIG.CHARGE_EFFICIENCY;
           
           const soc = safeGetNumber(IDs.soc, 0);
           return Math.min(charge, CONFIG.BATTERY_CAPACITY_KWH * (100 - soc) / 100.0);
    
       } catch(e) { 
           logWarn(`Fehler in Analyse: ${e}`);
           return 0; 
       }
    }
    
    async function runPredictiveAnalysis(): Promise<void> {
       if (!CONFIG.ENABLE_TIBBER) return;
       await loadTibberPriceData();
       calculateDynamicPriceReference();
       
       // 1. Accuracy berechnen
       const actualPV = safeGetNumber(IDs.pvTodayKWh, 0);
       const forecastSoFar = safeGetNumber(IDs.pvForecastNow, 0);
       
       let minTempToday = 99;
       if (existsState(IDs.forecastMinTempToday)) {
            minTempToday = parseFloat(safeGetString(IDs.forecastMinTempToday, "99")) || 99;
       }
       
       const acc = calculatePVAccuracy(actualPV, forecastSoFar, minTempToday, lastValidAccuracy);
       
       if (existsState(IDs.lastPVAccuracy)) {
           const accPercent = Math.round(acc * 100); 
           setState(IDs.lastPVAccuracy, accPercent, true);
           lastValidAccuracy = acc; 
       } else {
           logWarn(`[KI] Konnte PV-Genauigkeit nicht speichern - Datenpunkt fehlt: ${IDs.lastPVAccuracy}`);
       }
       
       // 2. Ladebedarf ermitteln
       predictedChargeKWh = await getPredictedChargeNeedKWh(acc);
       
       // 3. Intervalle planen
       const now = new Date();
       const bep = calculateBreakevenPrice();
       const lookaheadLimit = now.getTime() + (15 * 60 * 60 * 1000); 
       const future = tibberPriceData.filter(i => (i.start.getTime() + i.durationMs) > now.getTime());
       const nearFuture = future.filter(i => i.start.getTime() < lookaheadLimit);
       
       cheapestIntervals = nearFuture.filter(i => i.price < bep)
           .sort((a,b) => a.price - b.price)
           .slice(0, Math.ceil(predictedChargeKWh/((CONFIG.MAX_CHARGE_W/1000)*0.25*CONFIG.CHARGE_EFFICIENCY)))
           .map(i => i.start.getTime());
           
       expensiveIntervals = future.filter(i => i.price > dynamicPriceReference)
           .sort((a,b) => b.price - a.price)
           .slice(0, 10)
           .map(i => i.start.getTime());
       
       // 4. Reserve Berechnung
       let totalPeakKWh = 0;
       let avgHistTemp = 10;
       try { avgHistTemp = await getHistoryStats(IDs.temperature) || 10; } catch(e){}
       const currentTemp = safeGetNumber(IDs.temperature, 10);
       const tempDelta = avgHistTemp - currentTemp;
       const dailyHeatingSurcharge = Math.max(0, tempDelta * CONFIG.HEATING_FACTOR);
       const surchargePerSlot = expensiveIntervals.length > 0 ? dailyHeatingSurcharge / expensiveIntervals.length : 0; 
    
       for (const ts of expensiveIntervals) {
           const d = new Date(ts); const wd = d.getDay(); const hm = `${('0'+d.getHours()).slice(-2)}:${('0'+(Math.floor(d.getMinutes()/15)*15)).slice(-2)}`;
           
           if (historicalConsumptionProfile[wd]?.[hm]) {
               const validValues = historicalConsumptionProfile[wd][hm].filter(v => v > 0.01);
               if (validValues.length > 0) {
                   const sorted = validValues.sort((a, b) => a - b);
                   totalPeakKWh += sorted[Math.floor(sorted.length / 2)];
               } else {
                   totalPeakKWh += 0.05; 
               }
           } else {
                totalPeakKWh += 0.05;
           }
           totalPeakKWh += surchargePerSlot;
       }
       
       const bruteReserve = totalPeakKWh / CONFIG.DISCHARGE_EFFICIENCY;
       const fcTomVal = safeGetNumber(IDs.pvForecastTomorrow, 0);
       const safePVMorgen = fcTomVal * acc;
       
       globalReserveKWh = Math.max(bruteReserve * 0.2, bruteReserve - safePVMorgen);
       globalReserveKWh = Math.max(0.5, globalReserveKWh); 
    
       // 5. Logging
       logStrategyDetails(cheapestIntervals, "Lade", bep);
       logStrategyDetails(expensiveIntervals, "Entlade", dynamicPriceReference);
       
       if (CONFIG.INFO_LOGS) logInfo(`[KI] PV-Genauigkeit: ${(acc*100).toFixed(0)}% | Brutto-Res: ${bruteReserve.toFixed(2)}kWh | Netto-Res: ${globalReserveKWh.toFixed(2)}kWh (inkl. Wärme-Aufschlag)`);
    
       updateControlFlags(); 
    }
    
    function updateControlFlags(): void {
       const nowMs = Date.now();
       // @FIX: Robustes Time-Window Matching statt exaktem Timestamp
       controlCache.isTibberChargeNow = cheapestIntervals.some(ts => nowMs >= ts && nowMs < ts + 900000);
       controlCache.isTibberDischargeNow = expensiveIntervals.some(ts => nowMs >= ts && nowMs < (ts + 900000));
       
       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 < CONFIG.MIN_PRICE_FLOOR_CT_KWH;
       } else {
           controlCache.isPriceBelowFloor = false;
           controlCache.currentPrice = 0;
       }
       if (nowMs - hwMirror.lastSync > 180000) {
           const acVal = safeGetString(IDs.acMode, AC_MODES.OUTPUT);
           const inVal = safeGetNumber(IDs.inputSet, 0);
           const outVal = safeGetNumber(IDs.outputSet, 0);
           
           hwMirror.acMode = normalizeAcMode(acVal);
           hwMirror.inputSet = inVal;
           hwMirror.outputSet = outVal;
           hwMirror.lastSync = nowMs;
       }
    }
    
    
    // =====================================================================================
    // === 8. HARDWARE-KONTROLLE ===
    // =====================================================================================
    
    function setBatteryPower(power: number): void {
       power = Math.round(power); 
       const curMode = hwMirror.acMode; 
       let targetMode = hwMirror.acMode;
       let targetVal = Math.abs(power);
    
       if (power > 0) targetMode = AC_MODES.INPUT;
       if (power < 0) targetMode = AC_MODES.OUTPUT;
       
       const lastSetMode = safeGetString(IDs.acModeSet, AC_MODES.OUTPUT);
    
       const isSwitchingTimeout = (Date.now() - lastAcModeSwitch) > CONFIG.AC_MODE_COOLDOWN_MS;
       // Normalisiere Vergleich (Case-Insensitive check)
       const isSwitchingPending = !isSwitchingTimeout && (normalizeAcMode(lastSetMode) === normalizeAcMode(targetMode) && normalizeAcMode(curMode) !== normalizeAcMode(targetMode));
    
       if (normalizeAcMode(curMode) !== normalizeAcMode(targetMode) && !isSwitchingPending) {
           if (Date.now() - lastAcModeSwitch < CONFIG.AC_MODE_COOLDOWN_MS) {
                const setID = (normalizeAcMode(curMode) === AC_MODES.INPUT) ? IDs.inputSet : IDs.outputSet;
                if (hwMirror.inputSet !== 0 && normalizeAcMode(curMode) === AC_MODES.INPUT) { setState(IDs.inputSet, 0, false); hwMirror.inputSet = 0; }
                if (hwMirror.outputSet !== 0 && normalizeAcMode(curMode) === AC_MODES.OUTPUT) { setState(IDs.outputSet, 0, false); hwMirror.outputSet = 0; }
                return;
           }
           integral = 0; 
           lastAcModeSwitch = Date.now(); 
           logInfo(`Moduswechsel: ${curMode} -> ${targetMode} (Power: ${targetVal}W). Reset Integral.`);
           setState(IDs.acModeSet, targetMode, false);
           hwMirror.acMode = targetMode; 
       } else {
           const setID = (targetMode === AC_MODES.INPUT) ? IDs.inputSet : IDs.outputSet;
           const currentLimit = (targetMode === AC_MODES.INPUT ? hwMirror.inputSet : hwMirror.outputSet);
           if (isSwitchingPending && isSwitchingTimeout) { 
               logWarn("Hardware-Timeout beim Moduswechsel. Sende erneut...");
               lastAcModeSwitch = Date.now(); 
               setState(IDs.acModeSet, targetMode, false);
           }
           if ((targetVal === 0 && currentLimit !== 0) || Math.abs(currentLimit - targetVal) > 50) {
               // @FIX: Explizit als Number senden
               // @FIX: BUGFIX v7.3.1: MQTT erwartet String, auch wenn es im Pfad "number" heißt.
               setState(setID, String(targetVal), false);
               if (targetMode === AC_MODES.INPUT) hwMirror.inputSet = targetVal;
               else hwMirror.outputSet = targetVal;
           }
       }
    }
    
    
    // =====================================================================================
    // === 9. REGELUNG (MAIN LOOP) ===
    // =====================================================================================
    
    /**
    * Hauptschleife der Regelung.
    * @KI_HINWEIS Führt die zyklische Regelung durch (PI-Regler).
    * Prioritäten:
    * 1. Watchdog-Check (Sicherheit)
    * 2. Force Charge (Tibber günstig oder EVCC Command)
    * 3. Discharge Locks (EVCC Sperre, Preis-Limit, Reserve)
    * 4. Normaler PI-Regelbetrieb
    */
    function mainControlLoop(): void {
       try {
           const rawNetzState = getState(IDs.netz);
           if (!rawNetzState || rawNetzState.val === null || typeof rawNetzState.val !== 'number') {
               logWarn("Warnung: Netz-Datenpunkt liefert ungültigen Wert. Regelung pausiert.");
               return;
           }
           const rawNetz = rawNetzState.val;
    
           // @KI_HINWEIS: Safety Watchdog (Deaktiviert in v7.3.4 auf User-Wunsch)
           // const nowMs = Date.now();
           // if (nowMs - rawNetzState.ts > CONFIG.SENSOR_TIMEOUT_MS) { ... }
    
           const s: SystemState = {
               netz: rawNetz, 
               soc: safeGetNumber(IDs.soc, 0),
               // @FIX: Logik-Fix für minSoc=0 UND existsState Check (Crash Protection)
               minSoc: (() => {
                   try {
                       const st = getState(IDs.minSoc);
                       return (st && st.val != null && !isNaN(Number(st.val))) ? Number(st.val) : 10;
                   } catch { return 10; }
               })(),
               acMode: normalizeAcMode(hwMirror.acMode),
               // @FIX: EVCC Default Mode = 1 (Normalbetrieb), Fallback wenn null/0
               // Verhindert undefinierte Zustände, wenn der Datenpunkt leer ist.
               evccModus: (() => {
                   const m = safeGetNumber(IDs.evccModus, 1);
                   return [1,2,3].includes(m) ? m : 1;
               })(),
               netzTs: rawNetzState.ts
           };
           
           let dischargeLimit = CONFIG.MAX_DISCHARGE_W;
           let chargeLimit = CONFIG.MAX_CHARGE_W;
           let statusOverride: string | null = null;
           let forceMaxCharge = false; 
    
           // Priority 1: Force Charge
           const isTibberForceCharge = (CONFIG.ENABLE_TIBBER && controlCache.isTibberChargeNow && s.soc < 100);
           // @KI_HINWEIS: EVCC Modus 3 = Zwangsladung (Override der Regelung)
           const isEvccForceCharge = (CONFIG.ENABLE_EVCC && s.evccModus === 3);
           if (isTibberForceCharge || isEvccForceCharge) {
               forceMaxCharge = true;
               dischargeLimit = 0;
               statusOverride = isEvccForceCharge ? "EVCC: Force Charge" : "KI: Strategische Ladung";
           }
    
           // Priority 2: Discharge Locks
           if (!forceMaxCharge) {
               // @KI_HINWEIS: EVCC Modus 2 = Entladesperre (nur Laden bei Überschuss erlaubt)
               if (CONFIG.ENABLE_EVCC && s.evccModus === 2) {
                   dischargeLimit = 0;
                   statusOverride = "EVCC: Entladesperre";
               }
               // @KI_HINWEIS: EVCC Modus 1 (Normalbetrieb) fällt hier durch und nutzt die normale Regelung.
               
               if (CONFIG.ENABLE_TIBBER && controlCache.isPriceBelowFloor && s.soc < 90) {
                    if (!statusOverride) statusOverride = `Eco-Stop: Preis < Limit`;
                    dischargeLimit = 0;
               }
               // @KI_HINWEIS: Reserve-Berechnung.
               // Die 'usableKWh' ist der Energiegehalt *oberhalb* des hardwareseitigen minSoc.
               // Wenn diese nutzbare Energie kleiner ist als die KI-Reserve, wird das Entladen gestoppt.
               const usableKWh = (s.soc - s.minSoc) / 100 * CONFIG.BATTERY_CAPACITY_KWH;
               if (dischargeLimit > 0 && !controlCache.isTibberDischargeNow && usableKWh <= globalReserveKWh) {
                   dischargeLimit = 0;
                   if (!statusOverride) statusOverride = "Reserve-Schutz";
               }
               if (s.soc <= s.minSoc) dischargeLimit = 0;
           }
    
           let output = 0;
           const MAX_POWER = Math.max(CONFIG.MAX_CHARGE_W, CONFIG.MAX_DISCHARGE_W);
    
           if (forceMaxCharge) {
               output = chargeLimit;
               const targetNorm = chargeLimit / MAX_POWER;
               // @FIX: Safety Clamp für Integral Initialization, um Sprünge beim Moduswechsel zu dämpfen
               integral = Math.max(-1.0/CONFIG.KI_N, Math.min(1.0/CONFIG.KI_N, targetNorm / CONFIG.KI_N));
           } else {
               // @SAFETY: Wenn nicht Normalbetrieb (1), Integral resetten oder dämpfen, um Windup zu verhindern.
               // Dies ist wichtig beim Übergang von einer Sperre zurück in den Regelbetrieb.
               if (s.evccModus !== 1) {
                   integral = 0;
               }
    
               let error = CONFIG.TARGET_W - s.netz;
               if (Math.abs(s.netz) <= CONFIG.DEADBAND_W) error = 0.0;
               
               const eNorm = error / MAX_POWER;
               const dt = CONFIG.REGEL_INTERVALL_MS / 1000.0;
               
               const isCooldownActive = (Date.now() - lastAcModeSwitch < CONFIG.AC_MODE_COOLDOWN_MS);
               let isBlockedByCooldown = false;
               
               if (isCooldownActive && s.acMode === AC_MODES.OUTPUT && (error > 0 || integral > 0)) isBlockedByCooldown = true;
               if (isCooldownActive && s.acMode === AC_MODES.INPUT && (error < 0 || integral < 0)) isBlockedByCooldown = true;
    
               if (isBlockedByCooldown) {
                   chargeLimit = 0;
                   dischargeLimit = 0;
                   integral = integral * CONFIG.LEAK;
               } else {
                   integral = integral * CONFIG.LEAK + eNorm * dt;
               }
    
               const integrationLimit = 1.0 / CONFIG.KI_N;
               integral = Math.max(-integrationLimit, Math.min(integrationLimit, integral));
    
               if (Math.abs(integral) > integrationLimit * 0.95) {
                   if (integralWarningCount++ > 30) {
                        if (CONFIG.DEBUG) logDebug("Info: Regler dauerhaft an der Sättigungsgrenze.");
                        integralWarningCount = 0;
                   }
               } else {
                   integralWarningCount = 0;
               }
    
               const p = CONFIG.KP_N * eNorm;
               const i = CONFIG.KI_N * integral;
               const outNormRaw = p + i;
    
               const outRaw = outNormRaw * MAX_POWER;
               const outClamped = Math.max(-dischargeLimit, Math.min(chargeLimit, outRaw));
    
               const antiWindup = (outClamped - outRaw) / MAX_POWER;
               integral += antiWindup * CONFIG.KAW;
    
               output = outClamped;
               
               const curSetMode = hwMirror.acMode;
               const isInputIntended = (curSetMode === AC_MODES.INPUT);
               if (output > 0 && !isInputIntended && output < CONFIG.HYSTERESIS_SWITCH_W) output = 0;
               else if (output < 0 && isInputIntended && output > -CONFIG.HYSTERESIS_SWITCH_W) output = 0;
    
               if(CONFIG.DEBUG) {
                    logDebug(`PI: Err=${Math.round(error)}W | P=${(p*MAX_POWER).toFixed(0)}W | I=${(i*MAX_POWER).toFixed(0)}W | Out=${Math.round(output)}W`);
               }
           }
    
           setBatteryPower(output);
           
           if (statusOverride) setStatus(statusOverride);
           else {
               if (output > 0) setStatus("PI-Regelung: Laden");
               else if (output < 0) setStatus("PI-Regelung: Entladen");
               else setStatus("Leerlauf / Reserve");
           }
       } catch (e) { log(`Loop Error: ${e}`, 'error'); }
    }
    
    async function initialize(): Promise<void> {
       // @FIX: Version im Log angepasst
       logInfo("--- Batteriesteuerung v7.3.5-TS (OPTIONAL IDS) ---");
       
       await setupSystemStates();
    
       try {
           const acVal = safeGetString(IDs.acMode, AC_MODES.OUTPUT);
           const inVal = safeGetNumber(IDs.inputSet, 0);
           const outVal = safeGetNumber(IDs.outputSet, 0);
           
           hwMirror.acMode = normalizeAcMode(acVal);
           hwMirror.inputSet = inVal;
           hwMirror.outputSet = outVal;
           hwMirror.lastSync = Date.now();
           
           if (existsState(IDs.lastPVAccuracy)) {
               const val = safeGetNumber(IDs.lastPVAccuracy, 100);
               let finalVal = val;
               if (finalVal > 1.0) finalVal = finalVal / 100.0;
               lastValidAccuracy = finalVal;
               logInfo(`[Init] Memory geladen: PV-Accuracy ${lastValidAccuracy.toFixed(2)} (aus Speicher: ${val})`);
           }
    
           if (existsState(IDs.batTotalEnergy) && existsState(IDs.batDischargeTotal)) {
               const totalEnergyWh = safeGetNumber(IDs.batTotalEnergy, 0);
               const totalOutWh = safeGetNumber(IDs.batDischargeTotal, 0);
               const realInWh = Math.max(0, totalEnergyWh - totalOutWh);
               const eff = realInWh > 0 ? (totalOutWh / realInWh) : 0;
               logInfo(`[Statistik] Lifetime-Effizienz: ${(eff*100).toFixed(1)}% (In: ${(realInWh/1000).toFixed(1)}kWh, Out: ${(totalOutWh/1000).toFixed(1)}kWh)`);
           }
    
           let startPower = 0;
           if (hwMirror.acMode === AC_MODES.INPUT) startPower = hwMirror.inputSet;
           else if (hwMirror.acMode === AC_MODES.OUTPUT) startPower = -hwMirror.outputSet;
           
           if (startPower !== 0) {
               const MAX_POWER = Math.max(CONFIG.MAX_CHARGE_W, CONFIG.MAX_DISCHARGE_W);
               const sign = (hwMirror.acMode === AC_MODES.INPUT) ? 1.0 : -1.0;
               integral = sign * (Math.abs(startPower) / MAX_POWER) / CONFIG.KI_N;
               
               const maxInt = 1.0 / CONFIG.KI_N;
               integral = Math.max(-maxInt, Math.min(maxInt, integral));
               
               logInfo(`[Init] Laufenden Betrieb erkannt: ${hwMirror.acMode} @ ${Math.abs(startPower)}W. Integral auf ${integral.toFixed(3)} gesetzt.`);
           }
    
       } catch (e) { logWarn(`[Init] Konnte Start-Zustand nicht ermitteln: ${e}`); }
    
       lastAcModeSwitch = Date.now() - CONFIG.AC_MODE_COOLDOWN_MS; 
       await runPredictiveAnalysis(); 
       updateControlFlags(); 
    
       schedule('5 * * * *', runPredictiveAnalysis); 
       schedule('0 3 * * *', updateConsumptionProfileFromSQL); 
       schedule('*/3 * * * *', updateControlFlags); 
    
       setInterval(mainControlLoop, CONFIG.REGEL_INTERVALL_MS); 
    }
    
    initialize();
    
    

    1 Antwort Letzte Antwort
    0
    • S Online
      S Online
      Schimi
      schrieb am zuletzt editiert von
      #2

      so, lange dran rumgewerkelt und bei mir funktioniert es recht gut.... habe aber oft das Gefühl das der Akku viel größer sein müsste um es wirklich effektiv zu nutzen.

      Wollte es aber trotzdem mal veröffentlichen um den ganzen HomeAssistent Jungs nicht das Feld alleine zu überlassen ;-)

      Falls kein Interesse besteht (oder es zu kompliziert ist, ist halt sehr speziell) können wir es löschen oder in der Versenkung verschwinden lassen...

      Deshalb auch erstmal nur für ein Gerät, hatte auch kurz eine Version für max. 3, aber mangels Möglichkeit des testens, habe ich es erstmal gelassen

      1 Antwort Letzte Antwort
      0
      • L Offline
        L Offline
        lesiflo
        Most Active
        schrieb am zuletzt editiert von
        #3

        Hi,
        so was ähnliches habe ich mir auch schon mit javascript gebaut. Allerdings mit mehrere Scripten. Ein einziges wurde mir irgendwann zu unübersichtlich. Bei mir läuft ein Hauptscript für die beiden Hyper, zusätzlich habe ich auch noch eine große PV-Anlage. Weitere Scripts für PV-Forecast, Logging in InfluxDB, Balkendiagrammanzeige ...

        1 Antwort Letzte Antwort
        1
        Antworten
        • In einem neuen Thema antworten
        Anmelden zum Antworten
        • Älteste zuerst
        • Neuste zuerst
        • Meiste Stimmen


        Support us

        ioBroker
        Community Adapters
        Donate

        412

        Online

        32.6k

        Benutzer

        82.1k

        Themen

        1.3m

        Beiträge
        Community
        Impressum | Datenschutz-Bestimmungen | Nutzungsbedingungen | Einwilligungseinstellungen
        ioBroker Community 2014-2025
        logo
        • Anmelden

        • Du hast noch kein Konto? Registrieren

        • Anmelden oder registrieren, um zu suchen
        • Erster Beitrag
          Letzter Beitrag
        0
        • Home
        • Aktuell
        • Tags
        • Ungelesen 0
        • Kategorien
        • Unreplied
        • Beliebt
        • GitHub
        • Docu
        • Hilfe