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 minutesSince(ts, nowMs) { if (ts === null || nowMs === null) return null; return Math.max(0, (nowMs - ts) / 60000); } function secondsSince(ts, nowMs) { if (ts === null || nowMs === null) return null; return Math.max(0, (nowMs - ts) / 1000); } function isWithinWindow(nowMin, startMin, stopMin) { if (!Number.isFinite(startMin) || !Number.isFinite(stopMin)) return false; if (startMin === stopMin) return false; if (startMin < stopMin) return nowMin >= startMin && nowMin < stopMin; return nowMin >= startMin || nowMin < stopMin; } function hhmmssFromMinutes(minOfDay) { const mm = ((minOfDay % 1440) + 1440) % 1440; const hh = Math.floor(mm / 60); const mi = mm % 60; return `${to2(hh)}:${to2(mi)}:00`; } function formatParisStampFromMs(ms) { return getParisParts(ms).stamp; } function nextDailyOccurrenceMs(startMin, refMs) { if (!Number.isFinite(startMin)) return null; const today = getParisParts(refMs).date; const todayStamp = `${today} ${hhmmssFromMinutes(startMin)}`; const todayMs = parseParisStampToMs(todayStamp); if (todayMs !== null && todayMs > refMs + 1000) return todayMs; const tomorrow = getParisParts(refMs + 36 * 3600 * 1000).date; const tomorrowStamp = `${tomorrow} ${hhmmssFromMinutes(startMin)}`; return parseParisStampToMs(tomorrowStamp); } const CFG = { mobileNotifyDomain: 'notify', mobileNotifyService: 'mobile_app_iphone_16_de_tt', batteryUsableCapacityKwh: 17.5, nominalAcVoltageV: 230, evcsDedicatedMaxW: 4500, systemReserveW: 300, vebusDcVoltageSoftFloorV: 49.0, vebusDcVoltageHardFloorV: 48.6, departurePriorityWindowMinutes: 240, sampleSeconds: 30, cloudDebtRecoveryThresholdKwh: 0.05, cloudDebtMeaningfulThresholdKwh: 0.01, }; const raw = $input.first().json; const input = extractSnapshot(raw); const nowMs = Date.now(); const now = getParisParts(nowMs); // ============================================================================ // INPUTS // ============================================================================ const soc = clamp(num(input.soc_percent, 0), 0, 100); const batteryCurrentA = num(input.battery_current_a, 0); const pvPowerW = Math.max(0, num(input.pv_power_w, 0)); const pvPowerAvg10mW = Math.max(0, num(input.pv_power_10m_avg_w, pvPowerW)); const pvTrendW = num(input.pv_trend_w, 0); const houseAcPowerW = Math.max(0, num(input.house_ac_power_w, 0)); const gridPowerW = num(input.grid_power_w, 0); const gridIsOn = low(input.grid_state || 'off') === 'on'; const todayRemainingKwh = Math.max(0, num(input.today_remaining_kwh, 0)); const evcsDeviceConnected = low(input.evcs_device_connected || 'off') === 'on'; const evcsVehicleConnected = low(input.evcs_vehicle_connected || 'off') === 'on'; const evcsStatusRaw = (input.evcs_status_raw ?? '').toString().trim(); const evcsModeRaw = [input.evcs_mode_sensor, input.evcs_mode_fallback, input.evcs_mode] .map(v => (v ?? '').toString().trim()) .find(v => v && low(v) !== 'unknown' && low(v) !== 'unavailable') || 'unknown'; const evcsModeIsAuto = low(evcsModeRaw).includes('auto'); const evcsSetCurrentA = num(input.evcs_set_current_a, NaN); const evcsCommandCurrentA = num(input.evcs_command_current_a, NaN); const evcsCurrentA = Math.max(0, num(input.evcs_current_a, 0)); const evcsAcPowerW = Math.max(0, num(input.evcs_ac_power_w, 0)); const evcsChargingSwitchOn = low(input.evcs_charging_switch || 'off') === 'on'; const evcsAutoStartSwitchOn = low(input.evcs_auto_start_switch || 'off') === 'on'; const controllerEnabled = low(input.controller_enabled || 'off') === 'on'; const notificationsEnabled = low(input.notifications_enabled || 'off') === 'on'; const hardStopEnabledLegacy = low(input.hard_stop_enabled || 'off') === 'on'; const chargePermissionNow = low(input.charge_permission_now || 'off') === 'on'; const controlLatch = low(input.control_latch || 'off') === 'on'; const departurePriorityEnabled = low(input.departure_priority_enabled || 'off') === 'on'; const forecastModeEnabled = low(input.forecast_mode_enabled || 'off') === 'on'; const targetCurrentAutoA = clamp(num(input.target_current_auto_a, 6), 0, 32); const pvStartMinW = Math.max(0, num(input.pv_start_min_w, 1800)); const pvHoldMinW = Math.max(0, num(input.pv_hold_min_w, 1200)); const houseAcHeadroomW = Math.max(0, num(input.house_ac_headroom_w, 1200)); const batteryDischargeSoftA = num(input.battery_discharge_soft_a, -5); const batteryDischargeHardA = num(input.battery_discharge_hard_a, -15); const batteryReserveUpA = Math.max(0, num(input.battery_reserve_up_a, 8)); const batteryNeutralBandA = Math.max(0, num(input.battery_neutral_band_a, 3)); const socStartMin = clamp(num(input.soc_start_min, 60), 0, 100); const socStopMin = clamp(num(input.soc_stop_min, 35), 0, 100); const currentMinA = clamp(num(input.current_min_a, 6), 6, 32); const rawCurrentMaxAutoA = input.current_max_auto_a ?? null; const parsedCurrentMaxAutoA = num(rawCurrentMaxAutoA, 16); const currentMaxAutoA = clamp(parsedCurrentMaxAutoA, currentMinA, 32); const currentStepUpA = clamp(num(input.current_step_up_a, 1), 1, 5); const currentStepDownA = clamp(num(input.current_step_down_a, 2), 1, 8); const currentChangeCooldownSeconds = Math.max(5, num(input.current_change_cooldown_seconds, 60)); const minRunMinutes = Math.max(0, num(input.min_run_minutes, 3)); const minStopMinutes = Math.max(0, num(input.min_stop_minutes, 10)); const requiredEnergyKwh = Math.max(0, num(input.required_energy_kwh, 0)); const socComfortTarget = clamp(num(input.soc_comfort_target, 85), 0, 100); const endOfDaySocGuard = clamp(num(input.end_of_day_soc_guard, 85), 0, 100); const baseLoadRemainingKwh = Math.max(0, num(input.base_load_remaining_kwh, 0)); const flexibleSafetyMarginKwh = Math.max(0, num(input.flexible_safety_margin_kwh, 0)); const endOfDaySocTarget = clamp(num(input.end_of_day_soc_target, 85), 0, 100); const acLoadsMaxSafeW = Math.max(0, num(input.ac_loads_max_safe_w, 8500)); const batteryFullSoc = clamp(num(input.battery_full_soc, 90), 0, 100); const batteryFullForecastMinKwh = Math.max(0, num(input.battery_full_forecast_min_kwh, 8)); const forecastCommitSocFloor = clamp(num(input.forecast_commit_soc_floor, 35), 0, 100); const forecastCommitMinKwh = Math.max(0, num(input.forecast_commit_min_kwh, 25)); const cloudDebtPrevKwh = Math.max(0, num(input.cloud_debt_kwh, 0)); const cloudDebtMaxKwh = Math.max(0.05, num(input.cloud_debt_max_kwh, 1.2)); const cloudToleranceMinutes = Math.max(1, num(input.cloud_tolerance_minutes, 20)); const departureTimeMin = parseTimeToMinutes(input.departure_time, null); const dayStartTimeMin = parseTimeToMinutes(input.day_start_time, 7 * 60 + 30); const dayStopTimeMin = parseTimeToMinutes(input.day_stop_time, 20 * 60 + 30); // ============================================================================ // STRATÉGIE UTILISATEUR (V2.0 - NEW) // ============================================================================ const userStrategyRaw = input.victron_evcs_strategy || 'auto_solar'; const userStrategy = low(userStrategyRaw); // auto_solar | auto_solar_plus | force_grid_now | force_charge_now | stop // Mode Force (Ignore HC strict & PV minimaux) const isForceMode = (userStrategy === 'force_grid_now' || userStrategy === 'force_charge_now'); const isStopMode = (userStrategy === 'stop'); // Override for Force modes let effectivePvStartMinW = pvStartMinW; let effectiveAllowGridMidday = low(input.allow_grid_hc_midday || 'off') === 'on'; if (isForceMode) { effectivePvStartMinW = 0; effectiveAllowGridMidday = true; } // ============================================================================ // OPTIONAL V1.4 INPUTS // ============================================================================ const hcNightStartMin = parseTimeToMinutes(input.hc_night_start_time, 23 * 60); const hcNightStopMin = parseTimeToMinutes(input.hc_night_stop_time, 6 * 60); const morningStartMin = parseTimeToMinutes(input.morning_start_time, dayStartTimeMin); const morningStopMin = parseTimeToMinutes(input.morning_stop_time, 11 * 60); const hcMiddayStartMin = parseTimeToMinutes(input.hc_midday_start_time, 12 * 60 + 24); const hcMiddayStopMin = parseTimeToMinutes(input.hc_midday_stop_time, 14 * 60 + 24); const eveningStartMin = parseTimeToMinutes(input.evening_start_time, 17 * 60); const eveningStopMin = parseTimeToMinutes(input.evening_stop_time, dayStopTimeMin); const allowGridHcNight = low(input.allow_grid_hc_night || 'off') === 'on'; const allowGridHcMidday = effectiveAllowGridMidday; // Utilise le override si force mode const probeEnabled = low(input.probe_enabled || 'off') === 'on'; const probeMinutes = Math.max(1, num(input.probe_minutes, 3)); const probeRetryMinutes = Math.max(5, num(input.probe_retry_minutes, 45)); const probeCurrentA = clamp(num(input.probe_current_a, currentMinA), currentMinA, currentMaxAutoA); const gridBatterySoftA = num(input.grid_battery_soft_a, -3); const gridBatteryHardA = num(input.grid_battery_hard_a, -6); const morningProbePvAvgMinW = Math.max(0, num(input.morning_probe_pv_avg_min_w, 1500)); const morningProbePvTrendMinW = num(input.morning_probe_pv_trend_min_w, 0); const middayProbePvAvgMinW = Math.max(0, num(input.midday_probe_pv_avg_min_w, 1200)); const morningSocMin = clamp(num(input.morning_soc_min, 35), 0, 100); const middaySocMin = clamp(num(input.midday_soc_min, 75), 0, 100); const eveningSocStartMin = clamp(num(input.evening_soc_start_min, 85), 0, 100); const eveningSocStopMin = clamp(num(input.evening_soc_stop_min, 80), 0, 100); const morningBatteryMaxDischargeA = num(input.morning_battery_max_discharge_a, -20); const middayBatteryMaxDischargeA = num(input.midday_battery_max_discharge_a, -40); const eveningBatteryMaxDischargeA = num(input.evening_battery_max_discharge_a, -30); const lastBlockReason = (input.last_block_reason ?? '').toString().trim() || 'none'; const blockedUntilAt = (input.blocked_until ?? '').toString().trim(); const probeStartedAt = (input.probe_started_at ?? '').toString().trim(); const lastStartAt = (input.last_start_at ?? '').toString().trim(); const lastStopAt = (input.last_stop_at ?? '').toString().trim(); const lastCurrentChangeAt = (input.last_current_change_at ?? '').toString().trim(); const lastSignature = (input.last_signature ?? '').toString().trim(); const cloudDebtStartedAt = (input.cloud_debt_started_at ?? '').toString().trim(); const lastStartMs = parseParisStampToMs(lastStartAt); const lastStopMs = parseParisStampToMs(lastStopAt); const lastCurrentChangeMs = parseParisStampToMs(lastCurrentChangeAt); const blockedUntilMs = parseParisStampToMs(blockedUntilAt); const probeStartedMs = parseParisStampToMs(probeStartedAt); const cloudDebtStartedMsPrev = parseParisStampToMs(cloudDebtStartedAt); const minutesSinceStart = minutesSince(lastStartMs, nowMs); const minutesSinceStop = minutesSince(lastStopMs, nowMs); const secondsSinceCurrentChange = secondsSince(lastCurrentChangeMs, nowMs); const minutesSinceProbeStart = minutesSince(probeStartedMs, nowMs); const minRunElapsed = minutesSinceStart === null ? true : minutesSinceStart >= minRunMinutes; const minStopElapsed = minutesSinceStop === null ? true : minutesSinceStop >= minStopMinutes; const currentCooldownElapsed = secondsSinceCurrentChange === null ? true : secondsSinceCurrentChange >= currentChangeCooldownSeconds; const minutesUntilDeparture = departureTimeMin === null ? null : (() => { const rawDiff = departureTimeMin - now.minNow; return rawDiff >= 0 ? rawDiff : (1440 - now.minNow + departureTimeMin); })(); const departurePriorityActive = departurePriorityEnabled && Number.isFinite(minutesUntilDeparture) && minutesUntilDeparture >= 0 && minutesUntilDeparture <= CFG.departurePriorityWindowMinutes && evcsVehicleConnected; // ============================================================================ // VE.BUS INPUTS // ============================================================================ const vebusOverload = low(input.vebus_overload || 'off') === 'on'; const vebusL1Overload = low(input.vebus_l1_overload || 'off') === 'on'; const vebusLowBattery = low(input.vebus_low_battery || 'off') === 'on'; const vebusL1LowBattery = low(input.vebus_l1_low_battery || 'off') === 'on'; const vebusAcOutPowerW = Math.max(0, num(input.vebus_ac_out_l1_power_w, 0)); const vebusAcOutCurrentA = Math.max(0, num(input.vebus_ac_out_l1_current_a, 0)); const vebusAcOutVoltageV = num(input.vebus_ac_out_l1_voltage_v, CFG.nominalAcVoltageV); const vebusDcVoltageV = num(input.vebus_dc_voltage_v, NaN); const vebusDcCurrentA = num(input.vebus_dc_current_a, NaN); const vebusDcPowerW = num(input.vebus_dc_power_w, NaN); const vebusState = num(input.vebus_state, NaN); const vebusMainState = num(input.vebus_main_state, NaN); // ============================================================================ // DERIVED // ============================================================================ const acVoltageRef = Number.isFinite(vebusAcOutVoltageV) && vebusAcOutVoltageV > 180 ? vebusAcOutVoltageV : CFG.nominalAcVoltageV; const batteryVoltageRef = Number.isFinite(vebusDcVoltageV) && vebusDcVoltageV > 0 ? vebusDcVoltageV : 51.2; const houseNonEvcsW = Math.max(0, houseAcPowerW - evcsAcPowerW); const vebusNonEvcsW = Math.max(0, vebusAcOutPowerW - evcsAcPowerW); const totalVebusSoftCeilingW = Math.max(6500, acLoadsMaxSafeW || 8500); const totalVebusHardCeilingW = totalVebusSoftCeilingW + 400; const safeEvcsByDedicatedCapW = Math.max(0, CFG.evcsDedicatedMaxW - CFG.systemReserveW); const safeEvcsByVebusW = Math.max(0, totalVebusSoftCeilingW - vebusNonEvcsW - CFG.systemReserveW); const safeEvcsCombinedW = Math.max(0, Math.min(safeEvcsByDedicatedCapW, safeEvcsByVebusW)); const systemCapCurrentA = Math.max(0, Math.floor(safeEvcsCombinedW / acVoltageRef)); const physicalCapA = clamp(systemCapCurrentA, 0, currentMaxAutoA); const canRunAtMinCurrent = physicalCapA >= currentMinA; const endDayGuardSoc = Math.max(endOfDaySocGuard, endOfDaySocTarget); const energyNeededForEndDayGuardKwh = Math.max(0, ((endDayGuardSoc - soc) / 100) * CFG.batteryUsableCapacityKwh); const forecastBufferKwh = todayRemainingKwh - baseLoadRemainingKwh - flexibleSafetyMarginKwh - requiredEnergyKwh - energyNeededForEndDayGuardKwh; const projectedEndSocAfterCharge = clamp( soc + ( ( todayRemainingKwh - baseLoadRemainingKwh - flexibleSafetyMarginKwh - requiredEnergyKwh ) / CFG.batteryUsableCapacityKwh ) * 100, 0, 100 ); const currentReferenceA = clamp( Number.isFinite(evcsCommandCurrentA) ? evcsCommandCurrentA : Number.isFinite(evcsSetCurrentA) ? evcsSetCurrentA : targetCurrentAutoA, 0, 32 ); const anyVebusOverload = vebusOverload || vebusL1Overload; const anyVebusLowBattery = vebusLowBattery || vebusL1LowBattery; const vebusAcOutSoftReached = vebusAcOutPowerW >= totalVebusSoftCeilingW; const vebusAcOutHardReached = vebusAcOutPowerW >= totalVebusHardCeilingW; const vebusDcVoltageSoftLow = Number.isFinite(vebusDcVoltageV) && vebusDcVoltageV > 0 && vebusDcVoltageV <= CFG.vebusDcVoltageSoftFloorV; const vebusDcVoltageHardLow = Number.isFinite(vebusDcVoltageV) && vebusDcVoltageV > 0 && vebusDcVoltageV <= CFG.vebusDcVoltageHardFloorV; const houseLoadHighVsHeadroom = houseNonEvcsW > houseAcHeadroomW; // ============================================================================ // WINDOWS / PROFILE / SOURCE MODE (With Solar Potential Logic) // ============================================================================ const inNightHcWindow = isWithinWindow(now.minNow, hcNightStartMin, hcNightStopMin); const inMorningWindow = isWithinWindow(now.minNow, morningStartMin, morningStopMin); const inMiddayWindow = isWithinWindow(now.minNow, hcMiddayStartMin, hcMiddayStopMin); const inEveningWindow = isWithinWindow(now.minNow, eveningStartMin, eveningStopMin); const profile = inNightHcWindow ? 'night_hc' : inMorningWindow ? 'morning_solar' : inMiddayWindow ? 'midday_hc_or_solar' : inEveningWindow ? 'evening_high_soc' : 'outside_window'; const morningProbeEligible = profile === 'morning_solar' && probeEnabled && pvPowerAvg10mW >= morningProbePvAvgMinW && pvTrendW >= morningProbePvTrendMinW && soc >= morningSocMin && batteryCurrentA >= morningBatteryMaxDischargeA; const middayProbeEligible = profile === 'midday_hc_or_solar' && probeEnabled && pvPowerAvg10mW >= middayProbePvAvgMinW && soc >= middaySocMin && batteryCurrentA >= middayBatteryMaxDischargeA; // Solar Potential Condition (High Battery + Forecast) // IMPORTANT: // Ne pas utiliser cloudToleranceOk ici, car cloudToleranceOk est calculé plus bas // et dépend lui-même de sourceMode, qui dépend de isHighBatteryForPotential. // Sinon on crée une dépendance circulaire. const cloudDebtOkForPotential = cloudDebtPrevKwh <= cloudDebtMaxKwh; const batteryOkForPotential = batteryCurrentA >= -Math.max(1, batteryNeutralBandA); const isHighBatteryForPotential = soc >= 85 && forecastBufferKwh > 0 && cloudDebtOkForPotential && batteryOkForPotential; const potentialAllowed = userStrategy !== 'auto_solar'; // auto_solar_plus autorise aussi const isForcePotential = userStrategy === 'force_charge_now' || userStrategy === 'force_grid_now'; const strongSolarSignal = pvPowerW >= effectivePvStartMinW || pvPowerAvg10mW >= pvHoldMinW || (departurePriorityActive && pvPowerAvg10mW >= pvHoldMinW); let sourceMode = 'blocked'; if (isForcePotential) { sourceMode = 'grid_forced'; // Priorité à l'utilisateur } else if (profile === 'midday_hc_or_solar' || profile === 'morning_solar') { if (strongSolarSignal) { sourceMode = 'solar_only'; } else if (isHighBatteryForPotential && potentialAllowed) { sourceMode = 'solar_potential'; // Le nouveau comportement clé ! } else if (profile === 'midday_hc_or_solar' && middayProbeEligible) { sourceMode = 'hybrid_probe'; } else if (allowGridHcMidday && gridIsOn) { sourceMode = 'grid_hc_allowed'; } else { sourceMode = 'blocked'; // Fallback strict si PV bridé et pas de potentiel clair } } else if (profile === 'night_hc') { sourceMode = allowGridHcNight && gridIsOn ? 'grid_hc_allowed' : 'blocked'; } else if (profile === 'evening_high_soc') { sourceMode = strongSolarSignal ? 'solar_only' : 'blocked'; } else if (profile === 'outside_window' && userStrategy !== 'auto_solar') { // Nouvelle condition : autorise charge en dehors fenêtre si stratégie auto (pas strict auto_solar) if (strongSolarSignal) { sourceMode = 'solar_only'; } else if (isHighBatteryForPotential && potentialAllowed) { sourceMode = 'solar_potential'; } } const operatingMode = profile; // ============================================================================ // REASON-BASED LOCKS // ============================================================================ const nextWindowCandidates = [ nextDailyOccurrenceMs(morningStartMin, nowMs), nextDailyOccurrenceMs(hcMiddayStartMin, nowMs), nextDailyOccurrenceMs(eveningStartMin, nowMs), nextDailyOccurrenceMs(hcNightStartMin, nowMs), ].filter(v => Number.isFinite(v)); const nextAllowedWindowStartMs = nextWindowCandidates.length > 0 ? Math.min(...nextWindowCandidates) : null; const blockActive = blockedUntilMs !== null && blockedUntilMs > nowMs + 1000; const blockStillRelevant = lastBlockReason === 'outside_window' ? profile === 'outside_window' : lastBlockReason === 'probe_failed' ? (profile === 'morning_solar' || profile === 'midday_hc_or_solar') : lastBlockReason === 'battery_protection_stop' ? ( batteryCurrentA < -batteryNeutralBandA || anyVebusLowBattery || vebusDcVoltageSoftLow ) : lastBlockReason === 'grid_battery_guard' ? sourceMode === 'grid_hc_allowed' : false; const startBlockedByReason = blockActive && blockStillRelevant; // ============================================================================ // CLOUD DEBT // ============================================================================ const sessionObservedActive = evcsChargingSwitchOn || controlLatch || chargePermissionNow; const batteryNetPowerW = batteryCurrentA * batteryVoltageRef; let cloudDebtNextKwh = cloudDebtPrevKwh; if (sessionObservedActive) { if (batteryNetPowerW < 0 && sourceMode !== 'grid_hc_allowed') { cloudDebtNextKwh += (Math.abs(batteryNetPowerW) * CFG.sampleSeconds) / 3600000; } else if (batteryNetPowerW > 0) { cloudDebtNextKwh -= (batteryNetPowerW * CFG.sampleSeconds) / 3600000; } } else if (batteryNetPowerW > 0) { cloudDebtNextKwh -= (batteryNetPowerW * CFG.sampleSeconds) / 3600000; } cloudDebtNextKwh = clamp(cloudDebtNextKwh, 0, cloudDebtMaxKwh * 4); const cloudDebtMeaningful = cloudDebtNextKwh > CFG.cloudDebtMeaningfulThresholdKwh; const cloudEventDischargingNow = sessionObservedActive && sourceMode !== 'grid_hc_allowed' && batteryCurrentA < -Math.max(1, batteryNeutralBandA) && cloudDebtMeaningful; const continuingCloudEvent = cloudEventDischargingNow && cloudDebtStartedMsPrev !== null && cloudDebtPrevKwh > CFG.cloudDebtRecoveryThresholdKwh; const enteringCloudDebt = cloudEventDischargingNow && !continuingCloudEvent; const cloudDebtActive = cloudEventDischargingNow; const cloudDebtStartedMs = cloudEventDischargingNow ? (continuingCloudEvent ? cloudDebtStartedMsPrev : nowMs) : null; const cloudDebtDurationMinutes = cloudDebtActive && cloudDebtStartedMs !== null ? Math.max(0, (nowMs - cloudDebtStartedMs) / 60000) : 0; const cloudToleranceOk = cloudDebtNextKwh <= cloudDebtMaxKwh && ( !cloudDebtActive || cloudDebtDurationMinutes <= cloudToleranceMinutes ); // ============================================================================ // LEGACY SOLAR MODES KEPT // ============================================================================ const batteryFullHarvestMode = forecastModeEnabled && profile !== 'night_hc' && profile !== 'outside_window' && soc >= batteryFullSoc && todayRemainingKwh >= batteryFullForecastMinKwh && projectedEndSocAfterCharge >= (endDayGuardSoc - 2); const forecastCommitMode = forecastModeEnabled && profile !== 'night_hc' && profile !== 'outside_window' && soc >= forecastCommitSocFloor && todayRemainingKwh >= forecastCommitMinKwh && projectedEndSocAfterCharge >= endDayGuardSoc; const batteryPriorityMode = !batteryFullHarvestMode && !forecastCommitMode; // ============================================================================ // BASE CONDITIONS // ============================================================================ const commonSafetyStartOk = canRunAtMinCurrent && !anyVebusOverload && !anyVebusLowBattery && !vebusDcVoltageSoftLow && !vebusAcOutSoftReached && evcsDeviceConnected && evcsVehicleConnected && !evcsModeIsAuto; const commonSafetyHoldOk = canRunAtMinCurrent && !anyVebusOverload && !anyVebusLowBattery && !vebusDcVoltageHardLow && evcsDeviceConnected && evcsVehicleConnected && !evcsModeIsAuto; const profileSocStartFloor = profile === 'morning_solar' ? morningSocMin : profile === 'midday_hc_or_solar' ? middaySocMin : profile === 'evening_high_soc' ? eveningSocStartMin : socStartMin; const profileSocHoldFloor = profile === 'morning_solar' ? Math.max(socStopMin, morningSocMin - 10) : profile === 'midday_hc_or_solar' ? Math.max(socStopMin, middaySocMin - 15) : profile === 'evening_high_soc' ? eveningSocStopMin : socStopMin; const profileBatteryDischargeFloorA = profile === 'morning_solar' ? morningBatteryMaxDischargeA : profile === 'midday_hc_or_solar' ? middayBatteryMaxDischargeA : profile === 'evening_high_soc' ? eveningBatteryMaxDischargeA : batteryDischargeSoftA; const solarStartOk = sourceMode === 'solar_only' || sourceMode === 'solar_potential' && commonSafetyStartOk && soc >= profileSocStartFloor && batteryCurrentA >= profileBatteryDischargeFloorA && (sourceMode === 'solar_potential' || pvPowerW >= effectivePvStartMinW); const solarHoldOk = commonSafetyHoldOk && soc >= profileSocHoldFloor && batteryCurrentA >= profileBatteryDischargeFloorA && (pvPowerW >= pvHoldMinW || cloudToleranceOk); const gridStartOk = (sourceMode === 'grid_hc_allowed' || sourceMode === 'grid_forced') && commonSafetyStartOk && gridIsOn && batteryCurrentA >= gridBatterySoftA; const gridHoldOk = commonSafetyHoldOk && (sourceMode === 'grid_hc_allowed' || sourceMode === 'grid_forced') && gridIsOn && batteryCurrentA >= gridBatteryHardA; const probeStartOk = sourceMode === 'hybrid_probe' && commonSafetyStartOk && (morningProbeEligible || middayProbeEligible); const probeHoldGuardOk = commonSafetyHoldOk && batteryCurrentA >= profileBatteryDischargeFloorA; const probeWindowActive = sessionObservedActive && probeStartedMs !== null && minutesSinceProbeStart !== null && minutesSinceProbeStart < probeMinutes; const probeElapsedReached = sessionObservedActive && probeStartedMs !== null && minutesSinceProbeStart !== null && minutesSinceProbeStart >= probeMinutes; const probeSuccessNow = sessionObservedActive && probeStartedMs !== null && ( pvPowerW >= pvHoldMinW && batteryCurrentA >= Math.min(0, batteryDischargeSoftA) ); const probeFailedNow = probeElapsedReached && !probeSuccessNow; // ============================================================================ // STEP LOGIC // ============================================================================ const stepUpByBatteryReserve = batteryPriorityMode && batteryCurrentA >= batteryReserveUpA; const stepUpByForecastMode = !batteryPriorityMode && (pvPowerW >= pvHoldMinW || pvPowerAvg10mW >= pvHoldMinW) && forecastBufferKwh >= -1 && projectedEndSocAfterCharge >= (endDayGuardSoc - 5) && cloudToleranceOk; const solarShouldStepUp = currentCooldownElapsed && currentReferenceA < physicalCapA && physicalCapA >= currentMinA && !vebusAcOutSoftReached && !vebusDcVoltageSoftLow && !houseLoadHighVsHeadroom && (stepUpByBatteryReserve || stepUpByForecastMode || pvPowerW >= (effectivePvStartMinW + 400)); const gridShouldStepUp = currentCooldownElapsed && currentReferenceA < physicalCapA && physicalCapA >= currentMinA && !vebusAcOutSoftReached && !vebusDcVoltageSoftLow && batteryCurrentA >= gridBatterySoftA && !houseLoadHighVsHeadroom; const solarShouldStepDownBattery = batteryCurrentA <= batteryDischargeSoftA && currentCooldownElapsed && currentReferenceA > currentMinA; const solarShouldStepDownCloudDebt = !cloudToleranceOk && currentCooldownElapsed && currentReferenceA > currentMinA; const gridShouldStepDownBattery = batteryCurrentA < gridBatterySoftA && currentCooldownElapsed && currentReferenceA > currentMinA; const shouldStepDownVebusPower = vebusAcOutSoftReached && currentCooldownElapsed && currentReferenceA > currentMinA; const shouldStepDownVebusVoltage = vebusDcVoltageSoftLow && currentCooldownElapsed && currentReferenceA > currentMinA; const batteryCurrentHardExceeded = batteryCurrentA <= batteryDischargeHardA; // ============================================================================ // HARD STOPS // ============================================================================ const mustStopHard = anyVebusOverload || anyVebusLowBattery || vebusDcVoltageHardLow || vebusAcOutHardReached || batteryCurrentHardExceeded; const gridBatteryProtectionStop = sessionObservedActive && sourceMode === 'grid_hc_allowed' && batteryCurrentA < gridBatteryHardA; // ============================================================================ // DECISION INIT // ============================================================================ let desiredCharging = false; let desiredPermission = false; let desiredLatch = false; let desiredCurrentA = currentMinA; let state = 'idle'; let currentDecision = 'hold'; let dynamicOk = false; let mustStop = false; let reason = []; let setLastStartAt = false; let setLastStopAt = false; let setLastCurrentChangeAt = false; let setCloudDebtStartedAt = false; let setProbeStartedAt = false; let notifyWanted = false; let notifyTitle = 'Victron EVCS Surplus'; let notifyMessage = ''; let nextBlockReason = 'none'; let nextBlockedUntilStamp = now.stamp; // ============================================================================ // DECISION TREE (WITH STRATEGY LAYERS) // ============================================================================ if (!controllerEnabled) { desiredCharging = evcsChargingSwitchOn; desiredPermission = chargePermissionNow; desiredLatch = controlLatch; desiredCurrentA = clamp(currentReferenceA || currentMinA, currentMinA, currentMaxAutoA); state = 'controller_disabled'; reason.push("Contrôleur désactivé"); } else if (isStopMode) { desiredCharging = false; desiredPermission = false; desiredLatch = false; desiredCurrentA = currentMinA; state = 'stopped_user'; currentDecision = 'stop_stop_mode'; dynamicOk = false; mustStop = false; nextBlockReason = 'manual_stop'; nextBlockedUntilStamp = now.stamp; reason.push("Arrêt manuel via Stratégie STOP"); } else if (evcsModeIsAuto) { desiredCharging = false; desiredPermission = false; desiredLatch = false; desiredCurrentA = currentMinA; state = 'mode_auto_conflict'; currentDecision = 'stop_auto_mode_conflict'; dynamicOk = false; mustStop = true; reason.push("Borne en mode Auto interne"); } else if (!evcsDeviceConnected || !evcsVehicleConnected) { desiredCharging = false; desiredPermission = false; desiredLatch = false; desiredCurrentA = currentMinA; state = 'vehicle_not_connected'; currentDecision = 'stop_not_connected'; dynamicOk = false; mustStop = false; nextBlockReason = 'vehicle_not_connected'; nextBlockedUntilStamp = now.stamp; reason.push("Véhicule ou borne non connecté"); } else if (mustStopHard) { desiredCharging = false; desiredPermission = false; desiredLatch = false; desiredCurrentA = currentMinA; state = anyVebusOverload ? 'vebus_overload_hard_stop' : anyVebusLowBattery ? 'vebus_low_battery_hard_stop' : vebusDcVoltageHardLow ? 'vebus_dc_voltage_hard_stop' : vebusAcOutHardReached ? 'vebus_ac_hard_stop' : batteryCurrentHardExceeded ? 'battery_current_hard_stop' : 'hard_stop'; currentDecision = 'stop_hard'; dynamicOk = false; mustStop = true; nextBlockReason = 'battery_protection_stop'; nextBlockedUntilStamp = formatParisStampFromMs(nowMs + 15 * 60000); reason.push("Hard stop de protection"); } else if (sessionObservedActive) { if (enteringCloudDebt) { setCloudDebtStartedAt = true; } if (gridBatteryProtectionStop) { desiredCharging = false; desiredPermission = false; desiredLatch = false; desiredCurrentA = currentMinA; state = 'stop_grid_battery_guard'; currentDecision = 'stop'; dynamicOk = false; mustStop = true; nextBlockReason = 'grid_battery_guard'; nextBlockedUntilStamp = formatParisStampFromMs(nowMs + 20 * 60000); reason.push(`Arrêt HC réseau: batterie trop sollicitée (${round1(batteryCurrentA)}A)`); } else if (probeFailedNow) { desiredCharging = false; desiredPermission = false; desiredLatch = false; desiredCurrentA = currentMinA; state = 'probe_failed_lock'; currentDecision = 'stop'; dynamicOk = false; mustStop = true; nextBlockReason = 'probe_failed'; nextBlockedUntilStamp = formatParisStampFromMs(nowMs + probeRetryMinutes * 60000); reason.push("Mode sonde échoué"); } else if (physicalCapA < currentMinA) { if (minRunElapsed) { desiredCharging = false; desiredPermission = false; desiredLatch = false; desiredCurrentA = currentMinA; state = 'stop_power_cap'; currentDecision = 'stop_power_cap'; dynamicOk = false; mustStop = true; nextBlockReason = 'battery_protection_stop'; nextBlockedUntilStamp = formatParisStampFromMs(nowMs + 10 * 60000); reason.push("Capacité système insuffisante"); } else { desiredCharging = true; desiredPermission = true; desiredLatch = true; desiredCurrentA = currentMinA; state = 'hold_min_run_power_cap'; currentDecision = 'hold'; dynamicOk = false; mustStop = false; reason.push("Maintien jusqu'au temps mini ON"); } } else if (probeWindowActive && !probeSuccessNow) { if (!probeHoldGuardOk) { desiredCharging = false; desiredPermission = false; desiredLatch = false; desiredCurrentA = currentMinA; state = 'probe_stop_guard'; currentDecision = 'stop'; dynamicOk = false; mustStop = true; nextBlockReason = 'probe_failed'; nextBlockedUntilStamp = formatParisStampFromMs(nowMs + probeRetryMinutes * 60000); reason.push("Sonde interrompue: garde-fous non respectés"); } else { desiredCharging = true; desiredPermission = true; desiredLatch = true; desiredCurrentA = clamp(probeCurrentA, currentMinA, physicalCapA); state = 'probe_hold'; currentDecision = 'hold'; dynamicOk = true; mustStop = false; reason.push(`Mode sonde actif à ${round1(desiredCurrentA)}A`); } } else { const activeSourceMode = probeSuccessNow ? 'solar_only' : sourceMode; const holdConditionsOk = activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced' ? gridHoldOk : // Ajout ici aussi activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential' ? solarHoldOk : probeHoldGuardOk; if (activeSourceMode === 'blocked') { if (minRunElapsed) { desiredCharging = false; desiredPermission = false; desiredLatch = false; desiredCurrentA = currentMinA; state = 'stop_source_lost'; currentDecision = 'stop'; dynamicOk = false; mustStop = true; nextBlockReason = profile === 'outside_window' ? 'outside_window' : 'source_unavailable'; nextBlockedUntilStamp = profile === 'outside_window' && nextAllowedWindowStartMs !== null ? formatParisStampFromMs(nextAllowedWindowStartMs) : formatParisStampFromMs(nowMs + minStopMinutes * 60000); reason.push("Source autorisée perdue"); } else { desiredCharging = true; desiredPermission = true; desiredLatch = true; desiredCurrentA = clamp(Math.min(currentReferenceA, physicalCapA), currentMinA, physicalCapA); state = 'hold_min_run_source_lost'; currentDecision = 'hold'; dynamicOk = false; mustStop = false; reason.push("Source perdue mais maintien jusqu'au mini ON"); } } else if (!holdConditionsOk) { if (minRunElapsed) { desiredCharging = false; desiredPermission = false; desiredLatch = false; desiredCurrentA = currentMinA; state = 'stop_conditions_lost'; currentDecision = 'stop'; dynamicOk = false; mustStop = true; nextBlockReason = 'battery_protection_stop'; nextBlockedUntilStamp = formatParisStampFromMs(nowMs + 10 * 60000); reason.push("Conditions de maintien perdues"); } else { desiredCharging = true; desiredPermission = true; desiredLatch = true; desiredCurrentA = clamp(Math.min(currentReferenceA, physicalCapA), currentMinA, physicalCapA); state = 'hold_min_run'; currentDecision = 'hold'; dynamicOk = true; mustStop = false; reason.push("Maintien jusqu'au temps mini ON"); } } else if ((currentReferenceA > physicalCapA) && currentCooldownElapsed) { desiredCharging = true; desiredPermission = true; desiredLatch = true; desiredCurrentA = Math.max( currentMinA, Math.min(currentReferenceA - Math.max(currentStepDownA, 2), physicalCapA) ); state = 'charging_step_down_system_cap'; currentDecision = 'step_down'; dynamicOk = true; reason.push("Réduction pour respecter la capacité système"); } else if (shouldStepDownVebusPower) { desiredCharging = true; desiredPermission = true; desiredLatch = true; desiredCurrentA = Math.max( currentMinA, Math.min(currentReferenceA - Math.max(currentStepDownA + 1, 3), physicalCapA) ); state = 'charging_step_down_vebus_power'; currentDecision = 'step_down_fast'; dynamicOk = true; reason.push("Baisse rapide: puissance VE.Bus élevée"); } else if (shouldStepDownVebusVoltage) { desiredCharging = true; desiredPermission = true; desiredLatch = true; desiredCurrentA = Math.max( currentMinA, Math.min(currentReferenceA - Math.max(currentStepDownA + 1, 3), physicalCapA) ); state = 'charging_step_down_dc_voltage'; currentDecision = 'step_down_fast'; dynamicOk = true; reason.push("Baisse rapide: tension DC basse"); } else if (activeSourceMode === 'grid_hc_allowed' && gridShouldStepDownBattery) { desiredCharging = true; desiredPermission = true; desiredLatch = true; desiredCurrentA = Math.max( currentMinA, Math.min(currentReferenceA - currentStepDownA, physicalCapA) ); state = 'charging_step_down_grid_guard'; currentDecision = 'step_down'; dynamicOk = true; reason.push("Réduction HC: la batterie commence à participer"); } else if (activeSourceMode === 'solar_only' && solarShouldStepDownCloudDebt) { desiredCharging = true; desiredPermission = true; desiredLatch = true; desiredCurrentA = Math.max( currentMinA, Math.min(currentReferenceA - Math.max(currentStepDownA, 2), physicalCapA) ); state = 'charging_step_down_cloud_debt'; currentDecision = 'step_down'; dynamicOk = true; reason.push("Réduction: dette nuage trop élevée"); } else if (activeSourceMode === 'solar_only' && solarShouldStepDownBattery) { desiredCharging = true; desiredPermission = true; desiredLatch = true; desiredCurrentA = Math.max( currentMinA, Math.min(currentReferenceA - currentStepDownA, physicalCapA) ); state = 'charging_step_down_battery'; currentDecision = 'step_down'; dynamicOk = true; reason.push("Réduction: batterie trop sollicitée"); } else if (activeSourceMode === 'grid_hc_allowed' && gridShouldStepUp) { desiredCharging = true; desiredPermission = true; desiredLatch = true; desiredCurrentA = clamp(currentReferenceA + currentStepUpA, currentMinA, physicalCapA); state = 'charging_step_up_grid_hc'; currentDecision = 'step_up'; dynamicOk = true; reason.push("Hausse prudente du courant en HC réseau"); } else if (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') { // Potentiel autorise step up aussi desiredCharging = true; desiredPermission = true; desiredLatch = true; desiredCurrentA = clamp(currentReferenceA + currentStepUpA, currentMinA, physicalCapA); state = probeSuccessNow ? 'probe_success_step_up' : (batteryPriorityMode ? 'charging_step_up_battery' : 'charging_step_up_forecast'); currentDecision = 'step_up'; dynamicOk = true; reason.push("Hausse prudente du courant"); } else { desiredCharging = true; desiredPermission = true; desiredLatch = true; desiredCurrentA = clamp(Math.min(currentReferenceA, physicalCapA), currentMinA, physicalCapA); state = probeSuccessNow ? 'probe_success_hold' : activeSourceMode === 'grid_hc_allowed' ? 'charging_hold_grid_hc' : cloudDebtActive ? 'charging_hold_cloud_tolerance' : 'charging_hold'; currentDecision = 'hold'; dynamicOk = true; mustStop = false; reason.push("Maintien du courant"); } } } else { const startConditionsOk = (sourceMode === 'grid_hc_allowed' || sourceMode === 'grid_forced') ? gridStartOk : // Ajout de 'grid_forced' sourceMode === 'solar_only' || sourceMode === 'solar_potential' ? solarStartOk : // Potentiel autorise démarrage aussi sourceMode === 'hybrid_probe' ? probeStartOk : false; if (profile === 'outside_window') { // outside_window n'est plus un mur absolu en mode force ou avec potentiel if (sourceMode === 'solar_potential' || isForceMode) { // Continue à chercher startConditionsOk... } else { desiredCharging = false; desiredPermission = false; desiredLatch = false; desiredCurrentA = currentMinA; state = 'blocked_outside_window'; currentDecision = 'hold'; dynamicOk = false; mustStop = false; nextBlockReason = 'outside_window'; nextBlockedUntilStamp = nextAllowedWindowStartMs !== null ? formatParisStampFromMs(nextAllowedWindowStartMs) : now.stamp; reason.push("Hors fenêtre autorisée (pas de potentiel confirmé ni forcé)"); } } else if (!canRunAtMinCurrent) { desiredCharging = false; desiredPermission = false; desiredLatch = false; desiredCurrentA = currentMinA; state = 'blocked_by_capacity_min_current'; currentDecision = 'hold'; dynamicOk = false; mustStop = false; nextBlockReason = 'battery_protection_stop'; nextBlockedUntilStamp = formatParisStampFromMs(nowMs + 10 * 60000); reason.push("Capacité insuffisante pour courant mini"); } else if (sourceMode === 'blocked') { desiredCharging = false; desiredPermission = false; desiredLatch = false; desiredCurrentA = currentMinA; state = profile === 'night_hc' ? 'night_no_grid_hc_lock' : 'idle_wait_source'; currentDecision = 'hold'; dynamicOk = false; mustStop = false; nextBlockReason = profile === 'night_hc' ? 'outside_window' : 'source_unavailable'; nextBlockedUntilStamp = profile === 'night_hc' && nextAllowedWindowStartMs !== null ? formatParisStampFromMs(nextAllowedWindowStartMs) : formatParisStampFromMs(nowMs + 15 * 60000); reason.push(profile === 'night_hc' ? "Nuit sans autorisation HC réseau" : "Source non autorisée ou insuffisante"); } else if (startBlockedByReason) { desiredCharging = false; desiredPermission = false; desiredLatch = false; desiredCurrentA = currentMinA; state = 'wait_reason_lock'; currentDecision = 'hold'; dynamicOk = false; mustStop = false; nextBlockReason = lastBlockReason; nextBlockedUntilStamp = blockedUntilAt || now.stamp; reason.push(`Blocage anti-cliquetis: ${lastBlockReason}`); } else if (!startConditionsOk) { desiredCharging = false; desiredPermission = false; desiredLatch = false; desiredCurrentA = currentMinA; state = 'idle_wait_conditions'; currentDecision = 'hold'; dynamicOk = false; mustStop = false; reason.push("Conditions de démarrage non réunies"); } else if (!minStopElapsed) { desiredCharging = false; desiredPermission = false; desiredLatch = false; desiredCurrentA = currentMinA; state = 'wait_min_stop'; currentDecision = 'hold'; dynamicOk = false; mustStop = false; reason.push("Temps mini OFF non écoulé"); } else { desiredCharging = true; desiredPermission = true; desiredLatch = true; desiredCurrentA = sourceMode === 'hybrid_probe' ? clamp(probeCurrentA, currentMinA, physicalCapA) : currentMinA; state = sourceMode === 'grid_hc_allowed' ? 'start_grid_hc_min_current' : sourceMode === 'hybrid_probe' ? 'probe_start_6a' : sourceMode === 'solar_potential' ? 'start_forecast_solar_potential' : // NOUVEAU STATE batteryFullHarvestMode ? 'start_min_current_battery_full_harvest' : forecastCommitMode ? 'start_min_current_forecast_commit' : 'start_min_current'; currentDecision = 'start'; dynamicOk = true; mustStop = false; nextBlockReason = 'none'; // Clean blocking logic: set reason to none on start nextBlockedUntilStamp = now.stamp; setProbeStartedAt = sourceMode === 'hybrid_probe'; reason.push( sourceMode === 'grid_hc_allowed' ? "Démarrage en fenêtre HC réseau" : sourceMode === 'hybrid_probe' ? "Démarrage en mode sonde" : sourceMode === 'solar_potential' ? "Démarrage par potentiel solaire confirmé (SOC>=85%)" // NOUVEAU REASON TEXT : batteryFullHarvestMode ? "Démarrage harvest solaire" : forecastCommitMode ? "Démarrage forecast commit" : "Démarrage prudent à courant mini" ); } } // ============================================================================ // FINAL ACTIONS & BLOQUAGE PROPRE // ============================================================================ desiredCurrentA = desiredCharging ? clamp(desiredCurrentA, currentMinA, Math.max(currentMinA, physicalCapA || currentMinA)) : currentMinA; const chargingAction = desiredCharging === evcsChargingSwitchOn ? 'none' : desiredCharging ? 'turn_on' : 'turn_off'; const permissionAction = desiredPermission === chargePermissionNow ? 'none' : desiredPermission ? 'turn_on' : 'turn_off'; const latchAction = desiredLatch === controlLatch ? 'none' : desiredLatch ? 'turn_on' : 'turn_off'; const autoStartAction = controllerEnabled && evcsVehicleConnected && evcsAutoStartSwitchOn ? 'turn_off' : 'none'; const currentAction = desiredCharging && ( !Number.isFinite(currentReferenceA) || Math.abs(desiredCurrentA - currentReferenceA) >= 0.5 ) ? 'set' : 'none'; setLastStartAt = chargingAction === 'turn_on'; setLastStopAt = chargingAction === 'turn_off'; setLastCurrentChangeAt = currentAction === 'set'; // === BLOQUAGE PROPRE (Clean Blocking Logic) === const needsBlockReasonUpdate = (nextBlockReason !== 'none'); // On ne met à jour blocked_until que s'il y a une vraie raison de blocage, pas toujours. // La logique précédente forçait le set_blocked_until: true partout // ============================================================================ // REASONS / SIGNATURE // ============================================================================ const strategyLabel = isForceMode ? userStrategy : (userStrategy === 'auto_solar_plus' ? 'auto_plus' : 'auto'); reason.push(`Stratégie=${strategyLabel}`); reason.push(`Profil=${profile}`); reason.push(`Source=${sourceMode}`); reason.push(`Mode=${batteryFullHarvestMode ? 'battery_full_harvest_mode' : forecastCommitMode ? 'forecast_commit_mode' : 'battery_priority_mode'}`); reason.push(`SOC=${round1(soc)}%`); reason.push(`Batt=${round1(batteryCurrentA)}A`); reason.push(`PV=${Math.round(pvPowerW)}W`); reason.push(`PV10=${Math.round(pvPowerAvg10mW)}W`); reason.push(`Grid=${gridIsOn ? 'on' : 'off'} (${Math.round(gridPowerW)}W)`); reason.push(`Cap=${physicalCapA}A`); reason.push(`Ref=${round1(currentReferenceA)}A`); reason.push(`Target=${round1(desiredCurrentA)}A`); reason.push(`Debt=${round2(cloudDebtNextKwh)}kWh`); reason.push(`Vehicle=${evcsVehicleConnected ? 'on' : 'off'}`); reason.push(`EVCS mode=${evcsModeRaw}`); reason.push(`VE.Bus AC=${Math.round(vebusAcOutPowerW)}W`); reason.push(`VE.Bus DC=${Number.isFinite(vebusDcVoltageV) ? round1(vebusDcVoltageV) : 'n/a'}V`); const shortReason = reason.join(' | ').slice(0, 255); const sourceModeLabel = sourceMode === 'blocked' ? 'Bloqué' : sourceMode === 'solar_only' ? 'Solaire' : sourceMode === 'solar_potential' ? 'Potentiel solaire' : sourceMode === 'hybrid_probe' ? 'Probe hybride' : sourceMode === 'grid_hc_allowed' ? 'Réseau HC autorisé' : sourceMode === 'grid_forced' ? 'Réseau forcé' : sourceMode; const signature = [ state, `profile=${profile}`, `source=${sourceMode}`, `chg=${desiredCharging ? '1' : '0'}`, `perm=${desiredPermission ? '1' : '0'}`, `l=${desiredLatch ? '1' : '0'}`, `cur=${round1(desiredCurrentA)}`, `block=${nextBlockReason}`, `stop=${mustStop ? '1' : '0'}` ].join('|'); const signatureChanged = signature !== lastSignature; // ============================================================================ // NOTIFICATIONS // ============================================================================ if (notificationsEnabled && signatureChanged) { if (currentDecision === 'start') { notifyWanted = true; notifyTitle = '⚡ EVCS démarrage'; notifyMessage = shortReason; } else if (currentDecision === 'step_up') { notifyWanted = true; notifyTitle = '⬆️ EVCS hausse courant'; notifyMessage = shortReason; } else if (currentDecision === 'step_down' || currentDecision === 'step_down_fast') { notifyWanted = true; notifyTitle = '⬇️ EVCS baisse courant'; notifyMessage = shortReason; } else if (mustStop || currentDecision === 'stop' || currentDecision === 'stop_hard' || currentDecision === 'stop_power_cap') { notifyWanted = true; notifyTitle = '⛔ EVCS arrêt'; notifyMessage = shortReason; } else if (state === 'mode_auto_conflict') { notifyWanted = true; notifyTitle = '⚠️ EVCS mode Auto'; notifyMessage = shortReason; } } // ============================================================================ // OUTPUT // ================================================================ return [{ json: { timestamp_paris: now.stamp, inputs: input, computed: { operating_mode: operatingMode, profile, source_mode: sourceMode, in_night_hc_window: inNightHcWindow, in_morning_window: inMorningWindow, in_midday_window: inMiddayWindow, in_evening_window: inEveningWindow, departure_priority_active: departurePriorityActive, minutes_until_departure: minutesUntilDeparture, battery_priority_mode: batteryPriorityMode, battery_full_harvest_mode: batteryFullHarvestMode, forecast_commit_mode: forecastCommitMode, physical_cap_current_a: physicalCapA, can_run_at_min_current: canRunAtMinCurrent, forecast_buffer_kwh: round2(forecastBufferKwh), projected_end_soc_after_charge: round1(projectedEndSocAfterCharge), cloud_debt_next_kwh: round2(cloudDebtNextKwh), cloud_debt_duration_minutes: round1(cloudDebtDurationMinutes), cloud_debt_active: cloudDebtActive, cloud_tolerance_ok: cloudToleranceOk, start_blocked_by_reason: startBlockedByReason, probe_window_active: probeWindowActive, probe_success_now: probeSuccessNow, probe_failed_now: probeFailedNow, any_vebus_overload: anyVebusOverload, any_vebus_low_battery: anyVebusLowBattery, vebus_ac_out_soft_reached: vebusAcOutSoftReached, vebus_ac_out_hard_reached: vebusAcOutHardReached, vebus_dc_voltage_soft_low: vebusDcVoltageSoftLow, vebus_dc_voltage_hard_low: vebusDcVoltageHardLow, }, decision: { state, current_decision: currentDecision, desired_charging: desiredCharging, desired_permission: desiredPermission, desired_latch: desiredLatch, desired_current_a: round1(desiredCurrentA), charging_action: chargingAction, permission_action: permissionAction, latch_action: latchAction, current_action: currentAction, auto_start_action: autoStartAction, set_last_start_at: setLastStartAt, set_last_stop_at: setLastStopAt, set_last_current_change_at: setLastCurrentChangeAt, set_cloud_debt_started_at: setCloudDebtStartedAt, set_probe_started_at: setProbeStartedAt, // MODIFIÉ: Ne met à jour que si besoin réel set_block_reason: needsBlockReasonUpdate || !controllerEnabled, block_reason: nextBlockReason, // FORÇAGE : Mettre à jour systématiquement pour éviter les timestamps passés set_blocked_until: true, // Si blocage actif = heure futur. Si pas de blocage = Maintenant (pour effacer l'ancien). blocked_until: nextBlockReason === 'none' ? now.stamp : nextBlockedUntilStamp, dynamic_ok: dynamicOk, must_stop: mustStop, short_reason: shortReason, reason, signature, signature_changed: signatureChanged, should_notify: notifyWanted, notify_title: notifyTitle, notify_message: notifyMessage, }, ha_service_calls: { helper_state: { domain: 'input_text', service: 'set_value', data: { entity_id: 'input_text.victron_evcs_surplus_state', value: state } }, helper_current_decision: { domain: 'input_text', service: 'set_value', data: { entity_id: 'input_text.victron_evcs_surplus_current_decision', value: currentDecision } }, helper_reason: { domain: 'input_text', service: 'set_value', data: { entity_id: 'input_text.victron_evcs_surplus_reason', value: shortReason } }, helper_operating_mode: { domain: 'input_text', service: 'set_value', data: { entity_id: 'input_text.victron_evcs_operating_mode', value: operatingMode } }, helper_target_current: { domain: 'input_number', service: 'set_value', data: { entity_id: 'input_number.victron_evcs_target_current_auto', value: round1(desiredCurrentA) } }, helper_budget_remaining: { domain: 'input_number', service: 'set_value', data: { entity_id: 'input_number.victron_evcs_budget_remaining_kwh', value: round2(forecastBufferKwh) } }, helper_projected_soc: { domain: 'input_number', service: 'set_value', data: { entity_id: 'input_number.victron_evcs_projected_end_soc_after_charge', value: round1(projectedEndSocAfterCharge) } }, helper_cloud_debt: { domain: 'input_number', service: 'set_value', data: { entity_id: 'input_number.victron_evcs_cloud_debt_kwh', value: round2(cloudDebtNextKwh) } }, helper_dynamic_ok: { domain: 'input_boolean', service: dynamicOk ? 'turn_on' : 'turn_off', data: { entity_id: 'input_boolean.victron_evcs_dynamic_ok' } }, helper_must_stop: { domain: 'input_boolean', service: mustStop ? 'turn_on' : 'turn_off', data: { entity_id: 'input_boolean.victron_evcs_must_stop' } }, helper_cloud_debt_started_at: { domain: 'input_datetime', service: 'set_datetime', data: { entity_id: 'input_datetime.victron_evcs_cloud_debt_started_at', datetime: cloudDebtStartedMs ? formatParisStampFromMs(cloudDebtStartedMs) : now.stamp } }, helper_last_current_change_at: { domain: 'input_datetime', service: 'set_datetime', data: { entity_id: 'input_datetime.victron_evcs_last_current_change_at', datetime: now.stamp } }, helper_last_start_at: { domain: 'input_datetime', service: 'set_datetime', data: { entity_id: 'input_datetime.victron_evcs_last_start_at', datetime: now.stamp } }, helper_last_stop_at: { domain: 'input_datetime', service: 'set_datetime', data: { entity_id: 'input_datetime.victron_evcs_last_stop_at', datetime: now.stamp } }, helper_signature: { domain: 'input_text', service: 'set_value', data: { entity_id: 'input_text.victron_evcs_control_last_signature', value: signature } }, helper_block_reason: { domain: 'input_text', service: 'set_value', data: { entity_id: 'input_text.victron_evcs_last_block_reason', value: nextBlockReason } }, helper_blocked_until: { domain: 'input_datetime', service: 'set_datetime', data: { entity_id: 'input_datetime.victron_evcs_blocked_until', datetime: nextBlockedUntilStamp } }, helper_probe_started_at: { domain: 'input_datetime', service: 'set_datetime', data: { entity_id: 'input_datetime.victron_evcs_probe_started_at', datetime: now.stamp } }, evcs_auto_start_off: { domain: 'switch', service: 'turn_off', data: { entity_id: 'switch.victron_evcs_auto_start' } }, evcs_charging: { domain: 'switch', service: desiredCharging ? 'turn_on' : 'turn_off', data: { entity_id: 'switch.victron_evcs_charging' } }, helper_permission: { domain: 'input_boolean', service: desiredPermission ? 'turn_on' : 'turn_off', data: { entity_id: 'input_boolean.victron_evcs_charge_permission_now' } }, helper_latch: { domain: 'input_boolean', service: desiredLatch ? 'turn_on' : 'turn_off', data: { entity_id: 'input_boolean.victron_evcs_control_latch' } }, helper_source_mode: { execute: true, domain: 'input_text', service: 'set_value', data: { entity_id: 'input_text.victron_evcs_source_mode', value: sourceModeLabel } }, evcs_set_current: { domain: 'number', service: 'set_value', data: { entity_id: 'number.victron_evcs_set_current_command', value: round1(desiredCurrentA) } }, logbook: { domain: 'logbook', service: 'log', data: { name: 'Victron EVCS Surplus', message: shortReason } }, mobile_notification: { domain: CFG.mobileNotifyDomain, service: CFG.mobileNotifyService, data: { title: notifyTitle, message: notifyMessage || shortReason } } } } }];