diff --git a/src/nodes/HA_SERVICE_CALLS_OUTPUT.js b/src/nodes/HA_SERVICE_CALLS_OUTPUT.js index 969b24a..e674e9d 100644 --- a/src/nodes/HA_SERVICE_CALLS_OUTPUT.js +++ b/src/nodes/HA_SERVICE_CALLS_OUTPUT.js @@ -438,8 +438,28 @@ const dvccChargeLimitW = // l'existant, jamais pire. const setpointFromBalance = balanceUsableForSetpoint && dvccChargeLimitW !== null; +// 14/10/2026 : la part voiture suit ce que la voiture PREND quand elle plafonne +// sous la consigne. Boost EDF de la Volvo XC90 T8 (invite) : consigne borne +// 28 A, la voiture ne prend que 3,7 kW (chargeur embarque). La part valait +// 28 A x 230 V = 6,4 kW : les 2,7 kW de trop ont recharge la batterie maison +// en HEURES PLEINES (2,81 kWh de 20:09 a 22:01, batterie +30 A a SOC 86 %). +// Voiture plafonnee = puissance reelle franchement sous la consigne (2 A de +// tolerance de mesure) : la part vaut alors la puissance reelle ; la marge +// d'un pas de courant (gridSetpointSlackW) laisse la voiture remonter. Sinon +// (rampe en cours, voiture qui suit la consigne) : plafond de borne, inchange. +const EVCS_SATURATION_TOLERANCE_A = 2; +const evcsCommandW = Number.isFinite(derived.currentReferenceA) + ? derived.currentReferenceA * derived.acVoltageRef + : null; +const evcsCarSaturated = + parsed.evcsVehicleConnected && + evcsCommandW !== null && + parsed.evcsAcPowerW >= params.currentMinA * derived.acVoltageRef * 0.5 && + parsed.evcsAcPowerW < evcsCommandW - EVCS_SATURATION_TOLERANCE_A * derived.acVoltageRef; const evcsSetpointShareW = parsed.evcsVehicleConnected - ? derived.physicalCapA * derived.acVoltageRef + ? (evcsCarSaturated + ? Math.min(derived.physicalCapA * derived.acVoltageRef, parsed.evcsAcPowerW) + : derived.physicalCapA * derived.acVoltageRef) : 0; // Recharger la batterie sur le reseau n'a de sens economique QUE dans une diff --git a/tests/golden/fixtures/volvo-1410-2040.json b/tests/golden/fixtures/volvo-1410-2040.json new file mode 100644 index 0000000..78aba32 --- /dev/null +++ b/tests/golden/fixtures/volvo-1410-2040.json @@ -0,0 +1,217 @@ +{ + "ac_loads_max_safe_w": "9000.0", + "acin1_last_changed_ts": 1792001345.162854, + "afternoon_battery_max_discharge_a": "unknown", + "afternoon_probe_pv_avg_min_w": "1000.0", + "afternoon_probe_soc_min": "85.0", + "allow_grid_departure_urgent": "off", + "allow_grid_hc_midday": "on", + "allow_grid_hc_night": "on", + "balance_ecs_nuit": "off", + "balance_energy_to_cover_kwh": "12.46", + "balance_grid_energy_needed_kwh": "0.0", + "balance_last_run": "14/10 20:35", + "balance_night_vehicle_budget_kwh": "0.0", + "balance_required_soc": "63.5", + "balance_required_soc_prudent": "82.2", + "balance_status": "ok", + "balance_vehicle_grid_needed": "off", + "base_load_remaining_kwh": "2.2", + "batt_charge_reserve_25_40_a": "110.0", + "batt_charge_reserve_40_60_a": "70.0", + "batt_charge_reserve_60_80_a": "40.0", + "batt_charge_reserve_80_90_a": "10.0", + "batt_charge_reserve_gt90_a": "5.0", + "batt_charge_reserve_lt25_a": "150.0", + "batt_discharge_hard_25_40_a": "-15.0", + "batt_discharge_hard_40_60_a": "-30.0", + "batt_discharge_hard_60_80_a": "-50.0", + "batt_discharge_hard_80_90_a": "-80.0", + "batt_discharge_hard_gt90_a": "-100.0", + "batt_discharge_hard_lt25_a": "-8.0", + "batt_discharge_soft_25_40_a": "-5.0", + "batt_discharge_soft_40_60_a": "-15.0", + "batt_discharge_soft_60_80_a": "-20.0", + "batt_discharge_soft_80_90_a": "-40.0", + "batt_discharge_soft_gt90_a": "-50.0", + "batt_discharge_soft_lt25_a": "-3.0", + "batt_hard_tol_25_40_sec": "5.0", + "batt_hard_tol_40_60_sec": "10.0", + "batt_hard_tol_60_80_sec": "15.0", + "batt_hard_tol_80_90_sec": "20.0", + "batt_hard_tol_gt90_sec": "20.0", + "batt_hard_tol_lt25_sec": "2.0", + "batt_recovery_25_40_min": "12.0", + "batt_recovery_40_60_min": "10.0", + "batt_recovery_60_80_min": "8.0", + "batt_recovery_80_90_min": "6.0", + "batt_recovery_gt90_min": "6.0", + "batt_recovery_lt25_min": "15.0", + "batt_soft_tol_25_40_min": "2.0", + "batt_soft_tol_40_60_min": "4.0", + "batt_soft_tol_60_80_min": "6.0", + "batt_soft_tol_80_90_min": "10.0", + "batt_soft_tol_gt90_min": "12.0", + "batt_soft_tol_lt25_min": "1.0", + "battery_current_a": "31.600000381469727", + "battery_discharge_hard_a": "-25.0", + "battery_discharge_soft_a": "0.0", + "battery_full_forecast_min_kwh": "4.5", + "battery_full_soc": "90.0", + "battery_neutral_band_a": "0.0", + "battery_reserve_up_a": "8.0", + "battery_soft_breach_at": "", + "blocked_until": "2026-10-14 20:39:30", + "bms_max_charge_current_a": "250.0", + "bms_max_discharge_current_a": "250.0", + "budget_remaining_kwh": "-13.6", + "charge_permission_now": "on", + "cloud_debt_kwh": "0.0", + "cloud_debt_max_kwh": "1.2", + "cloud_debt_started_at": "2026-10-14 16:03:00", + "cloud_tolerance_minutes": "20.0", + "control_latch": "on", + "controller_enabled": "on", + "current_change_cooldown_seconds": "30.0", + "current_max_auto_a": "28.0", + "current_min_a": "6.0", + "current_step_down_a": "2.0", + "current_step_up_a": "1.0", + "day_start_time": "08:30:00", + "day_stop_time": "20:15:00", + "departure_day_ok": "on", + "departure_priority_enabled": "on", + "departure_time": "08:50:00", + "departure_time_jeep": "06:30:00", + "departure_time_zero": "08:50:00", + "dvcc_max_charge_current_a": "30", + "end_of_day_soc_guard": "85.0", + "end_of_day_soc_target": "85.0", + "evcs_ac_power_w": "3726.0", + "evcs_auto_start_switch": "off", + "evcs_charging_switch": "on", + "evcs_charging_time": "29m 25s", + "evcs_command_current_a": "28", + "evcs_current_a": "28.0", + "evcs_delivery_noise_ema": "0.003", + "evcs_delivery_ratio_ema": "0.999", + "evcs_device_connected": "on", + "evcs_mode_fallback": "unknown", + "evcs_mode_sensor": "Manuel", + "evcs_night_session_start_at": "", + "evcs_night_session_start_soc": "", + "evcs_no_demand_filter_eval_at": "2026-10-14 20:39:30", + "evcs_no_demand_score_ema": "0.001", + "evcs_no_demand_semantic_active": "off", + "evcs_no_demand_since_at": "2026-08-23 15:29:30", + "evcs_priority_manual_mode": "normal", + "evcs_priority_manual_override": "off", + "evcs_priority_manual_reason": "unknown", + "evcs_priority_manual_until": "2026-04-14 00:00:00", + "evcs_session_energy_kwh": "1.6", + "evcs_set_current_a": "28", + "evcs_status_raw": "2", + "evcs_total_energy_kwh": "4144.0", + "evcs_vehicle": "VW", + "evcs_vehicle_connected": "on", + "evcs_vehicle_connected_ts": 1792001026.503855, + "evening_battery_max_discharge_a": "-30.0", + "evening_soc_start_min": "85.0", + "evening_soc_stop_min": "80.0", + "evening_start_time": "17:00:00", + "evening_stop_time": "20:30:00", + "flexible_safety_margin_kwh": "0.9", + "force_mode_auto_revert_minutes": "120.0", + "forecast_commit_min_kwh": "10.0", + "forecast_commit_soc_floor": "35.0", + "forecast_mode_enabled": "off", + "grid_battery_hard_a": "-20.0", + "grid_battery_soft_a": "-10.0", + "grid_current_max_a": "28.0", + "grid_power_w": "6279.0", + "grid_setpoint_idle_w": "unknown", + "grid_setpoint_w": "7777", + "grid_settle_seconds": "unknown", + "grid_state": "on", + "grid_switch_last_changed_ts": 1792001344.316242, + "hard_stop_enabled": "on", + "hc_midday_start_time": "12:24:00", + "hc_midday_stop_time": "14:24:00", + "hc_night_start_time": "01:24:00", + "hc_night_stop_time": "07:24:00", + "house_ac_headroom_w": "5000.0", + "house_ac_power_prev_w": "4572.0", + "house_ac_power_w": "4572.0", + "house_spike_grace_minutes": "10.0", + "last_block_reason": "none", + "last_current_change_at": "2026-10-14 20:32:30", + "last_signature": "charging_hold_grid|profile=outside_window|source=grid_forced|chg=1|perm=1|l=1|cur=28|block=none|stop=0|prio=1|night=0|ntrg=63.5|nurg=0|vdf=0|vdfs=0.001", + "last_start_at": "2026-10-14 20:10:30", + "last_stop_at": "2026-10-14 16:33:00", + "midday_battery_max_discharge_a": "-40.0", + "midday_probe_pv_avg_min_w": "0.0", + "midday_soc_min": "75.0", + "min_run_minutes": "29.0", + "min_stop_minutes": "0.0", + "modules_en_service": "13.0", + "morning_battery_max_discharge_a": "-20.0", + "morning_probe_pv_avg_min_w": "1500.0", + "morning_probe_pv_trend_min_w": "-2000.0", + "morning_soc_min": "35.0", + "morning_start_time": "07:30:00", + "morning_stop_time": "11:00:00", + "mppt_operation_modes": "off,off,off,off,off,off,off", + "night_battery_charge_enabled": "on", + "night_battery_done_prev": "off", + "night_budget_prev": "", + "night_charge_mode": "sans_edf", + "night_grid_session_committed_prev": "off", + "night_takeover_prev": "off", + "notifications_enabled": "on", + "operating_mode_helper": "outside_window", + "overload_hold_seconds": "30.0", + "overload_step_down_a": "2.0", + "overload_stop_after_seconds": "120.0", + "prev_surplus_state": "charging_hold_grid", + "probe_current_a": "6.0", + "probe_enabled": "on", + "probe_minutes": "3.0", + "probe_retry_minutes": "45.0", + "probe_started_at": "2026-03-31 00:00:00", + "projected_end_soc_after_charge": "41.7", + "pv_hold_min_w": "500.0", + "pv_power_10m_avg_w": "2", + "pv_power_w": "0.0", + "pv_start_min_w": "700.0", + "pv_trend_w": "unknown", + "required_energy_kwh": "10.5", + "reset_borne": "off", + "soc_comfort_target": "85.0", + "soc_percent": "87.0", + "soc_start_min": "80.0", + "soc_stop_min": "29.0", + "solar_potential_grace_minutes": "12.0", + "step_down_cooldown_seconds": "60.0", + "step_up_cooldown_seconds": "30.0", + "target_current_auto_a": "28.0", + "today_remaining_kwh": "0.0", + "tomorrow_kwh": "29.504", + "vebus_ac_out_l1_current_a": "22.540000915527344", + "vebus_ac_out_l1_power_w": "4585", + "vebus_ac_out_l1_voltage_v": "222.69000244140625", + "vebus_dc_current_a": "31.600000381469727", + "vebus_dc_power_w": "1692.0", + "vebus_dc_voltage_v": "50.400001525878906", + "vebus_l1_low_battery": "off", + "vebus_l1_overload": "off", + "vebus_low_battery": "off", + "vebus_main_state": "9", + "vebus_overload": "off", + "vebus_state": "4", + "vehicle_capacity_kwh": "7.7", + "vehicle_delivered_since_level_kwh": 1.6000000000003638, + "vehicle_level_pct": "20.0", + "vehicle_level_valid": true, + "victron_acin1_available": "1", + "victron_evcs_strategy": "force_grid_now" +} \ No newline at end of file diff --git a/tests/golden/run.mjs b/tests/golden/run.mjs index bfa2641..9f60fad 100644 --- a/tests/golden/run.mjs +++ b/tests/golden/run.mjs @@ -184,6 +184,13 @@ for (const sc of scenarios) { const diffs = []; for (const [k, v] of Object.entries(sc.expect || {})) { + // 14/10/2026 : bornes sur la consigne reseau ecrite ce cycle. + if (k === 'gridSetpointBelowW' || k === 'gridSetpointAtLeastW') { + const w = got.gridSetpointW; + const ok = Number.isFinite(w) && (k === 'gridSetpointBelowW' ? w < v : w >= v); + if (!ok) diffs.push(`${k}: attendu ${k === 'gridSetpointBelowW' ? '<' : '>='} ${v}, obtenu ${JSON.stringify(w)}`); + continue; + } if (JSON.stringify(got[k]) !== JSON.stringify(v)) diffs.push(`${k}: attendu ${JSON.stringify(v)}, obtenu ${JSON.stringify(got[k])}`); } if (diffs.length) { diff --git a/tests/golden/scenarios.mjs b/tests/golden/scenarios.mjs index 9edcdd7..8111c1a 100644 --- a/tests/golden/scenarios.mjs +++ b/tests/golden/scenarios.mjs @@ -59,6 +59,9 @@ const VW_1210 = JSON.parse(readFileSync(new URL('./fixtures/vw-1210-1638.json', // Releve reel du 14/10 12:57:30 (entree de INPUT_PARSE) : JEEP branchee 12:55:22, 51 % par Uconnect, // 1,5 min de charge a 8 A puis interruption ; statut 3, energie de session affichee 0,0 kWh. const JEEP_1410 = JSON.parse(readFileSync(new URL('./fixtures/jeep-1410-1257.json', import.meta.url), 'utf8')); +// Releve reel du 14/10 20:40:00 : boost EDF (force_grid_now) de la Volvo XC90 T8 invitee, consigne 28 A, +// la voiture ne prend que 3,7 kW ; consigne reseau ecrite 7304 W, batterie maison +31,6 A en heures pleines. +const VOLVO_1410 = JSON.parse(readFileSync(new URL('./fixtures/volvo-1410-2040.json', import.meta.url), 'utf8')); export const scenarios = [ @@ -3632,4 +3635,20 @@ export const scenarios = [ overrides: { ...JEEP_1410, vehicle_level_pct: '90.0', vehicle_level_valid: true, vehicle_capacity_kwh: '10.0', vehicle_delivered_since_level_kwh: 0.7 }, expect: { state: 'day_vehicle_full_idle' }, }, + { + id: 'volvo-1410-boost-edf-ne-recharge-pas-la-batterie-en-hp', + label: 'RELEVE 14/10 20:40 — boost EDF, voiture plafonnee a 3,7 kW sous 28 A : la consigne suit la voiture, pas la borne', + time: '2026-10-14T20:40:00+02:00', + overrides: { ...VOLVO_1410 }, + // Ancien code : 7304 W (la valeur reellement ecrite ce soir-la) ; maison ~840 + voiture 3726 + un pas. + expect: { gridSetpointW: 4795, gridSetpointBelowW: 5500 }, + }, + { + id: 'volvo-1410-temoin-voiture-qui-suit-la-consigne', + label: 'temoin — meme releve, la voiture prend ses 28 A (6,4 kW) : plafond de borne, comme avant', + time: '2026-10-14T20:40:00+02:00', + overrides: { ...VOLVO_1410, evcs_ac_power_w: '6420.0' }, + // Identique a l'ancien code (6458 W). + expect: { gridSetpointW: 6458 }, + }, ]; diff --git a/workflows/evcs-surplus-controller-v2.6.json b/workflows/evcs-surplus-controller-v2.6.json index 0901579..a386370 100644 --- a/workflows/evcs-surplus-controller-v2.6.json +++ b/workflows/evcs-surplus-controller-v2.6.json @@ -107,7 +107,7 @@ }, { "parameters": { - "jsCode": "// Bornes de la consigne de puissance reseau. Le maximum est celui declare par\n// l'entite elle-meme (number.airtria_mpii_v1_ac_power_setpoint_l1, max 10000 W),\n// pas un chiffre choisi ici. La bande morte evite de reecrire la meme valeur\n// toutes les 30 s sans empecher la correction d'une derive externe.\nconst GRID_SETPOINT_MAX_W = 10000;\nconst GRID_SETPOINT_DEADBAND_W = 100;\n\nfunction round1(v) {\n return Math.round(v * 10) / 10;\n}\n\nfunction round2(v) {\n return Math.round(v * 100) / 100;\n}\n\nfunction round3(v) {\n return Math.round(v * 1000) / 1000;\n}\n\nfunction clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nfunction to2(n) {\n return String(n).padStart(2, '0');\n}\n\nfunction formatParisStampFromMs(ms) {\n const fmt = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris',\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n fmt.formatToParts(new Date(ms))\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour);\n return `${parts.year}-${parts.month}-${parts.day} ${to2(hour)}:${parts.minute}:${parts.second}`;\n}\n\nconst prev = $input.first().json;\n\nconst {\n input,\n now,\n nowMs,\n cfg,\n parsed,\n params,\n history,\n strategy,\n windows,\n lifecycle,\n context,\n derived,\n batteryTune,\n batteryPolicy,\n locks,\n cloud,\n modes,\n safeguards,\n conditions,\n decision,\n nightEvcs,\n vehicleDemandFilter,\n} = prev;\n\n// =====================================================\n// SAFE ACCESS NIGHT EVCS\n// =====================================================\nconst night = nightEvcs ?? {\n nightSessionActive: false,\n nightSessionNeedsCapture: false,\n nightSessionNeedsClear: false,\n nightSessionStartSocPct: null,\n nightSessionStartAt: '',\n batteryHardFloorPct: null,\n batteryMorningTargetPct: null,\n maxBatteryDischargePoints: null,\n gridTakeoverTriggerPct: null,\n batteryRecoveryUrgent: false,\n nightGridTakeoverNeeded: false,\n nightPreferredSourceMode: 'blocked',\n};\n\n// =====================================================\n// SAFE ACCESS VEHICLE DEMAND FILTER\n// =====================================================\nconst vdf = vehicleDemandFilter ?? {\n dtSec: null,\n commandCurrentA: null,\n realCurrentA: null,\n expectedPowerW: null,\n realPowerW: null,\n currentDeliveryRatioRaw: null,\n powerDeliveryRatioRaw: null,\n deliveryRatioRaw: null,\n deliveryRatioEma: null,\n deliveryNoiseEma: null,\n adaptiveQuietBand: null,\n noDemandEvidenceRaw: null,\n sessionElapsedSec: null,\n sessionArmFactor: null,\n commandFactor: null,\n sourceDeliverableForNoDemand: false,\n evcsSessionArmedForNoDemand: false,\n noDemandScoreRaw: null,\n noDemandScoreEma: null,\n noDemandScoreOn: null,\n noDemandScoreOff: null,\n evcsVehicleNoDemandSemantic: false,\n evcsNoDemandSinceMs: null,\n evcsNoDemandSinceMinutes: null,\n nextHistory: {\n evcsDeliveryRatioEma: null,\n evcsDeliveryNoiseEma: null,\n evcsNoDemandScoreEma: null,\n evcsNoDemandSemanticActive: false,\n evcsNoDemandSinceMs: null,\n evcsNoDemandFilterEvalMs: null\n }\n};\n\n// =====================================================\n// FINAL ACTIONS\n// =====================================================\nlet desiredCurrentA = decision.desiredCurrentA;\n\ndesiredCurrentA = decision.desiredCharging\n ? clamp(\n desiredCurrentA,\n params.currentMinA,\n Math.max(params.currentMinA, derived.physicalCapA || params.currentMinA)\n )\n : params.currentMinA;\n\nconst chargingAction =\n decision.desiredCharging === parsed.evcsChargingSwitchOn\n ? 'none'\n : decision.desiredCharging\n ? 'turn_on'\n : 'turn_off';\n\nconst permissionAction =\n decision.desiredPermission === parsed.chargePermissionNow\n ? 'none'\n : decision.desiredPermission\n ? 'turn_on'\n : 'turn_off';\n\nconst latchAction =\n decision.desiredLatch === parsed.controlLatch\n ? 'none'\n : decision.desiredLatch\n ? 'turn_on'\n : 'turn_off';\n\nconst autoStartAction =\n parsed.controllerEnabled &&\n parsed.evcsVehicleConnected &&\n parsed.evcsAutoStartSwitchOn\n ? 'turn_off'\n : 'none';\n\nconst currentAction =\n decision.desiredCharging &&\n (\n !Number.isFinite(derived.currentReferenceA) ||\n Math.abs(desiredCurrentA - derived.currentReferenceA) >= 0.5\n )\n ? 'set'\n : 'none';\n\nconst setLastStartAt = chargingAction === 'turn_on';\nconst setLastStopAt = chargingAction === 'turn_off';\nconst setLastCurrentChangeAt = currentAction === 'set';\n\n// =====================================================\n// CLEAN BLOCKING LOGIC\n// =====================================================\nconst blockedUntilToWrite =\n decision.nextBlockReason === 'none'\n ? now.stamp\n : decision.nextBlockedUntilStamp;\n\nconst needsBlockReasonUpdate =\n decision.nextBlockReason !== history.lastBlockReason;\n\n// =====================================================\n// GLOBAL EVCS PRIORITY\n// AUTO + OVERRIDE MANUEL\n// =====================================================\nconst forceWindowMinutes = Math.max(\n 30,\n batteryTune?.global?.forceModeAutoRevertMinutes ?? 120\n);\n\nconst activeChargeIntent =\n decision.desiredCharging ||\n ['start', 'hold', 'step_up', 'step_down', 'step_down_fast'].includes(decision.currentDecision);\n\nconst evcsVehicleNotRequestingPowerState =\n decision.state === 'vehicle_full_or_paused' ||\n decision.currentDecision === 'stop_vehicle_full_or_paused';\n\n// -------------------------\n// PRIORITÉ AUTO\n// -------------------------\nlet autoEvcsPriorityActive = false;\nlet autoEnergyPriorityMode = 'normal';\nlet autoEnergyPriorityReason = 'Aucune priorité EVCS';\nlet autoEvcsPriorityUntil = now.stamp;\n\nif (\n parsed.evcsVehicleConnected &&\n !strategy.isStopMode &&\n !evcsVehicleNotRequestingPowerState\n) {\n if (strategy.forceChargeRequested || strategy.forceGridRequested) {\n autoEvcsPriorityActive = true;\n autoEnergyPriorityMode = 'evcs_priority';\n autoEnergyPriorityReason =\n strategy.forceGridRequested\n ? 'EVCS prioritaire : force_grid_now'\n : 'EVCS prioritaire : force_charge_now';\n autoEvcsPriorityUntil = formatParisStampFromMs(nowMs + forceWindowMinutes * 60000);\n }\n\n else if (night.nightSessionActive) {\n autoEvcsPriorityActive = true;\n autoEnergyPriorityMode = 'evcs_priority';\n autoEnergyPriorityReason =\n night.batteryRecoveryUrgent\n ? 'EVCS prioritaire : session nuit + urgence départ'\n : night.nightGridTakeoverNeeded\n ? 'EVCS prioritaire : session nuit + takeover réseau'\n : 'EVCS prioritaire : session nuit active';\n\n autoEvcsPriorityUntil =\n Number.isFinite(lifecycle.minutesUntilDeparture)\n ? formatParisStampFromMs(\n nowMs + Math.max(1, lifecycle.minutesUntilDeparture) * 60000\n )\n : formatParisStampFromMs(nowMs + forceWindowMinutes * 60000);\n }\n\n else if (context.departurePriorityActive && Number.isFinite(lifecycle.minutesUntilDeparture)) {\n autoEvcsPriorityActive = true;\n autoEnergyPriorityMode = 'evcs_priority';\n autoEnergyPriorityReason = `EVCS prioritaire : départ dans ${Math.round(lifecycle.minutesUntilDeparture)} min`;\n autoEvcsPriorityUntil = formatParisStampFromMs(\n nowMs + Math.max(1, lifecycle.minutesUntilDeparture) * 60000\n );\n }\n\n else if (activeChargeIntent && context.profile === 'midday_hc_or_solar') {\n autoEvcsPriorityActive = true;\n autoEnergyPriorityMode = 'evcs_priority';\n autoEnergyPriorityReason = 'EVCS prioritaire : recharge active fenêtre midi';\n autoEvcsPriorityUntil = formatParisStampFromMs(nowMs + 90 * 60000);\n }\n}\n\n// -------------------------\n// OVERRIDE MANUEL\n// -------------------------\nconst manualPriorityOverrideActive =\n parsed.evcsPriorityManualOverride &&\n parsed.evcsPriorityManualMode === 'evcs_priority' &&\n history.evcsPriorityManualUntilMs !== null &&\n history.evcsPriorityManualUntilMs > nowMs + 1000;\n\nlet evcsPriorityActive = autoEvcsPriorityActive;\nlet energyPriorityMode = autoEnergyPriorityMode;\nlet energyPriorityReason = autoEnergyPriorityReason;\nlet evcsPriorityUntil = autoEvcsPriorityUntil;\nlet evcsPrioritySource = 'auto';\n\nif (manualPriorityOverrideActive && !evcsVehicleNotRequestingPowerState) {\n evcsPriorityActive = true;\n energyPriorityMode = 'evcs_priority';\n energyPriorityReason =\n parsed.evcsPriorityManualReason || 'EVCS prioritaire : override manuel';\n evcsPriorityUntil =\n history.evcsPriorityManualUntilAt || autoEvcsPriorityUntil;\n evcsPrioritySource = 'manual';\n}\n\n// =====================================================\n// REASONS / LABELS / SIGNATURE\n// =====================================================\nconst reason = [...decision.reason];\n\n// Budget voiture de la nuit (30/09) : dire pourquoi la voiture s'arrete.\nif (night.nightBudgetKwh !== null && night.nightBudgetKwh !== undefined) {\n const f = (x) => String(Math.round(x * 100) / 100).replace('.', ',');\n reason.push(`Budget batterie nuit ${f(night.nightBudgetDeliveredKwh ?? 0)}/${f(night.nightBudgetKwh)} kWh` +\n (night.nightCarRidesGridSession ? ' — sans EDF, mais EDF est ferme pour la batterie en heures creuses : la voiture en profite'\n : night.nightBatteryGridOnly ? ' — sans EDF : EDF ferme pour la batterie seule, la voiture attend le soleil'\n : night.nightCarWaitsNoGrid ? ' — atteint, sans EDF : la voiture attend le soleil'\n : night.nightBudgetReached ? ' — atteint, EDF finit la charge' : ''));\n} else if (night.nightCarRidesGridSession) {\n reason.push('Sans EDF, mais EDF est ferme pour la batterie en heures creuses : la voiture en profite');\n} else if (night.nightCarWaitsNoGrid) {\n reason.push('Pas de budget batterie exploitable, sans EDF : la voiture attend le soleil');\n}\nreason.push(`Stratégie=${strategy.userStrategy}`);\nreason.push(`Profil=${context.profile}`);\nreason.push(`Source=${context.sourceMode}`);\nreason.push(\n `Mode=${\n modes.batteryFullHarvestMode\n ? 'battery_full_harvest_mode'\n : modes.forecastCommitMode\n ? 'forecast_commit_mode'\n : 'battery_priority_mode'\n }`\n);\nreason.push(`SOC=${round1(parsed.soc)}%`);\nreason.push(`Batt=${round1(parsed.batteryCurrentA)}A`);\nreason.push(`PV=${Math.round(parsed.pvPowerW)}W`);\nreason.push(`PV10=${Math.round(parsed.pvPowerAvg10mW)}W`);\n// =====================================================\n// CONSIGNE DE PUISSANCE RESEAU (01/09/2026)\n// =====================================================\n// Fermer le contacteur ne suffit PAS. Au-dessus du SOC minimal, l'ESS en\n// autoconsommation ne preleve que `ac_power_setpoint` et laisse la batterie\n// porter tout le reste : nuit du 01/09, contacteur ferme, consigne 50 W,\n// batterie a -803 W pour 761 W de maison, voiture arretee par le garde\n// \"batterie trop sollicitee\". La consigne est donc le levier qui rend une\n// session reseau REELLE, et il doit appartenir au controleur, pas rester un\n// reglage manuel permanent (celui-ci provoquait la substitution du solaire\n// par le reseau en journee, cf. HANDOFF 27/28).\n//\n// La valeur n'est pas une constante : c'est le besoin mesure du moment, la\n// maison telle qu'elle consomme plus la voiture au courant que le controleur\n// s'autorise. Le plafond joue le role de marge : quand la voiture n'est pas a\n// son plafond, la consigne est deja genereuse, et l'exces ne peut pas partir\n// dans la batterie (DVCC tenue a 2 A par l'Auto Charge Manager pendant un\n// takeover). Hors session, retour a la valeur de repos.\n// ⚠️ LA SESSION ENGAGEE COMPTE AUSSI (04/09/2026).\n// Nuit du 03-04/09 : la session reseau unique a tenu le contacteur toute la\n// nuit — une seule bascule, comme voulu. Mais a 02:50, la voiture finie, le\n// sourceMode est passe a 'blocked' et la consigne est retombee de 5078 W a\n// 50 W. Contacteur ferme, reseau disponible, heures creuses, et l'ESS ne\n// demandait que 50 W pendant que la maison tirait 770 W SUR LA BATTERIE.\n// Trois heures et demie, SOC 44 % -> 17 %, 2,60 kWh pris a la batterie quand\n// le reseau etait la et bon marche.\n//\n// C'est le defaut du 01/09 revenu ailleurs : fermer le contacteur ne suffit\n// pas, la consigne doit demander la puissance. J'avais conditionne la consigne\n// au sourceMode, puis cree la veille une NOUVELLE raison de garder le\n// contacteur ferme — l'engagement de session — sans la brancher ici. La\n// session tenait le contacteur, pas l'energie.\n//\n// Regle : tant que le contacteur est ferme A NOTRE DEMANDE, la consigne doit\n// couvrir la maison. Peu importe laquelle des deux raisons le tient.\nconst gridSessionActive =\n parsed.acInAvailable &&\n (\n context.sourceMode === 'grid_forced' ||\n context.sourceMode === 'grid_hc_allowed' ||\n night.nightGridSessionCommitted === true ||\n // Batterie seule sur le reseau, voiture bloquee « sans EDF » (04/10).\n night.nightBatteryGridOnly === true\n );\n\n// =====================================================\n// DIMENSIONNEMENT SUR LE DEFICIT DU BILAN (18/09/2026)\n// =====================================================\n// Ce que la consigne remplace : `maison + plafond de la borne`, ou le plafond\n// de la borne n'a AUCUNE relation physique avec ce dont la batterie a besoin.\n// Qu'il tombe juste est un accident — releve du 17/09 a 13:51, voiture partie\n// depuis cinq minutes, consigne a 7032 W pour une maison a 933 W.\n//\n// La consigne additionne desormais des besoins NOMMES :\n// maison toujours ;\n// voiture seulement si un vehicule est branche ;\n// batterie seulement dans une fenetre d'heures creuses.\n//\n// ⚠️ MAX ET NON ADDITION, et ce n'est pas de la prudence. La consigne est une\n// AUTORISATION, pas une prediction de consommation — le commentaire de la bande\n// asymetrique le dit plus bas : « la consigne dit ce que le reseau a le DROIT\n// de servir, DVCC dit ce que la batterie a le droit de prendre ». Additionner\n// deux autorisations independantes n'est pas plus honnete, seulement plus\n// permissif. Mesure a l'appui : avec l'addition,\n// `consigne-session-engagee-couvre-la-maison` passe de 5595 a 6341 W, soit\n// 746 W d'autorisation en plus sur une nuit engagee qui fonctionne deja — une\n// regression sur le chemin DOMINANT pour corriger un chemin emprunte zero fois\n// en treize jours.\n//\n// ⚠️ ET LE PLAFOND DU TERME BATTERIE EST DVCC, PAS LE BMS. Le BMS declare\n// 250 A, soit 12,2 kW a 49 V : au-dessus de GRID_SETPOINT_MAX_W, donc il ne\n// bornerait rien. Mesure : en fin de fenetre (14 min restantes, manque\n// 5,672 kWh) la division rendait 17 kW, ecretes a 10 000 — PIRE que les 5437\n// d'aujourd'hui. Avec DVCC le terme vaut 1538 W et la consigne 2380 W.\n// Composer les deux leviers, ce n'est pas les confondre : le reseau n'est\n// autorise a servir la batterie qu'a hauteur de ce que la batterie est\n// autorisee a prendre.\n//\n// Corollaire : aucun plancher d'horizon n'est necessaire. Un horizon qui tend\n// vers zero donne `min(enorme, DVCC) = DVCC`. Il ne reste qu'une garde d'une\n// minute, qui est de l'arithmetique defensive et non une constante calibree.\n//\n// INVARIANT TENU PAR LA FORME : `maison + max(...)` ne peut jamais descendre\n// sous la maison, quel que soit DVCC. La regle du 01/09 et du 04/09 — « tant\n// que le contacteur est ferme A NOTRE DEMANDE, la consigne doit couvrir la\n// maison » — est donc garantie par construction. A DVCC nul, le plafond vaut\n// exactement la maison.\nconst SETPOINT_BALANCE_MAX_AGE_MIN = 30;\nconst setpointBalanceAgeMin = Number.isFinite(params.balanceLastRunMs)\n ? (nowMs - params.balanceLastRunMs) / 60000\n : null;\n\n// Le manque, pas l'energie a couvrir : voir le commentaire d'INPUT_PARSE.\nconst balanceUsableForSetpoint =\n params.balanceStatusOk &&\n Number.isFinite(params.balanceGridEnergyNeededKwh) &&\n setpointBalanceAgeMin !== null &&\n setpointBalanceAgeMin >= -5 &&\n setpointBalanceAgeMin <= SETPOINT_BALANCE_MAX_AGE_MIN;\n\nconst dvccChargeLimitW =\n Number.isFinite(params.dvccChargeLimitA) && params.dvccChargeLimitA >= 0\n ? params.dvccChargeLimitA * derived.batteryVoltageRef\n : null;\n\n// Toute lecture manquante retombe sur la formule d'avant : degradation vers\n// l'existant, jamais pire.\nconst setpointFromBalance = balanceUsableForSetpoint && dvccChargeLimitW !== null;\n\nconst evcsSetpointShareW = parsed.evcsVehicleConnected\n ? derived.physicalCapA * derived.acVoltageRef\n : 0;\n\n// Recharger la batterie sur le reseau n'a de sens economique QUE dans une\n// fenetre creuse. Hors fenetre le terme vaut zero — ce n'est pas un repli\n// faute d'horizon, c'est le comportement voulu, et le probleme de la division\n// par un temps qui tend vers zero disparait par construction.\nconst hcMinutesLeft = Number.isFinite(windows.minutesRemainingInHcWindow)\n ? windows.minutesRemainingInHcWindow\n : null;\n\nconst batterySetpointShareW =\n setpointFromBalance && hcMinutesLeft !== null && params.balanceGridEnergyNeededKwh > 0\n ? Math.min(\n (params.balanceGridEnergyNeededKwh * 1000) / Math.max(hcMinutesLeft / 60, 1 / 60),\n dvccChargeLimitW\n )\n : 0;\n\n// =====================================================\n// LE REPLI NE DOIT PAS RESSUSCITER LE DEFAUT (18/09/2026)\n// =====================================================\n// Quand la garde du bilan tombe -- statut degrade, mesure perimee, champ\n// illisible -- la consigne revenait a `maison + plafond de borne`, y compris\n// sans voiture. C'est-a-dire au defaut d'origine, et precisement au moment ou\n// le systeme est deja degrade. Mesure sur le chemin concerne (midi, fenetre HC\n// autorisee, aucun vehicule, bilan indisponible) : 5725 W autorises pour une\n// maison a 900 W. Avec la part voiture : 1130 W.\n//\n// La branche de repli emploie donc la meme part voiture que la branche\n// nominale. L'invariant du 01/09 et du 04/09 reste tenu PAR LA FORME : le\n// plafond vaut `maison + un terme positif ou nul`, il ne descend donc jamais\n// sous la maison, quelle que soit la branche empruntee.\n//\n// ⚠️ NE PAS REINTRODUIRE UN DISJOINT `nightGridSessionCommitted ||\n// forceGridRequested` SUR LA PART VOITURE. Il a ete ecrit, mesure, puis\n// retire : il ECRASE le terme batterie du bilan, puisque `max(4825 emprunte,\n// 1538 mesure)` retient l'emprunt. Cout mesure, trois attendus figes d'un coup :\n// nuit-sans-vehicule-session-engage 2380 -> 5437\n// consigne-bilan-fin-de-fenetre-bornee-par-dvcc 2380 -> 5437\n// consigne-bilan-plancher-nuage-exclut-la-batterie 4219 -> 6301\n// Ce disjoint avait un sens tant que la consigne n'avait AUCUN terme batterie.\n// Depuis qu'elle en a un, dimensionne sur le manque reel, il n'est plus une\n// protection mais un court-circuit.\n//\n// Et si l'idee revient malgre tout : pendant une panne de bilan,\n// `nightGridSessionCommitted` ne peut engager aucune session NOUVELLE, il ne\n// survit que par `committedPrev`. Dans le repli, ce sont donc\n// `forceGridRequested` et le terme vehicule qui portent reellement.\nconst gridSetpointCeilingW = clamp(\n Math.round(\n setpointFromBalance\n ? derived.houseNonEvcsW + Math.max(evcsSetpointShareW, batterySetpointShareW)\n : derived.houseNonEvcsW + evcsSetpointShareW\n ),\n params.gridSetpointIdleW,\n GRID_SETPOINT_MAX_W\n);\n\nconst gridSetpointCurrentW = Number.isFinite(parsed.gridSetpointW)\n ? parsed.gridSetpointW\n : null;\n\n// =====================================================\n// CREDIT SOLAIRE SUR LA CONSIGNE (11/09/2026)\n// =====================================================\n// Defaut mesure ce jour-la : BOOST 28 A en plein soleil, consigne calculee a\n// 8242 W (maison 1802 + 28 A x 230). L'ESS n'a qu'un moyen de prelever 8,2 kW\n// quand le soleil en donne 4 : brider les MPPT. Releve 17:02 -- PV 121 W,\n// reseau 7697 W, batterie 0 A, SOC 96 %. La production etait refusee, et par\n// NOTRE consigne. Le commentaire ci-dessus (\"une consigne trop HAUTE ne coute\n// rien\") a ete ecrit la nuit du 01/09, ou la PV valait zero : il n'y avait\n// alors rien a evincer. En plein jour il est faux.\n//\n// ⚠️ ON NE PEUT PAS SOUSTRAIRE LA PV MESUREE. Elle vaut 121 W *a cause* de\n// cette consigne : mesure censuree, la soustraire ne bouge rien et le systeme\n// reste sur son mauvais equilibre. Preuve le meme jour a 17:22 -- consigne\n// descendue A LA MAIN a 2280 W : PV 4758 W en 50 secondes, batterie +9 A. La\n// ressource etait la, seule la consigne l'evincait.\n//\n// On DESCEND donc, et on laisse la physique repondre. Deux signaux, tous deux\n// deja lus et tous deux INCENSURABLES :\n// - les chargeurs declarent eux-memes s'ils se BRIDENT (mpptLimitedCount).\n// `off` ne compte pas : la nuit ils sont tous `off` et il n'y a rien a\n// liberer -- c'est ce qui garantit que le comportement de nuit (§31, §57)\n// est strictement inchange ;\n// - le courant batterie dit si on est descendu trop bas, via la politique\n// par bande de SOC deja calibree (batterySoftBreachNow, §35).\n// La valeur courante vit dans le GX : l'etat est deja persiste, aucun helper\n// nouveau n'est necessaire.\n//\n// PLANCHER : la consigne ne descend JAMAIS sous la maison hors voiture. C'est\n// la garantie du 01/09 et du 04/09 -- le reseau, quand le contacteur est ferme\n// a notre demande, couvre la maison. On ne libere que la part VOITURE, qui est\n// exactement celle qui evince le solaire. Le solaire pourrait souvent couvrir\n// la maison en plus, mais l'affirmer demanderait ses propres scenarios.\n// ⚠️ SECOND PLANCHER : LE NUAGE (ajoute 11/09 18:05, apres incident).\n// Premiere version de la descente : consigne tombee a 1281 W alors que le\n// solaire portait tout (PV 6058 W, reseau 1163 W, voiture a 28 A). Un nuage a\n// fait chuter la PV de 4,6 kW en une minute ; le reseau etant plafonne a\n// 1281 W, la difference est tombee entierement sur la batterie -- 87 A pour\n// une limite dure de 70 A -> `battery_current_hard_stop`, charge coupee.\n//\n// Sans injection, la production peut s'effondrer de plusieurs kW en quelques\n// secondes. Une boucle a 30 s ne peut pas rattraper cela : il faut que le\n// reseau soit DEJA autorise a prendre le relais. La consigne ne descend donc\n// jamais au point ou la perte totale du solaire depasserait ce que la batterie\n// sait encaisser.\n//\n// Le budget vient de la politique batterie deja calibree par bande de SOC\n// (§35), pas d'une constante nouvelle, et on garde 20 % de marge sous la\n// limite DURE -- c'est elle qui coupe la charge, et une coupure est plus\n// couteuse que quelques centaines de watts de solaire non captes.\n//\n// Contrepartie assumee, et elle est reelle : voiture a 28 A, la descente est\n// nettement moins profonde qu'a 10 A. C'est proportionne -- c'est le courant\n// de la voiture qui cree l'exposition. Et c'est la regle du proprietaire :\n// protéger le materiel prime sur l'economie de kilowattheures.\n// `derived.batteryVoltageRef` est deja la tension retenue en amont (mesure\n// vebus, repli 51,2 V) : meme grandeur que celle qui sert a convertir le\n// courant batterie en puissance dans BATTERY_CLOUD_LOCKS.\nconst batterySurgeBudgetW = Math.max(\n 0,\n Math.abs(batteryPolicy.batteryDischargeHardDynamicA) * derived.batteryVoltageRef * 0.8\n);\n// ⚠️ LE PLANCHER NUAGE SUIT LA VOITURE, PAS LA BATTERIE. Il existe pour que la\n// consigne ne descende pas au point ou la perte totale du solaire depasserait\n// ce que la batterie sait encaisser — et le commentaire ci-dessus dit pourquoi :\n// « c'est le courant de la voiture qui cree l'exposition ». Une batterie qu'on\n// recharge depuis le reseau n'est PAS une exposition : si le solaire s'effondre,\n// la recharge ralentit et personne ne se fait couper. L'y inclure remonterait le\n// plancher sans aucune contrepartie de securite, et contre l'objectif.\n// Une seule branche depuis le 18/09/2026 : le plancher nuage se construit sur\n// la maison et la voiture dans les deux cas. La distinction n'avait de sens que\n// tant que le repli gardait le plafond de borne emprunte.\nconst gridSetpointLoadW = derived.houseNonEvcsW + evcsSetpointShareW;\nconst gridSetpointCloudFloorW = Math.round(\n Math.max(0, gridSetpointLoadW - batterySurgeBudgetW)\n);\n\nconst gridSetpointHouseFloorW = clamp(\n Math.max(Math.round(derived.houseNonEvcsW), gridSetpointCloudFloorW),\n params.gridSetpointIdleW,\n GRID_SETPOINT_MAX_W\n);\n\nconst solarResourceRefused =\n parsed.mpptReadableCount > 0 && parsed.mpptLimitedCount > 0;\n\n// Pendant la fenetre d'etablissement (§29) les mesures PV et batterie ne\n// decrivent aucun regime permanent : on ne descend pas sur elles.\n// ⚠️ MANQUE BATTERIE EN HC : NE DESCENDRE QUE SI LA BATTERIE EST SATUREE (09/10/2026).\n// HC de midi du 09/10, jour faible, 12,9 kWh demandes, ACM a 140 A : un chargeur\n// bride a fait descendre la consigne a 2 524 W (la maison) et la batterie n'a pris\n// que 32 A. Un chargeur bride ne prouve une ressource refusee QUE si la batterie\n// est pres de sa limite de charge (sinon elle prendrait le solaire) ; avec 7 kW de\n// soleil et la batterie a sa limite, descendre laisse bien entrer le solaire\n// (scenario du 18/09).\nconst batteryNearChargeLimit =\n Number.isFinite(params.dvccChargeLimitA) && params.dvccChargeLimitA > 0 &&\n parsed.batteryCurrentA >= 0.8 * params.dvccChargeLimitA;\nconst gridSetpointDescendOk =\n gridSessionActive &&\n (!(batterySetpointShareW > 0) || batteryNearChargeLimit) &&\n solarResourceRefused &&\n !batteryPolicy.batterySoftBreachNow &&\n !derived.gridSettling &&\n gridSetpointCurrentW !== null;\n\n// ⚠️ LE PLAFOND EST UN PLAFOND, PAS UNE CIBLE (corrige 11/09 17:52).\n// Premiere version de ce correctif, en production 12 minutes : la consigne\n// descendait bien a 2241 W et la PV remontait a 7074 W -- puis, tous les\n// chargeurs repassant `mppt_active`, la condition de descente devenait fausse,\n// le besoin revenait au plafond et la consigne etait REECRITE a 7100 W. La PV\n// s'effondrait a 1785 W, les chargeurs se bridaient, on redescendait.\n// Oscillateur de periode ~5 min. Releve : 17:40:30 -> 2241 / 17:47:19 -> 7100\n// / 17:47:28 PV 1785 / 17:48:30 -> 7368.\n//\n// Une fois la consigne descendue, on la TIENT tant que le solaire produit. Ce\n// n'est pas un etat a memoriser : la valeur courante est dans le GX, et le\n// plancher maison la fait remonter d'elle-meme si la maison grossit.\n//\n// La nuit, les chargeurs sont `off` (ils ne produisent pas) : on retombe sur le\n// comportement historique -- la consigne monte au plafond pour que le reseau\n// porte maison et voiture (§31, §57). C'est cette distinction qui empeche le\n// « on tient » de casser les nuits HC.\nconst solarProducing =\n parsed.mpptReadableCount > 0 && parsed.mpptAtMaxPowerCount > 0;\n\nlet gridSetpointNeedW;\nlet gridSetpointDescending = false;\n\nif (!gridSessionActive) {\n gridSetpointNeedW = params.gridSetpointIdleW;\n} else if (solarProducing && !batteryPolicy.batterySoftBreachNow && gridSetpointCurrentW !== null\n && !(batterySetpointShareW > 0)) {\n // On tient la valeur acquise, bornee par la maison et par le plafond.\n // ⚠️ SAUF si le bilan finance la batterie (07/10/2026) : HC de midi d'un jour\n // faible, SOC 31 pour 44 % requis, solaire a ~1,5 kW donc chargeurs « au\n // max » -> la consigne restait tenue a 5 198 W (la maison) et la batterie ne\n // prenait que 3 a 6 A, alors que l'Auto Charge Manager l'autorisait a 99 A.\n // Le maintien protege le solaire d'une consigne trop haute ; quand la\n // batterie a un MANQUE chiffre a reprendre en heures creuses, le solaire et\n // le reseau la rechargent ENSEMBLE : on va au plafond (maison + part batterie).\n gridSetpointNeedW = clamp(\n Math.min(gridSetpointCurrentW, gridSetpointCeilingW),\n gridSetpointHouseFloorW,\n gridSetpointCeilingW\n );\n} else {\n gridSetpointNeedW = gridSetpointCeilingW;\n}\n\nif (gridSetpointDescendOk) {\n // Un chargeur bride PROUVE qu'il reste de la ressource : on peut descendre\n // franchement. Une descente trop forte est rattrapee au cycle suivant (30 s\n // ou la batterie porte l'ecart, soit quelques dizaines de Wh), alors qu'une\n // descente trop timide laisse des kW de solaire refuses pendant dix minutes.\n const minStepW = Math.max(\n GRID_SETPOINT_DEADBAND_W,\n Math.round(params.currentStepUpA * derived.acVoltageRef)\n );\n const baseW = Math.min(gridSetpointCurrentW, gridSetpointCeilingW);\n const gapW = baseW - gridSetpointHouseFloorW;\n if (gapW > 0) {\n const stepW = Math.max(minStepW, Math.round(gapW / 2));\n gridSetpointNeedW = Math.max(gridSetpointHouseFloorW, baseW - stepW);\n gridSetpointDescending = true;\n }\n}\n\n// La bande est ASYMETRIQUE, et c'est le point. Une consigne trop BASSE fait\n// porter la difference par la batterie : c'est le defaut de la nuit du 01/09,\n// il se corrige immediatement. Une consigne trop HAUTE ne coute rien : l'ESS\n// ne preleve que ce qu'il peut placer, et le courant de charge de la batterie\n// est tenu par ailleurs (DVCC, Auto Charge Manager). Deux leviers, deux roles\n// -- la consigne dit ce que le reseau a le DROIT de servir, DVCC dit ce que la\n// batterie a le droit de prendre. Ne pas confondre les deux.\n//\n// On ecrit donc avec une marge d'un pas de courant EVCS (grandeur du systeme,\n// pas une constante inventee) pour que le bruit de la maison ne rende pas la\n// consigne insuffisante au cycle suivant, et on ne redescend que si elle est\n// devenue franchement inutile. Sans cela la boucle 30 s reecrivait a chaque\n// cycle en poursuivant le bruit.\nconst gridSetpointSlackW = gridSessionActive && !gridSetpointDescending\n ? Math.max(GRID_SETPOINT_DEADBAND_W, Math.round(params.currentStepUpA * derived.acVoltageRef))\n : 0;\n\nconst gridSetpointTargetW = clamp(\n gridSetpointNeedW + gridSetpointSlackW,\n 0,\n GRID_SETPOINT_MAX_W\n);\n\nconst gridSetpointNeedsWrite =\n gridSetpointCurrentW === null ||\n gridSetpointCurrentW < gridSetpointNeedW ||\n gridSetpointCurrentW > gridSetpointNeedW + 2 * gridSetpointSlackW + GRID_SETPOINT_DEADBAND_W;\n\nreason.push(`Grid=${parsed.gridAvailable ? 'on' : 'off'} (${Math.round(parsed.gridPowerW)}W)`);\n// Trace de la fenetre d'etablissement : sans elle, une lecture du logbook\n// pendant le transitoire donne l'impression que le systeme decide n'importe\n// quoi. Il decide, au contraire, de ne PAS decider sur ces mesures.\nif (derived.gridSettling) {\n reason.push(`ETABLISSEMENT reseau ${Math.round(parsed.gridTransitionAgeSec)}s/${params.gridSettleSeconds}s (mesures PV/batterie non fiables)`);\n}\nif (gridSessionActive) {\n reason.push(\n gridSetpointDescending\n ? `Consigne reseau=${gridSetpointTargetW}W (CREDIT SOLAIRE, ${parsed.mpptLimitedCount}/${parsed.mpptReadableCount} brides)`\n : `Consigne reseau=${gridSetpointTargetW}W`\n );\n}\n// Visible dans le logbook uniquement pendant un depassement souple : c'est le\n// seul moment ou l'information sert, et la raison est tronquee a 255 caracteres.\nif (batteryPolicy.batterySoftBreachNow) {\n reason.push(\n `Batt souple ${round1(batteryPolicy.batterySoftBreachMinutes)}/${round1(batteryPolicy.batterySoftToleranceMinutes)}min`\n );\n}\n// Idem : visible seulement quand le bilan energetique DURCIT le plancher du\n// soir. Sans cette trace, un blocage de charge un soir de SOC 94 % est\n// incomprehensible a la lecture du logbook.\nif (safeguards.eveningFloorFromBalance) {\n reason.push(\n `Plancher soir=${round1(safeguards.eveningSocStartFloorPct)}% (BILAN, ${round1(safeguards.balanceEnergyToCoverKwh)}kWh a couvrir)`\n );\n}\nreason.push(`Cap=${derived.physicalCapA}A`);\nreason.push(`Ref=${round1(derived.currentReferenceA)}A`);\nreason.push(`Target=${round1(desiredCurrentA)}A`);\nreason.push(`Debt=${round2(cloud.cloudDebtNextKwh)}kWh`);\nreason.push(`Vehicle=${parsed.evcsVehicleConnected ? 'on' : 'off'}`);\nreason.push(`EVCS mode=${parsed.evcsModeRaw}`);\nreason.push(`VE.Bus AC=${Math.round(parsed.vebusAcOutPowerW)}W`);\nreason.push(\n `VE.Bus DC=${\n Number.isFinite(parsed.vebusDcVoltageV)\n ? round1(parsed.vebusDcVoltageV)\n : 'n/a'\n }V`\n);\nreason.push(`PrioEVCS=${evcsPriorityActive ? 'on' : 'off'}(${evcsPrioritySource})`);\nreason.push(`VDF delivery=${Number.isFinite(vdf.deliveryRatioEma) ? round3(vdf.deliveryRatioEma) : 'na'}`);\nreason.push(`VDF noise=${Number.isFinite(vdf.deliveryNoiseEma) ? round3(vdf.deliveryNoiseEma) : 'na'}`);\nreason.push(`VDF score=${Number.isFinite(vdf.noDemandScoreEma) ? round3(vdf.noDemandScoreEma) : 'na'}`);\nreason.push(`VDF noDemand=${vdf.evcsVehicleNoDemandSemantic ? 'on' : 'off'}`);\n\nif (night.nightSessionActive) {\n reason.push(`NightTarget=${round1(night.batteryMorningTargetPct ?? 0)}%`);\n reason.push(`NightTrigger=${round1(night.gridTakeoverTriggerPct ?? 0)}%`);\n\n if (Number.isFinite(night.nightSessionStartSocPct)) {\n reason.push(`StartSOC=${round1(night.nightSessionStartSocPct)}%`);\n reason.push(`AllowDrop=${round1(night.maxBatteryDischargePoints ?? 0)}pts`);\n }\n\n reason.push(`NightPreferred=${night.nightPreferredSourceMode ?? 'blocked'}`);\n reason.push(`NightTakeover=${night.nightGridTakeoverNeeded ? 'on' : 'off'}`);\n reason.push(`NightUrgent=${night.batteryRecoveryUrgent ? 'on' : 'off'}`);\n}\n\nconst shortReason = reason.join(' | ').slice(0, 255);\n\nconst sourceModeLabel =\n context.sourceMode === 'blocked' ? 'Bloqué' :\n context.sourceMode === 'solar_only' ? 'Solaire' :\n context.sourceMode === 'solar_potential' ? 'Potentiel solaire' :\n context.sourceMode === 'hybrid_probe' ? 'Probe hybride' :\n context.sourceMode === 'grid_hc_allowed' ? 'Réseau HC autorisé' :\n context.sourceMode === 'grid_forced' ? 'Réseau forcé' :\n context.sourceMode === 'battery_assist_night' ? 'Assistance batterie nuit' :\n context.sourceMode;\n\nconst signature = [\n decision.state,\n `profile=${context.profile}`,\n `source=${context.sourceMode}`,\n `chg=${decision.desiredCharging ? '1' : '0'}`,\n `perm=${decision.desiredPermission ? '1' : '0'}`,\n `l=${decision.desiredLatch ? '1' : '0'}`,\n `cur=${round1(desiredCurrentA)}`,\n `block=${decision.nextBlockReason}`,\n `stop=${decision.mustStop ? '1' : '0'}`,\n `prio=${evcsPriorityActive ? '1' : '0'}`,\n `night=${night.nightSessionActive ? '1' : '0'}`,\n `ntrg=${Number.isFinite(night.gridTakeoverTriggerPct) ? round1(night.gridTakeoverTriggerPct) : 'na'}`,\n `nurg=${night.batteryRecoveryUrgent ? '1' : '0'}`,\n `vdf=${vdf.evcsVehicleNoDemandSemantic ? '1' : '0'}`,\n `vdfs=${Number.isFinite(vdf.noDemandScoreEma) ? round3(vdf.noDemandScoreEma) : 'na'}`\n].join('|');\n\nconst signatureChanged = signature !== history.lastSignature;\n\n// =====================================================\n// NOTIFICATIONS\n// =====================================================\nlet notifyWanted = false;\nlet notifyTitle = 'Victron EVCS Surplus';\nlet notifyMessage = shortReason;\n\nif (parsed.notificationsEnabled && signatureChanged) {\n if (decision.currentDecision === 'start') {\n notifyWanted = true;\n notifyTitle = '⚡ EVCS démarrage';\n } else if (decision.currentDecision === 'step_up') {\n notifyWanted = true;\n notifyTitle = '⬆️ EVCS hausse courant';\n } else if (\n decision.currentDecision === 'step_down' ||\n decision.currentDecision === 'step_down_fast'\n ) {\n notifyWanted = true;\n notifyTitle = '⬇️ EVCS baisse courant';\n } else if (\n decision.mustStop ||\n decision.currentDecision === 'stop' ||\n decision.currentDecision === 'stop_hard' ||\n decision.currentDecision === 'stop_power_cap'\n ) {\n notifyWanted = true;\n notifyTitle = '⛔ EVCS arrêt';\n } else if (decision.state === 'mode_auto_conflict') {\n notifyWanted = true;\n notifyTitle = '⚠️ EVCS mode Auto';\n } else if (decision.state === 'vehicle_full_or_paused') {\n notifyWanted = true;\n notifyTitle = '🚗 EVCS véhicule plein ou en pause';\n }\n}\n\n// =====================================================\n// TIMESTAMPS TO WRITE\n// =====================================================\nconst cloudDebtStartedAtToWrite =\n cloud.cloudDebtStartedMs\n ? formatParisStampFromMs(cloud.cloudDebtStartedMs)\n : now.stamp;\n\n// DEPASSEMENT SOUPLE BATTERIE (01/09/2026) : chaine vide quand il n'y a pas\n// de depassement en cours. Le helper n'est reecrit que sur changement reel\n// (debut ou fin), pas a chaque cycle : la valeur du debut doit RESTER figee\n// pendant toute la duree du depassement, sinon le compteur ne monte jamais.\nconst batterySoftBreachAtToWrite =\n batteryPolicy.batterySoftBreachStartedMs !== null &&\n batteryPolicy.batterySoftBreachStartedMs !== undefined\n ? formatParisStampFromMs(batteryPolicy.batterySoftBreachStartedMs)\n : '';\n\nconst setBatterySoftBreachAt =\n batterySoftBreachAtToWrite !== (history.batterySoftBreachAt || '');\n\nconst probeStartedAtToWrite = now.stamp;\nconst lastStartAtToWrite = now.stamp;\nconst lastStopAtToWrite = now.stamp;\nconst lastCurrentChangeAtToWrite = now.stamp;\n\n// =====================================================\n// NIGHT SESSION MEMORY\n// =====================================================\nconst nightSessionStartSocToWrite =\n decision.clearNightSessionStart\n ? ''\n : decision.setNightSessionStart\n ? String(round1(parsed.soc))\n : (\n Number.isFinite(history.nightSessionStartSocPct)\n ? String(round1(history.nightSessionStartSocPct))\n : ''\n );\n\nconst nightSessionStartAtToWrite =\n decision.clearNightSessionStart\n ? ''\n : decision.setNightSessionStart\n ? now.stamp\n : (history.nightSessionStartAt || '');\n\n// =====================================================\n// OUTPUT\n// =====================================================\nreturn [{\n json: {\n timestamp_paris: now.stamp,\n inputs: input,\n\n computed: {\n operating_mode: context.operatingMode,\n profile: context.profile,\n source_mode: context.sourceMode,\n source_mode_label: sourceModeLabel,\n user_strategy: strategy.userStrategy,\n\n in_night_hc_window: windows.inNightHcWindow,\n in_morning_window: windows.inMorningWindow,\n in_midday_window: windows.inMiddayWindow,\n in_evening_window: windows.inEveningWindow,\n\n // D'ou vient la consigne reseau. Sans cette trace, un repli est\n // SILENCIEUX : la consigne reprend la formule d'avant sans que rien ne le\n // dise, et c'est precisement l'information qui manquera le jour ou elle se\n // comportera mal. `bilan` = dimensionnee sur le manque, `heritee` = repli.\n setpoint_source: setpointFromBalance ? 'bilan' : 'heritee',\n setpoint_battery_share_w: Math.round(batterySetpointShareW),\n setpoint_evcs_share_w: Math.round(evcsSetpointShareW),\n dvcc_limit_w: dvccChargeLimitW === null ? null : Math.round(dvccChargeLimitW),\n hc_minutes_left: hcMinutesLeft,\n\n departure_priority_active: context.departurePriorityActive,\n minutes_until_departure: lifecycle.minutesUntilDeparture,\n\n battery_priority_mode: modes.batteryPriorityMode,\n battery_full_harvest_mode: modes.batteryFullHarvestMode,\n forecast_commit_mode: modes.forecastCommitMode,\n\n physical_cap_current_a: derived.physicalCapA,\n can_run_at_min_current: derived.canRunAtMinCurrent,\n\n forecast_buffer_kwh: round2(derived.forecastBufferKwh),\n projected_end_soc_after_charge: round1(derived.projectedEndSocAfterCharge),\n\n cloud_debt_next_kwh: round2(cloud.cloudDebtNextKwh),\n cloud_debt_duration_minutes: round1(cloud.cloudDebtDurationMinutes),\n cloud_debt_active: cloud.cloudDebtActive,\n cloud_tolerance_ok: cloud.cloudToleranceOk,\n\n start_blocked_by_reason: locks.startBlockedByReason,\n\n probe_window_active: conditions.probeWindowActive,\n probe_success_now: conditions.probeSuccessNow,\n probe_failed_now: conditions.probeFailedNow,\n\n any_vebus_overload: derived.anyVebusOverload,\n any_vebus_low_battery: derived.anyVebusLowBattery,\n vebus_ac_out_soft_reached: derived.vebusAcOutSoftReached,\n vebus_ac_out_hard_reached: derived.vebusAcOutHardReached,\n vebus_dc_voltage_soft_low: derived.vebusDcVoltageSoftLow,\n vebus_dc_voltage_hard_low: derived.vebusDcVoltageHardLow,\n\n soc_band: batteryPolicy.socBand,\n batt_discharge_soft_dynamic_a: round1(batteryPolicy.batteryDischargeSoftDynamicA),\n batt_discharge_hard_dynamic_a: round1(batteryPolicy.batteryDischargeHardDynamicA),\n batt_charge_reserve_dynamic_a: round1(batteryPolicy.batteryChargeReserveDynamicA),\n batt_soft_tolerance_min: round1(batteryPolicy.batterySoftToleranceMinutes),\n batt_hard_tolerance_sec: round1(batteryPolicy.batteryHardToleranceSeconds),\n batt_recovery_min: round1(batteryPolicy.batteryRecoveryMinutes),\n grid_battery_soft_dynamic_a: round1(batteryPolicy.gridBatterySoftDynamicA),\n grid_battery_hard_dynamic_a: round1(batteryPolicy.gridBatteryHardDynamicA),\n house_spike_detected: batteryPolicy.houseSpikeDetected,\n solar_potential_context: batteryPolicy.solarPotentialContext,\n\n batt_soft_breach_now: !!batteryPolicy.batterySoftBreachNow,\n batt_soft_breach_minutes: round1(batteryPolicy.batterySoftBreachMinutes || 0),\n batt_soft_discharge_hold_ok: batteryPolicy.batterySoftDischargeHoldOk !== false,\n\n evcs_priority_active: evcsPriorityActive,\n energy_priority_mode: energyPriorityMode,\n energy_priority_reason: energyPriorityReason,\n evcs_priority_until: evcsPriorityUntil,\n evcs_priority_source: evcsPrioritySource,\n evcs_priority_manual_override_active: manualPriorityOverrideActive,\n\n night_session_active: night.nightSessionActive,\n night_session_needs_capture: !!night.nightSessionNeedsCapture,\n night_session_needs_clear: !!night.nightSessionNeedsClear,\n night_session_start_soc_pct: Number.isFinite(night.nightSessionStartSocPct)\n ? round1(night.nightSessionStartSocPct)\n : null,\n night_session_start_at: night.nightSessionStartAt || null,\n\n battery_hard_floor_pct: Number.isFinite(night.batteryHardFloorPct)\n ? round1(night.batteryHardFloorPct)\n : null,\n battery_morning_target_pct: Number.isFinite(night.batteryMorningTargetPct)\n ? round1(night.batteryMorningTargetPct)\n : null,\n max_battery_discharge_points: Number.isFinite(night.maxBatteryDischargePoints)\n ? round1(night.maxBatteryDischargePoints)\n : null,\n grid_takeover_trigger_pct: Number.isFinite(night.gridTakeoverTriggerPct)\n ? round1(night.gridTakeoverTriggerPct)\n : null,\n\n battery_recovery_urgent: !!night.batteryRecoveryUrgent,\n night_grid_takeover_needed: !!night.nightGridTakeoverNeeded,\n night_preferred_source_mode: night.nightPreferredSourceMode || 'blocked',\n\n evcs_delivery_ratio_ema: Number.isFinite(vdf.deliveryRatioEma) ? round3(vdf.deliveryRatioEma) : null,\n evcs_delivery_noise_ema: Number.isFinite(vdf.deliveryNoiseEma) ? round3(vdf.deliveryNoiseEma) : null,\n evcs_no_demand_score_ema: Number.isFinite(vdf.noDemandScoreEma) ? round3(vdf.noDemandScoreEma) : null,\n evcs_vehicle_no_demand_semantic: !!vdf.evcsVehicleNoDemandSemantic,\n evcs_no_demand_since_minutes: Number.isFinite(vdf.evcsNoDemandSinceMinutes) ? round3(vdf.evcsNoDemandSinceMinutes) : null,\n evcs_source_deliverable_for_no_demand: !!vdf.sourceDeliverableForNoDemand,\n evcs_session_armed_for_no_demand: !!vdf.evcsSessionArmedForNoDemand,\n evcs_no_demand_score_on: Number.isFinite(vdf.noDemandScoreOn) ? round3(vdf.noDemandScoreOn) : null,\n evcs_no_demand_score_off: Number.isFinite(vdf.noDemandScoreOff) ? round3(vdf.noDemandScoreOff) : null\n },\n\n decision: {\n state: decision.state,\n current_decision: decision.currentDecision,\n desired_charging: decision.desiredCharging,\n desired_permission: decision.desiredPermission,\n desired_latch: decision.desiredLatch,\n desired_current_a: round1(desiredCurrentA),\n\n charging_action: chargingAction,\n permission_action: permissionAction,\n latch_action: latchAction,\n current_action: currentAction,\n auto_start_action: autoStartAction,\n\n set_last_start_at: setLastStartAt,\n set_last_stop_at: setLastStopAt,\n set_last_current_change_at: setLastCurrentChangeAt,\n set_cloud_debt_started_at: decision.setCloudDebtStartedAt,\n set_probe_started_at: decision.setProbeStartedAt,\n\n set_night_session_start: !!decision.setNightSessionStart,\n clear_night_session_start: !!decision.clearNightSessionStart,\n\n set_block_reason: needsBlockReasonUpdate || !parsed.controllerEnabled,\n block_reason: decision.nextBlockReason,\n\n set_blocked_until: true,\n blocked_until: blockedUntilToWrite,\n\n dynamic_ok: decision.dynamicOk,\n must_stop: decision.mustStop,\n\n short_reason: shortReason,\n reason,\n signature,\n signature_changed: signatureChanged,\n\n should_notify: notifyWanted,\n notify_title: notifyTitle,\n notify_message: notifyMessage,\n\n evcs_priority_active: evcsPriorityActive,\n energy_priority_mode: energyPriorityMode,\n energy_priority_reason: energyPriorityReason,\n evcs_priority_until: evcsPriorityUntil,\n evcs_priority_source: evcsPrioritySource\n },\n\n ha_service_calls: {\n helper_state: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_surplus_state',\n value: decision.state\n }\n },\n\n helper_current_decision: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_surplus_current_decision',\n value: decision.currentDecision\n }\n },\n\n helper_reason: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_surplus_reason',\n value: shortReason\n }\n },\n\n helper_operating_mode: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_operating_mode',\n value: context.operatingMode\n }\n },\n\n helper_source_mode: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_source_mode',\n value: sourceModeLabel\n }\n },\n\n helper_target_current: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_target_current_auto',\n value: round1(desiredCurrentA)\n }\n },\n\n helper_budget_remaining: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_budget_remaining_kwh',\n value: round2(derived.forecastBufferKwh)\n }\n },\n\n helper_projected_soc: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_projected_end_soc_after_charge',\n value: round1(derived.projectedEndSocAfterCharge)\n }\n },\n\n helper_cloud_debt: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_cloud_debt_kwh',\n value: round2(cloud.cloudDebtNextKwh)\n }\n },\n\n helper_dynamic_ok: {\n execute: true,\n domain: 'input_boolean',\n service: decision.dynamicOk ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_dynamic_ok'\n }\n },\n\n helper_must_stop: {\n execute: true,\n domain: 'input_boolean',\n service: decision.mustStop ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_must_stop'\n }\n },\n\n // Bouton reset borne (19/09/2026) : momentane. Il a deja fait son effet\n // dans WINDOWS (sessionFresh forcee ce cycle) ; on le rebascule off pour\n // qu'il ne force pas un retest en boucle. N'ecrit que s'il etait ON.\n helper_reset_borne_ack: {\n execute: !!params.resetBorneRequested,\n domain: 'input_boolean',\n service: 'turn_off',\n data: {\n entity_id: 'input_boolean.evcs_reset_borne'\n }\n },\n\n helper_cloud_debt_started_at: {\n execute: true,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_cloud_debt_started_at',\n datetime: cloudDebtStartedAtToWrite\n }\n },\n\n helper_battery_soft_breach_at: {\n execute: setBatterySoftBreachAt,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_battery_soft_breach_at',\n value: batterySoftBreachAtToWrite\n }\n },\n\n helper_last_current_change_at: {\n execute: setLastCurrentChangeAt,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_last_current_change_at',\n datetime: lastCurrentChangeAtToWrite\n }\n },\n\n helper_last_start_at: {\n execute: setLastStartAt,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_last_start_at',\n datetime: lastStartAtToWrite\n }\n },\n\n helper_last_stop_at: {\n execute: setLastStopAt,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_last_stop_at',\n datetime: lastStopAtToWrite\n }\n },\n\n helper_signature: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_control_last_signature',\n value: signature\n }\n },\n\n helper_block_reason: {\n execute: needsBlockReasonUpdate || !parsed.controllerEnabled,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_last_block_reason',\n value: decision.nextBlockReason\n }\n },\n\n helper_blocked_until: {\n execute: true,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_blocked_until',\n datetime: blockedUntilToWrite\n }\n },\n\n helper_probe_started_at: {\n execute: decision.setProbeStartedAt,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_probe_started_at',\n datetime: probeStartedAtToWrite\n }\n },\n\n helper_evcs_priority_active: {\n execute: true,\n domain: 'input_boolean',\n service: evcsPriorityActive ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_priority_active'\n }\n },\n\n helper_energy_priority_mode: {\n execute: true,\n domain: 'input_select',\n service: 'select_option',\n data: {\n entity_id: 'input_select.victron_energy_priority_mode',\n option: energyPriorityMode\n }\n },\n\n helper_energy_priority_reason: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_energy_priority_reason',\n value: energyPriorityReason\n }\n },\n\n helper_evcs_priority_until: {\n execute: true,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_priority_until',\n datetime: evcsPriorityUntil\n }\n },\n\n helper_night_target_soc: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_night_target_soc',\n value: round1(\n Number.isFinite(night.batteryMorningTargetPct)\n ? night.batteryMorningTargetPct\n : 0\n )\n }\n },\n\n helper_night_grid_trigger_soc: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_night_grid_trigger_soc',\n value: round1(\n Number.isFinite(night.gridTakeoverTriggerPct)\n ? night.gridTakeoverTriggerPct\n : 0\n )\n }\n },\n\n helper_night_preferred_source_mode: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_night_preferred_source_mode',\n value: night.nightPreferredSourceMode || 'blocked'\n }\n },\n\n // SESSION RESEAU UNIQUE (03/09/2026) : ce bit est ce qui rend la session\n // UNIQUE. Sans persistance, l'engagement serait reevalue a chaque cycle\n // et le systeme retomberait dans une bande — donc dans un oscillateur.\n // Ecrit uniquement sur changement reel.\n helper_night_grid_session_committed: {\n execute: night.nightGridSessionCommitted !== history.nightGridSessionCommittedPrev,\n domain: 'input_boolean',\n service: night.nightGridSessionCommitted ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_night_grid_session_committed'\n }\n },\n\n // P12 — LE VERROU ANTI-OSCILLATEUR.\n //\n // Le plancher de surplus (`required_soc_prudent`) DESCEND pendant la nuit,\n // et le SOC aussi. Mesure du 15/09 : voiture en charge, le SOC descend de\n // 7,1 pt/h et le plancher de 3,7 ; voiture ARRETEE, le SOC ne descend plus\n // que de 2,6 pt/h quand le plancher continue a 3,7. Le surplus\n // REAPPARAITRAIT donc tout seul et la charge repartirait — cinquieme\n // oscillateur du projet, par construction.\n //\n // Une seule passe de surplus par session de nuit, comme la recovery\n // batterie depuis le 27/08. Ecrit uniquement sur changement reel.\n helper_night_battery_done: {\n execute: !!night.nightBatterySurplusExhausted !== !!params.nightBatteryDonePrev,\n domain: 'input_boolean',\n service: night.nightBatterySurplusExhausted ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_night_battery_done'\n }\n },\n\n helper_night_grid_takeover_needed: {\n execute: true,\n domain: 'input_boolean',\n // P9 : la fenêtre \"départ imminent\" (jour) tient aussi le helper\n // takeover → override hystérésis + maintien batterie ACM pendant\n // la charge réseau pré-départ.\n service: (night.nightGridTakeoverNeeded || night.departureUrgentActive)\n ? 'turn_on'\n : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_night_grid_takeover_needed'\n }\n },\n\n helper_night_battery_recovery_urgent: {\n execute: true,\n domain: 'input_boolean',\n service: night.batteryRecoveryUrgent ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_night_battery_recovery_urgent'\n }\n },\n\n helper_night_session_active: {\n execute: true,\n domain: 'input_boolean',\n service: night.nightSessionActive ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_night_session_active'\n }\n },\n\n helper_night_session_start_soc: {\n execute: decision.setNightSessionStart || decision.clearNightSessionStart,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_night_session_start_soc',\n value: nightSessionStartSocToWrite\n }\n },\n\n // Budget voiture de la nuit (30/09) : fige a l'ouverture, efface en fin.\n helper_night_budget: {\n execute: night.nightBudgetToWrite !== null && night.nightBudgetToWrite !== undefined,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_night_budget',\n value: night.nightBudgetToWrite ?? ''\n }\n },\n\n helper_night_session_start_at: {\n execute: decision.setNightSessionStart || decision.clearNightSessionStart,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_night_session_start_at',\n value: nightSessionStartAtToWrite\n }\n },\n\n // =================================================\n // VEHICLE DEMAND FILTER HISTORY PERSISTENCE\n // =================================================\n helper_evcs_delivery_ratio_ema: {\n execute: Number.isFinite(vdf?.nextHistory?.evcsDeliveryRatioEma),\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_delivery_ratio_ema',\n value: round3(vdf.nextHistory.evcsDeliveryRatioEma)\n }\n },\n\n helper_evcs_delivery_noise_ema: {\n execute: Number.isFinite(vdf?.nextHistory?.evcsDeliveryNoiseEma),\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_delivery_noise_ema',\n value: round3(vdf.nextHistory.evcsDeliveryNoiseEma)\n }\n },\n\n helper_evcs_no_demand_score_ema: {\n execute: Number.isFinite(vdf?.nextHistory?.evcsNoDemandScoreEma),\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_no_demand_score_ema',\n value: round3(vdf.nextHistory.evcsNoDemandScoreEma)\n }\n },\n\n helper_evcs_no_demand_semantic_active: {\n execute: true,\n domain: 'input_boolean',\n service: vdf?.nextHistory?.evcsNoDemandSemanticActive ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_no_demand_semantic_active'\n }\n },\n\n helper_evcs_no_demand_since_at: {\n execute: Number.isFinite(vdf?.nextHistory?.evcsNoDemandSinceMs),\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_no_demand_since_at',\n datetime: formatParisStampFromMs(vdf.nextHistory.evcsNoDemandSinceMs)\n }\n },\n\n helper_evcs_no_demand_filter_eval_at: {\n execute: Number.isFinite(vdf?.nextHistory?.evcsNoDemandFilterEvalMs),\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_no_demand_filter_eval_at',\n datetime: formatParisStampFromMs(vdf.nextHistory.evcsNoDemandFilterEvalMs)\n }\n },\n\n // =================================================\n // PHYSICAL EDF GRID TAKEOVER\n // =================================================\n edf_general_ok_on: {\n execute:\n (['grid_forced', 'grid_hc_allowed'].includes(context.sourceMode) ||\n night.nightBatteryGridOnly === true) &&\n !parsed.gridAvailable,\n domain: 'switch',\n service: 'turn_on',\n data: {\n entity_id: 'switch.edf_general_ok_switch'\n }\n },\n\n grid_power_setpoint: {\n execute: gridSetpointNeedsWrite,\n domain: 'number',\n service: 'set_value',\n data: {\n entity_id: 'number.gx_device_ac_power_setpoint',\n value: gridSetpointTargetW\n }\n },\n\n evcs_auto_start_off: {\n execute: autoStartAction === 'turn_off',\n domain: 'switch',\n service: 'turn_off',\n data: {\n entity_id: 'switch.victron_evcs_auto_start'\n }\n },\n\n evcs_charging: {\n execute: chargingAction !== 'none',\n domain: 'switch',\n service: decision.desiredCharging ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'switch.victron_evcs_charging'\n }\n },\n\n helper_permission: {\n execute: permissionAction !== 'none',\n domain: 'input_boolean',\n service: decision.desiredPermission ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_charge_permission_now'\n }\n },\n\n helper_latch: {\n execute: latchAction !== 'none',\n domain: 'input_boolean',\n service: decision.desiredLatch ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_control_latch'\n }\n },\n\n evcs_set_current: {\n execute: currentAction === 'set',\n domain: 'number',\n service: 'set_value',\n data: {\n entity_id: 'number.victron_evcs_set_current_command',\n value: round1(desiredCurrentA)\n }\n },\n\n logbook: {\n execute: signatureChanged,\n domain: 'logbook',\n service: 'log',\n data: {\n name: 'Victron EVCS Surplus',\n message: shortReason\n }\n },\n\n mobile_notification: {\n execute: notifyWanted,\n domain: cfg.mobileNotifyDomain,\n service: cfg.mobileNotifyService,\n data: {\n title: notifyTitle,\n message: notifyMessage || shortReason\n }\n }\n }\n }\n}];\n" + "jsCode": "// Bornes de la consigne de puissance reseau. Le maximum est celui declare par\n// l'entite elle-meme (number.airtria_mpii_v1_ac_power_setpoint_l1, max 10000 W),\n// pas un chiffre choisi ici. La bande morte evite de reecrire la meme valeur\n// toutes les 30 s sans empecher la correction d'une derive externe.\nconst GRID_SETPOINT_MAX_W = 10000;\nconst GRID_SETPOINT_DEADBAND_W = 100;\n\nfunction round1(v) {\n return Math.round(v * 10) / 10;\n}\n\nfunction round2(v) {\n return Math.round(v * 100) / 100;\n}\n\nfunction round3(v) {\n return Math.round(v * 1000) / 1000;\n}\n\nfunction clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nfunction to2(n) {\n return String(n).padStart(2, '0');\n}\n\nfunction formatParisStampFromMs(ms) {\n const fmt = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris',\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n fmt.formatToParts(new Date(ms))\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour);\n return `${parts.year}-${parts.month}-${parts.day} ${to2(hour)}:${parts.minute}:${parts.second}`;\n}\n\nconst prev = $input.first().json;\n\nconst {\n input,\n now,\n nowMs,\n cfg,\n parsed,\n params,\n history,\n strategy,\n windows,\n lifecycle,\n context,\n derived,\n batteryTune,\n batteryPolicy,\n locks,\n cloud,\n modes,\n safeguards,\n conditions,\n decision,\n nightEvcs,\n vehicleDemandFilter,\n} = prev;\n\n// =====================================================\n// SAFE ACCESS NIGHT EVCS\n// =====================================================\nconst night = nightEvcs ?? {\n nightSessionActive: false,\n nightSessionNeedsCapture: false,\n nightSessionNeedsClear: false,\n nightSessionStartSocPct: null,\n nightSessionStartAt: '',\n batteryHardFloorPct: null,\n batteryMorningTargetPct: null,\n maxBatteryDischargePoints: null,\n gridTakeoverTriggerPct: null,\n batteryRecoveryUrgent: false,\n nightGridTakeoverNeeded: false,\n nightPreferredSourceMode: 'blocked',\n};\n\n// =====================================================\n// SAFE ACCESS VEHICLE DEMAND FILTER\n// =====================================================\nconst vdf = vehicleDemandFilter ?? {\n dtSec: null,\n commandCurrentA: null,\n realCurrentA: null,\n expectedPowerW: null,\n realPowerW: null,\n currentDeliveryRatioRaw: null,\n powerDeliveryRatioRaw: null,\n deliveryRatioRaw: null,\n deliveryRatioEma: null,\n deliveryNoiseEma: null,\n adaptiveQuietBand: null,\n noDemandEvidenceRaw: null,\n sessionElapsedSec: null,\n sessionArmFactor: null,\n commandFactor: null,\n sourceDeliverableForNoDemand: false,\n evcsSessionArmedForNoDemand: false,\n noDemandScoreRaw: null,\n noDemandScoreEma: null,\n noDemandScoreOn: null,\n noDemandScoreOff: null,\n evcsVehicleNoDemandSemantic: false,\n evcsNoDemandSinceMs: null,\n evcsNoDemandSinceMinutes: null,\n nextHistory: {\n evcsDeliveryRatioEma: null,\n evcsDeliveryNoiseEma: null,\n evcsNoDemandScoreEma: null,\n evcsNoDemandSemanticActive: false,\n evcsNoDemandSinceMs: null,\n evcsNoDemandFilterEvalMs: null\n }\n};\n\n// =====================================================\n// FINAL ACTIONS\n// =====================================================\nlet desiredCurrentA = decision.desiredCurrentA;\n\ndesiredCurrentA = decision.desiredCharging\n ? clamp(\n desiredCurrentA,\n params.currentMinA,\n Math.max(params.currentMinA, derived.physicalCapA || params.currentMinA)\n )\n : params.currentMinA;\n\nconst chargingAction =\n decision.desiredCharging === parsed.evcsChargingSwitchOn\n ? 'none'\n : decision.desiredCharging\n ? 'turn_on'\n : 'turn_off';\n\nconst permissionAction =\n decision.desiredPermission === parsed.chargePermissionNow\n ? 'none'\n : decision.desiredPermission\n ? 'turn_on'\n : 'turn_off';\n\nconst latchAction =\n decision.desiredLatch === parsed.controlLatch\n ? 'none'\n : decision.desiredLatch\n ? 'turn_on'\n : 'turn_off';\n\nconst autoStartAction =\n parsed.controllerEnabled &&\n parsed.evcsVehicleConnected &&\n parsed.evcsAutoStartSwitchOn\n ? 'turn_off'\n : 'none';\n\nconst currentAction =\n decision.desiredCharging &&\n (\n !Number.isFinite(derived.currentReferenceA) ||\n Math.abs(desiredCurrentA - derived.currentReferenceA) >= 0.5\n )\n ? 'set'\n : 'none';\n\nconst setLastStartAt = chargingAction === 'turn_on';\nconst setLastStopAt = chargingAction === 'turn_off';\nconst setLastCurrentChangeAt = currentAction === 'set';\n\n// =====================================================\n// CLEAN BLOCKING LOGIC\n// =====================================================\nconst blockedUntilToWrite =\n decision.nextBlockReason === 'none'\n ? now.stamp\n : decision.nextBlockedUntilStamp;\n\nconst needsBlockReasonUpdate =\n decision.nextBlockReason !== history.lastBlockReason;\n\n// =====================================================\n// GLOBAL EVCS PRIORITY\n// AUTO + OVERRIDE MANUEL\n// =====================================================\nconst forceWindowMinutes = Math.max(\n 30,\n batteryTune?.global?.forceModeAutoRevertMinutes ?? 120\n);\n\nconst activeChargeIntent =\n decision.desiredCharging ||\n ['start', 'hold', 'step_up', 'step_down', 'step_down_fast'].includes(decision.currentDecision);\n\nconst evcsVehicleNotRequestingPowerState =\n decision.state === 'vehicle_full_or_paused' ||\n decision.currentDecision === 'stop_vehicle_full_or_paused';\n\n// -------------------------\n// PRIORITÉ AUTO\n// -------------------------\nlet autoEvcsPriorityActive = false;\nlet autoEnergyPriorityMode = 'normal';\nlet autoEnergyPriorityReason = 'Aucune priorité EVCS';\nlet autoEvcsPriorityUntil = now.stamp;\n\nif (\n parsed.evcsVehicleConnected &&\n !strategy.isStopMode &&\n !evcsVehicleNotRequestingPowerState\n) {\n if (strategy.forceChargeRequested || strategy.forceGridRequested) {\n autoEvcsPriorityActive = true;\n autoEnergyPriorityMode = 'evcs_priority';\n autoEnergyPriorityReason =\n strategy.forceGridRequested\n ? 'EVCS prioritaire : force_grid_now'\n : 'EVCS prioritaire : force_charge_now';\n autoEvcsPriorityUntil = formatParisStampFromMs(nowMs + forceWindowMinutes * 60000);\n }\n\n else if (night.nightSessionActive) {\n autoEvcsPriorityActive = true;\n autoEnergyPriorityMode = 'evcs_priority';\n autoEnergyPriorityReason =\n night.batteryRecoveryUrgent\n ? 'EVCS prioritaire : session nuit + urgence départ'\n : night.nightGridTakeoverNeeded\n ? 'EVCS prioritaire : session nuit + takeover réseau'\n : 'EVCS prioritaire : session nuit active';\n\n autoEvcsPriorityUntil =\n Number.isFinite(lifecycle.minutesUntilDeparture)\n ? formatParisStampFromMs(\n nowMs + Math.max(1, lifecycle.minutesUntilDeparture) * 60000\n )\n : formatParisStampFromMs(nowMs + forceWindowMinutes * 60000);\n }\n\n else if (context.departurePriorityActive && Number.isFinite(lifecycle.minutesUntilDeparture)) {\n autoEvcsPriorityActive = true;\n autoEnergyPriorityMode = 'evcs_priority';\n autoEnergyPriorityReason = `EVCS prioritaire : départ dans ${Math.round(lifecycle.minutesUntilDeparture)} min`;\n autoEvcsPriorityUntil = formatParisStampFromMs(\n nowMs + Math.max(1, lifecycle.minutesUntilDeparture) * 60000\n );\n }\n\n else if (activeChargeIntent && context.profile === 'midday_hc_or_solar') {\n autoEvcsPriorityActive = true;\n autoEnergyPriorityMode = 'evcs_priority';\n autoEnergyPriorityReason = 'EVCS prioritaire : recharge active fenêtre midi';\n autoEvcsPriorityUntil = formatParisStampFromMs(nowMs + 90 * 60000);\n }\n}\n\n// -------------------------\n// OVERRIDE MANUEL\n// -------------------------\nconst manualPriorityOverrideActive =\n parsed.evcsPriorityManualOverride &&\n parsed.evcsPriorityManualMode === 'evcs_priority' &&\n history.evcsPriorityManualUntilMs !== null &&\n history.evcsPriorityManualUntilMs > nowMs + 1000;\n\nlet evcsPriorityActive = autoEvcsPriorityActive;\nlet energyPriorityMode = autoEnergyPriorityMode;\nlet energyPriorityReason = autoEnergyPriorityReason;\nlet evcsPriorityUntil = autoEvcsPriorityUntil;\nlet evcsPrioritySource = 'auto';\n\nif (manualPriorityOverrideActive && !evcsVehicleNotRequestingPowerState) {\n evcsPriorityActive = true;\n energyPriorityMode = 'evcs_priority';\n energyPriorityReason =\n parsed.evcsPriorityManualReason || 'EVCS prioritaire : override manuel';\n evcsPriorityUntil =\n history.evcsPriorityManualUntilAt || autoEvcsPriorityUntil;\n evcsPrioritySource = 'manual';\n}\n\n// =====================================================\n// REASONS / LABELS / SIGNATURE\n// =====================================================\nconst reason = [...decision.reason];\n\n// Budget voiture de la nuit (30/09) : dire pourquoi la voiture s'arrete.\nif (night.nightBudgetKwh !== null && night.nightBudgetKwh !== undefined) {\n const f = (x) => String(Math.round(x * 100) / 100).replace('.', ',');\n reason.push(`Budget batterie nuit ${f(night.nightBudgetDeliveredKwh ?? 0)}/${f(night.nightBudgetKwh)} kWh` +\n (night.nightCarRidesGridSession ? ' — sans EDF, mais EDF est ferme pour la batterie en heures creuses : la voiture en profite'\n : night.nightBatteryGridOnly ? ' — sans EDF : EDF ferme pour la batterie seule, la voiture attend le soleil'\n : night.nightCarWaitsNoGrid ? ' — atteint, sans EDF : la voiture attend le soleil'\n : night.nightBudgetReached ? ' — atteint, EDF finit la charge' : ''));\n} else if (night.nightCarRidesGridSession) {\n reason.push('Sans EDF, mais EDF est ferme pour la batterie en heures creuses : la voiture en profite');\n} else if (night.nightCarWaitsNoGrid) {\n reason.push('Pas de budget batterie exploitable, sans EDF : la voiture attend le soleil');\n}\nreason.push(`Stratégie=${strategy.userStrategy}`);\nreason.push(`Profil=${context.profile}`);\nreason.push(`Source=${context.sourceMode}`);\nreason.push(\n `Mode=${\n modes.batteryFullHarvestMode\n ? 'battery_full_harvest_mode'\n : modes.forecastCommitMode\n ? 'forecast_commit_mode'\n : 'battery_priority_mode'\n }`\n);\nreason.push(`SOC=${round1(parsed.soc)}%`);\nreason.push(`Batt=${round1(parsed.batteryCurrentA)}A`);\nreason.push(`PV=${Math.round(parsed.pvPowerW)}W`);\nreason.push(`PV10=${Math.round(parsed.pvPowerAvg10mW)}W`);\n// =====================================================\n// CONSIGNE DE PUISSANCE RESEAU (01/09/2026)\n// =====================================================\n// Fermer le contacteur ne suffit PAS. Au-dessus du SOC minimal, l'ESS en\n// autoconsommation ne preleve que `ac_power_setpoint` et laisse la batterie\n// porter tout le reste : nuit du 01/09, contacteur ferme, consigne 50 W,\n// batterie a -803 W pour 761 W de maison, voiture arretee par le garde\n// \"batterie trop sollicitee\". La consigne est donc le levier qui rend une\n// session reseau REELLE, et il doit appartenir au controleur, pas rester un\n// reglage manuel permanent (celui-ci provoquait la substitution du solaire\n// par le reseau en journee, cf. HANDOFF 27/28).\n//\n// La valeur n'est pas une constante : c'est le besoin mesure du moment, la\n// maison telle qu'elle consomme plus la voiture au courant que le controleur\n// s'autorise. Le plafond joue le role de marge : quand la voiture n'est pas a\n// son plafond, la consigne est deja genereuse, et l'exces ne peut pas partir\n// dans la batterie (DVCC tenue a 2 A par l'Auto Charge Manager pendant un\n// takeover). Hors session, retour a la valeur de repos.\n// ⚠️ LA SESSION ENGAGEE COMPTE AUSSI (04/09/2026).\n// Nuit du 03-04/09 : la session reseau unique a tenu le contacteur toute la\n// nuit — une seule bascule, comme voulu. Mais a 02:50, la voiture finie, le\n// sourceMode est passe a 'blocked' et la consigne est retombee de 5078 W a\n// 50 W. Contacteur ferme, reseau disponible, heures creuses, et l'ESS ne\n// demandait que 50 W pendant que la maison tirait 770 W SUR LA BATTERIE.\n// Trois heures et demie, SOC 44 % -> 17 %, 2,60 kWh pris a la batterie quand\n// le reseau etait la et bon marche.\n//\n// C'est le defaut du 01/09 revenu ailleurs : fermer le contacteur ne suffit\n// pas, la consigne doit demander la puissance. J'avais conditionne la consigne\n// au sourceMode, puis cree la veille une NOUVELLE raison de garder le\n// contacteur ferme — l'engagement de session — sans la brancher ici. La\n// session tenait le contacteur, pas l'energie.\n//\n// Regle : tant que le contacteur est ferme A NOTRE DEMANDE, la consigne doit\n// couvrir la maison. Peu importe laquelle des deux raisons le tient.\nconst gridSessionActive =\n parsed.acInAvailable &&\n (\n context.sourceMode === 'grid_forced' ||\n context.sourceMode === 'grid_hc_allowed' ||\n night.nightGridSessionCommitted === true ||\n // Batterie seule sur le reseau, voiture bloquee « sans EDF » (04/10).\n night.nightBatteryGridOnly === true\n );\n\n// =====================================================\n// DIMENSIONNEMENT SUR LE DEFICIT DU BILAN (18/09/2026)\n// =====================================================\n// Ce que la consigne remplace : `maison + plafond de la borne`, ou le plafond\n// de la borne n'a AUCUNE relation physique avec ce dont la batterie a besoin.\n// Qu'il tombe juste est un accident — releve du 17/09 a 13:51, voiture partie\n// depuis cinq minutes, consigne a 7032 W pour une maison a 933 W.\n//\n// La consigne additionne desormais des besoins NOMMES :\n// maison toujours ;\n// voiture seulement si un vehicule est branche ;\n// batterie seulement dans une fenetre d'heures creuses.\n//\n// ⚠️ MAX ET NON ADDITION, et ce n'est pas de la prudence. La consigne est une\n// AUTORISATION, pas une prediction de consommation — le commentaire de la bande\n// asymetrique le dit plus bas : « la consigne dit ce que le reseau a le DROIT\n// de servir, DVCC dit ce que la batterie a le droit de prendre ». Additionner\n// deux autorisations independantes n'est pas plus honnete, seulement plus\n// permissif. Mesure a l'appui : avec l'addition,\n// `consigne-session-engagee-couvre-la-maison` passe de 5595 a 6341 W, soit\n// 746 W d'autorisation en plus sur une nuit engagee qui fonctionne deja — une\n// regression sur le chemin DOMINANT pour corriger un chemin emprunte zero fois\n// en treize jours.\n//\n// ⚠️ ET LE PLAFOND DU TERME BATTERIE EST DVCC, PAS LE BMS. Le BMS declare\n// 250 A, soit 12,2 kW a 49 V : au-dessus de GRID_SETPOINT_MAX_W, donc il ne\n// bornerait rien. Mesure : en fin de fenetre (14 min restantes, manque\n// 5,672 kWh) la division rendait 17 kW, ecretes a 10 000 — PIRE que les 5437\n// d'aujourd'hui. Avec DVCC le terme vaut 1538 W et la consigne 2380 W.\n// Composer les deux leviers, ce n'est pas les confondre : le reseau n'est\n// autorise a servir la batterie qu'a hauteur de ce que la batterie est\n// autorisee a prendre.\n//\n// Corollaire : aucun plancher d'horizon n'est necessaire. Un horizon qui tend\n// vers zero donne `min(enorme, DVCC) = DVCC`. Il ne reste qu'une garde d'une\n// minute, qui est de l'arithmetique defensive et non une constante calibree.\n//\n// INVARIANT TENU PAR LA FORME : `maison + max(...)` ne peut jamais descendre\n// sous la maison, quel que soit DVCC. La regle du 01/09 et du 04/09 — « tant\n// que le contacteur est ferme A NOTRE DEMANDE, la consigne doit couvrir la\n// maison » — est donc garantie par construction. A DVCC nul, le plafond vaut\n// exactement la maison.\nconst SETPOINT_BALANCE_MAX_AGE_MIN = 30;\nconst setpointBalanceAgeMin = Number.isFinite(params.balanceLastRunMs)\n ? (nowMs - params.balanceLastRunMs) / 60000\n : null;\n\n// Le manque, pas l'energie a couvrir : voir le commentaire d'INPUT_PARSE.\nconst balanceUsableForSetpoint =\n params.balanceStatusOk &&\n Number.isFinite(params.balanceGridEnergyNeededKwh) &&\n setpointBalanceAgeMin !== null &&\n setpointBalanceAgeMin >= -5 &&\n setpointBalanceAgeMin <= SETPOINT_BALANCE_MAX_AGE_MIN;\n\nconst dvccChargeLimitW =\n Number.isFinite(params.dvccChargeLimitA) && params.dvccChargeLimitA >= 0\n ? params.dvccChargeLimitA * derived.batteryVoltageRef\n : null;\n\n// Toute lecture manquante retombe sur la formule d'avant : degradation vers\n// l'existant, jamais pire.\nconst setpointFromBalance = balanceUsableForSetpoint && dvccChargeLimitW !== null;\n\n// 14/10/2026 : la part voiture suit ce que la voiture PREND quand elle plafonne\n// sous la consigne. Boost EDF de la Volvo XC90 T8 (invite) : consigne borne\n// 28 A, la voiture ne prend que 3,7 kW (chargeur embarque). La part valait\n// 28 A x 230 V = 6,4 kW : les 2,7 kW de trop ont recharge la batterie maison\n// en HEURES PLEINES (2,81 kWh de 20:09 a 22:01, batterie +30 A a SOC 86 %).\n// Voiture plafonnee = puissance reelle franchement sous la consigne (2 A de\n// tolerance de mesure) : la part vaut alors la puissance reelle ; la marge\n// d'un pas de courant (gridSetpointSlackW) laisse la voiture remonter. Sinon\n// (rampe en cours, voiture qui suit la consigne) : plafond de borne, inchange.\nconst EVCS_SATURATION_TOLERANCE_A = 2;\nconst evcsCommandW = Number.isFinite(derived.currentReferenceA)\n ? derived.currentReferenceA * derived.acVoltageRef\n : null;\nconst evcsCarSaturated =\n parsed.evcsVehicleConnected &&\n evcsCommandW !== null &&\n parsed.evcsAcPowerW >= params.currentMinA * derived.acVoltageRef * 0.5 &&\n parsed.evcsAcPowerW < evcsCommandW - EVCS_SATURATION_TOLERANCE_A * derived.acVoltageRef;\nconst evcsSetpointShareW = parsed.evcsVehicleConnected\n ? (evcsCarSaturated\n ? Math.min(derived.physicalCapA * derived.acVoltageRef, parsed.evcsAcPowerW)\n : derived.physicalCapA * derived.acVoltageRef)\n : 0;\n\n// Recharger la batterie sur le reseau n'a de sens economique QUE dans une\n// fenetre creuse. Hors fenetre le terme vaut zero — ce n'est pas un repli\n// faute d'horizon, c'est le comportement voulu, et le probleme de la division\n// par un temps qui tend vers zero disparait par construction.\nconst hcMinutesLeft = Number.isFinite(windows.minutesRemainingInHcWindow)\n ? windows.minutesRemainingInHcWindow\n : null;\n\nconst batterySetpointShareW =\n setpointFromBalance && hcMinutesLeft !== null && params.balanceGridEnergyNeededKwh > 0\n ? Math.min(\n (params.balanceGridEnergyNeededKwh * 1000) / Math.max(hcMinutesLeft / 60, 1 / 60),\n dvccChargeLimitW\n )\n : 0;\n\n// =====================================================\n// LE REPLI NE DOIT PAS RESSUSCITER LE DEFAUT (18/09/2026)\n// =====================================================\n// Quand la garde du bilan tombe -- statut degrade, mesure perimee, champ\n// illisible -- la consigne revenait a `maison + plafond de borne`, y compris\n// sans voiture. C'est-a-dire au defaut d'origine, et precisement au moment ou\n// le systeme est deja degrade. Mesure sur le chemin concerne (midi, fenetre HC\n// autorisee, aucun vehicule, bilan indisponible) : 5725 W autorises pour une\n// maison a 900 W. Avec la part voiture : 1130 W.\n//\n// La branche de repli emploie donc la meme part voiture que la branche\n// nominale. L'invariant du 01/09 et du 04/09 reste tenu PAR LA FORME : le\n// plafond vaut `maison + un terme positif ou nul`, il ne descend donc jamais\n// sous la maison, quelle que soit la branche empruntee.\n//\n// ⚠️ NE PAS REINTRODUIRE UN DISJOINT `nightGridSessionCommitted ||\n// forceGridRequested` SUR LA PART VOITURE. Il a ete ecrit, mesure, puis\n// retire : il ECRASE le terme batterie du bilan, puisque `max(4825 emprunte,\n// 1538 mesure)` retient l'emprunt. Cout mesure, trois attendus figes d'un coup :\n// nuit-sans-vehicule-session-engage 2380 -> 5437\n// consigne-bilan-fin-de-fenetre-bornee-par-dvcc 2380 -> 5437\n// consigne-bilan-plancher-nuage-exclut-la-batterie 4219 -> 6301\n// Ce disjoint avait un sens tant que la consigne n'avait AUCUN terme batterie.\n// Depuis qu'elle en a un, dimensionne sur le manque reel, il n'est plus une\n// protection mais un court-circuit.\n//\n// Et si l'idee revient malgre tout : pendant une panne de bilan,\n// `nightGridSessionCommitted` ne peut engager aucune session NOUVELLE, il ne\n// survit que par `committedPrev`. Dans le repli, ce sont donc\n// `forceGridRequested` et le terme vehicule qui portent reellement.\nconst gridSetpointCeilingW = clamp(\n Math.round(\n setpointFromBalance\n ? derived.houseNonEvcsW + Math.max(evcsSetpointShareW, batterySetpointShareW)\n : derived.houseNonEvcsW + evcsSetpointShareW\n ),\n params.gridSetpointIdleW,\n GRID_SETPOINT_MAX_W\n);\n\nconst gridSetpointCurrentW = Number.isFinite(parsed.gridSetpointW)\n ? parsed.gridSetpointW\n : null;\n\n// =====================================================\n// CREDIT SOLAIRE SUR LA CONSIGNE (11/09/2026)\n// =====================================================\n// Defaut mesure ce jour-la : BOOST 28 A en plein soleil, consigne calculee a\n// 8242 W (maison 1802 + 28 A x 230). L'ESS n'a qu'un moyen de prelever 8,2 kW\n// quand le soleil en donne 4 : brider les MPPT. Releve 17:02 -- PV 121 W,\n// reseau 7697 W, batterie 0 A, SOC 96 %. La production etait refusee, et par\n// NOTRE consigne. Le commentaire ci-dessus (\"une consigne trop HAUTE ne coute\n// rien\") a ete ecrit la nuit du 01/09, ou la PV valait zero : il n'y avait\n// alors rien a evincer. En plein jour il est faux.\n//\n// ⚠️ ON NE PEUT PAS SOUSTRAIRE LA PV MESUREE. Elle vaut 121 W *a cause* de\n// cette consigne : mesure censuree, la soustraire ne bouge rien et le systeme\n// reste sur son mauvais equilibre. Preuve le meme jour a 17:22 -- consigne\n// descendue A LA MAIN a 2280 W : PV 4758 W en 50 secondes, batterie +9 A. La\n// ressource etait la, seule la consigne l'evincait.\n//\n// On DESCEND donc, et on laisse la physique repondre. Deux signaux, tous deux\n// deja lus et tous deux INCENSURABLES :\n// - les chargeurs declarent eux-memes s'ils se BRIDENT (mpptLimitedCount).\n// `off` ne compte pas : la nuit ils sont tous `off` et il n'y a rien a\n// liberer -- c'est ce qui garantit que le comportement de nuit (§31, §57)\n// est strictement inchange ;\n// - le courant batterie dit si on est descendu trop bas, via la politique\n// par bande de SOC deja calibree (batterySoftBreachNow, §35).\n// La valeur courante vit dans le GX : l'etat est deja persiste, aucun helper\n// nouveau n'est necessaire.\n//\n// PLANCHER : la consigne ne descend JAMAIS sous la maison hors voiture. C'est\n// la garantie du 01/09 et du 04/09 -- le reseau, quand le contacteur est ferme\n// a notre demande, couvre la maison. On ne libere que la part VOITURE, qui est\n// exactement celle qui evince le solaire. Le solaire pourrait souvent couvrir\n// la maison en plus, mais l'affirmer demanderait ses propres scenarios.\n// ⚠️ SECOND PLANCHER : LE NUAGE (ajoute 11/09 18:05, apres incident).\n// Premiere version de la descente : consigne tombee a 1281 W alors que le\n// solaire portait tout (PV 6058 W, reseau 1163 W, voiture a 28 A). Un nuage a\n// fait chuter la PV de 4,6 kW en une minute ; le reseau etant plafonne a\n// 1281 W, la difference est tombee entierement sur la batterie -- 87 A pour\n// une limite dure de 70 A -> `battery_current_hard_stop`, charge coupee.\n//\n// Sans injection, la production peut s'effondrer de plusieurs kW en quelques\n// secondes. Une boucle a 30 s ne peut pas rattraper cela : il faut que le\n// reseau soit DEJA autorise a prendre le relais. La consigne ne descend donc\n// jamais au point ou la perte totale du solaire depasserait ce que la batterie\n// sait encaisser.\n//\n// Le budget vient de la politique batterie deja calibree par bande de SOC\n// (§35), pas d'une constante nouvelle, et on garde 20 % de marge sous la\n// limite DURE -- c'est elle qui coupe la charge, et une coupure est plus\n// couteuse que quelques centaines de watts de solaire non captes.\n//\n// Contrepartie assumee, et elle est reelle : voiture a 28 A, la descente est\n// nettement moins profonde qu'a 10 A. C'est proportionne -- c'est le courant\n// de la voiture qui cree l'exposition. Et c'est la regle du proprietaire :\n// protéger le materiel prime sur l'economie de kilowattheures.\n// `derived.batteryVoltageRef` est deja la tension retenue en amont (mesure\n// vebus, repli 51,2 V) : meme grandeur que celle qui sert a convertir le\n// courant batterie en puissance dans BATTERY_CLOUD_LOCKS.\nconst batterySurgeBudgetW = Math.max(\n 0,\n Math.abs(batteryPolicy.batteryDischargeHardDynamicA) * derived.batteryVoltageRef * 0.8\n);\n// ⚠️ LE PLANCHER NUAGE SUIT LA VOITURE, PAS LA BATTERIE. Il existe pour que la\n// consigne ne descende pas au point ou la perte totale du solaire depasserait\n// ce que la batterie sait encaisser — et le commentaire ci-dessus dit pourquoi :\n// « c'est le courant de la voiture qui cree l'exposition ». Une batterie qu'on\n// recharge depuis le reseau n'est PAS une exposition : si le solaire s'effondre,\n// la recharge ralentit et personne ne se fait couper. L'y inclure remonterait le\n// plancher sans aucune contrepartie de securite, et contre l'objectif.\n// Une seule branche depuis le 18/09/2026 : le plancher nuage se construit sur\n// la maison et la voiture dans les deux cas. La distinction n'avait de sens que\n// tant que le repli gardait le plafond de borne emprunte.\nconst gridSetpointLoadW = derived.houseNonEvcsW + evcsSetpointShareW;\nconst gridSetpointCloudFloorW = Math.round(\n Math.max(0, gridSetpointLoadW - batterySurgeBudgetW)\n);\n\nconst gridSetpointHouseFloorW = clamp(\n Math.max(Math.round(derived.houseNonEvcsW), gridSetpointCloudFloorW),\n params.gridSetpointIdleW,\n GRID_SETPOINT_MAX_W\n);\n\nconst solarResourceRefused =\n parsed.mpptReadableCount > 0 && parsed.mpptLimitedCount > 0;\n\n// Pendant la fenetre d'etablissement (§29) les mesures PV et batterie ne\n// decrivent aucun regime permanent : on ne descend pas sur elles.\n// ⚠️ MANQUE BATTERIE EN HC : NE DESCENDRE QUE SI LA BATTERIE EST SATUREE (09/10/2026).\n// HC de midi du 09/10, jour faible, 12,9 kWh demandes, ACM a 140 A : un chargeur\n// bride a fait descendre la consigne a 2 524 W (la maison) et la batterie n'a pris\n// que 32 A. Un chargeur bride ne prouve une ressource refusee QUE si la batterie\n// est pres de sa limite de charge (sinon elle prendrait le solaire) ; avec 7 kW de\n// soleil et la batterie a sa limite, descendre laisse bien entrer le solaire\n// (scenario du 18/09).\nconst batteryNearChargeLimit =\n Number.isFinite(params.dvccChargeLimitA) && params.dvccChargeLimitA > 0 &&\n parsed.batteryCurrentA >= 0.8 * params.dvccChargeLimitA;\nconst gridSetpointDescendOk =\n gridSessionActive &&\n (!(batterySetpointShareW > 0) || batteryNearChargeLimit) &&\n solarResourceRefused &&\n !batteryPolicy.batterySoftBreachNow &&\n !derived.gridSettling &&\n gridSetpointCurrentW !== null;\n\n// ⚠️ LE PLAFOND EST UN PLAFOND, PAS UNE CIBLE (corrige 11/09 17:52).\n// Premiere version de ce correctif, en production 12 minutes : la consigne\n// descendait bien a 2241 W et la PV remontait a 7074 W -- puis, tous les\n// chargeurs repassant `mppt_active`, la condition de descente devenait fausse,\n// le besoin revenait au plafond et la consigne etait REECRITE a 7100 W. La PV\n// s'effondrait a 1785 W, les chargeurs se bridaient, on redescendait.\n// Oscillateur de periode ~5 min. Releve : 17:40:30 -> 2241 / 17:47:19 -> 7100\n// / 17:47:28 PV 1785 / 17:48:30 -> 7368.\n//\n// Une fois la consigne descendue, on la TIENT tant que le solaire produit. Ce\n// n'est pas un etat a memoriser : la valeur courante est dans le GX, et le\n// plancher maison la fait remonter d'elle-meme si la maison grossit.\n//\n// La nuit, les chargeurs sont `off` (ils ne produisent pas) : on retombe sur le\n// comportement historique -- la consigne monte au plafond pour que le reseau\n// porte maison et voiture (§31, §57). C'est cette distinction qui empeche le\n// « on tient » de casser les nuits HC.\nconst solarProducing =\n parsed.mpptReadableCount > 0 && parsed.mpptAtMaxPowerCount > 0;\n\nlet gridSetpointNeedW;\nlet gridSetpointDescending = false;\n\nif (!gridSessionActive) {\n gridSetpointNeedW = params.gridSetpointIdleW;\n} else if (solarProducing && !batteryPolicy.batterySoftBreachNow && gridSetpointCurrentW !== null\n && !(batterySetpointShareW > 0)) {\n // On tient la valeur acquise, bornee par la maison et par le plafond.\n // ⚠️ SAUF si le bilan finance la batterie (07/10/2026) : HC de midi d'un jour\n // faible, SOC 31 pour 44 % requis, solaire a ~1,5 kW donc chargeurs « au\n // max » -> la consigne restait tenue a 5 198 W (la maison) et la batterie ne\n // prenait que 3 a 6 A, alors que l'Auto Charge Manager l'autorisait a 99 A.\n // Le maintien protege le solaire d'une consigne trop haute ; quand la\n // batterie a un MANQUE chiffre a reprendre en heures creuses, le solaire et\n // le reseau la rechargent ENSEMBLE : on va au plafond (maison + part batterie).\n gridSetpointNeedW = clamp(\n Math.min(gridSetpointCurrentW, gridSetpointCeilingW),\n gridSetpointHouseFloorW,\n gridSetpointCeilingW\n );\n} else {\n gridSetpointNeedW = gridSetpointCeilingW;\n}\n\nif (gridSetpointDescendOk) {\n // Un chargeur bride PROUVE qu'il reste de la ressource : on peut descendre\n // franchement. Une descente trop forte est rattrapee au cycle suivant (30 s\n // ou la batterie porte l'ecart, soit quelques dizaines de Wh), alors qu'une\n // descente trop timide laisse des kW de solaire refuses pendant dix minutes.\n const minStepW = Math.max(\n GRID_SETPOINT_DEADBAND_W,\n Math.round(params.currentStepUpA * derived.acVoltageRef)\n );\n const baseW = Math.min(gridSetpointCurrentW, gridSetpointCeilingW);\n const gapW = baseW - gridSetpointHouseFloorW;\n if (gapW > 0) {\n const stepW = Math.max(minStepW, Math.round(gapW / 2));\n gridSetpointNeedW = Math.max(gridSetpointHouseFloorW, baseW - stepW);\n gridSetpointDescending = true;\n }\n}\n\n// La bande est ASYMETRIQUE, et c'est le point. Une consigne trop BASSE fait\n// porter la difference par la batterie : c'est le defaut de la nuit du 01/09,\n// il se corrige immediatement. Une consigne trop HAUTE ne coute rien : l'ESS\n// ne preleve que ce qu'il peut placer, et le courant de charge de la batterie\n// est tenu par ailleurs (DVCC, Auto Charge Manager). Deux leviers, deux roles\n// -- la consigne dit ce que le reseau a le DROIT de servir, DVCC dit ce que la\n// batterie a le droit de prendre. Ne pas confondre les deux.\n//\n// On ecrit donc avec une marge d'un pas de courant EVCS (grandeur du systeme,\n// pas une constante inventee) pour que le bruit de la maison ne rende pas la\n// consigne insuffisante au cycle suivant, et on ne redescend que si elle est\n// devenue franchement inutile. Sans cela la boucle 30 s reecrivait a chaque\n// cycle en poursuivant le bruit.\nconst gridSetpointSlackW = gridSessionActive && !gridSetpointDescending\n ? Math.max(GRID_SETPOINT_DEADBAND_W, Math.round(params.currentStepUpA * derived.acVoltageRef))\n : 0;\n\nconst gridSetpointTargetW = clamp(\n gridSetpointNeedW + gridSetpointSlackW,\n 0,\n GRID_SETPOINT_MAX_W\n);\n\nconst gridSetpointNeedsWrite =\n gridSetpointCurrentW === null ||\n gridSetpointCurrentW < gridSetpointNeedW ||\n gridSetpointCurrentW > gridSetpointNeedW + 2 * gridSetpointSlackW + GRID_SETPOINT_DEADBAND_W;\n\nreason.push(`Grid=${parsed.gridAvailable ? 'on' : 'off'} (${Math.round(parsed.gridPowerW)}W)`);\n// Trace de la fenetre d'etablissement : sans elle, une lecture du logbook\n// pendant le transitoire donne l'impression que le systeme decide n'importe\n// quoi. Il decide, au contraire, de ne PAS decider sur ces mesures.\nif (derived.gridSettling) {\n reason.push(`ETABLISSEMENT reseau ${Math.round(parsed.gridTransitionAgeSec)}s/${params.gridSettleSeconds}s (mesures PV/batterie non fiables)`);\n}\nif (gridSessionActive) {\n reason.push(\n gridSetpointDescending\n ? `Consigne reseau=${gridSetpointTargetW}W (CREDIT SOLAIRE, ${parsed.mpptLimitedCount}/${parsed.mpptReadableCount} brides)`\n : `Consigne reseau=${gridSetpointTargetW}W`\n );\n}\n// Visible dans le logbook uniquement pendant un depassement souple : c'est le\n// seul moment ou l'information sert, et la raison est tronquee a 255 caracteres.\nif (batteryPolicy.batterySoftBreachNow) {\n reason.push(\n `Batt souple ${round1(batteryPolicy.batterySoftBreachMinutes)}/${round1(batteryPolicy.batterySoftToleranceMinutes)}min`\n );\n}\n// Idem : visible seulement quand le bilan energetique DURCIT le plancher du\n// soir. Sans cette trace, un blocage de charge un soir de SOC 94 % est\n// incomprehensible a la lecture du logbook.\nif (safeguards.eveningFloorFromBalance) {\n reason.push(\n `Plancher soir=${round1(safeguards.eveningSocStartFloorPct)}% (BILAN, ${round1(safeguards.balanceEnergyToCoverKwh)}kWh a couvrir)`\n );\n}\nreason.push(`Cap=${derived.physicalCapA}A`);\nreason.push(`Ref=${round1(derived.currentReferenceA)}A`);\nreason.push(`Target=${round1(desiredCurrentA)}A`);\nreason.push(`Debt=${round2(cloud.cloudDebtNextKwh)}kWh`);\nreason.push(`Vehicle=${parsed.evcsVehicleConnected ? 'on' : 'off'}`);\nreason.push(`EVCS mode=${parsed.evcsModeRaw}`);\nreason.push(`VE.Bus AC=${Math.round(parsed.vebusAcOutPowerW)}W`);\nreason.push(\n `VE.Bus DC=${\n Number.isFinite(parsed.vebusDcVoltageV)\n ? round1(parsed.vebusDcVoltageV)\n : 'n/a'\n }V`\n);\nreason.push(`PrioEVCS=${evcsPriorityActive ? 'on' : 'off'}(${evcsPrioritySource})`);\nreason.push(`VDF delivery=${Number.isFinite(vdf.deliveryRatioEma) ? round3(vdf.deliveryRatioEma) : 'na'}`);\nreason.push(`VDF noise=${Number.isFinite(vdf.deliveryNoiseEma) ? round3(vdf.deliveryNoiseEma) : 'na'}`);\nreason.push(`VDF score=${Number.isFinite(vdf.noDemandScoreEma) ? round3(vdf.noDemandScoreEma) : 'na'}`);\nreason.push(`VDF noDemand=${vdf.evcsVehicleNoDemandSemantic ? 'on' : 'off'}`);\n\nif (night.nightSessionActive) {\n reason.push(`NightTarget=${round1(night.batteryMorningTargetPct ?? 0)}%`);\n reason.push(`NightTrigger=${round1(night.gridTakeoverTriggerPct ?? 0)}%`);\n\n if (Number.isFinite(night.nightSessionStartSocPct)) {\n reason.push(`StartSOC=${round1(night.nightSessionStartSocPct)}%`);\n reason.push(`AllowDrop=${round1(night.maxBatteryDischargePoints ?? 0)}pts`);\n }\n\n reason.push(`NightPreferred=${night.nightPreferredSourceMode ?? 'blocked'}`);\n reason.push(`NightTakeover=${night.nightGridTakeoverNeeded ? 'on' : 'off'}`);\n reason.push(`NightUrgent=${night.batteryRecoveryUrgent ? 'on' : 'off'}`);\n}\n\nconst shortReason = reason.join(' | ').slice(0, 255);\n\nconst sourceModeLabel =\n context.sourceMode === 'blocked' ? 'Bloqué' :\n context.sourceMode === 'solar_only' ? 'Solaire' :\n context.sourceMode === 'solar_potential' ? 'Potentiel solaire' :\n context.sourceMode === 'hybrid_probe' ? 'Probe hybride' :\n context.sourceMode === 'grid_hc_allowed' ? 'Réseau HC autorisé' :\n context.sourceMode === 'grid_forced' ? 'Réseau forcé' :\n context.sourceMode === 'battery_assist_night' ? 'Assistance batterie nuit' :\n context.sourceMode;\n\nconst signature = [\n decision.state,\n `profile=${context.profile}`,\n `source=${context.sourceMode}`,\n `chg=${decision.desiredCharging ? '1' : '0'}`,\n `perm=${decision.desiredPermission ? '1' : '0'}`,\n `l=${decision.desiredLatch ? '1' : '0'}`,\n `cur=${round1(desiredCurrentA)}`,\n `block=${decision.nextBlockReason}`,\n `stop=${decision.mustStop ? '1' : '0'}`,\n `prio=${evcsPriorityActive ? '1' : '0'}`,\n `night=${night.nightSessionActive ? '1' : '0'}`,\n `ntrg=${Number.isFinite(night.gridTakeoverTriggerPct) ? round1(night.gridTakeoverTriggerPct) : 'na'}`,\n `nurg=${night.batteryRecoveryUrgent ? '1' : '0'}`,\n `vdf=${vdf.evcsVehicleNoDemandSemantic ? '1' : '0'}`,\n `vdfs=${Number.isFinite(vdf.noDemandScoreEma) ? round3(vdf.noDemandScoreEma) : 'na'}`\n].join('|');\n\nconst signatureChanged = signature !== history.lastSignature;\n\n// =====================================================\n// NOTIFICATIONS\n// =====================================================\nlet notifyWanted = false;\nlet notifyTitle = 'Victron EVCS Surplus';\nlet notifyMessage = shortReason;\n\nif (parsed.notificationsEnabled && signatureChanged) {\n if (decision.currentDecision === 'start') {\n notifyWanted = true;\n notifyTitle = '⚡ EVCS démarrage';\n } else if (decision.currentDecision === 'step_up') {\n notifyWanted = true;\n notifyTitle = '⬆️ EVCS hausse courant';\n } else if (\n decision.currentDecision === 'step_down' ||\n decision.currentDecision === 'step_down_fast'\n ) {\n notifyWanted = true;\n notifyTitle = '⬇️ EVCS baisse courant';\n } else if (\n decision.mustStop ||\n decision.currentDecision === 'stop' ||\n decision.currentDecision === 'stop_hard' ||\n decision.currentDecision === 'stop_power_cap'\n ) {\n notifyWanted = true;\n notifyTitle = '⛔ EVCS arrêt';\n } else if (decision.state === 'mode_auto_conflict') {\n notifyWanted = true;\n notifyTitle = '⚠️ EVCS mode Auto';\n } else if (decision.state === 'vehicle_full_or_paused') {\n notifyWanted = true;\n notifyTitle = '🚗 EVCS véhicule plein ou en pause';\n }\n}\n\n// =====================================================\n// TIMESTAMPS TO WRITE\n// =====================================================\nconst cloudDebtStartedAtToWrite =\n cloud.cloudDebtStartedMs\n ? formatParisStampFromMs(cloud.cloudDebtStartedMs)\n : now.stamp;\n\n// DEPASSEMENT SOUPLE BATTERIE (01/09/2026) : chaine vide quand il n'y a pas\n// de depassement en cours. Le helper n'est reecrit que sur changement reel\n// (debut ou fin), pas a chaque cycle : la valeur du debut doit RESTER figee\n// pendant toute la duree du depassement, sinon le compteur ne monte jamais.\nconst batterySoftBreachAtToWrite =\n batteryPolicy.batterySoftBreachStartedMs !== null &&\n batteryPolicy.batterySoftBreachStartedMs !== undefined\n ? formatParisStampFromMs(batteryPolicy.batterySoftBreachStartedMs)\n : '';\n\nconst setBatterySoftBreachAt =\n batterySoftBreachAtToWrite !== (history.batterySoftBreachAt || '');\n\nconst probeStartedAtToWrite = now.stamp;\nconst lastStartAtToWrite = now.stamp;\nconst lastStopAtToWrite = now.stamp;\nconst lastCurrentChangeAtToWrite = now.stamp;\n\n// =====================================================\n// NIGHT SESSION MEMORY\n// =====================================================\nconst nightSessionStartSocToWrite =\n decision.clearNightSessionStart\n ? ''\n : decision.setNightSessionStart\n ? String(round1(parsed.soc))\n : (\n Number.isFinite(history.nightSessionStartSocPct)\n ? String(round1(history.nightSessionStartSocPct))\n : ''\n );\n\nconst nightSessionStartAtToWrite =\n decision.clearNightSessionStart\n ? ''\n : decision.setNightSessionStart\n ? now.stamp\n : (history.nightSessionStartAt || '');\n\n// =====================================================\n// OUTPUT\n// =====================================================\nreturn [{\n json: {\n timestamp_paris: now.stamp,\n inputs: input,\n\n computed: {\n operating_mode: context.operatingMode,\n profile: context.profile,\n source_mode: context.sourceMode,\n source_mode_label: sourceModeLabel,\n user_strategy: strategy.userStrategy,\n\n in_night_hc_window: windows.inNightHcWindow,\n in_morning_window: windows.inMorningWindow,\n in_midday_window: windows.inMiddayWindow,\n in_evening_window: windows.inEveningWindow,\n\n // D'ou vient la consigne reseau. Sans cette trace, un repli est\n // SILENCIEUX : la consigne reprend la formule d'avant sans que rien ne le\n // dise, et c'est precisement l'information qui manquera le jour ou elle se\n // comportera mal. `bilan` = dimensionnee sur le manque, `heritee` = repli.\n setpoint_source: setpointFromBalance ? 'bilan' : 'heritee',\n setpoint_battery_share_w: Math.round(batterySetpointShareW),\n setpoint_evcs_share_w: Math.round(evcsSetpointShareW),\n dvcc_limit_w: dvccChargeLimitW === null ? null : Math.round(dvccChargeLimitW),\n hc_minutes_left: hcMinutesLeft,\n\n departure_priority_active: context.departurePriorityActive,\n minutes_until_departure: lifecycle.minutesUntilDeparture,\n\n battery_priority_mode: modes.batteryPriorityMode,\n battery_full_harvest_mode: modes.batteryFullHarvestMode,\n forecast_commit_mode: modes.forecastCommitMode,\n\n physical_cap_current_a: derived.physicalCapA,\n can_run_at_min_current: derived.canRunAtMinCurrent,\n\n forecast_buffer_kwh: round2(derived.forecastBufferKwh),\n projected_end_soc_after_charge: round1(derived.projectedEndSocAfterCharge),\n\n cloud_debt_next_kwh: round2(cloud.cloudDebtNextKwh),\n cloud_debt_duration_minutes: round1(cloud.cloudDebtDurationMinutes),\n cloud_debt_active: cloud.cloudDebtActive,\n cloud_tolerance_ok: cloud.cloudToleranceOk,\n\n start_blocked_by_reason: locks.startBlockedByReason,\n\n probe_window_active: conditions.probeWindowActive,\n probe_success_now: conditions.probeSuccessNow,\n probe_failed_now: conditions.probeFailedNow,\n\n any_vebus_overload: derived.anyVebusOverload,\n any_vebus_low_battery: derived.anyVebusLowBattery,\n vebus_ac_out_soft_reached: derived.vebusAcOutSoftReached,\n vebus_ac_out_hard_reached: derived.vebusAcOutHardReached,\n vebus_dc_voltage_soft_low: derived.vebusDcVoltageSoftLow,\n vebus_dc_voltage_hard_low: derived.vebusDcVoltageHardLow,\n\n soc_band: batteryPolicy.socBand,\n batt_discharge_soft_dynamic_a: round1(batteryPolicy.batteryDischargeSoftDynamicA),\n batt_discharge_hard_dynamic_a: round1(batteryPolicy.batteryDischargeHardDynamicA),\n batt_charge_reserve_dynamic_a: round1(batteryPolicy.batteryChargeReserveDynamicA),\n batt_soft_tolerance_min: round1(batteryPolicy.batterySoftToleranceMinutes),\n batt_hard_tolerance_sec: round1(batteryPolicy.batteryHardToleranceSeconds),\n batt_recovery_min: round1(batteryPolicy.batteryRecoveryMinutes),\n grid_battery_soft_dynamic_a: round1(batteryPolicy.gridBatterySoftDynamicA),\n grid_battery_hard_dynamic_a: round1(batteryPolicy.gridBatteryHardDynamicA),\n house_spike_detected: batteryPolicy.houseSpikeDetected,\n solar_potential_context: batteryPolicy.solarPotentialContext,\n\n batt_soft_breach_now: !!batteryPolicy.batterySoftBreachNow,\n batt_soft_breach_minutes: round1(batteryPolicy.batterySoftBreachMinutes || 0),\n batt_soft_discharge_hold_ok: batteryPolicy.batterySoftDischargeHoldOk !== false,\n\n evcs_priority_active: evcsPriorityActive,\n energy_priority_mode: energyPriorityMode,\n energy_priority_reason: energyPriorityReason,\n evcs_priority_until: evcsPriorityUntil,\n evcs_priority_source: evcsPrioritySource,\n evcs_priority_manual_override_active: manualPriorityOverrideActive,\n\n night_session_active: night.nightSessionActive,\n night_session_needs_capture: !!night.nightSessionNeedsCapture,\n night_session_needs_clear: !!night.nightSessionNeedsClear,\n night_session_start_soc_pct: Number.isFinite(night.nightSessionStartSocPct)\n ? round1(night.nightSessionStartSocPct)\n : null,\n night_session_start_at: night.nightSessionStartAt || null,\n\n battery_hard_floor_pct: Number.isFinite(night.batteryHardFloorPct)\n ? round1(night.batteryHardFloorPct)\n : null,\n battery_morning_target_pct: Number.isFinite(night.batteryMorningTargetPct)\n ? round1(night.batteryMorningTargetPct)\n : null,\n max_battery_discharge_points: Number.isFinite(night.maxBatteryDischargePoints)\n ? round1(night.maxBatteryDischargePoints)\n : null,\n grid_takeover_trigger_pct: Number.isFinite(night.gridTakeoverTriggerPct)\n ? round1(night.gridTakeoverTriggerPct)\n : null,\n\n battery_recovery_urgent: !!night.batteryRecoveryUrgent,\n night_grid_takeover_needed: !!night.nightGridTakeoverNeeded,\n night_preferred_source_mode: night.nightPreferredSourceMode || 'blocked',\n\n evcs_delivery_ratio_ema: Number.isFinite(vdf.deliveryRatioEma) ? round3(vdf.deliveryRatioEma) : null,\n evcs_delivery_noise_ema: Number.isFinite(vdf.deliveryNoiseEma) ? round3(vdf.deliveryNoiseEma) : null,\n evcs_no_demand_score_ema: Number.isFinite(vdf.noDemandScoreEma) ? round3(vdf.noDemandScoreEma) : null,\n evcs_vehicle_no_demand_semantic: !!vdf.evcsVehicleNoDemandSemantic,\n evcs_no_demand_since_minutes: Number.isFinite(vdf.evcsNoDemandSinceMinutes) ? round3(vdf.evcsNoDemandSinceMinutes) : null,\n evcs_source_deliverable_for_no_demand: !!vdf.sourceDeliverableForNoDemand,\n evcs_session_armed_for_no_demand: !!vdf.evcsSessionArmedForNoDemand,\n evcs_no_demand_score_on: Number.isFinite(vdf.noDemandScoreOn) ? round3(vdf.noDemandScoreOn) : null,\n evcs_no_demand_score_off: Number.isFinite(vdf.noDemandScoreOff) ? round3(vdf.noDemandScoreOff) : null\n },\n\n decision: {\n state: decision.state,\n current_decision: decision.currentDecision,\n desired_charging: decision.desiredCharging,\n desired_permission: decision.desiredPermission,\n desired_latch: decision.desiredLatch,\n desired_current_a: round1(desiredCurrentA),\n\n charging_action: chargingAction,\n permission_action: permissionAction,\n latch_action: latchAction,\n current_action: currentAction,\n auto_start_action: autoStartAction,\n\n set_last_start_at: setLastStartAt,\n set_last_stop_at: setLastStopAt,\n set_last_current_change_at: setLastCurrentChangeAt,\n set_cloud_debt_started_at: decision.setCloudDebtStartedAt,\n set_probe_started_at: decision.setProbeStartedAt,\n\n set_night_session_start: !!decision.setNightSessionStart,\n clear_night_session_start: !!decision.clearNightSessionStart,\n\n set_block_reason: needsBlockReasonUpdate || !parsed.controllerEnabled,\n block_reason: decision.nextBlockReason,\n\n set_blocked_until: true,\n blocked_until: blockedUntilToWrite,\n\n dynamic_ok: decision.dynamicOk,\n must_stop: decision.mustStop,\n\n short_reason: shortReason,\n reason,\n signature,\n signature_changed: signatureChanged,\n\n should_notify: notifyWanted,\n notify_title: notifyTitle,\n notify_message: notifyMessage,\n\n evcs_priority_active: evcsPriorityActive,\n energy_priority_mode: energyPriorityMode,\n energy_priority_reason: energyPriorityReason,\n evcs_priority_until: evcsPriorityUntil,\n evcs_priority_source: evcsPrioritySource\n },\n\n ha_service_calls: {\n helper_state: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_surplus_state',\n value: decision.state\n }\n },\n\n helper_current_decision: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_surplus_current_decision',\n value: decision.currentDecision\n }\n },\n\n helper_reason: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_surplus_reason',\n value: shortReason\n }\n },\n\n helper_operating_mode: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_operating_mode',\n value: context.operatingMode\n }\n },\n\n helper_source_mode: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_source_mode',\n value: sourceModeLabel\n }\n },\n\n helper_target_current: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_target_current_auto',\n value: round1(desiredCurrentA)\n }\n },\n\n helper_budget_remaining: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_budget_remaining_kwh',\n value: round2(derived.forecastBufferKwh)\n }\n },\n\n helper_projected_soc: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_projected_end_soc_after_charge',\n value: round1(derived.projectedEndSocAfterCharge)\n }\n },\n\n helper_cloud_debt: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_cloud_debt_kwh',\n value: round2(cloud.cloudDebtNextKwh)\n }\n },\n\n helper_dynamic_ok: {\n execute: true,\n domain: 'input_boolean',\n service: decision.dynamicOk ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_dynamic_ok'\n }\n },\n\n helper_must_stop: {\n execute: true,\n domain: 'input_boolean',\n service: decision.mustStop ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_must_stop'\n }\n },\n\n // Bouton reset borne (19/09/2026) : momentane. Il a deja fait son effet\n // dans WINDOWS (sessionFresh forcee ce cycle) ; on le rebascule off pour\n // qu'il ne force pas un retest en boucle. N'ecrit que s'il etait ON.\n helper_reset_borne_ack: {\n execute: !!params.resetBorneRequested,\n domain: 'input_boolean',\n service: 'turn_off',\n data: {\n entity_id: 'input_boolean.evcs_reset_borne'\n }\n },\n\n helper_cloud_debt_started_at: {\n execute: true,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_cloud_debt_started_at',\n datetime: cloudDebtStartedAtToWrite\n }\n },\n\n helper_battery_soft_breach_at: {\n execute: setBatterySoftBreachAt,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_battery_soft_breach_at',\n value: batterySoftBreachAtToWrite\n }\n },\n\n helper_last_current_change_at: {\n execute: setLastCurrentChangeAt,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_last_current_change_at',\n datetime: lastCurrentChangeAtToWrite\n }\n },\n\n helper_last_start_at: {\n execute: setLastStartAt,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_last_start_at',\n datetime: lastStartAtToWrite\n }\n },\n\n helper_last_stop_at: {\n execute: setLastStopAt,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_last_stop_at',\n datetime: lastStopAtToWrite\n }\n },\n\n helper_signature: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_control_last_signature',\n value: signature\n }\n },\n\n helper_block_reason: {\n execute: needsBlockReasonUpdate || !parsed.controllerEnabled,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_last_block_reason',\n value: decision.nextBlockReason\n }\n },\n\n helper_blocked_until: {\n execute: true,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_blocked_until',\n datetime: blockedUntilToWrite\n }\n },\n\n helper_probe_started_at: {\n execute: decision.setProbeStartedAt,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_probe_started_at',\n datetime: probeStartedAtToWrite\n }\n },\n\n helper_evcs_priority_active: {\n execute: true,\n domain: 'input_boolean',\n service: evcsPriorityActive ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_priority_active'\n }\n },\n\n helper_energy_priority_mode: {\n execute: true,\n domain: 'input_select',\n service: 'select_option',\n data: {\n entity_id: 'input_select.victron_energy_priority_mode',\n option: energyPriorityMode\n }\n },\n\n helper_energy_priority_reason: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_energy_priority_reason',\n value: energyPriorityReason\n }\n },\n\n helper_evcs_priority_until: {\n execute: true,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_priority_until',\n datetime: evcsPriorityUntil\n }\n },\n\n helper_night_target_soc: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_night_target_soc',\n value: round1(\n Number.isFinite(night.batteryMorningTargetPct)\n ? night.batteryMorningTargetPct\n : 0\n )\n }\n },\n\n helper_night_grid_trigger_soc: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_night_grid_trigger_soc',\n value: round1(\n Number.isFinite(night.gridTakeoverTriggerPct)\n ? night.gridTakeoverTriggerPct\n : 0\n )\n }\n },\n\n helper_night_preferred_source_mode: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_night_preferred_source_mode',\n value: night.nightPreferredSourceMode || 'blocked'\n }\n },\n\n // SESSION RESEAU UNIQUE (03/09/2026) : ce bit est ce qui rend la session\n // UNIQUE. Sans persistance, l'engagement serait reevalue a chaque cycle\n // et le systeme retomberait dans une bande — donc dans un oscillateur.\n // Ecrit uniquement sur changement reel.\n helper_night_grid_session_committed: {\n execute: night.nightGridSessionCommitted !== history.nightGridSessionCommittedPrev,\n domain: 'input_boolean',\n service: night.nightGridSessionCommitted ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_night_grid_session_committed'\n }\n },\n\n // P12 — LE VERROU ANTI-OSCILLATEUR.\n //\n // Le plancher de surplus (`required_soc_prudent`) DESCEND pendant la nuit,\n // et le SOC aussi. Mesure du 15/09 : voiture en charge, le SOC descend de\n // 7,1 pt/h et le plancher de 3,7 ; voiture ARRETEE, le SOC ne descend plus\n // que de 2,6 pt/h quand le plancher continue a 3,7. Le surplus\n // REAPPARAITRAIT donc tout seul et la charge repartirait — cinquieme\n // oscillateur du projet, par construction.\n //\n // Une seule passe de surplus par session de nuit, comme la recovery\n // batterie depuis le 27/08. Ecrit uniquement sur changement reel.\n helper_night_battery_done: {\n execute: !!night.nightBatterySurplusExhausted !== !!params.nightBatteryDonePrev,\n domain: 'input_boolean',\n service: night.nightBatterySurplusExhausted ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_night_battery_done'\n }\n },\n\n helper_night_grid_takeover_needed: {\n execute: true,\n domain: 'input_boolean',\n // P9 : la fenêtre \"départ imminent\" (jour) tient aussi le helper\n // takeover → override hystérésis + maintien batterie ACM pendant\n // la charge réseau pré-départ.\n service: (night.nightGridTakeoverNeeded || night.departureUrgentActive)\n ? 'turn_on'\n : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_night_grid_takeover_needed'\n }\n },\n\n helper_night_battery_recovery_urgent: {\n execute: true,\n domain: 'input_boolean',\n service: night.batteryRecoveryUrgent ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_night_battery_recovery_urgent'\n }\n },\n\n helper_night_session_active: {\n execute: true,\n domain: 'input_boolean',\n service: night.nightSessionActive ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_night_session_active'\n }\n },\n\n helper_night_session_start_soc: {\n execute: decision.setNightSessionStart || decision.clearNightSessionStart,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_night_session_start_soc',\n value: nightSessionStartSocToWrite\n }\n },\n\n // Budget voiture de la nuit (30/09) : fige a l'ouverture, efface en fin.\n helper_night_budget: {\n execute: night.nightBudgetToWrite !== null && night.nightBudgetToWrite !== undefined,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_night_budget',\n value: night.nightBudgetToWrite ?? ''\n }\n },\n\n helper_night_session_start_at: {\n execute: decision.setNightSessionStart || decision.clearNightSessionStart,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_night_session_start_at',\n value: nightSessionStartAtToWrite\n }\n },\n\n // =================================================\n // VEHICLE DEMAND FILTER HISTORY PERSISTENCE\n // =================================================\n helper_evcs_delivery_ratio_ema: {\n execute: Number.isFinite(vdf?.nextHistory?.evcsDeliveryRatioEma),\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_delivery_ratio_ema',\n value: round3(vdf.nextHistory.evcsDeliveryRatioEma)\n }\n },\n\n helper_evcs_delivery_noise_ema: {\n execute: Number.isFinite(vdf?.nextHistory?.evcsDeliveryNoiseEma),\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_delivery_noise_ema',\n value: round3(vdf.nextHistory.evcsDeliveryNoiseEma)\n }\n },\n\n helper_evcs_no_demand_score_ema: {\n execute: Number.isFinite(vdf?.nextHistory?.evcsNoDemandScoreEma),\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_no_demand_score_ema',\n value: round3(vdf.nextHistory.evcsNoDemandScoreEma)\n }\n },\n\n helper_evcs_no_demand_semantic_active: {\n execute: true,\n domain: 'input_boolean',\n service: vdf?.nextHistory?.evcsNoDemandSemanticActive ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_no_demand_semantic_active'\n }\n },\n\n helper_evcs_no_demand_since_at: {\n execute: Number.isFinite(vdf?.nextHistory?.evcsNoDemandSinceMs),\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_no_demand_since_at',\n datetime: formatParisStampFromMs(vdf.nextHistory.evcsNoDemandSinceMs)\n }\n },\n\n helper_evcs_no_demand_filter_eval_at: {\n execute: Number.isFinite(vdf?.nextHistory?.evcsNoDemandFilterEvalMs),\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_no_demand_filter_eval_at',\n datetime: formatParisStampFromMs(vdf.nextHistory.evcsNoDemandFilterEvalMs)\n }\n },\n\n // =================================================\n // PHYSICAL EDF GRID TAKEOVER\n // =================================================\n edf_general_ok_on: {\n execute:\n (['grid_forced', 'grid_hc_allowed'].includes(context.sourceMode) ||\n night.nightBatteryGridOnly === true) &&\n !parsed.gridAvailable,\n domain: 'switch',\n service: 'turn_on',\n data: {\n entity_id: 'switch.edf_general_ok_switch'\n }\n },\n\n grid_power_setpoint: {\n execute: gridSetpointNeedsWrite,\n domain: 'number',\n service: 'set_value',\n data: {\n entity_id: 'number.gx_device_ac_power_setpoint',\n value: gridSetpointTargetW\n }\n },\n\n evcs_auto_start_off: {\n execute: autoStartAction === 'turn_off',\n domain: 'switch',\n service: 'turn_off',\n data: {\n entity_id: 'switch.victron_evcs_auto_start'\n }\n },\n\n evcs_charging: {\n execute: chargingAction !== 'none',\n domain: 'switch',\n service: decision.desiredCharging ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'switch.victron_evcs_charging'\n }\n },\n\n helper_permission: {\n execute: permissionAction !== 'none',\n domain: 'input_boolean',\n service: decision.desiredPermission ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_charge_permission_now'\n }\n },\n\n helper_latch: {\n execute: latchAction !== 'none',\n domain: 'input_boolean',\n service: decision.desiredLatch ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_control_latch'\n }\n },\n\n evcs_set_current: {\n execute: currentAction === 'set',\n domain: 'number',\n service: 'set_value',\n data: {\n entity_id: 'number.victron_evcs_set_current_command',\n value: round1(desiredCurrentA)\n }\n },\n\n logbook: {\n execute: signatureChanged,\n domain: 'logbook',\n service: 'log',\n data: {\n name: 'Victron EVCS Surplus',\n message: shortReason\n }\n },\n\n mobile_notification: {\n execute: notifyWanted,\n domain: cfg.mobileNotifyDomain,\n service: cfg.mobileNotifyService,\n data: {\n title: notifyTitle,\n message: notifyMessage || shortReason\n }\n }\n }\n }\n}];\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2,