function num(v, fallback = NaN) { const n = Number.parseFloat(v); return Number.isFinite(n) ? n : fallback; } function low(v) { return (v ?? '').toString().trim().toLowerCase(); } function clamp(v, min, max) { return Math.min(Math.max(v, min), max); } function round1(v) { return Math.round(v * 10) / 10; } function round2(v) { return Math.round(v * 100) / 100; } function to2(n) { return String(n).padStart(2, '0'); } function parseMaybeJson(v) { if (typeof v !== 'string') return v; try { return JSON.parse(v); } catch { return v; } } function extractSnapshot(input) { if (typeof input === 'string') return parseMaybeJson(input); if (input?.data && typeof input.data === 'string') return parseMaybeJson(input.data); if (input?.body && typeof input.body === 'string') return parseMaybeJson(input.body); if (input?.data && typeof input.data === 'object') return input.data; if (input?.body && typeof input.body === 'object') return input.body; return input; } function parseTimeToMinutes(v, fallback = null) { const s = (v ?? '').toString().trim(); const m = s.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/); if (!m) return fallback; const hh = Number(m[1]); const mm = Number(m[2]); if (!Number.isFinite(hh) || !Number.isFinite(mm)) return fallback; return (hh % 24) * 60 + mm; } function getParisParts(ms = Date.now()) { const fmt = new Intl.DateTimeFormat('fr-FR', { timeZone: 'Europe/Paris', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, }); const parts = Object.fromEntries( fmt.formatToParts(new Date(ms)) .filter(p => p.type !== 'literal') .map(p => [p.type, p.value]) ); const hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour); const date = `${parts.year}-${parts.month}-${parts.day}`; const stamp = `${date} ${to2(hour)}:${parts.minute}:${parts.second}`; return { year: Number(parts.year), month: Number(parts.month), day: Number(parts.day), hour, minute: Number(parts.minute), second: Number(parts.second), date, stamp, minNow: hour * 60 + Number(parts.minute), }; } function getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') { const dtf = new Intl.DateTimeFormat('en-US', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, }); const parts = Object.fromEntries( dtf.formatToParts(new Date(utcTs)) .filter(p => p.type !== 'literal') .map(p => [p.type, p.value]) ); const asIfUtc = Date.UTC( Number(parts.year), Number(parts.month) - 1, Number(parts.day), Number(parts.hour) === 24 ? 0 : Number(parts.hour), Number(parts.minute), Number(parts.second) ); const d = new Date(utcTs); const actualUtc = Date.UTC( d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds() ); return asIfUtc - actualUtc; } function parseParisStampToMs(s) { const text = (s ?? '').toString().trim(); const m = text.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/); if (!m) return null; const y = Number(m[1]); const mo = Number(m[2]); const d = Number(m[3]); const h = Number(m[4]); const mi = Number(m[5]); const se = Number(m[6]); if (![y, mo, d, h, mi, se].every(Number.isFinite)) return null; const guessUtc = Date.UTC(y, mo - 1, d, h, mi, se); const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris'); let ts = guessUtc - off1; const off2 = getTzOffsetMs(ts, 'Europe/Paris'); if (off2 !== off1) ts = guessUtc - off2; return ts; } function cleanText(v, fallback = '') { const s = (v ?? '').toString().trim(); const x = s.toLowerCase(); if (['unknown', 'unavailable', 'none', 'null', 'undefined'].includes(x)) return fallback; return s; } function finiteOrNull(v) { return Number.isFinite(v) ? v : null; } const raw = $input.first().json; const input = extractSnapshot(raw); const nowMs = Date.now(); const now = getParisParts(nowMs); const CFG = { mobileNotifyDomain: 'notify', mobileNotifyService: 'mobile_app_iphone_16_de_tt', batteryUsableCapacityKwh: 17.5, nominalAcVoltageV: 230, evcsDedicatedMaxW: 4900, systemReserveW: 300, vebusDcVoltageSoftFloorV: 49.0, vebusDcVoltageHardFloorV: 48.6, departurePriorityWindowMinutes: 240, sampleSeconds: 30, cloudDebtRecoveryThresholdKwh: 0.05, cloudDebtMeaningfulThresholdKwh: 0.01, }; // ===================================================== // PARSED SNAPSHOT // ===================================================== const parsed = { soc: clamp(num(input.soc_percent, 0), 0, 100), batteryCurrentA: num(input.battery_current_a, 0), pvPowerW: Math.max(0, num(input.pv_power_w, 0)), pvPowerAvg10mW: Math.max(0, num(input.pv_power_10m_avg_w, num(input.pv_power_w, 0))), pvTrendW: num(input.pv_trend_w, 0), houseAcPowerW: Math.max(0, num(input.house_ac_power_w, 0)), houseAcPowerPrevW: Math.max(0, num(input.house_ac_power_prev_w, num(input.house_ac_power_w, 0))), gridPowerW: num(input.grid_power_w, 0), gridIsOn: low(input.grid_state || 'off') === 'on', acInAvailable: num(input.victron_acin1_available ?? input.ac_in1_available, 0) === 1, gridAvailable: (low(input.grid_state || 'off') === 'on') || (num(input.victron_acin1_available ?? input.ac_in1_available, 0) === 1), todayRemainingKwh: Math.max(0, num(input.today_remaining_kwh, 0)), evcsDeviceConnected: low(input.evcs_device_connected || 'off') === 'on', evcsVehicleConnected: low(input.evcs_vehicle_connected || 'off') === 'on', evcsStatusRaw: cleanText(input.evcs_status_raw), evcsModeRaw: [ input.evcs_mode_sensor, input.evcs_mode_fallback, input.evcs_mode ] .map(v => cleanText(v)) .find(v => v) || 'unknown', evcsSetCurrentA: num(input.evcs_set_current_a, NaN), evcsCommandCurrentA: num(input.evcs_command_current_a, NaN), evcsCurrentA: Math.max(0, num(input.evcs_current_a, 0)), evcsAcPowerW: Math.max(0, num(input.evcs_ac_power_w, 0)), evcsChargingSwitchOn: low(input.evcs_charging_switch || 'off') === 'on', evcsAutoStartSwitchOn: low(input.evcs_auto_start_switch || 'off') === 'on', controllerEnabled: low(input.controller_enabled || 'off') === 'on', notificationsEnabled: low(input.notifications_enabled || 'off') === 'on', hardStopEnabledLegacy: low(input.hard_stop_enabled || 'off') === 'on', chargePermissionNow: low(input.charge_permission_now || 'off') === 'on', controlLatch: low(input.control_latch || 'off') === 'on', departurePriorityEnabled: low(input.departure_priority_enabled || 'off') === 'on', forecastModeEnabled: low(input.forecast_mode_enabled || 'off') === 'on', evcsPriorityManualOverride: low(input.evcs_priority_manual_override || 'off') === 'on', evcsPriorityManualMode: cleanText(input.evcs_priority_manual_mode || 'normal', 'normal').toLowerCase(), evcsPriorityManualReason: cleanText(input.evcs_priority_manual_reason), vebusOverload: low(input.vebus_overload || 'off') === 'on', vebusL1Overload: low(input.vebus_l1_overload || 'off') === 'on', vebusLowBattery: low(input.vebus_low_battery || 'off') === 'on', vebusL1LowBattery: low(input.vebus_l1_low_battery || 'off') === 'on', vebusAcOutPowerW: Math.max(0, num(input.vebus_ac_out_l1_power_w, 0)), vebusAcOutCurrentA: Math.max(0, num(input.vebus_ac_out_l1_current_a, 0)), vebusAcOutVoltageV: num(input.vebus_ac_out_l1_voltage_v, CFG.nominalAcVoltageV), vebusDcVoltageV: num(input.vebus_dc_voltage_v, NaN), vebusDcCurrentA: num(input.vebus_dc_current_a, NaN), vebusDcPowerW: num(input.vebus_dc_power_w, NaN), vebusState: num(input.vebus_state, NaN), vebusMainState: num(input.vebus_main_state, NaN), }; parsed.evcsModeIsAuto = low(parsed.evcsModeRaw).includes('auto'); // ===================================================== // PARAMETERS // ===================================================== const params = { userStrategyRaw: input.victron_evcs_strategy || 'auto_solar', targetCurrentAutoA: clamp(num(input.target_current_auto_a, 6), 0, 32), pvStartMinW: Math.max(0, num(input.pv_start_min_w, 1800)), pvHoldMinW: Math.max(0, num(input.pv_hold_min_w, 1200)), houseAcHeadroomW: Math.max(0, num(input.house_ac_headroom_w, 1200)), batteryDischargeSoftA: num(input.battery_discharge_soft_a, -5), batteryDischargeHardA: num(input.battery_discharge_hard_a, -15), batteryReserveUpA: Math.max(0, num(input.battery_reserve_up_a, 8)), batteryNeutralBandA: Math.max(0, num(input.battery_neutral_band_a, 3)), socStartMin: clamp(num(input.soc_start_min, 60), 0, 100), socStopMin: clamp(num(input.soc_stop_min, 35), 0, 100), currentMinA: clamp(num(input.current_min_a, 6), 6, 32), currentMaxAutoA: clamp( num(input.current_max_auto_a, 16), clamp(num(input.current_min_a, 6), 6, 32), 32 ), currentStepUpA: clamp(num(input.current_step_up_a, 1), 1, 5), currentStepDownA: clamp(num(input.current_step_down_a, 2), 1, 8), currentChangeCooldownSeconds: Math.max(5, num(input.current_change_cooldown_seconds, 60)), minRunMinutes: Math.max(0, num(input.min_run_minutes, 3)), minStopMinutes: Math.max(0, num(input.min_stop_minutes, 10)), requiredEnergyKwh: Math.max(0, num(input.required_energy_kwh, 0)), socComfortTarget: clamp(num(input.soc_comfort_target, 85), 0, 100), endOfDaySocGuard: clamp(num(input.end_of_day_soc_guard, 85), 0, 100), baseLoadRemainingKwh: Math.max(0, num(input.base_load_remaining_kwh, 0)), flexibleSafetyMarginKwh: Math.max(0, num(input.flexible_safety_margin_kwh, 0)), endOfDaySocTarget: clamp(num(input.end_of_day_soc_target, 85), 0, 100), acLoadsMaxSafeW: Math.max(0, num(input.ac_loads_max_safe_w, 8500)), batteryFullSoc: clamp(num(input.battery_full_soc, 90), 0, 100), batteryFullForecastMinKwh: Math.max(0, num(input.battery_full_forecast_min_kwh, 8)), forecastCommitSocFloor: clamp(num(input.forecast_commit_soc_floor, 35), 0, 100), forecastCommitMinKwh: Math.max(0, num(input.forecast_commit_min_kwh, 25)), cloudDebtPrevKwh: Math.max(0, num(input.cloud_debt_kwh, 0)), cloudDebtMaxKwh: Math.max(0.05, num(input.cloud_debt_max_kwh, 1.2)), cloudToleranceMinutes: Math.max(1, num(input.cloud_tolerance_minutes, 20)), departureTimeMin: parseTimeToMinutes(input.departure_time, null), dayStartTimeMin: parseTimeToMinutes(input.day_start_time, 7 * 60 + 30), dayStopTimeMin: parseTimeToMinutes(input.day_stop_time, 20 * 60 + 30), hcNightStartMin: parseTimeToMinutes(input.hc_night_start_time, 23 * 60), hcNightStopMin: parseTimeToMinutes(input.hc_night_stop_time, 6 * 60), morningStartMin: parseTimeToMinutes( input.morning_start_time, parseTimeToMinutes(input.day_start_time, 7 * 60 + 30) ), morningStopMin: parseTimeToMinutes(input.morning_stop_time, 11 * 60), hcMiddayStartMin: parseTimeToMinutes(input.hc_midday_start_time, 12 * 60 + 21), hcMiddayStopMin: parseTimeToMinutes(input.hc_midday_stop_time, 14 * 60 + 21), eveningStartMin: parseTimeToMinutes(input.evening_start_time, 17 * 60), eveningStopMin: parseTimeToMinutes( input.evening_stop_time, parseTimeToMinutes(input.day_stop_time, 20 * 60 + 30) ), allowGridHcNight: low(input.allow_grid_hc_night || 'off') === 'on', allowGridHcMidday: low(input.allow_grid_hc_midday || 'off') === 'on', probeEnabled: low(input.probe_enabled || 'off') === 'on', probeMinutes: Math.max(1, num(input.probe_minutes, 3)), probeRetryMinutes: Math.max(5, num(input.probe_retry_minutes, 45)), probeCurrentA: clamp( num(input.probe_current_a, clamp(num(input.current_min_a, 6), 6, 32)), clamp(num(input.current_min_a, 6), 6, 32), clamp(num(input.current_max_auto_a, 16), clamp(num(input.current_min_a, 6), 6, 32), 32) ), gridBatterySoftA: num(input.grid_battery_soft_a, -3), gridBatteryHardA: num(input.grid_battery_hard_a, -6), morningProbePvAvgMinW: Math.max(0, num(input.morning_probe_pv_avg_min_w, 1500)), morningProbePvTrendMinW: num(input.morning_probe_pv_trend_min_w, 0), middayProbePvAvgMinW: Math.max(0, num(input.midday_probe_pv_avg_min_w, 1200)), afternoonProbePvAvgMinW: Math.max(0, num(input.afternoon_probe_pv_avg_min_w, 1000)), afternoonProbeSocMin: clamp(num(input.afternoon_probe_soc_min, 85), 0, 100), afternoonBatteryMaxDischargeA: num(input.afternoon_battery_max_discharge_a, -20), morningSocMin: clamp(num(input.morning_soc_min, 35), 0, 100), middaySocMin: clamp(num(input.midday_soc_min, 75), 0, 100), eveningSocStartMin: clamp(num(input.evening_soc_start_min, 85), 0, 100), eveningSocStopMin: clamp(num(input.evening_soc_stop_min, 80), 0, 100), morningBatteryMaxDischargeA: num(input.morning_battery_max_discharge_a, -20), middayBatteryMaxDischargeA: num(input.midday_battery_max_discharge_a, -40), eveningBatteryMaxDischargeA: num(input.evening_battery_max_discharge_a, -30), // ===================================================== // EVCS NIGHT ENGINE — NEW PARAMETERS // ===================================================== evcsNightBatteryHardFloorPct: clamp( num(input.evcs_night_battery_hard_floor_pct, 22), 0, 100 ), evcsNightStrongForecastKwh: Math.max( 0, num(input.evcs_night_strong_forecast_kwh, 18) ), evcsNightMediumForecastKwh: Math.max( 0, num(input.evcs_night_medium_forecast_kwh, 10) ), evcsNightTargetStrongForecastPct: clamp( num(input.evcs_night_target_strong_forecast_pct, 35), 0, 100 ), evcsNightTargetMediumForecastPct: clamp( num(input.evcs_night_target_medium_forecast_pct, 55), 0, 100 ), evcsNightTargetWeakForecastPct: clamp( num(input.evcs_night_target_weak_forecast_pct, 85), 0, 100 ), evcsNightBatteryRecoveryPowerKw: Math.max( 0.1, num(input.evcs_night_battery_recovery_power_kw, 2.5) ), evcsNightDepartureSafetyMin: Math.max( 0, num(input.evcs_night_departure_safety_min, 30) ), }; // ===================================================== // HISTORY / PERSISTED HELPERS // ===================================================== const nightSessionStartSocRaw = num( input.evcs_night_session_start_soc ?? input.night_session_start_soc, NaN ); const nightSessionStartAtRaw = cleanText( input.evcs_night_session_start_at ?? input.night_session_start_at ); const history = { lastBlockReason: cleanText(input.last_block_reason, 'none') || 'none', blockedUntilAt: cleanText(input.blocked_until), probeStartedAt: cleanText(input.probe_started_at), lastStartAt: cleanText(input.last_start_at), lastStopAt: cleanText(input.last_stop_at), lastCurrentChangeAt: cleanText(input.last_current_change_at), lastSignature: cleanText(input.last_signature), cloudDebtStartedAt: cleanText(input.cloud_debt_started_at), evcsPriorityManualUntilAt: cleanText(input.evcs_priority_manual_until), blockedUntilMs: parseParisStampToMs(cleanText(input.blocked_until)), probeStartedMs: parseParisStampToMs(cleanText(input.probe_started_at)), lastStartMs: parseParisStampToMs(cleanText(input.last_start_at)), lastStopMs: parseParisStampToMs(cleanText(input.last_stop_at)), lastCurrentChangeMs: parseParisStampToMs(cleanText(input.last_current_change_at)), cloudDebtStartedMsPrev: parseParisStampToMs(cleanText(input.cloud_debt_started_at)), evcsPriorityManualUntilMs: parseParisStampToMs(cleanText(input.evcs_priority_manual_until)), // ===================================================== // EVCS NIGHT SESSION MEMORY // ===================================================== nightSessionStartSocPct: Number.isFinite(nightSessionStartSocRaw) ? clamp(nightSessionStartSocRaw, 0, 100) : null, nightSessionStartAt: nightSessionStartAtRaw || '', nightSessionStartMs: parseParisStampToMs(nightSessionStartAtRaw), }; // ===================================================== // BATTERY TUNING // ===================================================== const batteryTune = { global: { houseSpikeGraceMinutes: Math.max(0, num(input.house_spike_grace_minutes, 10)), solarPotentialGraceMinutes: Math.max(0, num(input.solar_potential_grace_minutes, 12)), stepUpCooldownSeconds: Math.max(5, num(input.step_up_cooldown_seconds, params.currentChangeCooldownSeconds)), stepDownCooldownSeconds: Math.max(5, num(input.step_down_cooldown_seconds, params.currentChangeCooldownSeconds)), forceModeAutoRevertMinutes: Math.max(0, num(input.force_mode_auto_revert_minutes, 120)), overloadStepDownA: Math.max(1, num(input.overload_step_down_a, 2)), overloadHoldSeconds: Math.max(5, num(input.overload_hold_seconds, 30)), overloadStopAfterSeconds: Math.max(10, num(input.overload_stop_after_seconds, 120)), }, bands: { gt90: { dischargeSoftA: num(input.batt_discharge_soft_gt90_a, -50), dischargeHardA: num(input.batt_discharge_hard_gt90_a, -100), chargeReserveA: Math.max(0, num(input.batt_charge_reserve_gt90_a, 5)), softToleranceMin: Math.max(0, num(input.batt_soft_tol_gt90_min, 12)), hardToleranceSec: Math.max(0, num(input.batt_hard_tol_gt90_sec, 20)), recoveryMin: Math.max(0, num(input.batt_recovery_gt90_min, 6)), }, '80_90': { dischargeSoftA: num(input.batt_discharge_soft_80_90_a, -40), dischargeHardA: num(input.batt_discharge_hard_80_90_a, -80), chargeReserveA: Math.max(0, num(input.batt_charge_reserve_80_90_a, 10)), softToleranceMin: Math.max(0, num(input.batt_soft_tol_80_90_min, 10)), hardToleranceSec: Math.max(0, num(input.batt_hard_tol_80_90_sec, 20)), recoveryMin: Math.max(0, num(input.batt_recovery_80_90_min, 6)), }, '60_80': { dischargeSoftA: num(input.batt_discharge_soft_60_80_a, -20), dischargeHardA: num(input.batt_discharge_hard_60_80_a, -50), chargeReserveA: Math.max(0, num(input.batt_charge_reserve_60_80_a, 40)), softToleranceMin: Math.max(0, num(input.batt_soft_tol_60_80_min, 6)), hardToleranceSec: Math.max(0, num(input.batt_hard_tol_60_80_sec, 15)), recoveryMin: Math.max(0, num(input.batt_recovery_60_80_min, 8)), }, '40_60': { dischargeSoftA: num(input.batt_discharge_soft_40_60_a, -12), dischargeHardA: num(input.batt_discharge_hard_40_60_a, -30), chargeReserveA: Math.max(0, num(input.batt_charge_reserve_40_60_a, 70)), softToleranceMin: Math.max(0, num(input.batt_soft_tol_40_60_min, 4)), hardToleranceSec: Math.max(0, num(input.batt_hard_tol_40_60_sec, 10)), recoveryMin: Math.max(0, num(input.batt_recovery_40_60_min, 10)), }, '25_40': { dischargeSoftA: num(input.batt_discharge_soft_25_40_a, -5), dischargeHardA: num(input.batt_discharge_hard_25_40_a, -15), chargeReserveA: Math.max(0, num(input.batt_charge_reserve_25_40_a, 110)), softToleranceMin: Math.max(0, num(input.batt_soft_tol_25_40_min, 2)), hardToleranceSec: Math.max(0, num(input.batt_hard_tol_25_40_sec, 5)), recoveryMin: Math.max(0, num(input.batt_recovery_25_40_min, 12)), }, lt25: { dischargeSoftA: num(input.batt_discharge_soft_lt25_a, -3), dischargeHardA: num(input.batt_discharge_hard_lt25_a, -8), chargeReserveA: Math.max(0, num(input.batt_charge_reserve_lt25_a, 150)), softToleranceMin: Math.max(0, num(input.batt_soft_tol_lt25_min, 1)), hardToleranceSec: Math.max(0, num(input.batt_hard_tol_lt25_sec, 2)), recoveryMin: Math.max(0, num(input.batt_recovery_lt25_min, 15)), }, }, }; // ===================================================== // OPTIONAL DEBUG / SUMMARY // ===================================================== const evcsNightConfig = { batteryHardFloorPct: params.evcsNightBatteryHardFloorPct, strongForecastKwh: params.evcsNightStrongForecastKwh, mediumForecastKwh: params.evcsNightMediumForecastKwh, targetStrongForecastPct: params.evcsNightTargetStrongForecastPct, targetMediumForecastPct: params.evcsNightTargetMediumForecastPct, targetWeakForecastPct: params.evcsNightTargetWeakForecastPct, batteryRecoveryPowerKw: params.evcsNightBatteryRecoveryPowerKw, departureSafetyMin: params.evcsNightDepartureSafetyMin, sessionStartSocPct: history.nightSessionStartSocPct, sessionStartAt: history.nightSessionStartAt || null, }; // ===================================================== // OUTPUT // ===================================================== return [{ json: { raw, input, nowMs, now, cfg: CFG, parsed, params, history, batteryTune, evcsNightConfig, } }];