// ============================================================================ // SOLARBATTERIE CONTROLLER für Shelly Plus Plug S (SpIn) // Refactored mit Factory Functions - Clean & Elegant // Enhanced: Partner-IP aus KVS + LED-Statusanzeige // ============================================================================ let DEFAULT_CONFIG = { // === BATTERIE & EFFIZIENZ === battery_capacity_wh: 1000, efficiency: 0.85, usv_idle_consumption_w: 8, // === SOC SCHWELLWERTE === soc_min_recharge: 15, soc_after_empty: 10, soc_min_disconnect: 30, // === FULL DETECTION (100%) === power_min_consumption_when_full: 5.6, // 0.7 * usv_idle_consumption_w power_max_consumption_when_full: 10.4, // 1.3 * usv_idle_consumption_w // === ALMOST FULL DETECTION (85%) === power_min_consumption_when_almost_full: 80, power_max_consumption_when_almost_full: 120, soc_almost_full_threshold: 85, // === NETZWERK & POLLING === spout_ip: "192.168.0.188", poll_interval_sec: 5, poll_timeout_sec: 3, poll_retries: 2, retry_delay_sec: 1, // === ZEITSTEUERUNG === target_soc_at_sunset: 100, // === STANDORT === latitude: 49.9929, longitude: 8.2473, timezone_offset_hours: 1, // === PERSISTENZ === soc_persist_interval_min: 30, // === ERWEITERT === debug_logging: true }; // ============================================================================ // GLOBALE VARIABLEN // ============================================================================ let CONFIG = null; let batterySOC = null; let spOutMonitor = null; let relayController = null; let solarStrategy = null; let logger = null; let configManager = null; let fullDetector = null; let almostFullDetector = null; let partnerIpManager = null; let ledController = null; let lastIsDay = null; // Kontaktverlust-Verriegelung let kontaktVerlustVerriegelung = false; // ============================================================================ // CONFIG KEYS (für Serialisierung) // ============================================================================ let CONFIG_KEYS = [ "battery_capacity_wh", "efficiency", "usv_idle_consumption_w", "soc_min_recharge", "soc_after_empty", "soc_min_disconnect", "power_min_consumption_when_full", "power_max_consumption_when_full", "power_min_consumption_when_almost_full", "power_max_consumption_when_almost_full", "soc_almost_full_threshold", "spout_ip", "poll_interval_sec", "poll_timeout_sec", "poll_retries", "retry_delay_sec", "latitude", "longitude", "timezone_offset_hours", "soc_persist_interval_min", "debug_logging" ]; function configToArray(config) { let arr = []; for (let i = 0; i < CONFIG_KEYS.length; i++) { arr.push(config[CONFIG_KEYS[i]]); } return arr; } function arrayToConfig(arr) { let config = {}; for (let i = 0; i < CONFIG_KEYS.length && i < arr.length; i++) { config[CONFIG_KEYS[i]] = arr[i]; } return config; } // ============================================================================ // LOGGER (Factory Function) // ============================================================================ function createLogger() { return { log: function(message, level) { level = level || "INFO"; if (!CONFIG.debug_logging && level === "DEBUG") return; let timestamp = new Date().toISOString(); print("[" + timestamp + "] [" + level + "] " + message); }, info: function(msg) { this.log(msg, "INFO"); }, debug: function(msg) { this.log(msg, "DEBUG"); }, warn: function(msg) { this.log(msg, "WARN"); }, error: function(msg) { this.log(msg, "ERROR"); } }; } // ============================================================================ // LED CONTROLLER (Factory Function) - NEU // ============================================================================ function createLEDController() { let LED_COLORS = { GRUEN: [0,100,0], // SPIN EIN, SPOUT OK ROT: [100,0,0], // SPIN AUS, SPOUT OK BLAU: [0,0,100], // SPIN EIN, SPOUT NICHT OK MAGENTA: [100,0,100] // SPIN AUS, SPOUT NICHT OK }; return { setColor: function(colorOn, colorOff, brightness) { brightness = brightness || 100; Shelly.call( "PLUGS_UI.SetConfig", { config: { leds: { mode: "switch", colors: { "switch:0": { on: { rgb: colorOn, brightness: brightness }, off: { rgb: colorOff, brightness: brightness } } } } } }, function(result, error_code, error_message) { if (error_code !== 0) { logger.error("LED-Fehler: " + error_message); } } ); }, updateStatus: function(switchOn, spoutReachable) { let colorOn = spoutReachable ? LED_COLORS.GRUEN : LED_COLORS.BLAU; let colorOff = spoutReachable ? LED_COLORS.ROT : LED_COLORS.MAGENTA; this.setColor(colorOn, colorOff,50); logger.debug("LED: " + (switchOn ? "EIN" : "AUS") + " / SPOUT: " + (spoutReachable ? "OK" : "VERLOREN")); } }; } // ============================================================================ // PARTNER IP MANAGER (Factory Function) - NEU // ============================================================================ function createPartnerIpManager() { return { load: function(callback) { logger.info("Lade Partner-IP aus KVS..."); Shelly.call( "KVS.Get", {key: "PartnerIp"}, function(result, error_code, error_message) { if (error_code === 0 && result.value) { try { let partnerData = JSON.parse(result.value); if (partnerData.partner_ip) { CONFIG.spout_ip = partnerData.partner_ip; logger.info("✓ Partner-IP geladen: " + CONFIG.spout_ip + " (" + (partnerData.partner_name || "Unknown") + ")"); if (callback) callback(true); return; } } catch (e) { logger.error("✗ Fehler beim Parsen der Partner-Daten: " + e); } } else { logger.warn("⚠ Keine Partner-IP im KVS gefunden"); } if (callback) callback(false); } ); } }; } // ============================================================================ // CONFIG MANAGER (Factory Function) // ============================================================================ function createConfigManager() { return { load: function() { logger.info("Loading configuration..."); Shelly.call("KVS.Get", {key: "user_config"}, function(result, error_code, error_msg) { if (error_code === 0 && result && result.value) { try { let parsed = JSON.parse(result.value); let userConfig = arrayToConfig(parsed); for (let key in userConfig) { if (CONFIG[key] !== undefined) { CONFIG[key] = userConfig[key]; } } logger.info("User config loaded from KVS"); } catch (e) { logger.error("Failed to parse user config: " + e); } } else { logger.info("No user config found, using defaults"); } logger.info("Config loaded. Battery: " + CONFIG.battery_capacity_wh + "Wh, SpOut: " + CONFIG.spout_ip); logger.info("Full detection: " + CONFIG.power_min_consumption_when_full.toFixed(1) + "-" + CONFIG.power_max_consumption_when_full.toFixed(1) + "W"); logger.info("Almost Full detection: " + CONFIG.power_min_consumption_when_almost_full + "-" + CONFIG.power_max_consumption_when_almost_full + "W @ " + CONFIG.soc_almost_full_threshold + "%"); }); }, save: function() { logger.info("Saving user config to KVS..."); let configStr = JSON.stringify(configToArray(CONFIG)); logger.debug("Config array length: " + configStr.length + " chars"); Shelly.call("KVS.Set", {key: "user_config", value: configStr}, function(result, error_code, error_msg) { if (error_code === 0) { logger.info("User config saved successfully (" + configStr.length + " bytes)"); } else { logger.error("Failed to save config: " + error_msg); } }); }, update: function(updates) { logger.info("Updating config..."); for (let key in updates) { if (CONFIG[key] !== undefined) { CONFIG[key] = updates[key]; logger.info("Updated " + key + " = " + updates[key]); } } this.save(); }, reset: function() { logger.warn("Resetting config to defaults..."); Shelly.call("KVS.Delete", {key: "user_config"}, function(result, error_code, error_msg) { if (error_code === 0) { logger.info("Config reset. Please restart script."); } else { logger.error("Failed to reset config: " + error_msg); } }); } }; } // ============================================================================ // BATTERY SOC TRACKER (Factory Function mit privaten Variablen) // ============================================================================ function createBatterySOC() { // Private State (nur über Methoden zugänglich) let state = { soc: null, lastUpdateTime: null, lastPersistTime: null, isCalibrated: false }; // Public Interface return { init: function() { Shelly.call("KVS.Get", {key: "battery_soc"}, function(result, error_code, error_msg) { if (error_code === 0 && result && result.value) { try { let data = JSON.parse(result.value); state.soc = data.soc; state.isCalibrated = data.calibrated || false; } catch (e) { state.soc = CONFIG.soc_after_empty; } } else { state.soc = CONFIG.soc_after_empty; } state.lastUpdateTime = Date.now(); state.lastPersistTime = Date.now(); }); }, update: function(spInPower, spOutPower, spOutReachable) { let now = Date.now(); let deltaTime = (now - state.lastUpdateTime) / 1000 / 3600; if (deltaTime <= 0) return state.soc; if (!spOutReachable) spOutPower = 0; let netChargePower = (spInPower - CONFIG.usv_idle_consumption_w) * CONFIG.efficiency - spOutPower; let deltaEnergy = netChargePower * deltaTime; let deltaSoc = (deltaEnergy / CONFIG.battery_capacity_wh) * 100; state.soc = Math.max(0, Math.min(100, state.soc + deltaSoc)); state.lastUpdateTime = now; // Periodisches Speichern let persistInterval = CONFIG.soc_persist_interval_min * 60 * 1000; if (now - state.lastPersistTime > persistInterval) { this.persist(); } return state.soc; }, persist: function() { let data = { soc: state.soc, calibrated: state.isCalibrated, timestamp: Date.now() }; Shelly.call("KVS.Set", {key: "battery_soc", value: JSON.stringify(data)}, function(result, error_code) { if (error_code === 0) { state.lastPersistTime = Date.now(); } }); }, reset: function(newSoc) { state.soc = newSoc; state.isCalibrated = false; this.persist(); }, setFull: function() { if (state.soc < 100) { logger.info("Battery SOC set to 100% (FULL detected)"); state.soc = 100; state.isCalibrated = true; this.persist(); } }, setAlmostFull: function(targetSoc) { if (state.soc < targetSoc) { logger.info("Battery SOC raised to " + targetSoc + "% (ALMOST FULL detected, was " + state.soc.toFixed(1) + "%)"); state.soc = targetSoc; state.isCalibrated = true; this.persist(); } }, getSoc: function() { return state.soc; }, isCalibrated: function() { return state.isCalibrated; } }; } // ============================================================================ // SPOUT MONITOR (Factory Function) - ERWEITERT mit LED-Update // ============================================================================ function createSpOutMonitor() { let state = { isReachable: false, lastPower: 0, consecutiveFailures: 0 }; return { init: function() { state.isReachable = true; state.consecutiveFailures = 0; }, poll: function(callback) { let url = "http://" + CONFIG.spout_ip + "/rpc/Switch.GetStatus?id=0"; let retryCount = 0; function attemptRequest() { Shelly.call("HTTP.GET", {url: url, timeout: CONFIG.poll_timeout_sec}, function(result, error_code, error_msg) { if (error_code === 0 && result && result.code === 200) { try { let data = JSON.parse(result.body); state.lastPower = data.apower || 0; // Statuswechsel: Verbindung wiederhergestellt if (!state.isReachable) { logger.info("✓ SPOUT-Verbindung wiederhergestellt"); state.isReachable = true; state.consecutiveFailures = 0; // LED auf normal zurücksetzen Shelly.call("Switch.GetStatus", {id: 0}, function(status) { if (status && status.output !== undefined) { ledController.updateStatus(status.output, true); } }); } else { state.isReachable = true; state.consecutiveFailures = 0; } logger.debug("SpOut response: apower=" + state.lastPower + "W, output=" + (data.output ? "ON" : "OFF")); callback(true, state.lastPower); } catch (e) { logger.error("SpOut parse error: " + e); handleFailure(); } } else { logger.debug("SpOut request failed: code=" + error_code + ", msg=" + error_msg); handleFailure(); } }); } function handleFailure() { retryCount++; if (retryCount <= CONFIG.poll_retries) { Timer.set(CONFIG.retry_delay_sec * 1000, false, attemptRequest); } else { state.consecutiveFailures++; // Statuswechsel: Verbindung verloren if (state.isReachable) { logger.warn("⚠ SPOUT-Kontakt verloren!"); state.isReachable = false; // LED auf Fehlerstatus setzen Shelly.call("Switch.GetStatus", {id: 0}, function(status) { if (status && status.output !== undefined) { ledController.updateStatus(status.output, false); } }); // Einmalig Partner-IP neu laden (falls nicht schon verriegelt) if (!kontaktVerlustVerriegelung) { logger.info("→ Versuche Partner-IP neu zu laden..."); kontaktVerlustVerriegelung = true; partnerIpManager.load(function(success) { if (success) { logger.info("→ Partner-IP aktualisiert, verwende: " + CONFIG.spout_ip); } }); } else { logger.debug("→ Neuladung bereits versucht, warte auf stündlichen Timer"); } } callback(false, 0); } } attemptRequest(); }, getPower: function() { return state.lastPower; }, getReachable: function() { return state.isReachable; } }; } // ============================================================================ // RELAY CONTROLLER (Factory Function) - KORRIGIERT // ============================================================================ function createRelayController() { let state = { isOn: false, lastStateChange: 0 }; return { init: function() { Shelly.call("Switch.GetStatus", {id: 0}, function(result, error_code, error_msg) { if (error_code === 0 && result) { state.isOn = result.output; } }); }, turnOn: function(reason) { if (state.isOn) return; Shelly.call("Switch.Set", {id: 0, on: true}, function(result, error_code) { if (error_code === 0) { state.isOn = true; state.lastStateChange = Date.now(); logger.info("Relay ON: " + reason); // LED aktualisieren - WICHTIG: Aktuellen SPOUT-Status berücksichtigen ledController.updateStatus(true, spOutMonitor.getReachable()); } }); }, turnOff: function(reason) { if (!state.isOn) return; Shelly.call("Switch.Set", {id: 0, on: false}, function(result, error_code) { if (error_code === 0) { state.isOn = false; state.lastStateChange = Date.now(); logger.info("Relay OFF: " + reason); // LED aktualisieren - WICHTIG: Aktuellen SPOUT-Status berücksichtigen ledController.updateStatus(false, spOutMonitor.getReachable()); } }); }, getState: function() { return state.isOn; } }; } // ============================================================================ // FULL DETECTOR (Factory Function) - 100% Detection // ============================================================================ function createFullDetector() { let state = { conditionMetSince: null, requiredDuration: 60 * 1000, // 1 Minute lastCheck: null, wasFullDetected: false }; return { init: function() { state.conditionMetSince = null; state.lastCheck = Date.now(); state.wasFullDetected = false; }, check: function(spInPower, spOutPower) { let now = Date.now(); let powerDiff = spInPower - spOutPower; let lowerBound = CONFIG.power_min_consumption_when_full; let upperBound = CONFIG.power_max_consumption_when_full; let conditionMet = powerDiff >= lowerBound && powerDiff <= upperBound; if (conditionMet) { if (state.conditionMetSince === null && !state.wasFullDetected) { state.conditionMetSince = now; logger.debug("Full detection (100%): condition started (IN-OUT: " + powerDiff.toFixed(1) + "W, range: " + lowerBound.toFixed(1) + "-" + upperBound.toFixed(1) + "W)"); } else if (!state.wasFullDetected) { let elapsed = now - state.conditionMetSince; let remaining = state.requiredDuration - elapsed; if (elapsed >= state.requiredDuration) { logger.info("Battery FULL (100%) detected (IN-OUT stable for 1min: " + powerDiff.toFixed(1) + "W)"); state.wasFullDetected = true; state.conditionMetSince = null; return true; } else { logger.debug("Full detection (100%): " + (remaining / 1000).toFixed(0) + "s remaining"); } } } else { if (state.conditionMetSince !== null) { logger.debug("Full detection (100%): condition broken (IN-OUT: " + powerDiff.toFixed(1) + "W)"); state.conditionMetSince = null; } // Reset wasFullDetected wenn außerhalb des Bereichs if (state.wasFullDetected) { logger.debug("Full detection (100%): reset (IN-OUT: " + powerDiff.toFixed(1) + "W outside range)"); state.wasFullDetected = false; } } state.lastCheck = now; return false; }, reset: function() { state.conditionMetSince = null; state.wasFullDetected = false; } }; } // ============================================================================ // ALMOST FULL DETECTOR (Factory Function) - 85% Detection // ============================================================================ function createAlmostFullDetector() { let state = { conditionMetSince: null, requiredDuration: 60 * 1000, // 1 Minute lastCheck: null, wasAlmostFullDetected: false }; return { init: function() { state.conditionMetSince = null; state.lastCheck = Date.now(); state.wasAlmostFullDetected = false; }, check: function(spInPower, spOutPower, currentSoc) { let now = Date.now(); let powerDiff = spInPower - spOutPower; let lowerBound = CONFIG.power_min_consumption_when_almost_full; let upperBound = CONFIG.power_max_consumption_when_almost_full; let targetSoc = CONFIG.soc_almost_full_threshold; // Nur prüfen wenn aktueller SOC unter dem Schwellwert liegt let conditionMet = powerDiff >= lowerBound && powerDiff <= upperBound && currentSoc < targetSoc; if (conditionMet) { if (state.conditionMetSince === null && !state.wasAlmostFullDetected) { state.conditionMetSince = now; logger.debug("Almost Full detection (" + targetSoc + "%): condition started (IN-OUT: " + powerDiff.toFixed(1) + "W, range: " + lowerBound + "-" + upperBound + "W, SOC: " + currentSoc.toFixed(1) + "%)"); } else if (!state.wasAlmostFullDetected) { let elapsed = now - state.conditionMetSince; let remaining = state.requiredDuration - elapsed; if (elapsed >= state.requiredDuration) { logger.info("Battery ALMOST FULL (" + targetSoc + "%) detected (IN-OUT stable for 1min: " + powerDiff.toFixed(1) + "W)"); state.wasAlmostFullDetected = true; state.conditionMetSince = null; return true; } else { logger.debug("Almost Full detection (" + targetSoc + "%): " + (remaining / 1000).toFixed(0) + "s remaining"); } } } else { if (state.conditionMetSince !== null) { logger.debug("Almost Full detection (" + targetSoc + "%): condition broken (IN-OUT: " + powerDiff.toFixed(1) + "W, SOC: " + currentSoc.toFixed(1) + "%)"); state.conditionMetSince = null; } // Reset wasAlmostFullDetected wenn SOC wieder deutlich unter Schwellwert fällt if (state.wasAlmostFullDetected && currentSoc < targetSoc - 5) { logger.debug("Almost Full detection (" + targetSoc + "%): reset (SOC dropped to " + currentSoc.toFixed(1) + "%)"); state.wasAlmostFullDetected = false; } } state.lastCheck = now; return false; }, reset: function() { state.conditionMetSince = null; state.wasAlmostFullDetected = false; } }; } // ============================================================================ // SOLAR STRATEGY (Factory Function) // ============================================================================ function createSolarStrategy() { // Private Hilfsfunktion function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); } return { init: function() {}, calculateSunTimes: function(date) { let lat = CONFIG.latitude; let lon = CONFIG.longitude; // Tag im Jahr (1-365) let start = new Date(date.getFullYear(), 0, 0); let diff = date - start; let oneDay = 1000 * 60 * 60 * 24; let dayOfYear = Math.floor(diff / oneDay); // Deklination der Sonne let decl = -23.44 * Math.cos((360 / 365) * (dayOfYear + 10) * Math.PI / 180); // Stundenwinkel bei Sonnenauf-/untergang let latRad = lat * Math.PI / 180; let declRad = decl * Math.PI / 180; let cosH = -Math.tan(latRad) * Math.tan(declRad); // Prüfung auf Polartag/-nacht if (cosH > 1) { return { sunrise: 12, sunset: 12 }; } else if (cosH < -1) { return { sunrise: 0, sunset: 24 }; } let H = Math.acos(cosH) * 180 / Math.PI; // Sonnenauf- und untergang in UTC (am Greenwich-Meridian) let sunriseGreenwich = 12 - H / 15; let sunsetGreenwich = 12 + H / 15; // Längengrad-Korrektur: Ost (positiv) = früher, also ADDIEREN let lonCorrection = -lon / 15; // UTC Zeit für unseren Längengrad let sunriseUTC = sunriseGreenwich + lonCorrection; let sunsetUTC = sunsetGreenwich + lonCorrection; // Lokale Zeit (UTC + Zeitzone) let sunrise = sunriseUTC + CONFIG.timezone_offset_hours; let sunset = sunsetUTC + CONFIG.timezone_offset_hours; // Normalisierung auf 0-24 Bereich (modulo) sunrise = sunrise % 24; if (sunrise < 0) sunrise += 24; sunset = sunset % 24; if (sunset < 0) sunset += 24; return { sunrise: sunrise, sunset: sunset }; } }; } // ============================================================================ // STÜNDLICHER TIMER - Partner-IP Refresh & Verriegelung zurücksetzen - NEU // ============================================================================ function startHourlyRefresh() { let hourlyInterval = 60 * 60 * 1000; // 60 Minuten Timer.set(hourlyInterval, true, function() { logger.info("=== Stündlicher Partner-IP Refresh ==="); // Verriegelung zurücksetzen kontaktVerlustVerriegelung = false; logger.debug("→ Kontaktverlust-Verriegelung zurückgesetzt"); // Partner-IP neu laden (überschreibt CONFIG.spout_ip) partnerIpManager.load(function(success) { if (success) { logger.info("→ Partner-IP aktualisiert: " + CONFIG.spout_ip); } }); }); logger.info("✓ Stündlicher Refresh-Timer gestartet"); } // ============================================================================ // MAIN CONTROL LOOP // ============================================================================ function mainLoop() { logger.debug("=== Main Loop ==="); Shelly.call("Switch.GetStatus", {id: 0}, function(result, error_code, error_msg) { if (error_code !== 0) { logger.error("Failed to read own status: " + error_msg); scheduleNextLoop(); return; } let spInPower = result.apower || 0; logger.debug("SpIn Power: " + spInPower + "W"); spOutMonitor.poll(function(reachable, spOutPower) { let relayIsOn = relayController.getState(); let effectiveSpInPower = relayIsOn ? spInPower : 0; let effectiveSpOutPower = reachable ? spOutPower : 0; let currentSoc = batterySOC.update(effectiveSpInPower, effectiveSpOutPower, reachable); logger.info("SOC: " + currentSoc.toFixed(1) + "%, Relay: " + (relayIsOn ? "ON" : "OFF") + ", IN: " + effectiveSpInPower + "W, OUT: " + (reachable ? spOutPower + "W" : "UNREACHABLE")); if (!reachable) { logger.warn("SpOut unreachable - assuming battery empty"); // WICHTIG: Relay-Zustand NICHT ändern wenn schon ON if (!relayIsOn) { relayController.turnOn("Battery empty (SpOut unreachable)"); } else { // Relay ist schon ON, nur LED muss auf BLAU bleiben // KEINE Aktion nötig, LED wurde bereits in spOutMonitor.poll() gesetzt logger.debug("Relay already ON, keeping LED status (BLUE)"); } batterySOC.reset(CONFIG.soc_after_empty); fullDetector.reset(); almostFullDetector.reset(); scheduleNextLoop(); return; } // Prüfe FULL (100%) und ALMOST FULL (85%) - nur wenn Relay ON if (relayIsOn) { let isFull = fullDetector.check(spInPower, spOutPower); if (isFull) { batterySOC.setFull(); } let isAlmostFull = almostFullDetector.check(spInPower, spOutPower, currentSoc); if (isAlmostFull) { batterySOC.setAlmostFull(CONFIG.soc_almost_full_threshold); } } applySimplifiedRules(currentSoc); scheduleNextLoop(); }); }); } function scheduleNextLoop() { let interval = CONFIG.poll_interval_sec * 1000; Timer.set(interval, false, mainLoop); } function applySimplifiedRules(currentSoc) { let now = new Date(); let sunTimes = solarStrategy.calculateSunTimes(now); let currentHour = now.getHours() + now.getMinutes() / 60; let sunrisePlus2 = sunTimes.sunrise + 2; let isDay = currentHour >= sunTimes.sunrise && currentHour < sunTimes.sunset; let wasDay = lastIsDay; lastIsDay = isDay; logger.debug("Rules check: SOC=" + currentSoc.toFixed(1) + "%, isDay=" + isDay + ", currentHour=" + currentHour.toFixed(2) + ", sunrise=" + sunTimes.sunrise.toFixed(2) + ", sunset=" + sunTimes.sunset.toFixed(2) + ", sunrise+2h=" + sunrisePlus2.toFixed(2)); if (currentSoc < CONFIG.soc_min_recharge) { relayController.turnOn("SOC < " + CONFIG.soc_min_recharge + "%"); return; } if (wasDay === true && isDay === false) { relayController.turnOff("Sunset"); return; } if (isDay) { if (currentHour >= sunrisePlus2) { relayController.turnOn("Day >= sunrise+2h (current: " + currentHour.toFixed(2) + " >= " + sunrisePlus2.toFixed(2) + ")"); } else { // Vor sunrise+2h: nur abschalten wenn SOC hoch genug if (relayController.getState() && currentSoc >= CONFIG.soc_min_disconnect) { relayController.turnOff("Early day reached " + CONFIG.soc_min_disconnect + "% (before sunrise+2h)"); } } } else { // Nachts: nur abschalten wenn SOC hoch genug, ansonsten Relay-Zustand beibehalten if (relayController.getState() && currentSoc >= CONFIG.soc_min_disconnect) { relayController.turnOff("Night reached " + CONFIG.soc_min_disconnect + "%"); } } } // ============================================================================ // PUBLIC API (für Console-Zugriff) // ============================================================================ function loadConfig() { configManager.load(); } function saveUserConfig() { configManager.save(); } function updateConfig(updates) { configManager.update(updates); } function resetConfig() { configManager.reset(); } // ============================================================================ // INITIALIZATION // ============================================================================ function init() { print("======================================================="); print(" SOLARBATTERIE CONTROLLER - Starting..."); print(" Enhanced: Partner-IP KVS + LED Status"); print("======================================================="); // Deep copy der Default-Config CONFIG = JSON.parse(JSON.stringify(DEFAULT_CONFIG)); // Erstelle alle Komponenten logger = createLogger(); configManager = createConfigManager(); partnerIpManager = createPartnerIpManager(); ledController = createLEDController(); // Lade User-Config loadConfig(); // Warte kurz, damit Config geladen wird Timer.set(1000, false, function() { // Lade Partner-IP aus KVS (überschreibt CONFIG.spout_ip) partnerIpManager.load(function(partnerLoaded) { // Initialisiere alle Komponenten batterySOC = createBatterySOC(); batterySOC.init(); spOutMonitor = createSpOutMonitor(); spOutMonitor.init(); relayController = createRelayController(); relayController.init(); fullDetector = createFullDetector(); fullDetector.init(); almostFullDetector = createAlmostFullDetector(); almostFullDetector.init(); solarStrategy = createSolarStrategy(); solarStrategy.init(); // Starte stündlichen Partner-IP Refresh startHourlyRefresh(); // Relay nach Neustart einschalten relayController.turnOn("System startup"); // Initiale LED-Status setzen Timer.set(500, false, function() { Shelly.call("Switch.GetStatus", {id: 0}, function(status) { if (status && status.output !== undefined) { ledController.updateStatus(status.output, spOutMonitor.getReachable()); } }); }); logger.info("System initialized. Using SpOut: " + CONFIG.spout_ip); logger.info("Starting main loop..."); // Starte Main Loop Timer.set(3000, false, mainLoop); }); }); } // ============================================================================ // START // ============================================================================ init();