From e2733b809bd2d0fe4b8e8c6ff1a9fe8ab1d3114e Mon Sep 17 00:00:00 2001 From: Thierry Tallagrand Date: Sun, 12 Apr 2026 03:01:51 +0200 Subject: [PATCH] avant modif --- build.js | 99 +++ helperv2.txt | 8 + legacy.txt | 5 + snapshot.js | 138 ++++ surplus.js | 1829 ++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 2079 insertions(+) create mode 100644 build.js create mode 100644 helperv2.txt create mode 100644 legacy.txt create mode 100644 snapshot.js create mode 100644 surplus.js diff --git a/build.js b/build.js new file mode 100644 index 0000000..e1fdcda --- /dev/null +++ b/build.js @@ -0,0 +1,99 @@ +const root = $input.first().json || {}; +const decision = root.decision || {}; +const calls = root.ha_service_calls || {}; +const actions = []; + +function pushAction(action, call) { + if (!call) return; + actions.push({ + json: { + action, + ...call + } + }); +} + +// Helpers toujours mis à jour +pushAction('helper_state', calls.helper_state); +pushAction('helper_current_decision', calls.helper_current_decision); +pushAction('helper_reason', calls.helper_reason); +pushAction('helper_operating_mode', calls.helper_operating_mode); +pushAction('helper_target_current', calls.helper_target_current); +pushAction('helper_budget_remaining', calls.helper_budget_remaining); +pushAction('helper_projected_soc', calls.helper_projected_soc); +pushAction('helper_cloud_debt', calls.helper_cloud_debt); +pushAction('helper_dynamic_ok', calls.helper_dynamic_ok); +pushAction('helper_must_stop', calls.helper_must_stop); + +// Helpers V1.4 additionnels +if (decision.set_cloud_debt_started_at) { + pushAction('helper_cloud_debt_started_at', calls.helper_cloud_debt_started_at); +} + +if (decision.set_block_reason) { + pushAction('helper_block_reason', calls.helper_block_reason); +} + +if (decision.set_blocked_until) { + pushAction('helper_blocked_until', calls.helper_blocked_until); +} + +if (decision.set_probe_started_at) { + pushAction('helper_probe_started_at', calls.helper_probe_started_at); +} + +// Forcer Auto Start OFF si besoin +if (decision.auto_start_action !== 'none') { + pushAction('evcs_auto_start_off', calls.evcs_auto_start_off); +} + +// Si on arrête : charging OFF d'abord +if (decision.charging_action === 'turn_off') { + pushAction('evcs_charging', calls.evcs_charging); +} + +// Permission / latch +if (decision.permission_action !== 'none') { + pushAction('helper_permission', calls.helper_permission); +} + +if (decision.latch_action !== 'none') { + pushAction('helper_latch', calls.helper_latch); +} + +// Courant si nécessaire +if (decision.current_action === 'set') { + pushAction('evcs_set_current', calls.evcs_set_current); + + if (decision.set_last_current_change_at) { + pushAction('helper_last_current_change_at', calls.helper_last_current_change_at); + } +} + +// Si on démarre : charging ON à la fin +if (decision.charging_action === 'turn_on') { + pushAction('evcs_charging', calls.evcs_charging); +} + +// Timestamps start / stop +if (decision.set_last_start_at) { + pushAction('helper_last_start_at', calls.helper_last_start_at); +} + +if (decision.set_last_stop_at) { + pushAction('helper_last_stop_at', calls.helper_last_stop_at); +} + +// Signature + logbook si changement +if (decision.signature_changed) { + pushAction('helper_signature', calls.helper_signature); + pushAction('logbook', calls.logbook); +} + +// Notifications +if (decision.should_notify) { + pushAction('mobile_notification', calls.mobile_notification); +} + +return actions; + diff --git a/helperv2.txt b/helperv2.txt new file mode 100644 index 0000000..c41f360 --- /dev/null +++ b/helperv2.txt @@ -0,0 +1,8 @@ +input_text.victron_evcs_surplus_state +input_text.victron_evcs_surplus_reason +input_text.victron_evcs_surplus_current_decision +input_text.victron_evcs_operating_mode +input_text.victron_evcs_source_mode +input_text.victron_evcs_last_block_reason +input_datetime.victron_evcs_blocked_until + diff --git a/legacy.txt b/legacy.txt new file mode 100644 index 0000000..115ac5a --- /dev/null +++ b/legacy.txt @@ -0,0 +1,5 @@ +input_text.victron_evcs_control_state +input_text.victron_evcs_control_reason +input_text.victron_evcs_current_decision +input_text.victron_evcs_control_last_signature + diff --git a/snapshot.js b/snapshot.js new file mode 100644 index 0000000..a462612 --- /dev/null +++ b/snapshot.js @@ -0,0 +1,138 @@ +{{ { + "soc_percent": states("sensor.victron_battery_soc"), + "battery_current_a": states("sensor.victron_battery_current"), + "pv_power_w": states("sensor.victron_pv_power_system"), + "pv_power_10m_avg_w": states("sensor.victron_pv_power_10m_avg"), + "pv_trend_w": states("sensor.victron_pv_power_trend_w"), + + "house_ac_power_w": states("sensor.victron_consumption_power_l1"), + "grid_power_w": states("sensor.victron_grid_power_l1"), + "grid_state": states("switch.edf_general_ok_switch"), + "today_remaining_kwh": states("sensor.energy_production_today_remaining"), + + "evcs_device_connected": states("binary_sensor.victron_evcs_device_connected"), + "evcs_vehicle_connected": states("binary_sensor.victron_evcs_vehicle_connected"), + "evcs_status_raw": states("sensor.victron_evcs_status_raw"), + "evcs_mode_sensor": states("sensor.victron_evcs_mode"), + "evcs_mode_fallback": states("input_text.sensor_victron_evcs_mode"), + "evcs_set_current_a": states("sensor.victron_evcs_set_current"), + "evcs_command_current_a": states("number.victron_evcs_set_current_command"), + "evcs_current_a": states("sensor.victron_evcs_current"), + "evcs_ac_power_w": states("sensor.victron_evcs_ac_power"), + "evcs_charging_switch": states("switch.victron_evcs_charging"), + "evcs_auto_start_switch": states("switch.victron_evcs_auto_start"), + + "controller_enabled": states("input_boolean.victron_evcs_surplus_auto_enabled"), + "notifications_enabled": states("input_boolean.victron_evcs_surplus_notifications"), + "hard_stop_enabled": states("input_boolean.victron_evcs_hard_stop_enabled"), + "charge_permission_now": states("input_boolean.victron_evcs_charge_permission_now"), + "control_latch": states("input_boolean.victron_evcs_control_latch"), + "departure_priority_enabled": states("input_boolean.victron_evcs_departure_priority_enabled"), + "forecast_mode_enabled": states("input_boolean.victron_evcs_forecast_mode_enabled"), + + "target_current_auto_a": states("input_number.victron_evcs_target_current_auto"), + "budget_remaining_kwh": states("input_number.victron_evcs_budget_remaining_kwh"), + "projected_end_soc_after_charge": states("input_number.victron_evcs_projected_end_soc_after_charge"), + + "pv_start_min_w": states("input_number.victron_evcs_pv_start_min_w"), + "pv_hold_min_w": states("input_number.victron_evcs_pv_hold_min_w"), + "house_ac_headroom_w": states("input_number.victron_evcs_house_ac_headroom_w"), + "battery_discharge_soft_a": states("input_number.victron_evcs_battery_discharge_soft_a"), + "battery_discharge_hard_a": states("input_number.victron_evcs_battery_discharge_hard_a"), + "battery_reserve_up_a": states("input_number.victron_evcs_battery_reserve_up_a"), + "battery_neutral_band_a": states("input_number.victron_evcs_battery_neutral_band_a"), + "soc_start_min": states("input_number.victron_evcs_soc_start_min"), + "soc_stop_min": states("input_number.victron_evcs_soc_stop_min"), + "current_min_a": states("input_number.victron_evcs_current_min_a"), + "current_max_auto_a": states("input_number.victron_evcs_current_max_auto_a"), + "current_step_up_a": states("input_number.victron_evcs_current_step_up_a"), + "current_step_down_a": states("input_number.victron_evcs_current_step_down_a"), + "current_change_cooldown_seconds": states("input_number.victron_evcs_current_change_cooldown_seconds"), + "min_run_minutes": states("input_number.victron_evcs_min_run_minutes"), + "min_stop_minutes": states("input_number.victron_evcs_min_stop_minutes"), + + "required_energy_kwh": states("input_number.victron_evcs_required_energy_kwh"), + "soc_comfort_target": states("input_number.victron_evcs_soc_comfort_target"), + "end_of_day_soc_guard": states("input_number.victron_evcs_end_of_day_soc_guard"), + + "base_load_remaining_kwh": states("input_number.victron_base_load_remaining_kwh"), + "flexible_safety_margin_kwh": states("input_number.victron_flexible_safety_margin_kwh"), + "end_of_day_soc_target": states("input_number.victron_end_of_day_soc_target"), + "ac_loads_max_safe_w": states("input_number.victron_ac_loads_max_safe_w"), + + "battery_full_soc": states("input_number.victron_evcs_battery_full_soc"), + "battery_full_forecast_min_kwh": states("input_number.victron_evcs_battery_full_forecast_min_kwh"), + "forecast_commit_soc_floor": states("input_number.victron_evcs_forecast_commit_soc_floor"), + "forecast_commit_min_kwh": states("input_number.victron_evcs_forecast_commit_min_kwh"), + "cloud_debt_kwh": states("input_number.victron_evcs_cloud_debt_kwh"), + "cloud_debt_max_kwh": states("input_number.victron_evcs_cloud_debt_max_kwh"), + "cloud_tolerance_minutes": states("input_number.victron_evcs_cloud_tolerance_minutes"), + "cloud_debt_started_at": states("input_datetime.victron_evcs_cloud_debt_started_at"), + "operating_mode_helper": states("input_text.victron_evcs_operating_mode"), + + "departure_time": states("input_datetime.victron_evcs_departure_time"), + "last_start_at": states("input_datetime.victron_evcs_last_start_at"), + "last_stop_at": states("input_datetime.victron_evcs_last_stop_at"), + "last_current_change_at": states("input_datetime.victron_evcs_last_current_change_at"), + + "day_start_time": states("input_datetime.victron_flexible_day_start_time"), + "day_stop_time": states("input_datetime.victron_flexible_day_stop_time"), + + "vebus_overload": states("binary_sensor.victron_ve_bus_overload"), + "vebus_l1_overload": states("binary_sensor.victron_ve_bus_l1_overload"), + "vebus_low_battery": states("binary_sensor.victron_ve_bus_low_battery"), + "vebus_l1_low_battery": states("binary_sensor.victron_ve_bus_l1_low_battery"), + + "vebus_ac_out_l1_power_w": states("sensor.victron_ve_bus_ac_out_l1_power"), + "vebus_ac_out_l1_current_a": states("sensor.victron_ve_bus_ac_out_l1_current"), + "vebus_ac_out_l1_voltage_v": states("sensor.victron_ve_bus_ac_out_l1_voltage"), + + "vebus_dc_voltage_v": states("sensor.victron_ve_bus_dc_voltage"), + "vebus_dc_current_a": states("sensor.victron_ve_bus_dc_current"), + "vebus_dc_power_w": states("sensor.victron_ve_bus_dc_power_system"), + + "vebus_state": states("sensor.victron_ve_bus_state"), + "vebus_main_state": states("sensor.victron_ve_bus_main_state"), + + "last_signature": states("input_text.victron_evcs_control_last_signature"), + + "hc_night_start_time": states("input_datetime.victron_evcs_hc_night_start"), + "hc_night_stop_time": states("input_datetime.victron_evcs_hc_night_stop"), + "morning_start_time": states("input_datetime.victron_evcs_morning_start"), + "morning_stop_time": states("input_datetime.victron_evcs_morning_stop"), + "hc_midday_start_time": states("input_datetime.victron_evcs_hc_midday_start"), + "hc_midday_stop_time": states("input_datetime.victron_evcs_hc_midday_stop"), + "evening_start_time": states("input_datetime.victron_evcs_evening_start"), + "evening_stop_time": states("input_datetime.victron_evcs_evening_stop"), + + "allow_grid_hc_night": states("input_boolean.victron_evcs_allow_grid_hc_night"), + "allow_grid_hc_midday": states("input_boolean.victron_evcs_allow_grid_hc_midday"), + "probe_enabled": states("input_boolean.victron_evcs_probe_enabled"), + + "probe_minutes": states("input_number.victron_evcs_probe_minutes"), + "probe_retry_minutes": states("input_number.victron_evcs_probe_retry_minutes"), + "probe_current_a": states("input_number.victron_evcs_probe_current_a"), + + "grid_battery_soft_a": states("input_number.victron_evcs_grid_battery_soft_a"), + "grid_battery_hard_a": states("input_number.victron_evcs_grid_battery_hard_a"), + + "morning_probe_pv_avg_min_w": states("input_number.victron_evcs_morning_probe_pv_avg_min_w"), + "morning_probe_pv_trend_min_w": states("input_number.victron_evcs_morning_probe_pv_trend_min_w"), + "midday_probe_pv_avg_min_w": states("input_number.victron_evcs_midday_probe_pv_avg_min_w"), + + "morning_soc_min": states("input_number.victron_evcs_morning_soc_min"), + "midday_soc_min": states("input_number.victron_evcs_midday_soc_min"), + "evening_soc_start_min": states("input_number.victron_evcs_evening_soc_start_min"), + "evening_soc_stop_min": states("input_number.victron_evcs_evening_soc_stop_min"), + + "morning_battery_max_discharge_a": states("input_number.victron_evcs_morning_battery_max_discharge_a"), + "midday_battery_max_discharge_a": states("input_number.victron_evcs_midday_battery_max_discharge_a"), + "evening_battery_max_discharge_a": states("input_number.victron_evcs_evening_battery_max_discharge_a"), + + "last_block_reason": states("input_text.victron_evcs_last_block_reason"), + "blocked_until": states("input_datetime.victron_evcs_blocked_until"), + "probe_started_at": states("input_datetime.victron_evcs_probe_started_at"), + + "victron_evcs_strategy": states("input_select.victron_evcs_strategy") +} | tojson }} + diff --git a/surplus.js b/surplus.js new file mode 100644 index 0000000..7254afe --- /dev/null +++ b/surplus.js @@ -0,0 +1,1829 @@ +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 + } + } + } + } +}]; +