diff --git a/HANDOFF.md b/HANDOFF.md index de0d597..ce01cce 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -732,3 +732,72 @@ haiku-4.5 ~9€/mois plein temps). Implémentation ~30 min, tests advisor inchan → Alors premier câblage réel LIMITÉ : night_target_soc uniquement, derrière un nouveau toggle `advisor_apply_night_target`, le reste inchangé. + +## 17. Session du 08/08/2026 — Incident rebond EDF nuit 07-08/08 : fixes A/B/C/D + +### Incident (captures HA analysées, faits dans docs/vision-notes.md) +P7 a chargé la VW dès 01:24 ✓ (session 1h32m). Ensuite : +1. Fin de charge détectée très tardivement (~04:15 au lieu de ~02:56) ; +2. Rebond EDF ON/OFF période ~10 min autour de SOC=35 (= cible matin) + flapping + ACM Charging/passthru 2-4 s (03:58→04:16) ; +3. Débranchement VW (04:17) → takeover bien relâché mais EDF maintenu + (hold_on_min_runtime 25 min) → arrêt manuel utilisateur 04:25 (stopped_user). + +### Root causes +- **A — point dégénéré SOC==cible** (WINDOWS_SOURCEV2) : engage takeover + `soc <= cible`, release `soc > cible`, recovery ACM `soc < cible` (strict), + et l'ACM recharge PILE à la cible → aucune position stable, oscillation. +- **B — snapshot incomplet** : `evcs_night_session_start_soc/_at` jamais relus + (absents du template) → `nightSessionNeedsCapture` vrai à CHAQUE cycle → + "Night Session Start At" réécrit toutes les 30 s toute la nuit (visible logs), + budget décharge nuit faussé (start SOC glissant). +- **C — hystérésis grid** : pas de coupure anticipée au débranchement + (min_on 25 min s'applique même véhicule parti et SOC ≥ seuil OFF). +- **D — détection fin de charge fragile** : VDF armé uniquement session + pleinement active ; stop par garde (ex battery_current_hard_stop) → filtre + désarmé → 'vehicle_full_or_paused' inatteignable pendant longtemps. + +### Fixes déployés (production 08/08 ~14:00, golden 23/23) +- **Fix A** (WINDOWS_SOURCEV2 + BCL passthrough + DECISION) : nouvel état + auto-entretenu **`night_recovery_done_idle`** émis quand + vehicleChargeCompleted && SOC ≥ cible. Takeover relâché DÉFINITIVEMENT ; + ré-engagement uniquement si SOC ≤ cible − 2 (bande NIGHT_RECOVERY_REARM_BAND_PCT). + ⚠ Le nom ne matche aucun pattern no-demand de l'hystérésis (comme done_idle). + Structure takeover : completed ? !nightRecoveryDone : (trigger || urgent || latch). +- **Fix B** (HA_SNAPSHOT_TEMPLATE) : +`evcs_night_session_start_soc`, + +`evcs_night_session_start_at` (helpers input_text existants, vérifiés via MCP). +- **Fix C** (workflow hystérésis 7iIlu7LLTW7TZA8o, patch + tools/patch-20260808-c-unplug-cut.mjs rejouable) : nouvel état + **`turn_off_vehicle_unplugged`** = véhicule débranché (es=vehicle_not_connected) + + latch ON + grid ON + pas d'override + SOC ≥ seuil OFF → coupure immédiate + (bypass min_on) + notification. Dry-run 4 cas validé (incident/SOC bas/ + takeover ON/recovery_done) AVANT push. Le fichier local du workflow est + désormais suivi : workflows/victron-grid-hysteresis-v1.json. +- **Fix D** (WINDOWS_SOURCEV2) : `evcs_status_raw == 3` (Victron "charged") + vaut fin de charge immédiate, sans attendre le VDF. + +### Golden : 19 → 23 scénarios +- 2 attendus mis à jour (transition/auto-entretien : SOC ≥ cible → état + `night_recovery_done_idle` désormais) ; +- 4 nouveaux : `night-recovery-atteinte-soc-cible` (LE cas du rebond), + `night-recovery-done-bande-collante` (34 % dans la bande), + `night-recovery-rearm-sous-bande` (32 % → ré-engagement), + `night-vw-statut-charged` (Fix D). +- Piège outillage : `tests/golden/run.mjs` exécute le JSON BUILDÉ + (workflows/*.json), pas src/ → toujours `node tools/n8n-build.mjs` avant un + run golden manuel (n8n-push le fait automatiquement). + +### Documentation +- MANUEL_COMPLET.md §11.6 : GRAFCET complet du cycle nuit (G1 EVCS / G2 + hystérésis) + états surplus fin de nuit + résumé incident. +- docs/vision-notes.md : OCR/faits des 9 captures de l'incident. + +### À surveiller (prochaine nuit HC, VW branchée) +1. Fin de charge : passage à vehicle_full_or_paused OU night_recovery_done_idle + rapide (Fix D si la VW publie le statut 3) ; +2. SOC à la cible → UNE coupure EDF, puis plus aucun ON/OFF (sauf SOC ≤ cible−2) ; +3. "Night Session Start At" écrit UNE fois en début de session (Fix B) ; +4. Débranchement → EDF OFF ≤ 1 min (turn_off_vehicle_unplugged, Fix C). + +--- +*Document mis à jour le : 08/08/2026 (~14:15) — fixes A/B/C/D déployés, golden 23/23* diff --git a/docs/MANUEL_COMPLET.md b/docs/MANUEL_COMPLET.md index df9eda2..390ae68 100644 --- a/docs/MANUEL_COMPLET.md +++ b/docs/MANUEL_COMPLET.md @@ -344,6 +344,64 @@ S'assurer que le véhicule est chargé avant l'heure de départ prévue. - **gridTakeoverTriggerPct** : Seuil de basculement réseau - **batteryRecoveryUrgent** : Si temps restant < temps de charge nécessaire +### 11.6 Cycle nuit HC complet — GRAFCET (P7 + P8 + fixes 08/08/2026) + +Deux automates coordonnés via les helpers HA (`night_grid_takeover_needed`, +`victron_evcs_surplus_state`) : +- **G1** = contrôleur EVCS V2.6 (boucle 30 s) +- **G2** = hystérésis EDF (« Victron - Battery / Grid Hysteresis Fix V1 », 1 min) + +``` +G1 — SESSION NUIT EVCS +────────────────────── +X0 JOUR / attente (profils solaires) + │ t1: fenêtre HC (01:24) ET véhicule branché +X1 SESSION NUIT ACTIVE — capture startSOC/startAt (une seule fois, Fix B) + │ t2: toggle "Réseau HC nuit" ON → nightGridHcPreferred (P7) +X2 TAKEOVER: helper ON → G2 force EDF ON ; ACM tient la batterie (~2 A) + │ t3: EDF ON confirmé (gridAvailable) +X3 CHARGE VE: start 6 A → step-up (plafond 20 A / boost 28 A) + │ t4: fin de charge = VDF no-demand OU statut EVCS brut 3 "charged" (Fix D) +X4 CHARGE TERMINÉE (collant): vehicleChargeCompleted, sourceMode='blocked' + │ + ├─ t5a: SOC < cible matin + │ X5a RECOVERY BATTERIE: takeover ON (EDF ON), ACM recharge → cible + │ │ t6: SOC ≥ cible + │ ▼ + └─ t5b: SOC ≥ cible matin + X5b RECOVERY DONE (collant, Fix A): état 'night_recovery_done_idle' + │ takeover OFF définitif → G2 coupe EDF (turn_off_recovered) + │ ré-engagement UNIQUEMENT si SOC ≤ cible − 2 (bande anti-rebond) + │ t7: débranchement OU fin de fenêtre (07:24) + X6 CLÔTURE: clear session → retour X0 (profil jour) + +G2 — HYSTÉRÉSIS EDF (seuils ON ≤ 20 % | OFF ≥ 30 % | min_on 25 | cooldown 15) +────────────────────────────────────────────────────────────────────────────── +Y0 EDF OFF (observe_only) + │ u1: override EVCS (takeover/force_grid/grid_forced) sans no-demand + │ u2: SOC ≤ seuil ON (bypass cooldown) +Y1 EDF ON + latch + │ v1: override disparu ET min_on écoulé ET SOC ≥ seuil OFF → turn_off_recovered + │ v2: force_grid + voiture pleine → coupure immédiate + │ v3: véhicule débranché + SOC ≥ seuil OFF → coupure immédiate + │ (turn_off_vehicle_unplugged, bypass min_on — Fix C 08/08/2026) + └─▶ retour Y0 (cooldown 15 min) +``` + +États surplus « fin de nuit » (aucun ne doit matcher les patterns no-demand +de G2 — full_or_paused / not_requesting_power / charge_complete / no_demand) : +- `night_session_done_idle` : charge finie, recovery batterie possible (EDF + maintenu si SOC < cible) +- `night_recovery_done_idle` : charge finie ET batterie à la cible → EDF + relâché définitivement (anti-rebond) + +#### Incident de référence (nuit 07-08/08/2026) +SOC bloqué pile à la cible (35 %) = point dégénéré (engage `soc ≤ 35`, +release `soc > 35`) → rebond EDF ON/OFF période ~10 min + flapping ACM 2-4 s, +et EDF maintenu 25 min après débranchement. Corrigé par Fix A (flag collant + +bande 2 pts), Fix B (snapshot relit startSOC/startAt), Fix C (coupure au +débranchement), Fix D (statut charged = fin de charge immédiate). + --- ## 12. Configuration opencode.json diff --git a/docs/vision-notes.md b/docs/vision-notes.md index 5976bdd..937f26e 100644 --- a/docs/vision-notes.md +++ b/docs/vision-notes.md @@ -169,4 +169,59 @@ Suite à la session de débogage du 17/07/2026: - Configuration actuelle: soc_start_min=80%, night_target_soc=70%, battery_discharge_soft_a=0A - Mode sonde activé (probe_enabled: ON) - Système fonctionnel avec delivery 99.9% -- EDF OFF la journée, uniquement la nuit \ No newline at end of file +- EDF OFF la journée, uniquement la nuit +--- + +# Vision notes — Incident nuit 07→08/08/2026 (rebond EDF post-P8) +Source +9 captures : dashboard EVCS Master (08/08 11:31) + timeline Activité HA 01:00→07:00. + +# OCR brut (faits clés) +- 01:25:03 Interrupteur hold_on_evcs_override | Grid=on | Latch=on | SOC=27% | AC=1021W | + Cause: night_grid_takeover_needed | PV 0W | EVCS inactif 0W | status_raw=6 | + signature es=battery_current_hard_stop +- 01:24:30→04:16 : "Victron EVCS Night Session Start At" RÉÉCRIT toutes les 30 s + (01:24:30, 01:25:00, 03:05:30, 03:59:30, 04:01:30...) + "Bloqué jusqu'à" glissant. +- 03:05 / 03:59 : hold_on_evcs_override maintenu, SOC 35%, seuils 20%/30%, PV 0W. +- 03:58:33→04:01:29 : "Victron mode (derived)" alterne Charging (AC-in) ↔ On grid + (passthru/import) toutes les 2-4 s (flapping ACM autour de SOC=cible). +- 04:16:03 turn_on_evcs_override : EDF re-forcé ON (Grid/Latch étaient OFF), SOC 35%, + cause night_grid_takeover_needed, es=night_session_done_idle. Latch hystérésis Activé. +- 04:18:03 hold_on_min_runtime (EDF maintenu ON, mini 25 min, 2 min écoulées), + status_raw=0, es=vehicle_not_connected ; Night Session Active + Takeover → Désactivés ; + VDF delivery=0 noise=0.003 score=0.002 noDemand=off ; victron_evcs_cloud_debt_kwh=1,1. +- 04:25:03 hysteresis_disabled | Grid=off | Latch=off | es=stopped_user (arrêt manuel). +- 04:26:03 "Dernier ON il y a 10 min | Dernier OFF il y a 11 min" → période de rebond ~10 min. +- Dashboard 11:31 : véhicule non détecté/débranché, consigne 20 A, durée charge 1h32m, + énergie session Inconnu ; Conseiller IA ombre : auto_solar_plus, cible 35%, accord, + modèle qwen3-vl, 35 accords / 4 divergences, kill-switch ON. + +# État actuel déduit +- P7 (charge HC auto 01:24) a fonctionné ; VW pleine ~02:56 (1h32m). +- P8 a fini par émettre night_session_done_idle mais TARDIVEMENT, puis rebond EDF + ON/OFF (~10 min de période) : SOC bloqué pile à la cible matin (35%) = + gridTakeoverTrigger (soc<=35 → EDF ON) vs release (soc>35 → EDF OFF) sans bande. +- Débranchement VW (04:17) → takeover bien désactivé mais EDF resté ON (min_runtime 25) + → arrêt manuel utilisateur 04:25. + +# Causes code identifiées (src/nodes/) +1. WINDOWS_SOURCEV2.js:272-283+348-356 : gridTakeoverTriggerPct == batteryMorningTargetPct, + engage `soc <= 35`, release implicite `soc > 35`, recovery ACM `soc < 35` (strict) + → point de fonctionnement dégénéré à SOC=cible → oscillation EDF + flapping ACM. +2. HA_SNAPSHOT_TEMPLATE.json.tpl : champs `evcs_night_session_start_soc` / + `evcs_night_session_start_at` ABSENTS → INPUT_PARSE ne relit jamais la capture → + nightSessionNeedsCapture=true à chaque cycle → réécriture toutes les 30 s (logs). +3. DECISION_ENGINEV2.js:322-337 : VDF armé uniquement session pleinement active ; + tout stop pour autre raison (ex battery_current_hard_stop) désarme le filtre → + détection "voiture pleine" retardée (~1h20 cette nuit). +4. Hystérésis grid (workflow 7iIlu7LLTW7TZA8o) : pas de coupure anticipée après + débranchement (min_on 25 min s'applique même si véhicule parti et SOC >= seuil OFF). + +# Actions recommandées +- Fix A (prioritaire) : flag collant `night_recovery_done` (recovery terminée une fois + SOC >= cible) + bande d'hystérésis (engage cible-2, release cible) dans WINDOWS_SOURCEV2. +- Fix B : ajouter les 2 champs night_session_start_* au snapshot template. +- Fix C : coupure EDF anticipée si vehicle_not_connected + SOC >= gridSocOff (bypass min_on). +- Fix D (optionnel) : fiabiliser fin de charge (statut EVCS "charged", ou VDF armable en hold). +- +2 scénarios golden : SOC==cible avec vehicleChargeCompleted (pas d'oscillation), débranché + pendant hold EDF. diff --git a/src/nodes/BATTERY_CLOUD_LOCKS.js b/src/nodes/BATTERY_CLOUD_LOCKS.js index 522ec60..0ab716b 100644 --- a/src/nodes/BATTERY_CLOUD_LOCKS.js +++ b/src/nodes/BATTERY_CLOUD_LOCKS.js @@ -383,6 +383,7 @@ const batteryAssistAllowed = !!nightPrev.batteryAssistAllowed; const nightGridTakeoverNeeded = !!nightPrev.nightGridTakeoverNeeded; const nightTakeoverLatched = !!nightPrev.nightTakeoverLatched; const vehicleChargeCompleted = !!nightPrev.vehicleChargeCompleted; +const nightRecoveryDone = !!nightPrev.nightRecoveryDone; const nightPreferredSourceMode = nightPrev.nightPreferredSourceMode || 'blocked'; // ===================================================== @@ -840,6 +841,7 @@ return [{ nightGridTakeoverNeeded, nightTakeoverLatched, vehicleChargeCompleted, + nightRecoveryDone, nightPreferredSourceMode, }, diff --git a/src/nodes/DECISION_ENGINEV2.js b/src/nodes/DECISION_ENGINEV2.js index 9ae7bba..3cc91ff 100644 --- a/src/nodes/DECISION_ENGINEV2.js +++ b/src/nodes/DECISION_ENGINEV2.js @@ -1064,11 +1064,18 @@ else { // P8 : véhicule plein en session nuit → veille auto-entretenue. // Le state est relu au cycle suivant (prev_surplus_state) : il maintient // vehicleChargeCompleted jusqu'au débranchement ou à la fin de fenêtre. + // Fix A (08/08/2026) : une fois la recovery batterie terminée (SOC ≥ cible), + // l'état devient 'night_recovery_done_idle' (collant) → takeover relâché + // définitivement, plus de rebond EDF autour de la cible. else if (context.profile === 'night_hc' && nightEvcs?.vehicleChargeCompleted) { setOff({ - stateName: 'night_session_done_idle', + stateName: nightEvcs?.nightRecoveryDone + ? 'night_recovery_done_idle' + : 'night_session_done_idle', decisionName: 'hold', - reasonText: 'Charge véhicule terminée : veille nuit, réseau relâché (recharge batterie si sous cible)', + reasonText: nightEvcs?.nightRecoveryDone + ? 'Charge véhicule terminée + batterie à la cible : veille nuit, réseau relâché' + : 'Charge véhicule terminée : veille nuit, réseau relâché (recharge batterie si sous cible)', dynamic: false, mustStopFlag: false, blockReason: 'none', diff --git a/src/nodes/HA_SNAPSHOT_TEMPLATE.json.tpl b/src/nodes/HA_SNAPSHOT_TEMPLATE.json.tpl index ce8ae3a..5091046 100644 --- a/src/nodes/HA_SNAPSHOT_TEMPLATE.json.tpl +++ b/src/nodes/HA_SNAPSHOT_TEMPLATE.json.tpl @@ -116,6 +116,8 @@ "allow_grid_hc_night": states("input_boolean.victron_evcs_allow_grid_hc_night"), "night_takeover_prev": states("input_boolean.victron_evcs_night_grid_takeover_needed"), "prev_surplus_state": states("input_text.victron_evcs_surplus_state"), + "evcs_night_session_start_soc": states("input_text.victron_evcs_night_session_start_soc"), + "evcs_night_session_start_at": states("input_text.victron_evcs_night_session_start_at"), "allow_grid_hc_midday": states("input_boolean.victron_evcs_allow_grid_hc_midday"), "probe_enabled": states("input_boolean.victron_evcs_probe_enabled"), diff --git a/src/nodes/WINDOWS_SOURCEV2.js b/src/nodes/WINDOWS_SOURCEV2.js index dba4bb6..483d110 100644 --- a/src/nodes/WINDOWS_SOURCEV2.js +++ b/src/nodes/WINDOWS_SOURCEV2.js @@ -317,11 +317,42 @@ const nightTakeoverPrev = // → DECISION émet night_session_done_idle → le flag s'auto-entretient jusqu'au // débranchement (state vehicle_not_connected) ou à la fin de la fenêtre nuit. const prevSurplusState = (input.prev_surplus_state ?? '').toString().trim().toLowerCase(); + +// Fix D (08/08/2026) : le statut natif EVCS "charged" (3) vaut aussi fin de +// charge, sans attendre le VehicleDemandFilter (désarmé si la session a été +// stoppée par une garde, ex battery_current_hard_stop → détection tardive +// observée la nuit du 07-08/08). +const evcsStatusCharged = Number.parseFloat(parsed.evcsStatusRaw) === 3; + const vehicleChargeCompleted = nightSessionActive && ( prevSurplusState === 'vehicle_full_or_paused' || - prevSurplusState === 'night_session_done_idle' + prevSurplusState === 'night_session_done_idle' || + prevSurplusState === 'night_recovery_done_idle' || + evcsStatusCharged + ); + +// Fix A (08/08/2026) : recovery batterie post-charge = UNE SEULE passe. +// Sans flag collant, SOC==cible est un point dégénéré (engage soc<=cible, +// release soc>cible, l'ACM recharge pile à la cible) → rebond EDF ON/OFF +// période ~10 min observé la nuit du 07-08/08. Une fois la cible atteinte, +// l'état 'night_recovery_done_idle' (auto-entretenu via prev_surplus_state) +// coupe le takeover. Ré-engagement uniquement sous cible − 2 pts (bande), +// hard floor et fenêtre/débranchement restent prioritaires. +// ⚠ 'night_recovery_done_idle' ne doit matcher AUCUN pattern no-demand de +// l'hystérésis (full_or_paused / not_requesting_power / charge_complete / +// no_demand) — même contrainte que 'night_session_done_idle'. +const NIGHT_RECOVERY_REARM_BAND_PCT = 2; +const nightRecoveryDonePrev = prevSurplusState === 'night_recovery_done_idle'; +const nightRecoveryDone = + vehicleChargeCompleted && + ( + parsed.soc >= batteryMorningTargetPct || + ( + nightRecoveryDonePrev && + parsed.soc > batteryMorningTargetPct - NIGHT_RECOVERY_REARM_BAND_PCT + ) ); // P8 : correction du fix 04/08 (logique inversée : "|| vehicleChargeCompleted" @@ -350,9 +381,17 @@ const nightGridTakeoverNeeded = ( nightGridHcPreferred || batteryBelowHardFloor || - batteryAtOrBelowGridTakeoverTrigger || - batteryRecoveryUrgent || - nightTakeoverLatched + ( + // Fix A : après fin de charge, le seul motif réseau restant est la + // recovery batterie, et elle ne se ré-engage pas une fois done. + vehicleChargeCompleted + ? !nightRecoveryDone + : ( + batteryAtOrBelowGridTakeoverTrigger || + batteryRecoveryUrgent || + nightTakeoverLatched + ) + ) ); const nightPreferredSourceMode = @@ -679,6 +718,7 @@ return [{ nightTakeoverLatched, nightGridHcPreferred, vehicleChargeCompleted, + nightRecoveryDone, nightPreferredSourceMode, } } diff --git a/tests/golden/scenarios.mjs b/tests/golden/scenarios.mjs index 2906112..4e24994 100644 --- a/tests/golden/scenarios.mjs +++ b/tests/golden/scenarios.mjs @@ -138,7 +138,7 @@ export const scenarios = [ // ================= FIN DE CHARGE NUIT (P8) ================= { id: 'night-vw-pleine-transition', - label: 'Nuit HC 04:00, cycle après vehicle_full_or_paused → veille done_idle, takeover relâché', + label: 'Nuit HC 04:00, cycle après vehicle_full_or_paused, SOC ≥ cible → recovery done, takeover relâché', time: '2026-08-08T04:00:00+02:00', overrides: { ...VEHICLE_NIGHT, soc_percent: '60', allow_grid_hc_night: 'on', @@ -150,7 +150,7 @@ export const scenarios = [ expect: { profile: 'night_hc', sourceMode: 'blocked', - state: 'night_session_done_idle', + state: 'night_recovery_done_idle', decision: 'hold', desiredCharging: false, currentA: 6, @@ -161,7 +161,7 @@ export const scenarios = [ }, { id: 'night-vw-pleine-autoentretien', - label: 'Nuit HC 05:00, veille done_idle auto-entretenue, EDF déjà coupé → reste en veille', + label: 'Nuit HC 05:00, veille auto-entretenue SOC ≥ cible, EDF déjà coupé → reste en recovery done', time: '2026-08-08T05:00:00+02:00', overrides: { ...VEHICLE_NIGHT, soc_percent: '55', allow_grid_hc_night: 'on', @@ -171,7 +171,7 @@ export const scenarios = [ expect: { profile: 'night_hc', sourceMode: 'blocked', - state: 'night_session_done_idle', + state: 'night_recovery_done_idle', decision: 'hold', desiredCharging: false, currentA: 6, @@ -204,6 +204,99 @@ export const scenarios = [ }, }, + // ===== FIX A/D 08/08/2026 : anti-rebond EDF post-charge (incident nuit 07-08/08) ===== + { + id: 'night-recovery-atteinte-soc-cible', + label: 'Nuit HC 04:15, VW pleine, SOC 35 == cible (le cas du rebond) → recovery done, takeover coupé une fois pour toutes', + time: '2026-08-08T04:15:00+02:00', + overrides: { + ...VEHICLE_NIGHT, soc_percent: '35', allow_grid_hc_night: 'on', + grid_state: 'on', victron_acin1_available: '1', + battery_current_a: '2', night_takeover_prev: 'on', + evcs_charging_switch: 'off', + prev_surplus_state: 'night_session_done_idle', + }, + expect: { + profile: 'night_hc', + sourceMode: 'blocked', + state: 'night_recovery_done_idle', + decision: 'hold', + desiredCharging: false, + currentA: 6, + takeoverNeeded: false, + edfOnAction: false, + takeoverHelperService: 'turn_off', + }, + }, + { + id: 'night-recovery-done-bande-collante', + label: 'Nuit HC 04:45, recovery done, SOC 34 dans la bande (cible−2 < 34 < 35) → PAS de ré-engagement (anti-rebond)', + time: '2026-08-08T04:45:00+02:00', + overrides: { + ...VEHICLE_NIGHT, soc_percent: '34', allow_grid_hc_night: 'on', + evcs_charging_switch: 'off', + prev_surplus_state: 'night_recovery_done_idle', + }, + expect: { + profile: 'night_hc', + sourceMode: 'blocked', + state: 'night_recovery_done_idle', + decision: 'hold', + desiredCharging: false, + currentA: 6, + takeoverNeeded: false, + edfOnAction: false, + takeoverHelperService: 'turn_off', + }, + }, + { + id: 'night-recovery-rearm-sous-bande', + label: 'Nuit HC 05:30, recovery done mais SOC 32 ≤ cible−2 → ré-engagement takeover (recharge batterie)', + time: '2026-08-08T05:30:00+02:00', + overrides: { + ...VEHICLE_NIGHT, soc_percent: '32', allow_grid_hc_night: 'on', + grid_state: 'on', victron_acin1_available: '1', + battery_current_a: '2', night_takeover_prev: 'on', + evcs_charging_switch: 'off', + prev_surplus_state: 'night_recovery_done_idle', + }, + expect: { + profile: 'night_hc', + sourceMode: 'blocked', + state: 'night_session_done_idle', + decision: 'hold', + desiredCharging: false, + currentA: 6, + takeoverNeeded: true, + edfOnAction: false, + takeoverHelperService: 'turn_on', + }, + }, + { + id: 'night-vw-statut-charged', + label: 'Nuit HC 03:00, statut EVCS brut 3 (charged), sans état VDF préalable → fin de charge immédiate (Fix D)', + time: '2026-08-08T03:00:00+02:00', + overrides: { + ...VEHICLE_NIGHT, soc_percent: '60', allow_grid_hc_night: 'on', + grid_state: 'on', victron_acin1_available: '1', + battery_current_a: '2', night_takeover_prev: 'on', + evcs_charging_switch: 'off', + evcs_status_raw: '3', + prev_surplus_state: '', + }, + expect: { + profile: 'night_hc', + sourceMode: 'blocked', + state: 'night_recovery_done_idle', + decision: 'hold', + desiredCharging: false, + currentA: 6, + takeoverNeeded: false, + edfOnAction: false, + takeoverHelperService: 'turn_off', + }, + }, + // ================= JOUR SOLAIRE ================= { id: 'morning-solaire-fort', diff --git a/tools/patch-20260808-c-unplug-cut.mjs b/tools/patch-20260808-c-unplug-cut.mjs new file mode 100644 index 0000000..fe056f7 --- /dev/null +++ b/tools/patch-20260808-c-unplug-cut.mjs @@ -0,0 +1,74 @@ +#!/usr/bin/env node +// Patch 08/08/2026 — FIX C : coupure EDF immédiate au débranchement du véhicule. +// Incident nuit 07-08/08 : après débranchement, EDF restait ON 25 min +// (hold_on_min_runtime) → l'utilisateur a dû couper à la main. +// Cible : workflows/victron-grid-hysteresis-v1.json (node "Compute Grid Hysteresis"). +// Rejouable : détecte si déjà appliqué. + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const FILE = path.join(ROOT, 'workflows/victron-grid-hysteresis-v1.json'); + +const wf = JSON.parse(fs.readFileSync(FILE, 'utf8')); +const node = wf.nodes.find(n => n.name === 'Compute Grid Hysteresis'); +if (!node) throw new Error('Node "Compute Grid Hysteresis" introuvable'); + +let code = node.parameters.jsCode; + +if (code.includes('turn_off_vehicle_unplugged')) { + console.log('Déjà appliqué — rien à faire.'); + process.exit(0); +} + +const anchor = ` reason.push('Force grid + voiture pleine : coupure immediate du reseau'); + forcedCut = true; +} +`; + +const insertion = ` reason.push('Force grid + voiture pleine : coupure immediate du reseau'); + forcedCut = true; +} + +// ====================================================== +// FIX C (08/08/2026): VEHICULE DEBRANCHE -> COUPURE IMMEDIATE +// Incident nuit 07-08/08 : apres debranchement du vehicule, EDF restait +// maintenu ON 25 min (hold_on_min_runtime) alors que plus rien ne le +// justifiait (SOC >= seuil OFF). Ici : coupure immediate, bypass min_on. +// ====================================================== +if ( + !forcedCut && + latchIsOn && + gridIsOn && + !evcsGridOverride && + evcsSurplusState === 'vehicle_not_connected' && + soc >= gridSocOff +) { + desiredGrid = 'off'; + desiredLatch = 'off'; + state = 'turn_off_vehicle_unplugged'; + reason.push('Vehicule debranche + SOC >= seuil OFF : coupure immediate du reseau (bypass min_on)'); + forcedCut = true; +} +`; + +if (!code.includes(anchor)) throw new Error('Ancre force_grid/forcedCut introuvable — code inattendu'); +code = code.replace(anchor, insertion); + +// Notification dédiée (facultative mais utile) : réutilise le bloc turn_off_recovered. +const notifAnchor = `} else if (state === 'turn_off_recovered') {`; +const notifInsertion = `} else if (state === 'turn_off_vehicle_unplugged') { + notifyTitle = '✅ EDF coupé (véhicule débranché)'; + notifyMessage = \`Véhicule débranché et SOC \${round1(soc)}% >= \${gridSocOff}%. Coupure immédiate du réseau.\`; + notifyWanted = true; + +} else if (state === 'turn_off_recovered') {`; + +if (!code.includes(notifAnchor)) throw new Error('Ancre notification turn_off_recovered introuvable'); +code = code.replace(notifAnchor, notifInsertion); + +node.parameters.jsCode = code; +fs.writeFileSync(FILE, JSON.stringify(wf, null, 2) + '\n'); +console.log('Patch appliqué →', FILE, `(jsCode ${code.length} chars)`); diff --git a/workflows/evcs-surplus-controller-v2.6.json b/workflows/evcs-surplus-controller-v2.6.json index 42cf7c2..b85be9a 100644 --- a/workflows/evcs-surplus-controller-v2.6.json +++ b/workflows/evcs-surplus-controller-v2.6.json @@ -81,7 +81,7 @@ }, { "parameters": { - "jsCode": "function 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 getParisParts(ms = Date.now()) {\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 const date = `${parts.year}-${parts.month}-${parts.day}`;\n\n return {\n date,\n hour,\n minute: Number(parts.minute),\n second: Number(parts.second),\n };\n}\n\nfunction getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') {\n const dtf = new Intl.DateTimeFormat('en-US', {\n timeZone,\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 dtf.formatToParts(new Date(utcTs))\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const asIfUtc = Date.UTC(\n Number(parts.year),\n Number(parts.month) - 1,\n Number(parts.day),\n Number(parts.hour) === 24 ? 0 : Number(parts.hour),\n Number(parts.minute),\n Number(parts.second)\n );\n\n const d = new Date(utcTs);\n const actualUtc = Date.UTC(\n d.getUTCFullYear(),\n d.getUTCMonth(),\n d.getUTCDate(),\n d.getUTCHours(),\n d.getUTCMinutes(),\n d.getUTCSeconds()\n );\n\n return asIfUtc - actualUtc;\n}\n\nfunction parseParisStampToMs(s) {\n const text = (s ?? '').toString().trim();\n const m = text.match(/^(\\d{4})-(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2})$/);\n if (!m) return null;\n\n const y = Number(m[1]);\n const mo = Number(m[2]);\n const d = Number(m[3]);\n const h = Number(m[4]);\n const mi = Number(m[5]);\n const se = Number(m[6]);\n\n const guessUtc = Date.UTC(y, mo - 1, d, h, mi, se);\n const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris');\n let ts = guessUtc - off1;\n const off2 = getTzOffsetMs(ts, 'Europe/Paris');\n\n if (off2 !== off1) ts = guessUtc - off2;\n return ts;\n}\n\nfunction hhmmssFromMinutes(minOfDay) {\n const mm = ((minOfDay % 1440) + 1440) % 1440;\n const hh = Math.floor(mm / 60);\n const mi = mm % 60;\n return `${to2(hh)}:${to2(mi)}:00`;\n}\n\nfunction nextDailyOccurrenceMs(startMin, refMs) {\n if (!Number.isFinite(startMin)) return null;\n\n const today = getParisParts(refMs).date;\n const todayStamp = `${today} ${hhmmssFromMinutes(startMin)}`;\n const todayMs = parseParisStampToMs(todayStamp);\n\n if (todayMs !== null && todayMs > refMs + 1000) return todayMs;\n\n const tomorrow = getParisParts(refMs + 36 * 3600 * 1000).date;\n const tomorrowStamp = `${tomorrow} ${hhmmssFromMinutes(startMin)}`;\n return parseParisStampToMs(tomorrowStamp);\n}\n\nconst prev = $input.first().json;\n\nconst {\n nowMs,\n cfg,\n parsed,\n params,\n history,\n batteryTune,\n strategy,\n derived,\n context,\n lifecycle,\n} = prev;\n\n// =====================================================\n// SESSION / SOURCE\n// =====================================================\nconst sessionObservedActive =\n parsed.evcsChargingSwitchOn ||\n parsed.controlLatch ||\n parsed.chargePermissionNow;\n\nconst isGridSource =\n context.sourceMode === 'grid_hc_allowed' ||\n context.sourceMode === 'grid_forced';\n\nconst isLocalSolarSource =\n context.sourceMode === 'solar_only' ||\n context.sourceMode === 'solar_potential';\n\n// =====================================================\n// SOC BAND / BATTERY DYNAMIC POLICY\n// =====================================================\nconst socBand =\n parsed.soc > 90 ? 'gt90' :\n parsed.soc > 80 ? '80_90' :\n parsed.soc > 60 ? '60_80' :\n parsed.soc > 40 ? '40_60' :\n parsed.soc > 25 ? '25_40' :\n 'lt25';\n\nconst band = batteryTune.bands[socBand];\n\nconst dischargeModeFactor =\n strategy.forceChargeRequested ? 1.2 :\n strategy.forceGridRequested ? 0.7 :\n strategy.userStrategy === 'auto_solar' ? 0.8 :\n 1.0;\n\nconst chargeReserveModeFactor =\n strategy.forceChargeRequested ? 0.8 :\n strategy.forceGridRequested ? 1.1 :\n strategy.userStrategy === 'auto_solar' ? 1.1 :\n 1.0;\n\nconst toleranceModeFactor =\n strategy.forceChargeRequested ? 1.25 :\n strategy.forceGridRequested ? 0.8 :\n strategy.userStrategy === 'auto_solar' ? 0.8 :\n 1.0;\n\nconst batteryDischargeSoftDynamicA = band.dischargeSoftA * dischargeModeFactor;\nconst batteryDischargeHardDynamicA = band.dischargeHardA * dischargeModeFactor;\nconst batteryChargeReserveDynamicA = Math.max(0, band.chargeReserveA * chargeReserveModeFactor);\nconst batterySoftToleranceMinutes = band.softToleranceMin * toleranceModeFactor;\nconst batteryHardToleranceSeconds = band.hardToleranceSec;\nconst batteryRecoveryMinutes = band.recoveryMin;\n\n// =====================================================\n// CONTEXT HELPERS\n// =====================================================\nconst houseSpikeThresholdW = 1500;\nconst houseSpikeDetected =\n (parsed.houseAcPowerW - parsed.houseAcPowerPrevW) >= houseSpikeThresholdW;\n\nconst solarPotentialContext =\n context.sourceMode === 'solar_potential' ||\n (\n parsed.soc >= 80 &&\n derived.forecastBufferKwh > 0 &&\n parsed.pvPowerW < context.effectivePvStartMinW &&\n derived.houseNonEvcsW < 1200\n );\n\n// =====================================================\n// OUTSIDE-WINDOW LOCAL BYPASS\n// IMPORTANT:\n// en auto_solar_plus ou force_charge_now, un contexte local solaire\n// ne doit plus rester bloqué juste parce qu'on est hors fenêtre.\n// =====================================================\nconst outsideWindowLocalBypass =\n context.profile === 'outside_window' &&\n !strategy.forceGridRequested &&\n (\n strategy.forceChargeRequested ||\n strategy.userStrategy === 'auto_solar_plus'\n ) &&\n isLocalSolarSource;\n\n// =====================================================\n// REASON-BASED LOCKS\n// =====================================================\nconst nextWindowCandidates = [\n nextDailyOccurrenceMs(params.morningStartMin, nowMs),\n nextDailyOccurrenceMs(params.hcMiddayStartMin, nowMs),\n nextDailyOccurrenceMs(params.eveningStartMin, nowMs),\n nextDailyOccurrenceMs(params.hcNightStartMin, nowMs),\n].filter(v => Number.isFinite(v));\n\nconst nextAllowedWindowStartMs =\n nextWindowCandidates.length > 0 ? Math.min(...nextWindowCandidates) : null;\n\nconst blockActive =\n history.blockedUntilMs !== null &&\n history.blockedUntilMs > nowMs + 1000;\n\nconst blockStillRelevant =\n history.lastBlockReason === 'outside_window'\n ? (context.profile === 'outside_window' && !outsideWindowLocalBypass)\n : history.lastBlockReason === 'probe_failed'\n ? context.sourceMode === 'hybrid_probe'\n : history.lastBlockReason === 'battery_protection_stop'\n ? (\n parsed.batteryCurrentA < -params.batteryNeutralBandA ||\n derived.anyVebusLowBattery ||\n derived.vebusDcVoltageSoftLow\n )\n : history.lastBlockReason === 'grid_battery_guard'\n ? isGridSource\n : history.lastBlockReason === 'vebus_overload_persistent'\n ? derived.anyVebusOverload\n : false;\n\nconst startBlockedByReason = blockActive && blockStillRelevant;\n\n// =====================================================\n// CLOUD DEBT\n// =====================================================\nconst batteryNetPowerW = parsed.batteryCurrentA * derived.batteryVoltageRef;\nlet cloudDebtNextKwh = params.cloudDebtPrevKwh;\n\nif (sessionObservedActive) {\n if (batteryNetPowerW < 0 && !isGridSource) {\n cloudDebtNextKwh += (Math.abs(batteryNetPowerW) * cfg.sampleSeconds) / 3600000;\n } else if (batteryNetPowerW > 0) {\n cloudDebtNextKwh -= (batteryNetPowerW * cfg.sampleSeconds) / 3600000;\n }\n} else if (batteryNetPowerW > 0) {\n cloudDebtNextKwh -= (batteryNetPowerW * cfg.sampleSeconds) / 3600000;\n}\n\ncloudDebtNextKwh = clamp(cloudDebtNextKwh, 0, params.cloudDebtMaxKwh * 4);\n\nconst cloudDebtMeaningful =\n cloudDebtNextKwh > cfg.cloudDebtMeaningfulThresholdKwh;\n\nconst cloudEventDischargingNow =\n sessionObservedActive &&\n !isGridSource &&\n parsed.batteryCurrentA < -Math.max(1, params.batteryNeutralBandA) &&\n cloudDebtMeaningful;\n\nconst continuingCloudEvent =\n cloudEventDischargingNow &&\n history.cloudDebtStartedMsPrev !== null &&\n params.cloudDebtPrevKwh > cfg.cloudDebtRecoveryThresholdKwh;\n\nconst enteringCloudDebt =\n cloudEventDischargingNow && !continuingCloudEvent;\n\nconst cloudDebtActive = cloudEventDischargingNow;\n\nconst cloudDebtStartedMs =\n cloudEventDischargingNow\n ? (continuingCloudEvent ? history.cloudDebtStartedMsPrev : nowMs)\n : null;\n\nconst cloudDebtDurationMinutes =\n cloudDebtActive && cloudDebtStartedMs !== null\n ? Math.max(0, (nowMs - cloudDebtStartedMs) / 60000)\n : 0;\n\nconst effectiveCloudToleranceMinutes =\n params.cloudToleranceMinutes +\n (solarPotentialContext ? batteryTune.global.solarPotentialGraceMinutes : 0) +\n (houseSpikeDetected ? batteryTune.global.houseSpikeGraceMinutes : 0);\n\nconst cloudToleranceOk =\n cloudDebtNextKwh <= params.cloudDebtMaxKwh &&\n (\n !cloudDebtActive ||\n cloudDebtDurationMinutes <= effectiveCloudToleranceMinutes\n );\n\n// =====================================================\n// LEGACY / FORECAST MODES\n// =====================================================\nconst batteryFullHarvestMode =\n parsed.forecastModeEnabled &&\n context.profile !== 'night_hc' &&\n context.profile !== 'outside_window' &&\n parsed.soc >= params.batteryFullSoc &&\n parsed.todayRemainingKwh >= params.batteryFullForecastMinKwh &&\n derived.projectedEndSocAfterCharge >= (derived.endDayGuardSoc - 2);\n\nconst forecastCommitMode =\n parsed.forecastModeEnabled &&\n context.profile !== 'night_hc' &&\n context.profile !== 'outside_window' &&\n parsed.soc >= params.forecastCommitSocFloor &&\n parsed.todayRemainingKwh >= params.forecastCommitMinKwh &&\n derived.projectedEndSocAfterCharge >= derived.endDayGuardSoc;\n\nconst batteryPriorityMode =\n !batteryFullHarvestMode && !forecastCommitMode;\n\n// =====================================================\n// EVCS NIGHT ENGINE — R1 (07/08/2026)\n// Le moteur nuit est calculé UNE SEULE FOIS dans WINDOWS_SOURCEV2\n// (payload nightEvcsPreview). Ce node CONSOMME, il ne recalcule plus.\n// TOUTE évolution de la logique nuit se fait dans WINDOWS_SOURCEV2.\n// Fallback sûr si preview absent : session inactive / 'blocked'.\n// =====================================================\nconst nightPrev = prev.nightEvcsPreview || {};\n\nconst nightSessionActive = !!nightPrev.nightSessionActive;\nconst nightSessionNeedsCapture = !!nightPrev.nightSessionNeedsCapture;\nconst nightSessionNeedsClear = !!nightPrev.nightSessionNeedsClear;\nconst nightSessionStartSocPct =\n Number.isFinite(nightPrev.nightSessionStartSocPct) ? nightPrev.nightSessionStartSocPct : null;\nconst nightSessionStartAt = nightPrev.nightSessionStartAt || '';\nconst nightSessionStartMs =\n Number.isFinite(nightPrev.nightSessionStartMs) ? nightPrev.nightSessionStartMs : null;\n\nconst batteryHardFloorPct =\n Number.isFinite(nightPrev.batteryHardFloorPct)\n ? nightPrev.batteryHardFloorPct\n : params.evcsNightBatteryHardFloorPct;\nconst batteryMorningTargetPct =\n Number.isFinite(nightPrev.batteryMorningTargetPct)\n ? nightPrev.batteryMorningTargetPct\n : params.evcsNightTargetWeakForecastPct;\nconst maxBatteryDischargePoints =\n Number.isFinite(nightPrev.maxBatteryDischargePoints) ? nightPrev.maxBatteryDischargePoints : 0;\nconst gridTakeoverTriggerPct =\n Number.isFinite(nightPrev.gridTakeoverTriggerPct)\n ? nightPrev.gridTakeoverTriggerPct\n : Math.max(batteryHardFloorPct, batteryMorningTargetPct);\n\nconst batteryBelowHardFloor = !!nightPrev.batteryBelowHardFloor;\nconst batteryBelowMorningTarget = !!nightPrev.batteryBelowMorningTarget;\nconst batteryAtOrBelowGridTakeoverTrigger = !!nightPrev.batteryAtOrBelowGridTakeoverTrigger;\n\nconst batteryKwhMissingToTarget =\n Number.isFinite(nightPrev.batteryKwhMissingToTarget) ? nightPrev.batteryKwhMissingToTarget : 0;\nconst batteryRecoveryMinutesNeeded =\n Number.isFinite(nightPrev.batteryRecoveryMinutesNeeded) ? nightPrev.batteryRecoveryMinutesNeeded : 0;\nconst batteryRecoveryUrgent = !!nightPrev.batteryRecoveryUrgent;\nconst batteryRecoveryAllowedNow = !!nightPrev.batteryRecoveryAllowedNow;\nconst batteryTargetReached = !!nightPrev.batteryTargetReached;\n\nconst batteryAssistAllowed = !!nightPrev.batteryAssistAllowed;\nconst nightGridTakeoverNeeded = !!nightPrev.nightGridTakeoverNeeded;\nconst nightTakeoverLatched = !!nightPrev.nightTakeoverLatched;\nconst vehicleChargeCompleted = !!nightPrev.vehicleChargeCompleted;\nconst nightPreferredSourceMode = nightPrev.nightPreferredSourceMode || 'blocked';\n\n// =====================================================\n// PROFILE-BASED THRESHOLDS\n// =====================================================\nconst profileSocStartFloor =\n context.profile === 'morning_solar' ? params.morningSocMin :\n context.profile === 'midday_hc_or_solar' ? params.middaySocMin :\n context.profile === 'evening_high_soc' ? params.eveningSocStartMin :\n params.socStartMin;\n\n// P6 ABONDANCE : PV moyen élevé + batterie en charge franche => le plancher SOC\n// de démarrage perd son sens (la batterie continue de monter même avec l'EVCS à 6 A).\n// Jours nuageux ou batterie peu chargée par le PV : rien ne change.\nconst PV_ABUNDANCE_AVG_MIN_W = 4000;\nconst ABUNDANCE_BATTERY_CHARGE_MIN_A = 20;\nconst ABUNDANCE_SOC_FLOOR_PCT = 40;\nconst solarAbundance =\n parsed.pvPowerAvg10mW >= PV_ABUNDANCE_AVG_MIN_W &&\n parsed.batteryCurrentA >= ABUNDANCE_BATTERY_CHARGE_MIN_A;\nconst effectiveSocStartFloor = solarAbundance\n ? Math.min(profileSocStartFloor, ABUNDANCE_SOC_FLOOR_PCT)\n : profileSocStartFloor;\n\nconst profileSocHoldFloor =\n context.profile === 'morning_solar' ? Math.max(params.socStopMin, params.morningSocMin - 10) :\n context.profile === 'midday_hc_or_solar' ? Math.max(params.socStopMin, params.middaySocMin - 15) :\n context.profile === 'evening_high_soc' ? params.eveningSocStopMin :\n params.socStopMin;\n\nconst profileSpecificDischargeFloorA =\n context.profile === 'morning_solar' ? params.morningBatteryMaxDischargeA :\n context.profile === 'midday_hc_or_solar' ? params.middayBatteryMaxDischargeA :\n context.profile === 'evening_high_soc' ? params.eveningBatteryMaxDischargeA :\n params.batteryDischargeSoftA;\n\nconst profileBatteryDischargeFloorA =\n Math.max(profileSpecificDischargeFloorA, batteryDischargeSoftDynamicA);\n\n// =====================================================\n// GRID RELAX IN force_charge_now\n// =====================================================\n// Assouplissement uniquement pour la garde réseau,\n// sans toucher aux sécurités dures globales.\nconst gridForceChargeRelaxSoftA =\n strategy.forceChargeRequested && isGridSource\n ? (\n parsed.soc >= 90 ? 28 :\n parsed.soc >= 80 ? 16 :\n parsed.soc >= 60 ? 8 :\n parsed.soc >= 40 ? 4 :\n 0\n )\n : 0;\n\nconst gridForceChargeRelaxHardA =\n strategy.forceChargeRequested && isGridSource\n ? (\n parsed.soc >= 90 ? 39 :\n parsed.soc >= 80 ? 29 :\n parsed.soc >= 60 ? 18 :\n parsed.soc >= 40 ? 8 :\n 0\n )\n : 0;\n\nconst gridBatterySoftDynamicABase =\n Math.max(params.gridBatterySoftA, batteryDischargeSoftDynamicA);\n\nconst gridBatteryHardDynamicABase =\n Math.max(params.gridBatteryHardA, batteryDischargeHardDynamicA);\n\nlet gridBatterySoftDynamicA =\n gridBatterySoftDynamicABase - gridForceChargeRelaxSoftA;\n\nlet gridBatteryHardDynamicA =\n gridBatteryHardDynamicABase - gridForceChargeRelaxHardA;\n\n// Garantit que le hard reste plus permissif que le soft d'au moins 3 A\nif (gridBatteryHardDynamicA > (gridBatterySoftDynamicA - 3)) {\n gridBatteryHardDynamicA = gridBatterySoftDynamicA - 3;\n}\n\n// =====================================================\n// BASE SAFETY\n// =====================================================\nconst commonSafetyStartOk =\n derived.canRunAtMinCurrent &&\n !derived.anyVebusOverload &&\n !derived.anyVebusLowBattery &&\n !derived.vebusDcVoltageSoftLow &&\n !derived.vebusAcOutSoftReached &&\n parsed.evcsDeviceConnected &&\n parsed.evcsVehicleConnected &&\n !parsed.evcsModeIsAuto;\n\nconst commonSafetyHoldOk =\n derived.canRunAtMinCurrent &&\n !derived.anyVebusLowBattery &&\n !derived.vebusDcVoltageHardLow &&\n parsed.evcsDeviceConnected &&\n parsed.evcsVehicleConnected &&\n !parsed.evcsModeIsAuto;\n\n// =====================================================\n// BATTERY-ASSISTED SOLAR START/HOLD\n// IMPORTANT:\n// Installation sans injection:\n// le PV visible peut rester bas tant que l'EVCS ne démarre pas.\n// On autorise donc un démarrage/maintien local si la batterie est haute\n// et que la dette nuage reste faible.\n// =====================================================\nconst batteryAssistSolarPolicyEligible =\n !strategy.forceGridRequested &&\n (\n strategy.forceChargeRequested ||\n strategy.userStrategy === 'auto_solar_plus'\n ) &&\n isLocalSolarSource;\n\nconst batteryAssistSolarStartSocMin = 90;\nconst batteryAssistSolarHoldSocMin = 85;\n\nconst batteryAssistSolarDebtStartMaxKwh = Math.max(\n cfg.cloudDebtRecoveryThresholdKwh,\n Math.min(params.cloudDebtMaxKwh, 0.05)\n);\n\nconst batteryAssistSolarStartOk =\n batteryAssistSolarPolicyEligible &&\n commonSafetyStartOk &&\n !isGridSource &&\n parsed.soc >= Math.max(profileSocStartFloor, batteryAssistSolarStartSocMin) &&\n parsed.soc > batteryAssistSolarHoldSocMin &&\n parsed.batteryCurrentA >= profileBatteryDischargeFloorA &&\n cloudDebtNextKwh <= batteryAssistSolarDebtStartMaxKwh &&\n !cloudDebtActive;\n\nconst batteryAssistSolarHoldOk =\n batteryAssistSolarPolicyEligible &&\n commonSafetyHoldOk &&\n !isGridSource &&\n parsed.soc >= Math.max(profileSocHoldFloor, batteryAssistSolarHoldSocMin) &&\n parsed.batteryCurrentA >= profileBatteryDischargeFloorA &&\n cloudToleranceOk;\n\n// =====================================================\n// START / HOLD CONDITIONS\n// =====================================================\nconst rawSolarStartOk =\n (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') &&\n commonSafetyStartOk &&\n parsed.soc >= effectiveSocStartFloor &&\n parsed.batteryCurrentA >= profileBatteryDischargeFloorA &&\n (\n context.sourceMode === 'solar_potential' ||\n parsed.pvPowerW >= context.effectivePvStartMinW\n );\n\nconst solarStartOk =\n rawSolarStartOk || batteryAssistSolarStartOk;\n\nconst rawSolarHoldOk =\n (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') &&\n commonSafetyHoldOk &&\n parsed.soc >= profileSocHoldFloor &&\n parsed.batteryCurrentA >= profileBatteryDischargeFloorA &&\n (\n parsed.pvPowerW >= params.pvHoldMinW ||\n cloudToleranceOk\n );\n\nconst solarHoldOk =\n rawSolarHoldOk || batteryAssistSolarHoldOk;\n\n// FIX V2.5: Garde SOC - pas de grid pour EVCS si SOC > 35% sauf force_grid_now\nconst gridAllowedBySoc = \n strategy.forceGridRequested ||\n context.profile === 'night_hc' ||\n parsed.soc <= 35;\n\nconst gridStartOk =\n gridAllowedBySoc &&\n isGridSource &&\n commonSafetyStartOk &&\n parsed.gridAvailable &&\n parsed.batteryCurrentA >= gridBatterySoftDynamicA;\n\nconst gridHoldOk =\n gridAllowedBySoc &&\n isGridSource &&\n commonSafetyHoldOk &&\n parsed.gridAvailable &&\n parsed.batteryCurrentA >= gridBatteryHardDynamicA;\n\nconst batteryAssistNightStartOk =\n context.sourceMode === 'battery_assist_night' &&\n commonSafetyStartOk &&\n nightSessionActive &&\n batteryAssistAllowed &&\n parsed.soc > batteryHardFloorPct &&\n parsed.soc > gridTakeoverTriggerPct &&\n parsed.batteryCurrentA >= profileBatteryDischargeFloorA;\n\nconst batteryAssistNightHoldOk =\n context.sourceMode === 'battery_assist_night' &&\n commonSafetyHoldOk &&\n nightSessionActive &&\n batteryAssistAllowed &&\n parsed.soc > batteryHardFloorPct &&\n parsed.soc > gridTakeoverTriggerPct &&\n parsed.batteryCurrentA >= batteryDischargeHardDynamicA;\n\n// =====================================================\n// PROBE LOGIC — STRICTEMENT LIMITÉE AU MODE PROBE\n// =====================================================\nconst probeModeActive = context.sourceMode === 'hybrid_probe';\n\nconst probeStartOk =\n probeModeActive &&\n commonSafetyStartOk &&\n (context.morningProbeEligible || context.middayProbeEligible || context.afternoonProbeEligible);\n\nconst probeHoldGuardOk =\n probeModeActive &&\n commonSafetyHoldOk &&\n parsed.batteryCurrentA >= profileBatteryDischargeFloorA;\n\nconst probeWindowActive =\n probeModeActive &&\n sessionObservedActive &&\n history.probeStartedMs !== null &&\n lifecycle.minutesSinceProbeStart !== null &&\n lifecycle.minutesSinceProbeStart < params.probeMinutes;\n\nconst probeElapsedReached =\n probeModeActive &&\n sessionObservedActive &&\n history.probeStartedMs !== null &&\n lifecycle.minutesSinceProbeStart !== null &&\n lifecycle.minutesSinceProbeStart >= params.probeMinutes;\n\nconst probeSuccessNow =\n probeModeActive &&\n sessionObservedActive &&\n history.probeStartedMs !== null &&\n (\n parsed.pvPowerW >= params.pvHoldMinW &&\n parsed.batteryCurrentA >= Math.min(0, batteryDischargeSoftDynamicA)\n );\n\nconst probeFailedNow =\n probeModeActive &&\n probeElapsedReached &&\n !probeSuccessNow;\n\n// =====================================================\n// STEP-UP LOGIC\n// =====================================================\nconst stepUpByBatteryReserve =\n batteryPriorityMode &&\n parsed.batteryCurrentA >= batteryChargeReserveDynamicA;\n\nconst stepUpByForecastMode =\n !batteryPriorityMode &&\n (\n parsed.pvPowerW >= params.pvHoldMinW ||\n parsed.pvPowerAvg10mW >= params.pvHoldMinW\n ) &&\n derived.forecastBufferKwh >= -1 &&\n derived.projectedEndSocAfterCharge >= (derived.endDayGuardSoc - 5) &&\n cloudToleranceOk;\n\nconst solarShouldStepUp =\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA < derived.physicalCapA &&\n derived.physicalCapA >= params.currentMinA &&\n !derived.vebusAcOutSoftReached &&\n !derived.vebusDcVoltageSoftLow &&\n !derived.houseLoadHighVsHeadroom &&\n (\n stepUpByBatteryReserve ||\n stepUpByForecastMode ||\n parsed.pvPowerW >= (context.effectivePvStartMinW + 400)\n );\n\nconst gridShouldStepUp =\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA < derived.physicalCapA &&\n derived.physicalCapA >= params.currentMinA &&\n !derived.vebusAcOutSoftReached &&\n !derived.vebusDcVoltageSoftLow &&\n parsed.batteryCurrentA >= gridBatterySoftDynamicA &&\n !derived.houseLoadHighVsHeadroom;\n\nconst batteryAssistNightShouldStepUp =\n context.sourceMode === 'battery_assist_night' &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA < derived.physicalCapA &&\n derived.physicalCapA >= params.currentMinA &&\n !derived.vebusAcOutSoftReached &&\n !derived.vebusDcVoltageSoftLow &&\n !derived.houseLoadHighVsHeadroom &&\n parsed.batteryCurrentA >= batteryChargeReserveDynamicA &&\n batteryAssistAllowed;\n\n// =====================================================\n// STEP-DOWN LOGIC\n// =====================================================\nconst solarShouldStepDownBattery =\n parsed.batteryCurrentA <= batteryDischargeSoftDynamicA &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst solarShouldStepDownCloudDebt =\n !cloudToleranceOk &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst gridShouldStepDownBattery =\n parsed.batteryCurrentA < gridBatterySoftDynamicA &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst batteryAssistNightShouldStepDown =\n context.sourceMode === 'battery_assist_night' &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA &&\n (\n parsed.batteryCurrentA <= batteryDischargeSoftDynamicA ||\n parsed.soc <= gridTakeoverTriggerPct ||\n batteryRecoveryUrgent\n );\n\nconst shouldStepDownVebusPower =\n derived.vebusAcOutSoftReached &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst shouldStepDownVebusVoltage =\n derived.vebusDcVoltageSoftLow &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst shouldStepDownOverload =\n derived.anyVebusOverload &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst shouldStopOverloadAtMin =\n derived.anyVebusOverload &&\n derived.currentReferenceA <= params.currentMinA &&\n sessionObservedActive;\n\n// =====================================================\n// HARD STOPS / PROTECTIONS\n// =====================================================\nconst batteryCurrentHardExceeded =\n parsed.batteryCurrentA <= batteryDischargeHardDynamicA;\n\n// overload volontairement exclu du hard stop immédiat\nconst mustStopHard =\n derived.anyVebusLowBattery ||\n derived.vebusDcVoltageHardLow ||\n derived.vebusAcOutHardReached ||\n batteryCurrentHardExceeded;\n\nconst gridBatteryProtectionStop =\n sessionObservedActive &&\n isGridSource &&\n parsed.batteryCurrentA < gridBatteryHardDynamicA;\n\n// =====================================================\n// OUTPUT\n// =====================================================\nreturn [{\n json: {\n ...prev,\n\n batteryPolicy: {\n socBand,\n batteryDischargeSoftDynamicA,\n batteryDischargeHardDynamicA,\n batteryChargeReserveDynamicA,\n batterySoftToleranceMinutes,\n batteryHardToleranceSeconds,\n batteryRecoveryMinutes,\n\n gridBatterySoftDynamicA,\n gridBatteryHardDynamicA,\n gridForceChargeRelaxSoftA,\n gridForceChargeRelaxHardA,\n\n batteryAssistSolarStartSocMin,\n batteryAssistSolarHoldSocMin,\n batteryAssistSolarDebtStartMaxKwh,\n\n houseSpikeDetected,\n solarPotentialContext,\n outsideWindowLocalBypass,\n },\n\n locks: {\n nextAllowedWindowStartMs,\n blockActive,\n blockStillRelevant,\n startBlockedByReason,\n },\n\n cloud: {\n sessionObservedActive,\n batteryNetPowerW,\n cloudDebtNextKwh,\n cloudDebtMeaningful,\n cloudEventDischargingNow,\n continuingCloudEvent,\n enteringCloudDebt,\n cloudDebtActive,\n cloudDebtStartedMs,\n cloudDebtDurationMinutes,\n effectiveCloudToleranceMinutes,\n cloudToleranceOk,\n },\n\n modes: {\n batteryFullHarvestMode,\n forecastCommitMode,\n batteryPriorityMode,\n },\n\n nightEvcs: {\n nightSessionActive,\n nightSessionNeedsCapture,\n nightSessionNeedsClear,\n nightSessionStartSocPct,\n nightSessionStartAt,\n nightSessionStartMs,\n\n batteryHardFloorPct,\n batteryMorningTargetPct,\n maxBatteryDischargePoints,\n gridTakeoverTriggerPct,\n\n batteryBelowHardFloor,\n batteryBelowMorningTarget,\n batteryAtOrBelowGridTakeoverTrigger,\n\n batteryKwhMissingToTarget,\n batteryRecoveryMinutesNeeded,\n batteryRecoveryUrgent,\n batteryRecoveryAllowedNow,\n batteryTargetReached,\n\n batteryAssistAllowed,\n nightGridTakeoverNeeded,\n nightTakeoverLatched,\n vehicleChargeCompleted,\n nightPreferredSourceMode,\n },\n\n safeguards: {\n isGridSource,\n commonSafetyStartOk,\n commonSafetyHoldOk,\n profileSocStartFloor,\n effectiveSocStartFloor,\n solarAbundance,\n profileSocHoldFloor,\n profileBatteryDischargeFloorA,\n batteryCurrentHardExceeded,\n mustStopHard,\n gridBatteryProtectionStop,\n },\n\n conditions: {\n rawSolarStartOk,\n rawSolarHoldOk,\n batteryAssistSolarPolicyEligible,\n batteryAssistSolarStartOk,\n batteryAssistSolarHoldOk,\n\n solarStartOk,\n solarHoldOk,\n gridStartOk,\n gridHoldOk,\n\n batteryAssistNightStartOk,\n batteryAssistNightHoldOk,\n\n probeStartOk,\n probeHoldGuardOk,\n probeWindowActive,\n probeElapsedReached,\n probeSuccessNow,\n probeFailedNow,\n\n solarShouldStepUp,\n gridShouldStepUp,\n batteryAssistNightShouldStepUp,\n\n solarShouldStepDownBattery,\n solarShouldStepDownCloudDebt,\n gridShouldStepDownBattery,\n batteryAssistNightShouldStepDown,\n\n shouldStepDownVebusPower,\n shouldStepDownVebusVoltage,\n shouldStepDownOverload,\n shouldStopOverloadAtMin,\n }\n }\n}];\n" + "jsCode": "function 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 getParisParts(ms = Date.now()) {\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 const date = `${parts.year}-${parts.month}-${parts.day}`;\n\n return {\n date,\n hour,\n minute: Number(parts.minute),\n second: Number(parts.second),\n };\n}\n\nfunction getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') {\n const dtf = new Intl.DateTimeFormat('en-US', {\n timeZone,\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 dtf.formatToParts(new Date(utcTs))\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const asIfUtc = Date.UTC(\n Number(parts.year),\n Number(parts.month) - 1,\n Number(parts.day),\n Number(parts.hour) === 24 ? 0 : Number(parts.hour),\n Number(parts.minute),\n Number(parts.second)\n );\n\n const d = new Date(utcTs);\n const actualUtc = Date.UTC(\n d.getUTCFullYear(),\n d.getUTCMonth(),\n d.getUTCDate(),\n d.getUTCHours(),\n d.getUTCMinutes(),\n d.getUTCSeconds()\n );\n\n return asIfUtc - actualUtc;\n}\n\nfunction parseParisStampToMs(s) {\n const text = (s ?? '').toString().trim();\n const m = text.match(/^(\\d{4})-(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2})$/);\n if (!m) return null;\n\n const y = Number(m[1]);\n const mo = Number(m[2]);\n const d = Number(m[3]);\n const h = Number(m[4]);\n const mi = Number(m[5]);\n const se = Number(m[6]);\n\n const guessUtc = Date.UTC(y, mo - 1, d, h, mi, se);\n const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris');\n let ts = guessUtc - off1;\n const off2 = getTzOffsetMs(ts, 'Europe/Paris');\n\n if (off2 !== off1) ts = guessUtc - off2;\n return ts;\n}\n\nfunction hhmmssFromMinutes(minOfDay) {\n const mm = ((minOfDay % 1440) + 1440) % 1440;\n const hh = Math.floor(mm / 60);\n const mi = mm % 60;\n return `${to2(hh)}:${to2(mi)}:00`;\n}\n\nfunction nextDailyOccurrenceMs(startMin, refMs) {\n if (!Number.isFinite(startMin)) return null;\n\n const today = getParisParts(refMs).date;\n const todayStamp = `${today} ${hhmmssFromMinutes(startMin)}`;\n const todayMs = parseParisStampToMs(todayStamp);\n\n if (todayMs !== null && todayMs > refMs + 1000) return todayMs;\n\n const tomorrow = getParisParts(refMs + 36 * 3600 * 1000).date;\n const tomorrowStamp = `${tomorrow} ${hhmmssFromMinutes(startMin)}`;\n return parseParisStampToMs(tomorrowStamp);\n}\n\nconst prev = $input.first().json;\n\nconst {\n nowMs,\n cfg,\n parsed,\n params,\n history,\n batteryTune,\n strategy,\n derived,\n context,\n lifecycle,\n} = prev;\n\n// =====================================================\n// SESSION / SOURCE\n// =====================================================\nconst sessionObservedActive =\n parsed.evcsChargingSwitchOn ||\n parsed.controlLatch ||\n parsed.chargePermissionNow;\n\nconst isGridSource =\n context.sourceMode === 'grid_hc_allowed' ||\n context.sourceMode === 'grid_forced';\n\nconst isLocalSolarSource =\n context.sourceMode === 'solar_only' ||\n context.sourceMode === 'solar_potential';\n\n// =====================================================\n// SOC BAND / BATTERY DYNAMIC POLICY\n// =====================================================\nconst socBand =\n parsed.soc > 90 ? 'gt90' :\n parsed.soc > 80 ? '80_90' :\n parsed.soc > 60 ? '60_80' :\n parsed.soc > 40 ? '40_60' :\n parsed.soc > 25 ? '25_40' :\n 'lt25';\n\nconst band = batteryTune.bands[socBand];\n\nconst dischargeModeFactor =\n strategy.forceChargeRequested ? 1.2 :\n strategy.forceGridRequested ? 0.7 :\n strategy.userStrategy === 'auto_solar' ? 0.8 :\n 1.0;\n\nconst chargeReserveModeFactor =\n strategy.forceChargeRequested ? 0.8 :\n strategy.forceGridRequested ? 1.1 :\n strategy.userStrategy === 'auto_solar' ? 1.1 :\n 1.0;\n\nconst toleranceModeFactor =\n strategy.forceChargeRequested ? 1.25 :\n strategy.forceGridRequested ? 0.8 :\n strategy.userStrategy === 'auto_solar' ? 0.8 :\n 1.0;\n\nconst batteryDischargeSoftDynamicA = band.dischargeSoftA * dischargeModeFactor;\nconst batteryDischargeHardDynamicA = band.dischargeHardA * dischargeModeFactor;\nconst batteryChargeReserveDynamicA = Math.max(0, band.chargeReserveA * chargeReserveModeFactor);\nconst batterySoftToleranceMinutes = band.softToleranceMin * toleranceModeFactor;\nconst batteryHardToleranceSeconds = band.hardToleranceSec;\nconst batteryRecoveryMinutes = band.recoveryMin;\n\n// =====================================================\n// CONTEXT HELPERS\n// =====================================================\nconst houseSpikeThresholdW = 1500;\nconst houseSpikeDetected =\n (parsed.houseAcPowerW - parsed.houseAcPowerPrevW) >= houseSpikeThresholdW;\n\nconst solarPotentialContext =\n context.sourceMode === 'solar_potential' ||\n (\n parsed.soc >= 80 &&\n derived.forecastBufferKwh > 0 &&\n parsed.pvPowerW < context.effectivePvStartMinW &&\n derived.houseNonEvcsW < 1200\n );\n\n// =====================================================\n// OUTSIDE-WINDOW LOCAL BYPASS\n// IMPORTANT:\n// en auto_solar_plus ou force_charge_now, un contexte local solaire\n// ne doit plus rester bloqué juste parce qu'on est hors fenêtre.\n// =====================================================\nconst outsideWindowLocalBypass =\n context.profile === 'outside_window' &&\n !strategy.forceGridRequested &&\n (\n strategy.forceChargeRequested ||\n strategy.userStrategy === 'auto_solar_plus'\n ) &&\n isLocalSolarSource;\n\n// =====================================================\n// REASON-BASED LOCKS\n// =====================================================\nconst nextWindowCandidates = [\n nextDailyOccurrenceMs(params.morningStartMin, nowMs),\n nextDailyOccurrenceMs(params.hcMiddayStartMin, nowMs),\n nextDailyOccurrenceMs(params.eveningStartMin, nowMs),\n nextDailyOccurrenceMs(params.hcNightStartMin, nowMs),\n].filter(v => Number.isFinite(v));\n\nconst nextAllowedWindowStartMs =\n nextWindowCandidates.length > 0 ? Math.min(...nextWindowCandidates) : null;\n\nconst blockActive =\n history.blockedUntilMs !== null &&\n history.blockedUntilMs > nowMs + 1000;\n\nconst blockStillRelevant =\n history.lastBlockReason === 'outside_window'\n ? (context.profile === 'outside_window' && !outsideWindowLocalBypass)\n : history.lastBlockReason === 'probe_failed'\n ? context.sourceMode === 'hybrid_probe'\n : history.lastBlockReason === 'battery_protection_stop'\n ? (\n parsed.batteryCurrentA < -params.batteryNeutralBandA ||\n derived.anyVebusLowBattery ||\n derived.vebusDcVoltageSoftLow\n )\n : history.lastBlockReason === 'grid_battery_guard'\n ? isGridSource\n : history.lastBlockReason === 'vebus_overload_persistent'\n ? derived.anyVebusOverload\n : false;\n\nconst startBlockedByReason = blockActive && blockStillRelevant;\n\n// =====================================================\n// CLOUD DEBT\n// =====================================================\nconst batteryNetPowerW = parsed.batteryCurrentA * derived.batteryVoltageRef;\nlet cloudDebtNextKwh = params.cloudDebtPrevKwh;\n\nif (sessionObservedActive) {\n if (batteryNetPowerW < 0 && !isGridSource) {\n cloudDebtNextKwh += (Math.abs(batteryNetPowerW) * cfg.sampleSeconds) / 3600000;\n } else if (batteryNetPowerW > 0) {\n cloudDebtNextKwh -= (batteryNetPowerW * cfg.sampleSeconds) / 3600000;\n }\n} else if (batteryNetPowerW > 0) {\n cloudDebtNextKwh -= (batteryNetPowerW * cfg.sampleSeconds) / 3600000;\n}\n\ncloudDebtNextKwh = clamp(cloudDebtNextKwh, 0, params.cloudDebtMaxKwh * 4);\n\nconst cloudDebtMeaningful =\n cloudDebtNextKwh > cfg.cloudDebtMeaningfulThresholdKwh;\n\nconst cloudEventDischargingNow =\n sessionObservedActive &&\n !isGridSource &&\n parsed.batteryCurrentA < -Math.max(1, params.batteryNeutralBandA) &&\n cloudDebtMeaningful;\n\nconst continuingCloudEvent =\n cloudEventDischargingNow &&\n history.cloudDebtStartedMsPrev !== null &&\n params.cloudDebtPrevKwh > cfg.cloudDebtRecoveryThresholdKwh;\n\nconst enteringCloudDebt =\n cloudEventDischargingNow && !continuingCloudEvent;\n\nconst cloudDebtActive = cloudEventDischargingNow;\n\nconst cloudDebtStartedMs =\n cloudEventDischargingNow\n ? (continuingCloudEvent ? history.cloudDebtStartedMsPrev : nowMs)\n : null;\n\nconst cloudDebtDurationMinutes =\n cloudDebtActive && cloudDebtStartedMs !== null\n ? Math.max(0, (nowMs - cloudDebtStartedMs) / 60000)\n : 0;\n\nconst effectiveCloudToleranceMinutes =\n params.cloudToleranceMinutes +\n (solarPotentialContext ? batteryTune.global.solarPotentialGraceMinutes : 0) +\n (houseSpikeDetected ? batteryTune.global.houseSpikeGraceMinutes : 0);\n\nconst cloudToleranceOk =\n cloudDebtNextKwh <= params.cloudDebtMaxKwh &&\n (\n !cloudDebtActive ||\n cloudDebtDurationMinutes <= effectiveCloudToleranceMinutes\n );\n\n// =====================================================\n// LEGACY / FORECAST MODES\n// =====================================================\nconst batteryFullHarvestMode =\n parsed.forecastModeEnabled &&\n context.profile !== 'night_hc' &&\n context.profile !== 'outside_window' &&\n parsed.soc >= params.batteryFullSoc &&\n parsed.todayRemainingKwh >= params.batteryFullForecastMinKwh &&\n derived.projectedEndSocAfterCharge >= (derived.endDayGuardSoc - 2);\n\nconst forecastCommitMode =\n parsed.forecastModeEnabled &&\n context.profile !== 'night_hc' &&\n context.profile !== 'outside_window' &&\n parsed.soc >= params.forecastCommitSocFloor &&\n parsed.todayRemainingKwh >= params.forecastCommitMinKwh &&\n derived.projectedEndSocAfterCharge >= derived.endDayGuardSoc;\n\nconst batteryPriorityMode =\n !batteryFullHarvestMode && !forecastCommitMode;\n\n// =====================================================\n// EVCS NIGHT ENGINE — R1 (07/08/2026)\n// Le moteur nuit est calculé UNE SEULE FOIS dans WINDOWS_SOURCEV2\n// (payload nightEvcsPreview). Ce node CONSOMME, il ne recalcule plus.\n// TOUTE évolution de la logique nuit se fait dans WINDOWS_SOURCEV2.\n// Fallback sûr si preview absent : session inactive / 'blocked'.\n// =====================================================\nconst nightPrev = prev.nightEvcsPreview || {};\n\nconst nightSessionActive = !!nightPrev.nightSessionActive;\nconst nightSessionNeedsCapture = !!nightPrev.nightSessionNeedsCapture;\nconst nightSessionNeedsClear = !!nightPrev.nightSessionNeedsClear;\nconst nightSessionStartSocPct =\n Number.isFinite(nightPrev.nightSessionStartSocPct) ? nightPrev.nightSessionStartSocPct : null;\nconst nightSessionStartAt = nightPrev.nightSessionStartAt || '';\nconst nightSessionStartMs =\n Number.isFinite(nightPrev.nightSessionStartMs) ? nightPrev.nightSessionStartMs : null;\n\nconst batteryHardFloorPct =\n Number.isFinite(nightPrev.batteryHardFloorPct)\n ? nightPrev.batteryHardFloorPct\n : params.evcsNightBatteryHardFloorPct;\nconst batteryMorningTargetPct =\n Number.isFinite(nightPrev.batteryMorningTargetPct)\n ? nightPrev.batteryMorningTargetPct\n : params.evcsNightTargetWeakForecastPct;\nconst maxBatteryDischargePoints =\n Number.isFinite(nightPrev.maxBatteryDischargePoints) ? nightPrev.maxBatteryDischargePoints : 0;\nconst gridTakeoverTriggerPct =\n Number.isFinite(nightPrev.gridTakeoverTriggerPct)\n ? nightPrev.gridTakeoverTriggerPct\n : Math.max(batteryHardFloorPct, batteryMorningTargetPct);\n\nconst batteryBelowHardFloor = !!nightPrev.batteryBelowHardFloor;\nconst batteryBelowMorningTarget = !!nightPrev.batteryBelowMorningTarget;\nconst batteryAtOrBelowGridTakeoverTrigger = !!nightPrev.batteryAtOrBelowGridTakeoverTrigger;\n\nconst batteryKwhMissingToTarget =\n Number.isFinite(nightPrev.batteryKwhMissingToTarget) ? nightPrev.batteryKwhMissingToTarget : 0;\nconst batteryRecoveryMinutesNeeded =\n Number.isFinite(nightPrev.batteryRecoveryMinutesNeeded) ? nightPrev.batteryRecoveryMinutesNeeded : 0;\nconst batteryRecoveryUrgent = !!nightPrev.batteryRecoveryUrgent;\nconst batteryRecoveryAllowedNow = !!nightPrev.batteryRecoveryAllowedNow;\nconst batteryTargetReached = !!nightPrev.batteryTargetReached;\n\nconst batteryAssistAllowed = !!nightPrev.batteryAssistAllowed;\nconst nightGridTakeoverNeeded = !!nightPrev.nightGridTakeoverNeeded;\nconst nightTakeoverLatched = !!nightPrev.nightTakeoverLatched;\nconst vehicleChargeCompleted = !!nightPrev.vehicleChargeCompleted;\nconst nightRecoveryDone = !!nightPrev.nightRecoveryDone;\nconst nightPreferredSourceMode = nightPrev.nightPreferredSourceMode || 'blocked';\n\n// =====================================================\n// PROFILE-BASED THRESHOLDS\n// =====================================================\nconst profileSocStartFloor =\n context.profile === 'morning_solar' ? params.morningSocMin :\n context.profile === 'midday_hc_or_solar' ? params.middaySocMin :\n context.profile === 'evening_high_soc' ? params.eveningSocStartMin :\n params.socStartMin;\n\n// P6 ABONDANCE : PV moyen élevé + batterie en charge franche => le plancher SOC\n// de démarrage perd son sens (la batterie continue de monter même avec l'EVCS à 6 A).\n// Jours nuageux ou batterie peu chargée par le PV : rien ne change.\nconst PV_ABUNDANCE_AVG_MIN_W = 4000;\nconst ABUNDANCE_BATTERY_CHARGE_MIN_A = 20;\nconst ABUNDANCE_SOC_FLOOR_PCT = 40;\nconst solarAbundance =\n parsed.pvPowerAvg10mW >= PV_ABUNDANCE_AVG_MIN_W &&\n parsed.batteryCurrentA >= ABUNDANCE_BATTERY_CHARGE_MIN_A;\nconst effectiveSocStartFloor = solarAbundance\n ? Math.min(profileSocStartFloor, ABUNDANCE_SOC_FLOOR_PCT)\n : profileSocStartFloor;\n\nconst profileSocHoldFloor =\n context.profile === 'morning_solar' ? Math.max(params.socStopMin, params.morningSocMin - 10) :\n context.profile === 'midday_hc_or_solar' ? Math.max(params.socStopMin, params.middaySocMin - 15) :\n context.profile === 'evening_high_soc' ? params.eveningSocStopMin :\n params.socStopMin;\n\nconst profileSpecificDischargeFloorA =\n context.profile === 'morning_solar' ? params.morningBatteryMaxDischargeA :\n context.profile === 'midday_hc_or_solar' ? params.middayBatteryMaxDischargeA :\n context.profile === 'evening_high_soc' ? params.eveningBatteryMaxDischargeA :\n params.batteryDischargeSoftA;\n\nconst profileBatteryDischargeFloorA =\n Math.max(profileSpecificDischargeFloorA, batteryDischargeSoftDynamicA);\n\n// =====================================================\n// GRID RELAX IN force_charge_now\n// =====================================================\n// Assouplissement uniquement pour la garde réseau,\n// sans toucher aux sécurités dures globales.\nconst gridForceChargeRelaxSoftA =\n strategy.forceChargeRequested && isGridSource\n ? (\n parsed.soc >= 90 ? 28 :\n parsed.soc >= 80 ? 16 :\n parsed.soc >= 60 ? 8 :\n parsed.soc >= 40 ? 4 :\n 0\n )\n : 0;\n\nconst gridForceChargeRelaxHardA =\n strategy.forceChargeRequested && isGridSource\n ? (\n parsed.soc >= 90 ? 39 :\n parsed.soc >= 80 ? 29 :\n parsed.soc >= 60 ? 18 :\n parsed.soc >= 40 ? 8 :\n 0\n )\n : 0;\n\nconst gridBatterySoftDynamicABase =\n Math.max(params.gridBatterySoftA, batteryDischargeSoftDynamicA);\n\nconst gridBatteryHardDynamicABase =\n Math.max(params.gridBatteryHardA, batteryDischargeHardDynamicA);\n\nlet gridBatterySoftDynamicA =\n gridBatterySoftDynamicABase - gridForceChargeRelaxSoftA;\n\nlet gridBatteryHardDynamicA =\n gridBatteryHardDynamicABase - gridForceChargeRelaxHardA;\n\n// Garantit que le hard reste plus permissif que le soft d'au moins 3 A\nif (gridBatteryHardDynamicA > (gridBatterySoftDynamicA - 3)) {\n gridBatteryHardDynamicA = gridBatterySoftDynamicA - 3;\n}\n\n// =====================================================\n// BASE SAFETY\n// =====================================================\nconst commonSafetyStartOk =\n derived.canRunAtMinCurrent &&\n !derived.anyVebusOverload &&\n !derived.anyVebusLowBattery &&\n !derived.vebusDcVoltageSoftLow &&\n !derived.vebusAcOutSoftReached &&\n parsed.evcsDeviceConnected &&\n parsed.evcsVehicleConnected &&\n !parsed.evcsModeIsAuto;\n\nconst commonSafetyHoldOk =\n derived.canRunAtMinCurrent &&\n !derived.anyVebusLowBattery &&\n !derived.vebusDcVoltageHardLow &&\n parsed.evcsDeviceConnected &&\n parsed.evcsVehicleConnected &&\n !parsed.evcsModeIsAuto;\n\n// =====================================================\n// BATTERY-ASSISTED SOLAR START/HOLD\n// IMPORTANT:\n// Installation sans injection:\n// le PV visible peut rester bas tant que l'EVCS ne démarre pas.\n// On autorise donc un démarrage/maintien local si la batterie est haute\n// et que la dette nuage reste faible.\n// =====================================================\nconst batteryAssistSolarPolicyEligible =\n !strategy.forceGridRequested &&\n (\n strategy.forceChargeRequested ||\n strategy.userStrategy === 'auto_solar_plus'\n ) &&\n isLocalSolarSource;\n\nconst batteryAssistSolarStartSocMin = 90;\nconst batteryAssistSolarHoldSocMin = 85;\n\nconst batteryAssistSolarDebtStartMaxKwh = Math.max(\n cfg.cloudDebtRecoveryThresholdKwh,\n Math.min(params.cloudDebtMaxKwh, 0.05)\n);\n\nconst batteryAssistSolarStartOk =\n batteryAssistSolarPolicyEligible &&\n commonSafetyStartOk &&\n !isGridSource &&\n parsed.soc >= Math.max(profileSocStartFloor, batteryAssistSolarStartSocMin) &&\n parsed.soc > batteryAssistSolarHoldSocMin &&\n parsed.batteryCurrentA >= profileBatteryDischargeFloorA &&\n cloudDebtNextKwh <= batteryAssistSolarDebtStartMaxKwh &&\n !cloudDebtActive;\n\nconst batteryAssistSolarHoldOk =\n batteryAssistSolarPolicyEligible &&\n commonSafetyHoldOk &&\n !isGridSource &&\n parsed.soc >= Math.max(profileSocHoldFloor, batteryAssistSolarHoldSocMin) &&\n parsed.batteryCurrentA >= profileBatteryDischargeFloorA &&\n cloudToleranceOk;\n\n// =====================================================\n// START / HOLD CONDITIONS\n// =====================================================\nconst rawSolarStartOk =\n (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') &&\n commonSafetyStartOk &&\n parsed.soc >= effectiveSocStartFloor &&\n parsed.batteryCurrentA >= profileBatteryDischargeFloorA &&\n (\n context.sourceMode === 'solar_potential' ||\n parsed.pvPowerW >= context.effectivePvStartMinW\n );\n\nconst solarStartOk =\n rawSolarStartOk || batteryAssistSolarStartOk;\n\nconst rawSolarHoldOk =\n (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') &&\n commonSafetyHoldOk &&\n parsed.soc >= profileSocHoldFloor &&\n parsed.batteryCurrentA >= profileBatteryDischargeFloorA &&\n (\n parsed.pvPowerW >= params.pvHoldMinW ||\n cloudToleranceOk\n );\n\nconst solarHoldOk =\n rawSolarHoldOk || batteryAssistSolarHoldOk;\n\n// FIX V2.5: Garde SOC - pas de grid pour EVCS si SOC > 35% sauf force_grid_now\nconst gridAllowedBySoc = \n strategy.forceGridRequested ||\n context.profile === 'night_hc' ||\n parsed.soc <= 35;\n\nconst gridStartOk =\n gridAllowedBySoc &&\n isGridSource &&\n commonSafetyStartOk &&\n parsed.gridAvailable &&\n parsed.batteryCurrentA >= gridBatterySoftDynamicA;\n\nconst gridHoldOk =\n gridAllowedBySoc &&\n isGridSource &&\n commonSafetyHoldOk &&\n parsed.gridAvailable &&\n parsed.batteryCurrentA >= gridBatteryHardDynamicA;\n\nconst batteryAssistNightStartOk =\n context.sourceMode === 'battery_assist_night' &&\n commonSafetyStartOk &&\n nightSessionActive &&\n batteryAssistAllowed &&\n parsed.soc > batteryHardFloorPct &&\n parsed.soc > gridTakeoverTriggerPct &&\n parsed.batteryCurrentA >= profileBatteryDischargeFloorA;\n\nconst batteryAssistNightHoldOk =\n context.sourceMode === 'battery_assist_night' &&\n commonSafetyHoldOk &&\n nightSessionActive &&\n batteryAssistAllowed &&\n parsed.soc > batteryHardFloorPct &&\n parsed.soc > gridTakeoverTriggerPct &&\n parsed.batteryCurrentA >= batteryDischargeHardDynamicA;\n\n// =====================================================\n// PROBE LOGIC — STRICTEMENT LIMITÉE AU MODE PROBE\n// =====================================================\nconst probeModeActive = context.sourceMode === 'hybrid_probe';\n\nconst probeStartOk =\n probeModeActive &&\n commonSafetyStartOk &&\n (context.morningProbeEligible || context.middayProbeEligible || context.afternoonProbeEligible);\n\nconst probeHoldGuardOk =\n probeModeActive &&\n commonSafetyHoldOk &&\n parsed.batteryCurrentA >= profileBatteryDischargeFloorA;\n\nconst probeWindowActive =\n probeModeActive &&\n sessionObservedActive &&\n history.probeStartedMs !== null &&\n lifecycle.minutesSinceProbeStart !== null &&\n lifecycle.minutesSinceProbeStart < params.probeMinutes;\n\nconst probeElapsedReached =\n probeModeActive &&\n sessionObservedActive &&\n history.probeStartedMs !== null &&\n lifecycle.minutesSinceProbeStart !== null &&\n lifecycle.minutesSinceProbeStart >= params.probeMinutes;\n\nconst probeSuccessNow =\n probeModeActive &&\n sessionObservedActive &&\n history.probeStartedMs !== null &&\n (\n parsed.pvPowerW >= params.pvHoldMinW &&\n parsed.batteryCurrentA >= Math.min(0, batteryDischargeSoftDynamicA)\n );\n\nconst probeFailedNow =\n probeModeActive &&\n probeElapsedReached &&\n !probeSuccessNow;\n\n// =====================================================\n// STEP-UP LOGIC\n// =====================================================\nconst stepUpByBatteryReserve =\n batteryPriorityMode &&\n parsed.batteryCurrentA >= batteryChargeReserveDynamicA;\n\nconst stepUpByForecastMode =\n !batteryPriorityMode &&\n (\n parsed.pvPowerW >= params.pvHoldMinW ||\n parsed.pvPowerAvg10mW >= params.pvHoldMinW\n ) &&\n derived.forecastBufferKwh >= -1 &&\n derived.projectedEndSocAfterCharge >= (derived.endDayGuardSoc - 5) &&\n cloudToleranceOk;\n\nconst solarShouldStepUp =\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA < derived.physicalCapA &&\n derived.physicalCapA >= params.currentMinA &&\n !derived.vebusAcOutSoftReached &&\n !derived.vebusDcVoltageSoftLow &&\n !derived.houseLoadHighVsHeadroom &&\n (\n stepUpByBatteryReserve ||\n stepUpByForecastMode ||\n parsed.pvPowerW >= (context.effectivePvStartMinW + 400)\n );\n\nconst gridShouldStepUp =\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA < derived.physicalCapA &&\n derived.physicalCapA >= params.currentMinA &&\n !derived.vebusAcOutSoftReached &&\n !derived.vebusDcVoltageSoftLow &&\n parsed.batteryCurrentA >= gridBatterySoftDynamicA &&\n !derived.houseLoadHighVsHeadroom;\n\nconst batteryAssistNightShouldStepUp =\n context.sourceMode === 'battery_assist_night' &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA < derived.physicalCapA &&\n derived.physicalCapA >= params.currentMinA &&\n !derived.vebusAcOutSoftReached &&\n !derived.vebusDcVoltageSoftLow &&\n !derived.houseLoadHighVsHeadroom &&\n parsed.batteryCurrentA >= batteryChargeReserveDynamicA &&\n batteryAssistAllowed;\n\n// =====================================================\n// STEP-DOWN LOGIC\n// =====================================================\nconst solarShouldStepDownBattery =\n parsed.batteryCurrentA <= batteryDischargeSoftDynamicA &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst solarShouldStepDownCloudDebt =\n !cloudToleranceOk &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst gridShouldStepDownBattery =\n parsed.batteryCurrentA < gridBatterySoftDynamicA &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst batteryAssistNightShouldStepDown =\n context.sourceMode === 'battery_assist_night' &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA &&\n (\n parsed.batteryCurrentA <= batteryDischargeSoftDynamicA ||\n parsed.soc <= gridTakeoverTriggerPct ||\n batteryRecoveryUrgent\n );\n\nconst shouldStepDownVebusPower =\n derived.vebusAcOutSoftReached &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst shouldStepDownVebusVoltage =\n derived.vebusDcVoltageSoftLow &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst shouldStepDownOverload =\n derived.anyVebusOverload &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst shouldStopOverloadAtMin =\n derived.anyVebusOverload &&\n derived.currentReferenceA <= params.currentMinA &&\n sessionObservedActive;\n\n// =====================================================\n// HARD STOPS / PROTECTIONS\n// =====================================================\nconst batteryCurrentHardExceeded =\n parsed.batteryCurrentA <= batteryDischargeHardDynamicA;\n\n// overload volontairement exclu du hard stop immédiat\nconst mustStopHard =\n derived.anyVebusLowBattery ||\n derived.vebusDcVoltageHardLow ||\n derived.vebusAcOutHardReached ||\n batteryCurrentHardExceeded;\n\nconst gridBatteryProtectionStop =\n sessionObservedActive &&\n isGridSource &&\n parsed.batteryCurrentA < gridBatteryHardDynamicA;\n\n// =====================================================\n// OUTPUT\n// =====================================================\nreturn [{\n json: {\n ...prev,\n\n batteryPolicy: {\n socBand,\n batteryDischargeSoftDynamicA,\n batteryDischargeHardDynamicA,\n batteryChargeReserveDynamicA,\n batterySoftToleranceMinutes,\n batteryHardToleranceSeconds,\n batteryRecoveryMinutes,\n\n gridBatterySoftDynamicA,\n gridBatteryHardDynamicA,\n gridForceChargeRelaxSoftA,\n gridForceChargeRelaxHardA,\n\n batteryAssistSolarStartSocMin,\n batteryAssistSolarHoldSocMin,\n batteryAssistSolarDebtStartMaxKwh,\n\n houseSpikeDetected,\n solarPotentialContext,\n outsideWindowLocalBypass,\n },\n\n locks: {\n nextAllowedWindowStartMs,\n blockActive,\n blockStillRelevant,\n startBlockedByReason,\n },\n\n cloud: {\n sessionObservedActive,\n batteryNetPowerW,\n cloudDebtNextKwh,\n cloudDebtMeaningful,\n cloudEventDischargingNow,\n continuingCloudEvent,\n enteringCloudDebt,\n cloudDebtActive,\n cloudDebtStartedMs,\n cloudDebtDurationMinutes,\n effectiveCloudToleranceMinutes,\n cloudToleranceOk,\n },\n\n modes: {\n batteryFullHarvestMode,\n forecastCommitMode,\n batteryPriorityMode,\n },\n\n nightEvcs: {\n nightSessionActive,\n nightSessionNeedsCapture,\n nightSessionNeedsClear,\n nightSessionStartSocPct,\n nightSessionStartAt,\n nightSessionStartMs,\n\n batteryHardFloorPct,\n batteryMorningTargetPct,\n maxBatteryDischargePoints,\n gridTakeoverTriggerPct,\n\n batteryBelowHardFloor,\n batteryBelowMorningTarget,\n batteryAtOrBelowGridTakeoverTrigger,\n\n batteryKwhMissingToTarget,\n batteryRecoveryMinutesNeeded,\n batteryRecoveryUrgent,\n batteryRecoveryAllowedNow,\n batteryTargetReached,\n\n batteryAssistAllowed,\n nightGridTakeoverNeeded,\n nightTakeoverLatched,\n vehicleChargeCompleted,\n nightRecoveryDone,\n nightPreferredSourceMode,\n },\n\n safeguards: {\n isGridSource,\n commonSafetyStartOk,\n commonSafetyHoldOk,\n profileSocStartFloor,\n effectiveSocStartFloor,\n solarAbundance,\n profileSocHoldFloor,\n profileBatteryDischargeFloorA,\n batteryCurrentHardExceeded,\n mustStopHard,\n gridBatteryProtectionStop,\n },\n\n conditions: {\n rawSolarStartOk,\n rawSolarHoldOk,\n batteryAssistSolarPolicyEligible,\n batteryAssistSolarStartOk,\n batteryAssistSolarHoldOk,\n\n solarStartOk,\n solarHoldOk,\n gridStartOk,\n gridHoldOk,\n\n batteryAssistNightStartOk,\n batteryAssistNightHoldOk,\n\n probeStartOk,\n probeHoldGuardOk,\n probeWindowActive,\n probeElapsedReached,\n probeSuccessNow,\n probeFailedNow,\n\n solarShouldStepUp,\n gridShouldStepUp,\n batteryAssistNightShouldStepUp,\n\n solarShouldStepDownBattery,\n solarShouldStepDownCloudDebt,\n gridShouldStepDownBattery,\n batteryAssistNightShouldStepDown,\n\n shouldStepDownVebusPower,\n shouldStepDownVebusVoltage,\n shouldStepDownOverload,\n shouldStopOverloadAtMin,\n }\n }\n}];\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2, @@ -142,7 +142,7 @@ "parameters": [ { "name": "template", - "value": "{{ {\n \"soc_percent\": states(\"sensor.victron_battery_soc\"),\n \"battery_current_a\": states(\"sensor.victron_battery_current\"),\n\n \"pv_power_w\": states(\"sensor.victron_pv_power_system\"),\n \"pv_power_10m_avg_w\": states(\"sensor.victron_pv_power_10m_avg\"),\n \"pv_trend_w\": states(\"sensor.victron_pv_power_trend_w\"),\n\n \"house_ac_power_w\": states(\"sensor.victron_consumption_power_l1\"),\n \"house_ac_power_prev_w\": states(\"sensor.victron_consumption_power_l1\"),\n\n \"grid_power_w\": states(\"sensor.victron_grid_power_l1\"),\n \"grid_state\": states(\"switch.edf_general_ok_switch\"),\n \"victron_acin1_available\": states(\"sensor.victron_ac_in1_available\"),\n\n \"today_remaining_kwh\": states(\"sensor.energy_production_today_remaining\"),\n \"tomorrow_kwh\": states(\"sensor.energy_production_tomorrow\"),\n\n \"evcs_device_connected\": states(\"binary_sensor.victron_evcs_device_connected\"),\n \"evcs_vehicle_connected\": states(\"binary_sensor.victron_evcs_vehicle_connected\"),\n \"evcs_status_raw\": states(\"sensor.victron_evcs_status_raw\"),\n \"evcs_mode_sensor\": states(\"sensor.victron_evcs_mode\"),\n \"evcs_mode_fallback\": states(\"input_text.sensor_victron_evcs_mode\"),\n \"evcs_set_current_a\": states(\"sensor.victron_evcs_set_current\"),\n \"evcs_command_current_a\": states(\"number.victron_evcs_set_current_command\"),\n \"evcs_current_a\": states(\"sensor.victron_evcs_current\"),\n \"evcs_ac_power_w\": states(\"sensor.victron_evcs_ac_power\"),\n \"evcs_charging_switch\": states(\"switch.victron_evcs_charging\"),\n \"evcs_auto_start_switch\": states(\"switch.victron_evcs_auto_start\"),\n\n \"controller_enabled\": states(\"input_boolean.victron_evcs_surplus_auto_enabled\"),\n \"notifications_enabled\": states(\"input_boolean.victron_evcs_surplus_notifications\"),\n \"hard_stop_enabled\": states(\"input_boolean.victron_evcs_hard_stop_enabled\"),\n \"charge_permission_now\": states(\"input_boolean.victron_evcs_charge_permission_now\"),\n \"control_latch\": states(\"input_boolean.victron_evcs_control_latch\"),\n \"departure_priority_enabled\": states(\"input_boolean.victron_evcs_departure_priority_enabled\"),\n \"forecast_mode_enabled\": states(\"input_boolean.victron_evcs_forecast_mode_enabled\"),\n\n \"target_current_auto_a\": states(\"input_number.victron_evcs_target_current_auto\"),\n \"budget_remaining_kwh\": states(\"input_number.victron_evcs_budget_remaining_kwh\"),\n \"projected_end_soc_after_charge\": states(\"input_number.victron_evcs_projected_end_soc_after_charge\"),\n\n \"pv_start_min_w\": states(\"input_number.victron_evcs_pv_start_min_w\"),\n \"pv_hold_min_w\": states(\"input_number.victron_evcs_pv_hold_min_w\"),\n \"house_ac_headroom_w\": states(\"input_number.victron_evcs_house_ac_headroom_w\"),\n \"battery_discharge_soft_a\": states(\"input_number.victron_evcs_battery_discharge_soft_a\"),\n \"battery_discharge_hard_a\": states(\"input_number.victron_evcs_battery_discharge_hard_a\"),\n \"battery_reserve_up_a\": states(\"input_number.victron_evcs_battery_reserve_up_a\"),\n \"battery_neutral_band_a\": states(\"input_number.victron_evcs_battery_neutral_band_a\"),\n \"soc_start_min\": states(\"input_number.victron_evcs_soc_start_min\"),\n \"soc_stop_min\": states(\"input_number.victron_evcs_soc_stop_min\"),\n \"current_min_a\": states(\"input_number.victron_evcs_current_min_a\"),\n \"current_max_auto_a\": states(\"input_number.victron_evcs_current_max_auto_a\"),\n \"grid_current_max_a\": states(\"input_number.evcs_plafond_courant_edf_on\"),\n \"current_step_up_a\": states(\"input_number.victron_evcs_current_step_up_a\"),\n \"current_step_down_a\": states(\"input_number.victron_evcs_current_step_down_a\"),\n \"current_change_cooldown_seconds\": states(\"input_number.victron_evcs_current_change_cooldown_seconds\"),\n \"min_run_minutes\": states(\"input_number.victron_evcs_min_run_minutes\"),\n \"min_stop_minutes\": states(\"input_number.victron_evcs_min_stop_minutes\"),\n\n \"required_energy_kwh\": states(\"input_number.victron_evcs_required_energy_kwh\"),\n \"soc_comfort_target\": states(\"input_number.victron_evcs_soc_comfort_target\"),\n \"end_of_day_soc_guard\": states(\"input_number.victron_evcs_end_of_day_soc_guard\"),\n\n \"base_load_remaining_kwh\": states(\"input_number.victron_base_load_remaining_kwh\"),\n \"flexible_safety_margin_kwh\": states(\"input_number.victron_flexible_safety_margin_kwh\"),\n \"end_of_day_soc_target\": states(\"input_number.victron_end_of_day_soc_target\"),\n \"ac_loads_max_safe_w\": states(\"input_number.victron_ac_loads_max_safe_w\"),\n\n \"battery_full_soc\": states(\"input_number.victron_evcs_battery_full_soc\"),\n \"battery_full_forecast_min_kwh\": states(\"input_number.victron_evcs_battery_full_forecast_min_kwh\"),\n \"forecast_commit_soc_floor\": states(\"input_number.victron_evcs_forecast_commit_soc_floor\"),\n \"forecast_commit_min_kwh\": states(\"input_number.victron_evcs_forecast_commit_min_kwh\"),\n\n \"cloud_debt_kwh\": states(\"input_number.victron_evcs_cloud_debt_kwh\"),\n \"cloud_debt_max_kwh\": states(\"input_number.victron_evcs_cloud_debt_max_kwh\"),\n \"cloud_tolerance_minutes\": states(\"input_number.victron_evcs_cloud_tolerance_minutes\"),\n \"cloud_debt_started_at\": states(\"input_datetime.victron_evcs_cloud_debt_started_at\"),\n \"operating_mode_helper\": states(\"input_text.victron_evcs_operating_mode\"),\n\n \"departure_time\": states(\"input_datetime.victron_evcs_departure_time\"),\n \"last_start_at\": states(\"input_datetime.victron_evcs_last_start_at\"),\n \"last_stop_at\": states(\"input_datetime.victron_evcs_last_stop_at\"),\n \"last_current_change_at\": states(\"input_datetime.victron_evcs_last_current_change_at\"),\n\n \"day_start_time\": states(\"input_datetime.victron_flexible_day_start_time\"),\n \"day_stop_time\": states(\"input_datetime.victron_flexible_day_stop_time\"),\n\n \"vebus_overload\": states(\"binary_sensor.victron_ve_bus_overload\"),\n \"vebus_l1_overload\": states(\"binary_sensor.victron_ve_bus_l1_overload\"),\n \"vebus_low_battery\": states(\"binary_sensor.victron_ve_bus_low_battery\"),\n \"vebus_l1_low_battery\": states(\"binary_sensor.victron_ve_bus_l1_low_battery\"),\n\n \"vebus_ac_out_l1_power_w\": states(\"sensor.victron_ve_bus_ac_out_l1_power\"),\n \"vebus_ac_out_l1_current_a\": states(\"sensor.victron_ve_bus_ac_out_l1_current\"),\n \"vebus_ac_out_l1_voltage_v\": states(\"sensor.victron_ve_bus_ac_out_l1_voltage\"),\n\n \"vebus_dc_voltage_v\": states(\"sensor.victron_ve_bus_dc_voltage\"),\n \"vebus_dc_current_a\": states(\"sensor.victron_ve_bus_dc_current\"),\n \"vebus_dc_power_w\": states(\"sensor.victron_ve_bus_dc_power_system\"),\n\n \"vebus_state\": states(\"sensor.victron_ve_bus_state\"),\n \"vebus_main_state\": states(\"sensor.victron_ve_bus_main_state\"),\n\n \"last_signature\": states(\"input_text.victron_evcs_control_last_signature\"),\n\n \"hc_night_start_time\": states(\"input_datetime.victron_evcs_hc_night_start\"),\n \"hc_night_stop_time\": states(\"input_datetime.victron_evcs_hc_night_stop\"),\n \"morning_start_time\": states(\"input_datetime.victron_evcs_morning_start\"),\n \"morning_stop_time\": states(\"input_datetime.victron_evcs_morning_stop\"),\n \"hc_midday_start_time\": states(\"input_datetime.victron_evcs_hc_midday_start\"),\n \"hc_midday_stop_time\": states(\"input_datetime.victron_evcs_hc_midday_stop\"),\n \"evening_start_time\": states(\"input_datetime.victron_evcs_evening_start\"),\n \"evening_stop_time\": states(\"input_datetime.victron_evcs_evening_stop\"),\n\n \"allow_grid_hc_night\": states(\"input_boolean.victron_evcs_allow_grid_hc_night\"),\n \"night_takeover_prev\": states(\"input_boolean.victron_evcs_night_grid_takeover_needed\"),\n \"prev_surplus_state\": states(\"input_text.victron_evcs_surplus_state\"),\n \"allow_grid_hc_midday\": states(\"input_boolean.victron_evcs_allow_grid_hc_midday\"),\n \"probe_enabled\": states(\"input_boolean.victron_evcs_probe_enabled\"),\n\n \"probe_minutes\": states(\"input_number.victron_evcs_probe_minutes\"),\n \"probe_retry_minutes\": states(\"input_number.victron_evcs_probe_retry_minutes\"),\n \"probe_current_a\": states(\"input_number.victron_evcs_probe_current_a\"),\n\n \"grid_battery_soft_a\": states(\"input_number.victron_evcs_grid_battery_soft_a\"),\n \"grid_battery_hard_a\": states(\"input_number.victron_evcs_grid_battery_hard_a\"),\n\n \"morning_probe_pv_avg_min_w\": states(\"input_number.victron_evcs_morning_probe_pv_avg_min_w\"),\n \"morning_probe_pv_trend_min_w\": states(\"input_number.victron_evcs_morning_probe_pv_trend_min_w\"),\n \"midday_probe_pv_avg_min_w\": states(\"input_number.victron_evcs_midday_probe_pv_avg_min_w\"),\n \"afternoon_probe_pv_avg_min_w\": states(\"input_number.victron_evcs_afternoon_probe_pv_avg_min_w\"),\n \"afternoon_probe_soc_min\": states(\"input_number.victron_evcs_afternoon_probe_soc_min\"),\n \"afternoon_battery_max_discharge_a\": states(\"input_number.victron_evcs_afternoon_battery_max_discharge_a\"),\n \"morning_soc_min\": states(\"input_number.victron_evcs_morning_soc_min\"),\n \"midday_soc_min\": states(\"input_number.victron_evcs_midday_soc_min\"),\n \"evening_soc_start_min\": states(\"input_number.victron_evcs_evening_soc_start_min\"),\n \"evening_soc_stop_min\": states(\"input_number.victron_evcs_evening_soc_stop_min\"),\n\n \"morning_battery_max_discharge_a\": states(\"input_number.victron_evcs_morning_battery_max_discharge_a\"),\n \"midday_battery_max_discharge_a\": states(\"input_number.victron_evcs_midday_battery_max_discharge_a\"),\n \"evening_battery_max_discharge_a\": states(\"input_number.victron_evcs_evening_battery_max_discharge_a\"),\n\n \"last_block_reason\": states(\"input_text.victron_evcs_last_block_reason\"),\n \"blocked_until\": states(\"input_datetime.victron_evcs_blocked_until\"),\n \"probe_started_at\": states(\"input_datetime.victron_evcs_probe_started_at\"),\n\n \"victron_evcs_strategy\": states(\"input_select.victron_evcs_strategy\"),\n \"evcs_priority_manual_override\": states(\"input_boolean.victron_evcs_priority_manual_override\"),\n \"evcs_priority_manual_mode\": states(\"input_select.victron_evcs_priority_manual_mode\"),\n \"evcs_priority_manual_reason\": states(\"input_text.victron_evcs_priority_manual_reason\"),\n \"evcs_priority_manual_until\": states(\"input_datetime.victron_evcs_priority_manual_until\"),\n\n \"house_spike_grace_minutes\": states(\"input_number.victron_evcs_house_spike_grace_minutes\"),\n \"solar_potential_grace_minutes\": states(\"input_number.victron_evcs_solar_potential_grace_minutes\"),\n \"step_up_cooldown_seconds\": states(\"input_number.victron_evcs_step_up_cooldown_seconds\"),\n \"step_down_cooldown_seconds\": states(\"input_number.victron_evcs_step_down_cooldown_seconds\"),\n \"force_mode_auto_revert_minutes\": states(\"input_number.victron_evcs_force_mode_auto_revert_minutes\"),\n\n \"overload_step_down_a\": states(\"input_number.victron_evcs_overload_step_down_a\"),\n \"overload_hold_seconds\": states(\"input_number.victron_evcs_overload_hold_seconds\"),\n \"overload_stop_after_seconds\": states(\"input_number.victron_evcs_overload_stop_after_seconds\"),\n\n \"batt_discharge_soft_gt90_a\": states(\"input_number.victron_evcs_batt_discharge_soft_gt90_a\"),\n \"batt_discharge_hard_gt90_a\": states(\"input_number.victron_evcs_batt_discharge_hard_gt90_a\"),\n \"batt_charge_reserve_gt90_a\": states(\"input_number.victron_evcs_batt_charge_reserve_gt90_a\"),\n \"batt_soft_tol_gt90_min\": states(\"input_number.victron_evcs_batt_soft_tol_gt90_min\"),\n \"batt_hard_tol_gt90_sec\": states(\"input_number.victron_evcs_batt_hard_tol_gt90_sec\"),\n \"batt_recovery_gt90_min\": states(\"input_number.victron_evcs_batt_recovery_gt90_min\"),\n\n \"batt_discharge_soft_80_90_a\": states(\"input_number.victron_evcs_batt_discharge_soft_80_90_a\"),\n \"batt_discharge_hard_80_90_a\": states(\"input_number.victron_evcs_batt_discharge_hard_80_90_a\"),\n \"batt_charge_reserve_80_90_a\": states(\"input_number.victron_evcs_batt_charge_reserve_80_90_a\"),\n \"batt_soft_tol_80_90_min\": states(\"input_number.victron_evcs_batt_soft_tol_80_90_min\"),\n \"batt_hard_tol_80_90_sec\": states(\"input_number.victron_evcs_batt_hard_tol_80_90_sec\"),\n \"batt_recovery_80_90_min\": states(\"input_number.victron_evcs_batt_recovery_80_90_min\"),\n\n \"batt_discharge_soft_60_80_a\": states(\"input_number.victron_evcs_batt_discharge_soft_60_80_a\"),\n \"batt_discharge_hard_60_80_a\": states(\"input_number.victron_evcs_batt_discharge_hard_60_80_a\"),\n \"batt_charge_reserve_60_80_a\": states(\"input_number.victron_evcs_batt_charge_reserve_60_80_a\"),\n \"batt_soft_tol_60_80_min\": states(\"input_number.victron_evcs_batt_soft_tol_60_80_min\"),\n \"batt_hard_tol_60_80_sec\": states(\"input_number.victron_evcs_batt_hard_tol_60_80_sec\"),\n \"batt_recovery_60_80_min\": states(\"input_number.victron_evcs_batt_recovery_60_80_min\"),\n\n \"batt_discharge_soft_40_60_a\": states(\"input_number.victron_evcs_batt_discharge_soft_40_60_a\"),\n \"batt_discharge_hard_40_60_a\": states(\"input_number.victron_evcs_batt_discharge_hard_40_60_a\"),\n \"batt_charge_reserve_40_60_a\": states(\"input_number.victron_evcs_batt_charge_reserve_40_60_a\"),\n \"batt_soft_tol_40_60_min\": states(\"input_number.victron_evcs_batt_soft_tol_40_60_min\"),\n \"batt_hard_tol_40_60_sec\": states(\"input_number.victron_evcs_batt_hard_tol_40_60_sec\"),\n \"batt_recovery_40_60_min\": states(\"input_number.victron_evcs_batt_recovery_40_60_min\"),\n\n \"batt_discharge_soft_25_40_a\": states(\"input_number.victron_evcs_batt_discharge_soft_25_40_a\"),\n \"batt_discharge_hard_25_40_a\": states(\"input_number.victron_evcs_batt_discharge_hard_25_40_a\"),\n \"batt_charge_reserve_25_40_a\": states(\"input_number.victron_evcs_batt_charge_reserve_25_40_a\"),\n \"batt_soft_tol_25_40_min\": states(\"input_number.victron_evcs_batt_soft_tol_25_40_min\"),\n \"batt_hard_tol_25_40_sec\": states(\"input_number.victron_evcs_batt_hard_tol_25_40_sec\"),\n \"batt_recovery_25_40_min\": states(\"input_number.victron_evcs_batt_recovery_25_40_min\"),\n\n \"batt_discharge_soft_lt25_a\": states(\"input_number.victron_evcs_batt_discharge_soft_lt25_a\"),\n \"batt_discharge_hard_lt25_a\": states(\"input_number.victron_evcs_batt_discharge_hard_lt25_a\"),\n \"batt_charge_reserve_lt25_a\": states(\"input_number.victron_evcs_batt_charge_reserve_lt25_a\"),\n \"batt_soft_tol_lt25_min\": states(\"input_number.victron_evcs_batt_soft_tol_lt25_min\"),\n \"batt_hard_tol_lt25_sec\": states(\"input_number.victron_evcs_batt_hard_tol_lt25_sec\"),\n \"batt_recovery_lt25_min\": states(\"input_number.victron_evcs_batt_recovery_lt25_min\"),\n\n \"evcs_delivery_ratio_ema\": states(\"input_number.victron_evcs_delivery_ratio_ema\"),\n \"evcs_delivery_noise_ema\": states(\"input_number.victron_evcs_delivery_noise_ema\"),\n \"evcs_no_demand_score_ema\": states(\"input_number.victron_evcs_no_demand_score_ema\"),\n \"evcs_no_demand_semantic_active\": states(\"input_boolean.victron_evcs_no_demand_semantic_active\"),\n \"evcs_no_demand_since_at\": states(\"input_datetime.victron_evcs_no_demand_since_at\"),\n \"evcs_no_demand_filter_eval_at\": states(\"input_datetime.victron_evcs_no_demand_filter_eval_at\")\n} | tojson }}\n" + "value": "{{ {\n \"soc_percent\": states(\"sensor.victron_battery_soc\"),\n \"battery_current_a\": states(\"sensor.victron_battery_current\"),\n\n \"pv_power_w\": states(\"sensor.victron_pv_power_system\"),\n \"pv_power_10m_avg_w\": states(\"sensor.victron_pv_power_10m_avg\"),\n \"pv_trend_w\": states(\"sensor.victron_pv_power_trend_w\"),\n\n \"house_ac_power_w\": states(\"sensor.victron_consumption_power_l1\"),\n \"house_ac_power_prev_w\": states(\"sensor.victron_consumption_power_l1\"),\n\n \"grid_power_w\": states(\"sensor.victron_grid_power_l1\"),\n \"grid_state\": states(\"switch.edf_general_ok_switch\"),\n \"victron_acin1_available\": states(\"sensor.victron_ac_in1_available\"),\n\n \"today_remaining_kwh\": states(\"sensor.energy_production_today_remaining\"),\n \"tomorrow_kwh\": states(\"sensor.energy_production_tomorrow\"),\n\n \"evcs_device_connected\": states(\"binary_sensor.victron_evcs_device_connected\"),\n \"evcs_vehicle_connected\": states(\"binary_sensor.victron_evcs_vehicle_connected\"),\n \"evcs_status_raw\": states(\"sensor.victron_evcs_status_raw\"),\n \"evcs_mode_sensor\": states(\"sensor.victron_evcs_mode\"),\n \"evcs_mode_fallback\": states(\"input_text.sensor_victron_evcs_mode\"),\n \"evcs_set_current_a\": states(\"sensor.victron_evcs_set_current\"),\n \"evcs_command_current_a\": states(\"number.victron_evcs_set_current_command\"),\n \"evcs_current_a\": states(\"sensor.victron_evcs_current\"),\n \"evcs_ac_power_w\": states(\"sensor.victron_evcs_ac_power\"),\n \"evcs_charging_switch\": states(\"switch.victron_evcs_charging\"),\n \"evcs_auto_start_switch\": states(\"switch.victron_evcs_auto_start\"),\n\n \"controller_enabled\": states(\"input_boolean.victron_evcs_surplus_auto_enabled\"),\n \"notifications_enabled\": states(\"input_boolean.victron_evcs_surplus_notifications\"),\n \"hard_stop_enabled\": states(\"input_boolean.victron_evcs_hard_stop_enabled\"),\n \"charge_permission_now\": states(\"input_boolean.victron_evcs_charge_permission_now\"),\n \"control_latch\": states(\"input_boolean.victron_evcs_control_latch\"),\n \"departure_priority_enabled\": states(\"input_boolean.victron_evcs_departure_priority_enabled\"),\n \"forecast_mode_enabled\": states(\"input_boolean.victron_evcs_forecast_mode_enabled\"),\n\n \"target_current_auto_a\": states(\"input_number.victron_evcs_target_current_auto\"),\n \"budget_remaining_kwh\": states(\"input_number.victron_evcs_budget_remaining_kwh\"),\n \"projected_end_soc_after_charge\": states(\"input_number.victron_evcs_projected_end_soc_after_charge\"),\n\n \"pv_start_min_w\": states(\"input_number.victron_evcs_pv_start_min_w\"),\n \"pv_hold_min_w\": states(\"input_number.victron_evcs_pv_hold_min_w\"),\n \"house_ac_headroom_w\": states(\"input_number.victron_evcs_house_ac_headroom_w\"),\n \"battery_discharge_soft_a\": states(\"input_number.victron_evcs_battery_discharge_soft_a\"),\n \"battery_discharge_hard_a\": states(\"input_number.victron_evcs_battery_discharge_hard_a\"),\n \"battery_reserve_up_a\": states(\"input_number.victron_evcs_battery_reserve_up_a\"),\n \"battery_neutral_band_a\": states(\"input_number.victron_evcs_battery_neutral_band_a\"),\n \"soc_start_min\": states(\"input_number.victron_evcs_soc_start_min\"),\n \"soc_stop_min\": states(\"input_number.victron_evcs_soc_stop_min\"),\n \"current_min_a\": states(\"input_number.victron_evcs_current_min_a\"),\n \"current_max_auto_a\": states(\"input_number.victron_evcs_current_max_auto_a\"),\n \"grid_current_max_a\": states(\"input_number.evcs_plafond_courant_edf_on\"),\n \"current_step_up_a\": states(\"input_number.victron_evcs_current_step_up_a\"),\n \"current_step_down_a\": states(\"input_number.victron_evcs_current_step_down_a\"),\n \"current_change_cooldown_seconds\": states(\"input_number.victron_evcs_current_change_cooldown_seconds\"),\n \"min_run_minutes\": states(\"input_number.victron_evcs_min_run_minutes\"),\n \"min_stop_minutes\": states(\"input_number.victron_evcs_min_stop_minutes\"),\n\n \"required_energy_kwh\": states(\"input_number.victron_evcs_required_energy_kwh\"),\n \"soc_comfort_target\": states(\"input_number.victron_evcs_soc_comfort_target\"),\n \"end_of_day_soc_guard\": states(\"input_number.victron_evcs_end_of_day_soc_guard\"),\n\n \"base_load_remaining_kwh\": states(\"input_number.victron_base_load_remaining_kwh\"),\n \"flexible_safety_margin_kwh\": states(\"input_number.victron_flexible_safety_margin_kwh\"),\n \"end_of_day_soc_target\": states(\"input_number.victron_end_of_day_soc_target\"),\n \"ac_loads_max_safe_w\": states(\"input_number.victron_ac_loads_max_safe_w\"),\n\n \"battery_full_soc\": states(\"input_number.victron_evcs_battery_full_soc\"),\n \"battery_full_forecast_min_kwh\": states(\"input_number.victron_evcs_battery_full_forecast_min_kwh\"),\n \"forecast_commit_soc_floor\": states(\"input_number.victron_evcs_forecast_commit_soc_floor\"),\n \"forecast_commit_min_kwh\": states(\"input_number.victron_evcs_forecast_commit_min_kwh\"),\n\n \"cloud_debt_kwh\": states(\"input_number.victron_evcs_cloud_debt_kwh\"),\n \"cloud_debt_max_kwh\": states(\"input_number.victron_evcs_cloud_debt_max_kwh\"),\n \"cloud_tolerance_minutes\": states(\"input_number.victron_evcs_cloud_tolerance_minutes\"),\n \"cloud_debt_started_at\": states(\"input_datetime.victron_evcs_cloud_debt_started_at\"),\n \"operating_mode_helper\": states(\"input_text.victron_evcs_operating_mode\"),\n\n \"departure_time\": states(\"input_datetime.victron_evcs_departure_time\"),\n \"last_start_at\": states(\"input_datetime.victron_evcs_last_start_at\"),\n \"last_stop_at\": states(\"input_datetime.victron_evcs_last_stop_at\"),\n \"last_current_change_at\": states(\"input_datetime.victron_evcs_last_current_change_at\"),\n\n \"day_start_time\": states(\"input_datetime.victron_flexible_day_start_time\"),\n \"day_stop_time\": states(\"input_datetime.victron_flexible_day_stop_time\"),\n\n \"vebus_overload\": states(\"binary_sensor.victron_ve_bus_overload\"),\n \"vebus_l1_overload\": states(\"binary_sensor.victron_ve_bus_l1_overload\"),\n \"vebus_low_battery\": states(\"binary_sensor.victron_ve_bus_low_battery\"),\n \"vebus_l1_low_battery\": states(\"binary_sensor.victron_ve_bus_l1_low_battery\"),\n\n \"vebus_ac_out_l1_power_w\": states(\"sensor.victron_ve_bus_ac_out_l1_power\"),\n \"vebus_ac_out_l1_current_a\": states(\"sensor.victron_ve_bus_ac_out_l1_current\"),\n \"vebus_ac_out_l1_voltage_v\": states(\"sensor.victron_ve_bus_ac_out_l1_voltage\"),\n\n \"vebus_dc_voltage_v\": states(\"sensor.victron_ve_bus_dc_voltage\"),\n \"vebus_dc_current_a\": states(\"sensor.victron_ve_bus_dc_current\"),\n \"vebus_dc_power_w\": states(\"sensor.victron_ve_bus_dc_power_system\"),\n\n \"vebus_state\": states(\"sensor.victron_ve_bus_state\"),\n \"vebus_main_state\": states(\"sensor.victron_ve_bus_main_state\"),\n\n \"last_signature\": states(\"input_text.victron_evcs_control_last_signature\"),\n\n \"hc_night_start_time\": states(\"input_datetime.victron_evcs_hc_night_start\"),\n \"hc_night_stop_time\": states(\"input_datetime.victron_evcs_hc_night_stop\"),\n \"morning_start_time\": states(\"input_datetime.victron_evcs_morning_start\"),\n \"morning_stop_time\": states(\"input_datetime.victron_evcs_morning_stop\"),\n \"hc_midday_start_time\": states(\"input_datetime.victron_evcs_hc_midday_start\"),\n \"hc_midday_stop_time\": states(\"input_datetime.victron_evcs_hc_midday_stop\"),\n \"evening_start_time\": states(\"input_datetime.victron_evcs_evening_start\"),\n \"evening_stop_time\": states(\"input_datetime.victron_evcs_evening_stop\"),\n\n \"allow_grid_hc_night\": states(\"input_boolean.victron_evcs_allow_grid_hc_night\"),\n \"night_takeover_prev\": states(\"input_boolean.victron_evcs_night_grid_takeover_needed\"),\n \"prev_surplus_state\": states(\"input_text.victron_evcs_surplus_state\"),\n \"evcs_night_session_start_soc\": states(\"input_text.victron_evcs_night_session_start_soc\"),\n \"evcs_night_session_start_at\": states(\"input_text.victron_evcs_night_session_start_at\"),\n \"allow_grid_hc_midday\": states(\"input_boolean.victron_evcs_allow_grid_hc_midday\"),\n \"probe_enabled\": states(\"input_boolean.victron_evcs_probe_enabled\"),\n\n \"probe_minutes\": states(\"input_number.victron_evcs_probe_minutes\"),\n \"probe_retry_minutes\": states(\"input_number.victron_evcs_probe_retry_minutes\"),\n \"probe_current_a\": states(\"input_number.victron_evcs_probe_current_a\"),\n\n \"grid_battery_soft_a\": states(\"input_number.victron_evcs_grid_battery_soft_a\"),\n \"grid_battery_hard_a\": states(\"input_number.victron_evcs_grid_battery_hard_a\"),\n\n \"morning_probe_pv_avg_min_w\": states(\"input_number.victron_evcs_morning_probe_pv_avg_min_w\"),\n \"morning_probe_pv_trend_min_w\": states(\"input_number.victron_evcs_morning_probe_pv_trend_min_w\"),\n \"midday_probe_pv_avg_min_w\": states(\"input_number.victron_evcs_midday_probe_pv_avg_min_w\"),\n \"afternoon_probe_pv_avg_min_w\": states(\"input_number.victron_evcs_afternoon_probe_pv_avg_min_w\"),\n \"afternoon_probe_soc_min\": states(\"input_number.victron_evcs_afternoon_probe_soc_min\"),\n \"afternoon_battery_max_discharge_a\": states(\"input_number.victron_evcs_afternoon_battery_max_discharge_a\"),\n \"morning_soc_min\": states(\"input_number.victron_evcs_morning_soc_min\"),\n \"midday_soc_min\": states(\"input_number.victron_evcs_midday_soc_min\"),\n \"evening_soc_start_min\": states(\"input_number.victron_evcs_evening_soc_start_min\"),\n \"evening_soc_stop_min\": states(\"input_number.victron_evcs_evening_soc_stop_min\"),\n\n \"morning_battery_max_discharge_a\": states(\"input_number.victron_evcs_morning_battery_max_discharge_a\"),\n \"midday_battery_max_discharge_a\": states(\"input_number.victron_evcs_midday_battery_max_discharge_a\"),\n \"evening_battery_max_discharge_a\": states(\"input_number.victron_evcs_evening_battery_max_discharge_a\"),\n\n \"last_block_reason\": states(\"input_text.victron_evcs_last_block_reason\"),\n \"blocked_until\": states(\"input_datetime.victron_evcs_blocked_until\"),\n \"probe_started_at\": states(\"input_datetime.victron_evcs_probe_started_at\"),\n\n \"victron_evcs_strategy\": states(\"input_select.victron_evcs_strategy\"),\n \"evcs_priority_manual_override\": states(\"input_boolean.victron_evcs_priority_manual_override\"),\n \"evcs_priority_manual_mode\": states(\"input_select.victron_evcs_priority_manual_mode\"),\n \"evcs_priority_manual_reason\": states(\"input_text.victron_evcs_priority_manual_reason\"),\n \"evcs_priority_manual_until\": states(\"input_datetime.victron_evcs_priority_manual_until\"),\n\n \"house_spike_grace_minutes\": states(\"input_number.victron_evcs_house_spike_grace_minutes\"),\n \"solar_potential_grace_minutes\": states(\"input_number.victron_evcs_solar_potential_grace_minutes\"),\n \"step_up_cooldown_seconds\": states(\"input_number.victron_evcs_step_up_cooldown_seconds\"),\n \"step_down_cooldown_seconds\": states(\"input_number.victron_evcs_step_down_cooldown_seconds\"),\n \"force_mode_auto_revert_minutes\": states(\"input_number.victron_evcs_force_mode_auto_revert_minutes\"),\n\n \"overload_step_down_a\": states(\"input_number.victron_evcs_overload_step_down_a\"),\n \"overload_hold_seconds\": states(\"input_number.victron_evcs_overload_hold_seconds\"),\n \"overload_stop_after_seconds\": states(\"input_number.victron_evcs_overload_stop_after_seconds\"),\n\n \"batt_discharge_soft_gt90_a\": states(\"input_number.victron_evcs_batt_discharge_soft_gt90_a\"),\n \"batt_discharge_hard_gt90_a\": states(\"input_number.victron_evcs_batt_discharge_hard_gt90_a\"),\n \"batt_charge_reserve_gt90_a\": states(\"input_number.victron_evcs_batt_charge_reserve_gt90_a\"),\n \"batt_soft_tol_gt90_min\": states(\"input_number.victron_evcs_batt_soft_tol_gt90_min\"),\n \"batt_hard_tol_gt90_sec\": states(\"input_number.victron_evcs_batt_hard_tol_gt90_sec\"),\n \"batt_recovery_gt90_min\": states(\"input_number.victron_evcs_batt_recovery_gt90_min\"),\n\n \"batt_discharge_soft_80_90_a\": states(\"input_number.victron_evcs_batt_discharge_soft_80_90_a\"),\n \"batt_discharge_hard_80_90_a\": states(\"input_number.victron_evcs_batt_discharge_hard_80_90_a\"),\n \"batt_charge_reserve_80_90_a\": states(\"input_number.victron_evcs_batt_charge_reserve_80_90_a\"),\n \"batt_soft_tol_80_90_min\": states(\"input_number.victron_evcs_batt_soft_tol_80_90_min\"),\n \"batt_hard_tol_80_90_sec\": states(\"input_number.victron_evcs_batt_hard_tol_80_90_sec\"),\n \"batt_recovery_80_90_min\": states(\"input_number.victron_evcs_batt_recovery_80_90_min\"),\n\n \"batt_discharge_soft_60_80_a\": states(\"input_number.victron_evcs_batt_discharge_soft_60_80_a\"),\n \"batt_discharge_hard_60_80_a\": states(\"input_number.victron_evcs_batt_discharge_hard_60_80_a\"),\n \"batt_charge_reserve_60_80_a\": states(\"input_number.victron_evcs_batt_charge_reserve_60_80_a\"),\n \"batt_soft_tol_60_80_min\": states(\"input_number.victron_evcs_batt_soft_tol_60_80_min\"),\n \"batt_hard_tol_60_80_sec\": states(\"input_number.victron_evcs_batt_hard_tol_60_80_sec\"),\n \"batt_recovery_60_80_min\": states(\"input_number.victron_evcs_batt_recovery_60_80_min\"),\n\n \"batt_discharge_soft_40_60_a\": states(\"input_number.victron_evcs_batt_discharge_soft_40_60_a\"),\n \"batt_discharge_hard_40_60_a\": states(\"input_number.victron_evcs_batt_discharge_hard_40_60_a\"),\n \"batt_charge_reserve_40_60_a\": states(\"input_number.victron_evcs_batt_charge_reserve_40_60_a\"),\n \"batt_soft_tol_40_60_min\": states(\"input_number.victron_evcs_batt_soft_tol_40_60_min\"),\n \"batt_hard_tol_40_60_sec\": states(\"input_number.victron_evcs_batt_hard_tol_40_60_sec\"),\n \"batt_recovery_40_60_min\": states(\"input_number.victron_evcs_batt_recovery_40_60_min\"),\n\n \"batt_discharge_soft_25_40_a\": states(\"input_number.victron_evcs_batt_discharge_soft_25_40_a\"),\n \"batt_discharge_hard_25_40_a\": states(\"input_number.victron_evcs_batt_discharge_hard_25_40_a\"),\n \"batt_charge_reserve_25_40_a\": states(\"input_number.victron_evcs_batt_charge_reserve_25_40_a\"),\n \"batt_soft_tol_25_40_min\": states(\"input_number.victron_evcs_batt_soft_tol_25_40_min\"),\n \"batt_hard_tol_25_40_sec\": states(\"input_number.victron_evcs_batt_hard_tol_25_40_sec\"),\n \"batt_recovery_25_40_min\": states(\"input_number.victron_evcs_batt_recovery_25_40_min\"),\n\n \"batt_discharge_soft_lt25_a\": states(\"input_number.victron_evcs_batt_discharge_soft_lt25_a\"),\n \"batt_discharge_hard_lt25_a\": states(\"input_number.victron_evcs_batt_discharge_hard_lt25_a\"),\n \"batt_charge_reserve_lt25_a\": states(\"input_number.victron_evcs_batt_charge_reserve_lt25_a\"),\n \"batt_soft_tol_lt25_min\": states(\"input_number.victron_evcs_batt_soft_tol_lt25_min\"),\n \"batt_hard_tol_lt25_sec\": states(\"input_number.victron_evcs_batt_hard_tol_lt25_sec\"),\n \"batt_recovery_lt25_min\": states(\"input_number.victron_evcs_batt_recovery_lt25_min\"),\n\n \"evcs_delivery_ratio_ema\": states(\"input_number.victron_evcs_delivery_ratio_ema\"),\n \"evcs_delivery_noise_ema\": states(\"input_number.victron_evcs_delivery_noise_ema\"),\n \"evcs_no_demand_score_ema\": states(\"input_number.victron_evcs_no_demand_score_ema\"),\n \"evcs_no_demand_semantic_active\": states(\"input_boolean.victron_evcs_no_demand_semantic_active\"),\n \"evcs_no_demand_since_at\": states(\"input_datetime.victron_evcs_no_demand_since_at\"),\n \"evcs_no_demand_filter_eval_at\": states(\"input_datetime.victron_evcs_no_demand_filter_eval_at\")\n} | tojson }}\n" } ] }, @@ -165,7 +165,7 @@ }, { "parameters": { - "jsCode": "function minutesSince(ts, nowMs) {\n if (ts === null || nowMs === null) return null;\n return Math.max(0, (nowMs - ts) / 60000);\n}\n\nfunction secondsSince(ts, nowMs) {\n if (ts === null || nowMs === null) return null;\n return Math.max(0, (nowMs - ts) / 1000);\n}\n\nfunction isWithinWindow(nowMin, startMin, stopMin) {\n if (!Number.isFinite(startMin) || !Number.isFinite(stopMin)) return false;\n if (startMin === stopMin) return false;\n if (startMin < stopMin) return nowMin >= startMin && nowMin < stopMin;\n return nowMin >= startMin || nowMin < stopMin;\n}\n\nfunction clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nconst prev = $input.first().json;\nconst {\n input,\n nowMs,\n now,\n cfg,\n parsed,\n params,\n history,\n batteryTune\n} = prev;\n\n// =====================================================\n// STRATEGY\n// =====================================================\nconst userStrategy = (params.userStrategyRaw ?? 'auto_solar').toString().trim().toLowerCase();\n\nconst isStopMode = userStrategy === 'stop';\nconst forceGridRequested = userStrategy === 'force_grid_now';\nconst forceChargeRequested = userStrategy === 'force_charge_now';\nconst isForceMode = forceGridRequested || forceChargeRequested;\n\n// =====================================================\n// LIFECYCLE / TIMING\n// =====================================================\nconst minutesSinceStart = minutesSince(history.lastStartMs, nowMs);\nconst minutesSinceStop = minutesSince(history.lastStopMs, nowMs);\nconst secondsSinceCurrentChange = secondsSince(history.lastCurrentChangeMs, nowMs);\nconst minutesSinceProbeStart = minutesSince(history.probeStartedMs, nowMs);\n\nconst minRunElapsed =\n minutesSinceStart === null ? true : minutesSinceStart >= params.minRunMinutes;\n\nconst minStopElapsed =\n minutesSinceStop === null ? true : minutesSinceStop >= params.minStopMinutes;\n\nconst currentCooldownBaseSec = parsed.evcsChargingSwitchOn\n ? batteryTune.global.stepDownCooldownSeconds\n : batteryTune.global.stepUpCooldownSeconds;\n\nconst currentCooldownElapsed =\n secondsSinceCurrentChange === null\n ? true\n : secondsSinceCurrentChange >= Math.max(\n params.currentChangeCooldownSeconds,\n currentCooldownBaseSec\n );\n\n// =====================================================\n// DEPARTURE PRIORITY\n// =====================================================\nconst minutesUntilDeparture =\n params.departureTimeMin === null\n ? null\n : (() => {\n const rawDiff = params.departureTimeMin - now.minNow;\n return rawDiff >= 0 ? rawDiff : (1440 - now.minNow + params.departureTimeMin);\n })();\n\nconst departurePriorityActive =\n parsed.departurePriorityEnabled &&\n Number.isFinite(minutesUntilDeparture) &&\n minutesUntilDeparture >= 0 &&\n minutesUntilDeparture <= cfg.departurePriorityWindowMinutes &&\n parsed.evcsVehicleConnected;\n\n// =====================================================\n// MANUAL CHARGE SEMANTICS\n// IMPORTANT:\n// force_charge_now = bypass fenêtres pour la charge,\n// MAIS PAS demande implicite de réseau.\n// =====================================================\nconst manualChargeBypassWindows = forceChargeRequested;\nconst manualChargeLocalRequested = forceChargeRequested && !forceGridRequested;\n\n// Compatibilité ascendante : on garde le champ dans context,\n// mais on le neutralise volontairement.\nconst forceChargeDepartureGridOverride = false;\n\n// =====================================================\n// SYSTEM DERIVED VALUES\n// =====================================================\nconst acVoltageRef =\n Number.isFinite(parsed.vebusAcOutVoltageV) && parsed.vebusAcOutVoltageV > 180\n ? parsed.vebusAcOutVoltageV\n : cfg.nominalAcVoltageV;\n\nconst batteryVoltageRef =\n Number.isFinite(parsed.vebusDcVoltageV) && parsed.vebusDcVoltageV > 0\n ? parsed.vebusDcVoltageV\n : 51.2;\n\nconst houseNonEvcsW = Math.max(0, parsed.houseAcPowerW - parsed.evcsAcPowerW);\nconst vebusNonEvcsW = Math.max(0, parsed.vebusAcOutPowerW - parsed.evcsAcPowerW);\n\nconst totalVebusSoftCeilingW = Math.max(6500, params.acLoadsMaxSafeW || 8500);\nconst totalVebusHardCeilingW = totalVebusSoftCeilingW + 400;\n\n// P5 : plafond EVCS configurable quand le réseau alimente réellement AC-IN\n// (input_number.evcs_plafond_courant_edf_on). Hors réseau : plafond câblé prudent.\nconst evcsDedicatedCapBaseW = Math.max(0, cfg.evcsDedicatedMaxW - cfg.systemReserveW);\nconst safeEvcsByDedicatedCapW = parsed.acInAvailable\n ? Math.max(0, params.gridCurrentMaxA * acVoltageRef)\n : evcsDedicatedCapBaseW;\nconst safeEvcsByVebusW = Math.max(0, totalVebusSoftCeilingW - vebusNonEvcsW - cfg.systemReserveW);\nconst safeEvcsCombinedW = Math.max(0, Math.min(safeEvcsByDedicatedCapW, safeEvcsByVebusW));\n\nconst systemCapCurrentA = Math.max(0, Math.floor(safeEvcsCombinedW / acVoltageRef));\nconst physicalCapA = clamp(\n systemCapCurrentA,\n 0,\n parsed.acInAvailable\n ? Math.max(params.currentMaxAutoA, params.gridCurrentMaxA)\n : params.currentMaxAutoA\n);\nconst canRunAtMinCurrent = physicalCapA >= params.currentMinA;\n\nconst endDayGuardSoc = Math.max(params.endOfDaySocGuard, params.endOfDaySocTarget);\n\nconst energyNeededForEndDayGuardKwh = Math.max(\n 0,\n ((endDayGuardSoc - parsed.soc) / 100) * cfg.batteryUsableCapacityKwh\n);\n\nconst forecastBufferKwh =\n parsed.todayRemainingKwh -\n params.baseLoadRemainingKwh -\n params.flexibleSafetyMarginKwh -\n params.requiredEnergyKwh -\n energyNeededForEndDayGuardKwh;\n\nconst projectedEndSocAfterCharge = clamp(\n parsed.soc + (\n (\n parsed.todayRemainingKwh -\n params.baseLoadRemainingKwh -\n params.flexibleSafetyMarginKwh -\n params.requiredEnergyKwh\n ) / cfg.batteryUsableCapacityKwh\n ) * 100,\n 0,\n 100\n);\n\nconst currentReferenceA = clamp(\n Number.isFinite(parsed.evcsCommandCurrentA)\n ? parsed.evcsCommandCurrentA\n : Number.isFinite(parsed.evcsSetCurrentA)\n ? parsed.evcsSetCurrentA\n : params.targetCurrentAutoA,\n 0,\n 32\n);\n\nconst anyVebusOverload = parsed.vebusOverload || parsed.vebusL1Overload;\nconst anyVebusLowBattery = parsed.vebusLowBattery || parsed.vebusL1LowBattery;\n\nconst vebusAcOutSoftReached = parsed.vebusAcOutPowerW >= totalVebusSoftCeilingW;\nconst vebusAcOutHardReached = parsed.vebusAcOutPowerW >= totalVebusHardCeilingW;\n\nconst vebusDcVoltageSoftLow =\n Number.isFinite(parsed.vebusDcVoltageV) &&\n parsed.vebusDcVoltageV > 0 &&\n parsed.vebusDcVoltageV <= cfg.vebusDcVoltageSoftFloorV;\n\nconst vebusDcVoltageHardLow =\n Number.isFinite(parsed.vebusDcVoltageV) &&\n parsed.vebusDcVoltageV > 0 &&\n parsed.vebusDcVoltageV <= cfg.vebusDcVoltageHardFloorV;\n\nconst houseLoadHighVsHeadroom = houseNonEvcsW > params.houseAcHeadroomW;\n\n// =====================================================\n// WINDOWS / PROFILE\n// =====================================================\nconst inNightHcWindow = isWithinWindow(now.minNow, params.hcNightStartMin, params.hcNightStopMin);\nconst inMorningWindow = isWithinWindow(now.minNow, params.morningStartMin, params.morningStopMin);\nconst inMiddayWindow = isWithinWindow(now.minNow, params.hcMiddayStartMin, params.hcMiddayStopMin);\nconst inEveningWindow = isWithinWindow(now.minNow, params.eveningStartMin, params.eveningStopMin);\n\nconst profile =\n inNightHcWindow ? 'night_hc' :\n inMorningWindow ? 'morning_solar' :\n inMiddayWindow ? 'midday_hc_or_solar' :\n inEveningWindow ? 'evening_high_soc' :\n 'outside_window';\n\n// =====================================================\n// EVCS NIGHT PREVIEW ENGINE\n// =====================================================\nconst nightSessionActive =\n profile === 'night_hc' &&\n parsed.evcsVehicleConnected;\n\nconst nightSessionStartSocPct =\n nightSessionActive\n ? (\n Number.isFinite(history.nightSessionStartSocPct)\n ? history.nightSessionStartSocPct\n : parsed.soc\n )\n : null;\n\nconst nightSessionStartAt =\n nightSessionActive\n ? (history.nightSessionStartAt || '')\n : '';\n\nconst nightSessionStartMs =\n nightSessionActive\n ? (history.nightSessionStartMs ?? null)\n : null;\n\nconst nightSessionNeedsCapture =\n nightSessionActive &&\n (\n !Number.isFinite(history.nightSessionStartSocPct) ||\n !nightSessionStartAt ||\n nightSessionStartMs === null\n );\n\nconst nightSessionNeedsClear =\n !nightSessionActive &&\n (\n Number.isFinite(history.nightSessionStartSocPct) ||\n !!history.nightSessionStartAt\n );\n\nconst batteryHardFloorPct =\n params.evcsNightBatteryHardFloorPct;\n\n// P4 : la nuit avant minuit, today_remaining ~ 0 => utiliser la prévision du lendemain.\nconst nightForecastHour = now.hour;\nconst nightForecastKwh =\n (nightForecastHour >= 20 || nightForecastHour < 8)\n ? Math.max(parsed.todayRemainingKwh, parsed.tomorrowKwh || 0)\n : parsed.todayRemainingKwh;\n\nconst batteryMorningTargetPct =\n nightForecastKwh >= params.evcsNightStrongForecastKwh\n ? params.evcsNightTargetStrongForecastPct\n : nightForecastKwh >= params.evcsNightMediumForecastKwh\n ? params.evcsNightTargetMediumForecastPct\n : params.evcsNightTargetWeakForecastPct;\n\nconst maxBatteryDischargePoints =\n nightSessionActive && Number.isFinite(nightSessionStartSocPct)\n ? Math.max(0, nightSessionStartSocPct - batteryMorningTargetPct)\n : 0;\n\nconst gridTakeoverTriggerPct =\n Math.max(batteryHardFloorPct, batteryMorningTargetPct);\n\nconst batteryBelowHardFloor =\n parsed.soc <= batteryHardFloorPct;\n\nconst batteryBelowMorningTarget =\n parsed.soc < batteryMorningTargetPct;\n\nconst batteryAtOrBelowGridTakeoverTrigger =\n parsed.soc <= gridTakeoverTriggerPct;\n\nconst batteryKwhMissingToTarget =\n nightSessionActive\n ? Math.max(\n 0,\n ((batteryMorningTargetPct - parsed.soc) / 100) * cfg.batteryUsableCapacityKwh\n )\n : 0;\n\nconst batteryRecoveryMinutesNeeded =\n nightSessionActive && params.evcsNightBatteryRecoveryPowerKw > 0\n ? (batteryKwhMissingToTarget / params.evcsNightBatteryRecoveryPowerKw) * 60\n : 0;\n\nconst batteryRecoveryUrgent =\n nightSessionActive &&\n Number.isFinite(minutesUntilDeparture) &&\n minutesUntilDeparture >= 0 &&\n (\n batteryRecoveryMinutesNeeded + params.evcsNightDepartureSafetyMin\n >= minutesUntilDeparture\n );\n\n// P2 ANTI-CRENEAUX : continuité du takeover réseau nocturne.\n// Une fois le réseau HC engagé pendant une session nuit, on y reste\n// jusqu'à la fin de la session (fenêtre/débranchement) ou batterie quasi pleine.\n// Évite le cycle batterie->jeep->réseau->batterie (pertes + charge hachée).\nconst NIGHT_TAKEOVER_RELEASE_SOC_PCT = 90;\nconst nightTakeoverPrev =\n (input.night_takeover_prev ?? 'off').toString().trim().toLowerCase() === 'on';\n\n// P8 (07/08/2026) : \"charge terminée\" COLLANT via l'état surplus du cycle\n// précédent (helper input_text.victron_evcs_surplus_state relu par le snapshot).\n// Chaîne : VDF stoppe la charge (state vehicle_full_or_paused) → ce flag s'allume\n// → DECISION émet night_session_done_idle → le flag s'auto-entretient jusqu'au\n// débranchement (state vehicle_not_connected) ou à la fin de la fenêtre nuit.\nconst prevSurplusState = (input.prev_surplus_state ?? '').toString().trim().toLowerCase();\nconst vehicleChargeCompleted =\n nightSessionActive &&\n (\n prevSurplusState === 'vehicle_full_or_paused' ||\n prevSurplusState === 'night_session_done_idle'\n );\n\n// P8 : correction du fix 04/08 (logique inversée : \"|| vehicleChargeCompleted\"\n// GARDAIT le latch après la charge au lieu de le libérer).\nconst nightTakeoverLatched =\n nightSessionActive &&\n nightTakeoverPrev &&\n parsed.soc < NIGHT_TAKEOVER_RELEASE_SOC_PCT &&\n !vehicleChargeCompleted;\n\nconst nightGridHcPreferred =\n nightSessionActive &&\n params.allowGridHcNight &&\n !vehicleChargeCompleted;\n\nconst batteryAssistAllowed =\n nightSessionActive &&\n !nightGridHcPreferred &&\n parsed.soc > batteryHardFloorPct &&\n parsed.soc > gridTakeoverTriggerPct &&\n !nightTakeoverLatched &&\n !batteryRecoveryUrgent;\n\nconst nightGridTakeoverNeeded =\n nightSessionActive &&\n (\n nightGridHcPreferred ||\n batteryBelowHardFloor ||\n batteryAtOrBelowGridTakeoverTrigger ||\n batteryRecoveryUrgent ||\n nightTakeoverLatched\n );\n\nconst nightPreferredSourceMode =\n !nightSessionActive\n ? 'blocked'\n : vehicleChargeCompleted\n ? 'blocked'\n : batteryAssistAllowed\n ? 'battery_assist_night'\n : (\n batteryBelowHardFloor ||\n batteryBelowMorningTarget ||\n batteryRecoveryUrgent\n )\n ? 'grid_forced'\n : 'grid_hc_allowed';\n\nconst batteryRecoveryAllowedNow =\n nightSessionActive &&\n nightGridTakeoverNeeded &&\n (\n batteryBelowMorningTarget ||\n batteryRecoveryUrgent\n );\n\nconst batteryTargetReached =\n nightSessionActive &&\n parsed.soc >= batteryMorningTargetPct;\n\n// =====================================================\n// PROBES / SOLAR POTENTIAL\n// =====================================================\n// IMPORTANT:\n// On ne met plus effectivePvStartMinW à 0 en force_charge_now,\n// sinon la nuit 0W >= 0W ferait croire à un faux signal solaire.\nconst effectivePvStartMinW = params.pvStartMinW;\n\nconst morningProbeEligible =\n profile === 'morning_solar' &&\n params.probeEnabled &&\n parsed.pvPowerAvg10mW >= params.morningProbePvAvgMinW &&\n parsed.pvTrendW >= params.morningProbePvTrendMinW &&\n parsed.soc >= params.morningSocMin &&\n parsed.batteryCurrentA >= params.morningBatteryMaxDischargeA;\n\nconst middayProbeEligible =\n profile === 'midday_hc_or_solar' &&\n params.probeEnabled &&\n parsed.pvPowerAvg10mW >= params.middayProbePvAvgMinW &&\n parsed.soc >= params.middaySocMin &&\n parsed.batteryCurrentA >= params.middayBatteryMaxDischargeA;\n\nconst afternoonProbeEligible =\n profile === 'outside_window' &&\n params.probeEnabled &&\n parsed.soc >= params.afternoonProbeSocMin &&\n parsed.batteryCurrentA >= params.afternoonBatteryMaxDischargeA &&\n parsed.pvPowerAvg10mW >= params.afternoonProbePvAvgMinW;\n\nconst cloudDebtOkForPotential = params.cloudDebtPrevKwh <= params.cloudDebtMaxKwh;\nconst batteryOkForPotential = parsed.batteryCurrentA >= -Math.max(1, params.batteryNeutralBandA);\n\n// P3 PROVOCATION PV (installation sans injection) :\n// Batteries quasi pleines => le MPPT bride la production à la conso maison.\n// Les seuils PV observés, le forecast buffer et la cloud debt ne veulent\n// alors plus rien dire (la dette ne peut d'ailleurs plus se rembourser,\n// batterie pleine = courant nul). On autorise solar_potential sur la seule\n// santé batterie : SOC très haut + batterie non déchargée.\n// Les gardes aval (BATTERY_CLOUD_LOCKS / DECISION) restent actives.\nconst PV_PROVOCATION_SOC_MIN = 90;\nconst pvProvocationEligible =\n parsed.soc >= PV_PROVOCATION_SOC_MIN &&\n batteryOkForPotential;\n\nconst isHighBatteryForPotential =\n pvProvocationEligible ||\n (\n parsed.soc >= 85 &&\n forecastBufferKwh > 0 &&\n cloudDebtOkForPotential &&\n batteryOkForPotential\n );\n\nconst potentialAllowed = userStrategy !== 'auto_solar';\n\n// IMPORTANT:\n// On impose un vrai PV > 0 pour le test sur pvPowerW.\n// Ça évite qu'en force_charge_now la nuit, 0W soit pris pour un signal solaire.\nconst strongSolarSignal =\n (\n parsed.pvPowerW > 0 &&\n parsed.pvPowerW >= effectivePvStartMinW\n ) ||\n parsed.pvPowerAvg10mW >= params.pvHoldMinW ||\n (departurePriorityActive && parsed.pvPowerAvg10mW >= params.pvHoldMinW);\n\n// =====================================================\n// SOURCE MODE\n// SÉMANTIQUE CORRIGÉE:\n// - force_charge_now bypass les fenêtres\n// - mais ne devient jamais grid_forced à lui seul\n// - outside_window = pas de réseau programmé\n// - la charge locale peut rester autorisée\n// =====================================================\nlet sourceMode = 'blocked';\n\nif (isStopMode) {\n sourceMode = 'blocked';\n}\n\nelse if (forceGridRequested) {\n // Demande explicite de réseau.\n sourceMode = 'grid_forced';\n}\n\nelse if (forceChargeRequested) {\n // IMPORTANT:\n // force_charge_now = intention de charger maintenant,\n // sans exiger le réseau.\n\n if (strongSolarSignal) {\n sourceMode = 'solar_only';\n }\n\n else if (nightSessionActive && batteryAssistAllowed) {\n sourceMode = 'battery_assist_night';\n }\n\n else if (isHighBatteryForPotential && potentialAllowed) {\n sourceMode = 'solar_potential';\n }\n\n else if (profile === 'midday_hc_or_solar' && middayProbeEligible) {\n sourceMode = 'hybrid_probe';\n }\n\n else if (profile === 'morning_solar' && morningProbeEligible) {\n sourceMode = 'hybrid_probe';\n }\n\n else {\n // Fallback manuel local :\n // on n'impose pas le grid, on laisse l'étage aval décider\n // selon les garde-fous physiques / batterie.\n sourceMode = 'solar_potential';\n }\n}\n\nelse if (profile === 'night_hc') {\n if (nightSessionActive) {\n sourceMode = nightPreferredSourceMode;\n } else {\n sourceMode = 'blocked';\n }\n}\n\nelse if (profile === 'midday_hc_or_solar' || profile === 'morning_solar') {\n if (strongSolarSignal) {\n sourceMode = 'solar_only';\n }\n\n else if (isHighBatteryForPotential && potentialAllowed) {\n sourceMode = 'solar_potential';\n }\n\n else if (profile === 'midday_hc_or_solar' && middayProbeEligible) {\n sourceMode = 'hybrid_probe';\n }\n\n else if (profile === 'morning_solar' && morningProbeEligible) {\n sourceMode = 'hybrid_probe';\n }\n\n else if (profile === 'midday_hc_or_solar' && params.allowGridHcMidday && parsed.gridAvailable) {\n sourceMode = 'grid_hc_allowed';\n }\n\n else {\n sourceMode = 'blocked';\n }\n}\n\nelse if (profile === 'evening_high_soc') {\n if (strongSolarSignal) {\n sourceMode = 'solar_only';\n }\n\n else if (isHighBatteryForPotential && potentialAllowed) {\n sourceMode = 'solar_potential';\n }\n\n else {\n sourceMode = 'blocked';\n }\n}\n\nelse if (profile === 'outside_window') {\n if (strongSolarSignal) {\n sourceMode = 'solar_only';\n }\n\n else if (isHighBatteryForPotential && potentialAllowed) {\n sourceMode = 'solar_potential';\n }\n\n else if (afternoonProbeEligible) {\n sourceMode = 'hybrid_probe';\n }\n\n else {\n // IMPORTANT:\n // hors fenêtre = pas de réseau programmé.\n // Donc on ne bascule plus vers grid_forced juste parce que le réseau existe.\n sourceMode = 'blocked';\n }\n}\n\nconst operatingMode = profile;\n\n// =====================================================\n// OUTPUT\n// =====================================================\nreturn [{\n json: {\n ...prev,\n\n strategy: {\n userStrategy,\n isStopMode,\n isForceMode,\n forceGridRequested,\n forceChargeRequested,\n },\n\n lifecycle: {\n minutesSinceStart,\n minutesSinceStop,\n secondsSinceCurrentChange,\n minutesSinceProbeStart,\n minRunElapsed,\n minStopElapsed,\n currentCooldownElapsed,\n minutesUntilDeparture,\n },\n\n windows: {\n inNightHcWindow,\n inMorningWindow,\n inMiddayWindow,\n inEveningWindow,\n },\n\n derived: {\n acVoltageRef,\n batteryVoltageRef,\n houseNonEvcsW,\n vebusNonEvcsW,\n totalVebusSoftCeilingW,\n totalVebusHardCeilingW,\n safeEvcsByDedicatedCapW,\n safeEvcsByVebusW,\n safeEvcsCombinedW,\n systemCapCurrentA,\n physicalCapA,\n canRunAtMinCurrent,\n endDayGuardSoc,\n energyNeededForEndDayGuardKwh,\n forecastBufferKwh,\n projectedEndSocAfterCharge,\n currentReferenceA,\n anyVebusOverload,\n anyVebusLowBattery,\n vebusAcOutSoftReached,\n vebusAcOutHardReached,\n vebusDcVoltageSoftLow,\n vebusDcVoltageHardLow,\n houseLoadHighVsHeadroom,\n },\n\n context: {\n departurePriorityActive,\n forceChargeDepartureGridOverride,\n manualChargeBypassWindows,\n manualChargeLocalRequested,\n morningProbeEligible,\n middayProbeEligible,\n afternoonProbeEligible,\n pvProvocationEligible,\n isHighBatteryForPotential,\n strongSolarSignal,\n effectivePvStartMinW,\n profile,\n operatingMode,\n sourceMode,\n },\n\n // R1 : source de vérité UNIQUE du moteur nuit.\n // BATTERY_CLOUD_LOCKS consomme cet objet, il ne recalcule plus rien.\n nightEvcsPreview: {\n nightSessionActive,\n nightSessionNeedsCapture,\n nightSessionNeedsClear,\n nightSessionStartSocPct,\n nightSessionStartAt,\n nightSessionStartMs,\n\n batteryHardFloorPct,\n batteryMorningTargetPct,\n maxBatteryDischargePoints,\n gridTakeoverTriggerPct,\n\n batteryBelowHardFloor,\n batteryBelowMorningTarget,\n batteryAtOrBelowGridTakeoverTrigger,\n\n batteryKwhMissingToTarget,\n batteryRecoveryMinutesNeeded,\n batteryRecoveryUrgent,\n batteryRecoveryAllowedNow,\n batteryTargetReached,\n\n batteryAssistAllowed,\n nightGridTakeoverNeeded,\n nightTakeoverLatched,\n nightGridHcPreferred,\n vehicleChargeCompleted,\n nightPreferredSourceMode,\n }\n }\n}];\n" + "jsCode": "function minutesSince(ts, nowMs) {\n if (ts === null || nowMs === null) return null;\n return Math.max(0, (nowMs - ts) / 60000);\n}\n\nfunction secondsSince(ts, nowMs) {\n if (ts === null || nowMs === null) return null;\n return Math.max(0, (nowMs - ts) / 1000);\n}\n\nfunction isWithinWindow(nowMin, startMin, stopMin) {\n if (!Number.isFinite(startMin) || !Number.isFinite(stopMin)) return false;\n if (startMin === stopMin) return false;\n if (startMin < stopMin) return nowMin >= startMin && nowMin < stopMin;\n return nowMin >= startMin || nowMin < stopMin;\n}\n\nfunction clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nconst prev = $input.first().json;\nconst {\n input,\n nowMs,\n now,\n cfg,\n parsed,\n params,\n history,\n batteryTune\n} = prev;\n\n// =====================================================\n// STRATEGY\n// =====================================================\nconst userStrategy = (params.userStrategyRaw ?? 'auto_solar').toString().trim().toLowerCase();\n\nconst isStopMode = userStrategy === 'stop';\nconst forceGridRequested = userStrategy === 'force_grid_now';\nconst forceChargeRequested = userStrategy === 'force_charge_now';\nconst isForceMode = forceGridRequested || forceChargeRequested;\n\n// =====================================================\n// LIFECYCLE / TIMING\n// =====================================================\nconst minutesSinceStart = minutesSince(history.lastStartMs, nowMs);\nconst minutesSinceStop = minutesSince(history.lastStopMs, nowMs);\nconst secondsSinceCurrentChange = secondsSince(history.lastCurrentChangeMs, nowMs);\nconst minutesSinceProbeStart = minutesSince(history.probeStartedMs, nowMs);\n\nconst minRunElapsed =\n minutesSinceStart === null ? true : minutesSinceStart >= params.minRunMinutes;\n\nconst minStopElapsed =\n minutesSinceStop === null ? true : minutesSinceStop >= params.minStopMinutes;\n\nconst currentCooldownBaseSec = parsed.evcsChargingSwitchOn\n ? batteryTune.global.stepDownCooldownSeconds\n : batteryTune.global.stepUpCooldownSeconds;\n\nconst currentCooldownElapsed =\n secondsSinceCurrentChange === null\n ? true\n : secondsSinceCurrentChange >= Math.max(\n params.currentChangeCooldownSeconds,\n currentCooldownBaseSec\n );\n\n// =====================================================\n// DEPARTURE PRIORITY\n// =====================================================\nconst minutesUntilDeparture =\n params.departureTimeMin === null\n ? null\n : (() => {\n const rawDiff = params.departureTimeMin - now.minNow;\n return rawDiff >= 0 ? rawDiff : (1440 - now.minNow + params.departureTimeMin);\n })();\n\nconst departurePriorityActive =\n parsed.departurePriorityEnabled &&\n Number.isFinite(minutesUntilDeparture) &&\n minutesUntilDeparture >= 0 &&\n minutesUntilDeparture <= cfg.departurePriorityWindowMinutes &&\n parsed.evcsVehicleConnected;\n\n// =====================================================\n// MANUAL CHARGE SEMANTICS\n// IMPORTANT:\n// force_charge_now = bypass fenêtres pour la charge,\n// MAIS PAS demande implicite de réseau.\n// =====================================================\nconst manualChargeBypassWindows = forceChargeRequested;\nconst manualChargeLocalRequested = forceChargeRequested && !forceGridRequested;\n\n// Compatibilité ascendante : on garde le champ dans context,\n// mais on le neutralise volontairement.\nconst forceChargeDepartureGridOverride = false;\n\n// =====================================================\n// SYSTEM DERIVED VALUES\n// =====================================================\nconst acVoltageRef =\n Number.isFinite(parsed.vebusAcOutVoltageV) && parsed.vebusAcOutVoltageV > 180\n ? parsed.vebusAcOutVoltageV\n : cfg.nominalAcVoltageV;\n\nconst batteryVoltageRef =\n Number.isFinite(parsed.vebusDcVoltageV) && parsed.vebusDcVoltageV > 0\n ? parsed.vebusDcVoltageV\n : 51.2;\n\nconst houseNonEvcsW = Math.max(0, parsed.houseAcPowerW - parsed.evcsAcPowerW);\nconst vebusNonEvcsW = Math.max(0, parsed.vebusAcOutPowerW - parsed.evcsAcPowerW);\n\nconst totalVebusSoftCeilingW = Math.max(6500, params.acLoadsMaxSafeW || 8500);\nconst totalVebusHardCeilingW = totalVebusSoftCeilingW + 400;\n\n// P5 : plafond EVCS configurable quand le réseau alimente réellement AC-IN\n// (input_number.evcs_plafond_courant_edf_on). Hors réseau : plafond câblé prudent.\nconst evcsDedicatedCapBaseW = Math.max(0, cfg.evcsDedicatedMaxW - cfg.systemReserveW);\nconst safeEvcsByDedicatedCapW = parsed.acInAvailable\n ? Math.max(0, params.gridCurrentMaxA * acVoltageRef)\n : evcsDedicatedCapBaseW;\nconst safeEvcsByVebusW = Math.max(0, totalVebusSoftCeilingW - vebusNonEvcsW - cfg.systemReserveW);\nconst safeEvcsCombinedW = Math.max(0, Math.min(safeEvcsByDedicatedCapW, safeEvcsByVebusW));\n\nconst systemCapCurrentA = Math.max(0, Math.floor(safeEvcsCombinedW / acVoltageRef));\nconst physicalCapA = clamp(\n systemCapCurrentA,\n 0,\n parsed.acInAvailable\n ? Math.max(params.currentMaxAutoA, params.gridCurrentMaxA)\n : params.currentMaxAutoA\n);\nconst canRunAtMinCurrent = physicalCapA >= params.currentMinA;\n\nconst endDayGuardSoc = Math.max(params.endOfDaySocGuard, params.endOfDaySocTarget);\n\nconst energyNeededForEndDayGuardKwh = Math.max(\n 0,\n ((endDayGuardSoc - parsed.soc) / 100) * cfg.batteryUsableCapacityKwh\n);\n\nconst forecastBufferKwh =\n parsed.todayRemainingKwh -\n params.baseLoadRemainingKwh -\n params.flexibleSafetyMarginKwh -\n params.requiredEnergyKwh -\n energyNeededForEndDayGuardKwh;\n\nconst projectedEndSocAfterCharge = clamp(\n parsed.soc + (\n (\n parsed.todayRemainingKwh -\n params.baseLoadRemainingKwh -\n params.flexibleSafetyMarginKwh -\n params.requiredEnergyKwh\n ) / cfg.batteryUsableCapacityKwh\n ) * 100,\n 0,\n 100\n);\n\nconst currentReferenceA = clamp(\n Number.isFinite(parsed.evcsCommandCurrentA)\n ? parsed.evcsCommandCurrentA\n : Number.isFinite(parsed.evcsSetCurrentA)\n ? parsed.evcsSetCurrentA\n : params.targetCurrentAutoA,\n 0,\n 32\n);\n\nconst anyVebusOverload = parsed.vebusOverload || parsed.vebusL1Overload;\nconst anyVebusLowBattery = parsed.vebusLowBattery || parsed.vebusL1LowBattery;\n\nconst vebusAcOutSoftReached = parsed.vebusAcOutPowerW >= totalVebusSoftCeilingW;\nconst vebusAcOutHardReached = parsed.vebusAcOutPowerW >= totalVebusHardCeilingW;\n\nconst vebusDcVoltageSoftLow =\n Number.isFinite(parsed.vebusDcVoltageV) &&\n parsed.vebusDcVoltageV > 0 &&\n parsed.vebusDcVoltageV <= cfg.vebusDcVoltageSoftFloorV;\n\nconst vebusDcVoltageHardLow =\n Number.isFinite(parsed.vebusDcVoltageV) &&\n parsed.vebusDcVoltageV > 0 &&\n parsed.vebusDcVoltageV <= cfg.vebusDcVoltageHardFloorV;\n\nconst houseLoadHighVsHeadroom = houseNonEvcsW > params.houseAcHeadroomW;\n\n// =====================================================\n// WINDOWS / PROFILE\n// =====================================================\nconst inNightHcWindow = isWithinWindow(now.minNow, params.hcNightStartMin, params.hcNightStopMin);\nconst inMorningWindow = isWithinWindow(now.minNow, params.morningStartMin, params.morningStopMin);\nconst inMiddayWindow = isWithinWindow(now.minNow, params.hcMiddayStartMin, params.hcMiddayStopMin);\nconst inEveningWindow = isWithinWindow(now.minNow, params.eveningStartMin, params.eveningStopMin);\n\nconst profile =\n inNightHcWindow ? 'night_hc' :\n inMorningWindow ? 'morning_solar' :\n inMiddayWindow ? 'midday_hc_or_solar' :\n inEveningWindow ? 'evening_high_soc' :\n 'outside_window';\n\n// =====================================================\n// EVCS NIGHT PREVIEW ENGINE\n// =====================================================\nconst nightSessionActive =\n profile === 'night_hc' &&\n parsed.evcsVehicleConnected;\n\nconst nightSessionStartSocPct =\n nightSessionActive\n ? (\n Number.isFinite(history.nightSessionStartSocPct)\n ? history.nightSessionStartSocPct\n : parsed.soc\n )\n : null;\n\nconst nightSessionStartAt =\n nightSessionActive\n ? (history.nightSessionStartAt || '')\n : '';\n\nconst nightSessionStartMs =\n nightSessionActive\n ? (history.nightSessionStartMs ?? null)\n : null;\n\nconst nightSessionNeedsCapture =\n nightSessionActive &&\n (\n !Number.isFinite(history.nightSessionStartSocPct) ||\n !nightSessionStartAt ||\n nightSessionStartMs === null\n );\n\nconst nightSessionNeedsClear =\n !nightSessionActive &&\n (\n Number.isFinite(history.nightSessionStartSocPct) ||\n !!history.nightSessionStartAt\n );\n\nconst batteryHardFloorPct =\n params.evcsNightBatteryHardFloorPct;\n\n// P4 : la nuit avant minuit, today_remaining ~ 0 => utiliser la prévision du lendemain.\nconst nightForecastHour = now.hour;\nconst nightForecastKwh =\n (nightForecastHour >= 20 || nightForecastHour < 8)\n ? Math.max(parsed.todayRemainingKwh, parsed.tomorrowKwh || 0)\n : parsed.todayRemainingKwh;\n\nconst batteryMorningTargetPct =\n nightForecastKwh >= params.evcsNightStrongForecastKwh\n ? params.evcsNightTargetStrongForecastPct\n : nightForecastKwh >= params.evcsNightMediumForecastKwh\n ? params.evcsNightTargetMediumForecastPct\n : params.evcsNightTargetWeakForecastPct;\n\nconst maxBatteryDischargePoints =\n nightSessionActive && Number.isFinite(nightSessionStartSocPct)\n ? Math.max(0, nightSessionStartSocPct - batteryMorningTargetPct)\n : 0;\n\nconst gridTakeoverTriggerPct =\n Math.max(batteryHardFloorPct, batteryMorningTargetPct);\n\nconst batteryBelowHardFloor =\n parsed.soc <= batteryHardFloorPct;\n\nconst batteryBelowMorningTarget =\n parsed.soc < batteryMorningTargetPct;\n\nconst batteryAtOrBelowGridTakeoverTrigger =\n parsed.soc <= gridTakeoverTriggerPct;\n\nconst batteryKwhMissingToTarget =\n nightSessionActive\n ? Math.max(\n 0,\n ((batteryMorningTargetPct - parsed.soc) / 100) * cfg.batteryUsableCapacityKwh\n )\n : 0;\n\nconst batteryRecoveryMinutesNeeded =\n nightSessionActive && params.evcsNightBatteryRecoveryPowerKw > 0\n ? (batteryKwhMissingToTarget / params.evcsNightBatteryRecoveryPowerKw) * 60\n : 0;\n\nconst batteryRecoveryUrgent =\n nightSessionActive &&\n Number.isFinite(minutesUntilDeparture) &&\n minutesUntilDeparture >= 0 &&\n (\n batteryRecoveryMinutesNeeded + params.evcsNightDepartureSafetyMin\n >= minutesUntilDeparture\n );\n\n// P2 ANTI-CRENEAUX : continuité du takeover réseau nocturne.\n// Une fois le réseau HC engagé pendant une session nuit, on y reste\n// jusqu'à la fin de la session (fenêtre/débranchement) ou batterie quasi pleine.\n// Évite le cycle batterie->jeep->réseau->batterie (pertes + charge hachée).\nconst NIGHT_TAKEOVER_RELEASE_SOC_PCT = 90;\nconst nightTakeoverPrev =\n (input.night_takeover_prev ?? 'off').toString().trim().toLowerCase() === 'on';\n\n// P8 (07/08/2026) : \"charge terminée\" COLLANT via l'état surplus du cycle\n// précédent (helper input_text.victron_evcs_surplus_state relu par le snapshot).\n// Chaîne : VDF stoppe la charge (state vehicle_full_or_paused) → ce flag s'allume\n// → DECISION émet night_session_done_idle → le flag s'auto-entretient jusqu'au\n// débranchement (state vehicle_not_connected) ou à la fin de la fenêtre nuit.\nconst prevSurplusState = (input.prev_surplus_state ?? '').toString().trim().toLowerCase();\n\n// Fix D (08/08/2026) : le statut natif EVCS \"charged\" (3) vaut aussi fin de\n// charge, sans attendre le VehicleDemandFilter (désarmé si la session a été\n// stoppée par une garde, ex battery_current_hard_stop → détection tardive\n// observée la nuit du 07-08/08).\nconst evcsStatusCharged = Number.parseFloat(parsed.evcsStatusRaw) === 3;\n\nconst vehicleChargeCompleted =\n nightSessionActive &&\n (\n prevSurplusState === 'vehicle_full_or_paused' ||\n prevSurplusState === 'night_session_done_idle' ||\n prevSurplusState === 'night_recovery_done_idle' ||\n evcsStatusCharged\n );\n\n// Fix A (08/08/2026) : recovery batterie post-charge = UNE SEULE passe.\n// Sans flag collant, SOC==cible est un point dégénéré (engage soc<=cible,\n// release soc>cible, l'ACM recharge pile à la cible) → rebond EDF ON/OFF\n// période ~10 min observé la nuit du 07-08/08. Une fois la cible atteinte,\n// l'état 'night_recovery_done_idle' (auto-entretenu via prev_surplus_state)\n// coupe le takeover. Ré-engagement uniquement sous cible − 2 pts (bande),\n// hard floor et fenêtre/débranchement restent prioritaires.\n// ⚠ 'night_recovery_done_idle' ne doit matcher AUCUN pattern no-demand de\n// l'hystérésis (full_or_paused / not_requesting_power / charge_complete /\n// no_demand) — même contrainte que 'night_session_done_idle'.\nconst NIGHT_RECOVERY_REARM_BAND_PCT = 2;\nconst nightRecoveryDonePrev = prevSurplusState === 'night_recovery_done_idle';\nconst nightRecoveryDone =\n vehicleChargeCompleted &&\n (\n parsed.soc >= batteryMorningTargetPct ||\n (\n nightRecoveryDonePrev &&\n parsed.soc > batteryMorningTargetPct - NIGHT_RECOVERY_REARM_BAND_PCT\n )\n );\n\n// P8 : correction du fix 04/08 (logique inversée : \"|| vehicleChargeCompleted\"\n// GARDAIT le latch après la charge au lieu de le libérer).\nconst nightTakeoverLatched =\n nightSessionActive &&\n nightTakeoverPrev &&\n parsed.soc < NIGHT_TAKEOVER_RELEASE_SOC_PCT &&\n !vehicleChargeCompleted;\n\nconst nightGridHcPreferred =\n nightSessionActive &&\n params.allowGridHcNight &&\n !vehicleChargeCompleted;\n\nconst batteryAssistAllowed =\n nightSessionActive &&\n !nightGridHcPreferred &&\n parsed.soc > batteryHardFloorPct &&\n parsed.soc > gridTakeoverTriggerPct &&\n !nightTakeoverLatched &&\n !batteryRecoveryUrgent;\n\nconst nightGridTakeoverNeeded =\n nightSessionActive &&\n (\n nightGridHcPreferred ||\n batteryBelowHardFloor ||\n (\n // Fix A : après fin de charge, le seul motif réseau restant est la\n // recovery batterie, et elle ne se ré-engage pas une fois done.\n vehicleChargeCompleted\n ? !nightRecoveryDone\n : (\n batteryAtOrBelowGridTakeoverTrigger ||\n batteryRecoveryUrgent ||\n nightTakeoverLatched\n )\n )\n );\n\nconst nightPreferredSourceMode =\n !nightSessionActive\n ? 'blocked'\n : vehicleChargeCompleted\n ? 'blocked'\n : batteryAssistAllowed\n ? 'battery_assist_night'\n : (\n batteryBelowHardFloor ||\n batteryBelowMorningTarget ||\n batteryRecoveryUrgent\n )\n ? 'grid_forced'\n : 'grid_hc_allowed';\n\nconst batteryRecoveryAllowedNow =\n nightSessionActive &&\n nightGridTakeoverNeeded &&\n (\n batteryBelowMorningTarget ||\n batteryRecoveryUrgent\n );\n\nconst batteryTargetReached =\n nightSessionActive &&\n parsed.soc >= batteryMorningTargetPct;\n\n// =====================================================\n// PROBES / SOLAR POTENTIAL\n// =====================================================\n// IMPORTANT:\n// On ne met plus effectivePvStartMinW à 0 en force_charge_now,\n// sinon la nuit 0W >= 0W ferait croire à un faux signal solaire.\nconst effectivePvStartMinW = params.pvStartMinW;\n\nconst morningProbeEligible =\n profile === 'morning_solar' &&\n params.probeEnabled &&\n parsed.pvPowerAvg10mW >= params.morningProbePvAvgMinW &&\n parsed.pvTrendW >= params.morningProbePvTrendMinW &&\n parsed.soc >= params.morningSocMin &&\n parsed.batteryCurrentA >= params.morningBatteryMaxDischargeA;\n\nconst middayProbeEligible =\n profile === 'midday_hc_or_solar' &&\n params.probeEnabled &&\n parsed.pvPowerAvg10mW >= params.middayProbePvAvgMinW &&\n parsed.soc >= params.middaySocMin &&\n parsed.batteryCurrentA >= params.middayBatteryMaxDischargeA;\n\nconst afternoonProbeEligible =\n profile === 'outside_window' &&\n params.probeEnabled &&\n parsed.soc >= params.afternoonProbeSocMin &&\n parsed.batteryCurrentA >= params.afternoonBatteryMaxDischargeA &&\n parsed.pvPowerAvg10mW >= params.afternoonProbePvAvgMinW;\n\nconst cloudDebtOkForPotential = params.cloudDebtPrevKwh <= params.cloudDebtMaxKwh;\nconst batteryOkForPotential = parsed.batteryCurrentA >= -Math.max(1, params.batteryNeutralBandA);\n\n// P3 PROVOCATION PV (installation sans injection) :\n// Batteries quasi pleines => le MPPT bride la production à la conso maison.\n// Les seuils PV observés, le forecast buffer et la cloud debt ne veulent\n// alors plus rien dire (la dette ne peut d'ailleurs plus se rembourser,\n// batterie pleine = courant nul). On autorise solar_potential sur la seule\n// santé batterie : SOC très haut + batterie non déchargée.\n// Les gardes aval (BATTERY_CLOUD_LOCKS / DECISION) restent actives.\nconst PV_PROVOCATION_SOC_MIN = 90;\nconst pvProvocationEligible =\n parsed.soc >= PV_PROVOCATION_SOC_MIN &&\n batteryOkForPotential;\n\nconst isHighBatteryForPotential =\n pvProvocationEligible ||\n (\n parsed.soc >= 85 &&\n forecastBufferKwh > 0 &&\n cloudDebtOkForPotential &&\n batteryOkForPotential\n );\n\nconst potentialAllowed = userStrategy !== 'auto_solar';\n\n// IMPORTANT:\n// On impose un vrai PV > 0 pour le test sur pvPowerW.\n// Ça évite qu'en force_charge_now la nuit, 0W soit pris pour un signal solaire.\nconst strongSolarSignal =\n (\n parsed.pvPowerW > 0 &&\n parsed.pvPowerW >= effectivePvStartMinW\n ) ||\n parsed.pvPowerAvg10mW >= params.pvHoldMinW ||\n (departurePriorityActive && parsed.pvPowerAvg10mW >= params.pvHoldMinW);\n\n// =====================================================\n// SOURCE MODE\n// SÉMANTIQUE CORRIGÉE:\n// - force_charge_now bypass les fenêtres\n// - mais ne devient jamais grid_forced à lui seul\n// - outside_window = pas de réseau programmé\n// - la charge locale peut rester autorisée\n// =====================================================\nlet sourceMode = 'blocked';\n\nif (isStopMode) {\n sourceMode = 'blocked';\n}\n\nelse if (forceGridRequested) {\n // Demande explicite de réseau.\n sourceMode = 'grid_forced';\n}\n\nelse if (forceChargeRequested) {\n // IMPORTANT:\n // force_charge_now = intention de charger maintenant,\n // sans exiger le réseau.\n\n if (strongSolarSignal) {\n sourceMode = 'solar_only';\n }\n\n else if (nightSessionActive && batteryAssistAllowed) {\n sourceMode = 'battery_assist_night';\n }\n\n else if (isHighBatteryForPotential && potentialAllowed) {\n sourceMode = 'solar_potential';\n }\n\n else if (profile === 'midday_hc_or_solar' && middayProbeEligible) {\n sourceMode = 'hybrid_probe';\n }\n\n else if (profile === 'morning_solar' && morningProbeEligible) {\n sourceMode = 'hybrid_probe';\n }\n\n else {\n // Fallback manuel local :\n // on n'impose pas le grid, on laisse l'étage aval décider\n // selon les garde-fous physiques / batterie.\n sourceMode = 'solar_potential';\n }\n}\n\nelse if (profile === 'night_hc') {\n if (nightSessionActive) {\n sourceMode = nightPreferredSourceMode;\n } else {\n sourceMode = 'blocked';\n }\n}\n\nelse if (profile === 'midday_hc_or_solar' || profile === 'morning_solar') {\n if (strongSolarSignal) {\n sourceMode = 'solar_only';\n }\n\n else if (isHighBatteryForPotential && potentialAllowed) {\n sourceMode = 'solar_potential';\n }\n\n else if (profile === 'midday_hc_or_solar' && middayProbeEligible) {\n sourceMode = 'hybrid_probe';\n }\n\n else if (profile === 'morning_solar' && morningProbeEligible) {\n sourceMode = 'hybrid_probe';\n }\n\n else if (profile === 'midday_hc_or_solar' && params.allowGridHcMidday && parsed.gridAvailable) {\n sourceMode = 'grid_hc_allowed';\n }\n\n else {\n sourceMode = 'blocked';\n }\n}\n\nelse if (profile === 'evening_high_soc') {\n if (strongSolarSignal) {\n sourceMode = 'solar_only';\n }\n\n else if (isHighBatteryForPotential && potentialAllowed) {\n sourceMode = 'solar_potential';\n }\n\n else {\n sourceMode = 'blocked';\n }\n}\n\nelse if (profile === 'outside_window') {\n if (strongSolarSignal) {\n sourceMode = 'solar_only';\n }\n\n else if (isHighBatteryForPotential && potentialAllowed) {\n sourceMode = 'solar_potential';\n }\n\n else if (afternoonProbeEligible) {\n sourceMode = 'hybrid_probe';\n }\n\n else {\n // IMPORTANT:\n // hors fenêtre = pas de réseau programmé.\n // Donc on ne bascule plus vers grid_forced juste parce que le réseau existe.\n sourceMode = 'blocked';\n }\n}\n\nconst operatingMode = profile;\n\n// =====================================================\n// OUTPUT\n// =====================================================\nreturn [{\n json: {\n ...prev,\n\n strategy: {\n userStrategy,\n isStopMode,\n isForceMode,\n forceGridRequested,\n forceChargeRequested,\n },\n\n lifecycle: {\n minutesSinceStart,\n minutesSinceStop,\n secondsSinceCurrentChange,\n minutesSinceProbeStart,\n minRunElapsed,\n minStopElapsed,\n currentCooldownElapsed,\n minutesUntilDeparture,\n },\n\n windows: {\n inNightHcWindow,\n inMorningWindow,\n inMiddayWindow,\n inEveningWindow,\n },\n\n derived: {\n acVoltageRef,\n batteryVoltageRef,\n houseNonEvcsW,\n vebusNonEvcsW,\n totalVebusSoftCeilingW,\n totalVebusHardCeilingW,\n safeEvcsByDedicatedCapW,\n safeEvcsByVebusW,\n safeEvcsCombinedW,\n systemCapCurrentA,\n physicalCapA,\n canRunAtMinCurrent,\n endDayGuardSoc,\n energyNeededForEndDayGuardKwh,\n forecastBufferKwh,\n projectedEndSocAfterCharge,\n currentReferenceA,\n anyVebusOverload,\n anyVebusLowBattery,\n vebusAcOutSoftReached,\n vebusAcOutHardReached,\n vebusDcVoltageSoftLow,\n vebusDcVoltageHardLow,\n houseLoadHighVsHeadroom,\n },\n\n context: {\n departurePriorityActive,\n forceChargeDepartureGridOverride,\n manualChargeBypassWindows,\n manualChargeLocalRequested,\n morningProbeEligible,\n middayProbeEligible,\n afternoonProbeEligible,\n pvProvocationEligible,\n isHighBatteryForPotential,\n strongSolarSignal,\n effectivePvStartMinW,\n profile,\n operatingMode,\n sourceMode,\n },\n\n // R1 : source de vérité UNIQUE du moteur nuit.\n // BATTERY_CLOUD_LOCKS consomme cet objet, il ne recalcule plus rien.\n nightEvcsPreview: {\n nightSessionActive,\n nightSessionNeedsCapture,\n nightSessionNeedsClear,\n nightSessionStartSocPct,\n nightSessionStartAt,\n nightSessionStartMs,\n\n batteryHardFloorPct,\n batteryMorningTargetPct,\n maxBatteryDischargePoints,\n gridTakeoverTriggerPct,\n\n batteryBelowHardFloor,\n batteryBelowMorningTarget,\n batteryAtOrBelowGridTakeoverTrigger,\n\n batteryKwhMissingToTarget,\n batteryRecoveryMinutesNeeded,\n batteryRecoveryUrgent,\n batteryRecoveryAllowedNow,\n batteryTargetReached,\n\n batteryAssistAllowed,\n nightGridTakeoverNeeded,\n nightTakeoverLatched,\n nightGridHcPreferred,\n vehicleChargeCompleted,\n nightRecoveryDone,\n nightPreferredSourceMode,\n }\n }\n}];\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2, @@ -178,7 +178,7 @@ }, { "parameters": { - "jsCode": "function clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nfunction round1(v) {\n return Math.round(v * 10) / 10;\n}\n\nfunction round3(v) {\n return Math.round(v * 1000) / 1000;\n}\n\nfunction to2(n) {\n return String(n).padStart(2, '0');\n}\n\nfunction finiteOr(v, fallback) {\n return Number.isFinite(v) ? v : fallback;\n}\n\nfunction sigmoid(x) {\n return 1 / (1 + Math.exp(-x));\n}\n\nfunction alphaFromTau(dtSec, tauSec) {\n const dt = Math.max(1, finiteOr(dtSec, 60));\n const tau = Math.max(1, finiteOr(tauSec, 60));\n return Math.min(1, Math.max(0.001, 1 - Math.exp(-dt / tau)));\n}\n\nfunction ema(prev, sample, alpha) {\n if (!Number.isFinite(sample)) return Number.isFinite(prev) ? prev : 0;\n if (!Number.isFinite(prev)) return sample;\n return prev + alpha * (sample - prev);\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 now,\n nowMs,\n parsed,\n params,\n history,\n strategy,\n lifecycle,\n context,\n derived,\n locks,\n cloud,\n modes,\n safeguards,\n conditions,\n batteryTune,\n nightEvcs,\n} = prev;\n\n// =====================================================\n// DECISION INIT\n// =====================================================\nlet desiredCharging = false;\nlet desiredPermission = false;\nlet desiredLatch = false;\nlet desiredCurrentA = params.currentMinA;\n\nlet state = 'idle';\nlet currentDecision = 'hold';\nlet dynamicOk = false;\nlet mustStop = false;\n\nlet reason = [];\n\nlet setCloudDebtStartedAt = false;\nlet setProbeStartedAt = false;\n\n// EVCS night session memory\nlet setNightSessionStart = false;\nlet clearNightSessionStart = false;\n\nlet notifyWanted = false;\nlet notifyTitle = 'Victron EVCS Surplus';\nlet notifyMessage = '';\n\nlet nextBlockReason = 'none';\nlet nextBlockedUntilStamp = now.stamp;\n\n// =====================================================\n// HELPERS\n// =====================================================\nfunction setOff({\n stateName,\n decisionName = 'stop',\n reasonText,\n dynamic = false,\n mustStopFlag = false,\n blockReason = 'none',\n blockedUntilStamp = now.stamp,\n}) {\n desiredCharging = false;\n desiredPermission = false;\n desiredLatch = false;\n desiredCurrentA = params.currentMinA;\n\n state = stateName;\n currentDecision = decisionName;\n dynamicOk = dynamic;\n mustStop = mustStopFlag;\n nextBlockReason = blockReason;\n nextBlockedUntilStamp = blockedUntilStamp;\n\n if (reasonText) reason.push(reasonText);\n}\n\nfunction setOn({\n stateName,\n decisionName = 'hold',\n currentA = params.currentMinA,\n reasonText,\n dynamic = true,\n mustStopFlag = false,\n}) {\n desiredCharging = true;\n desiredPermission = true;\n desiredLatch = true;\n desiredCurrentA = clamp(\n currentA,\n params.currentMinA,\n Math.max(params.currentMinA, derived.physicalCapA || params.currentMinA)\n );\n\n state = stateName;\n currentDecision = decisionName;\n dynamicOk = dynamic;\n mustStop = mustStopFlag;\n\n if (reasonText) reason.push(reasonText);\n}\n\n// =====================================================\n// SESSION MEMORY SIDE EFFECTS\n// =====================================================\nif (nightEvcs?.nightSessionNeedsCapture) {\n setNightSessionStart = true;\n}\n\nif (nightEvcs?.nightSessionNeedsClear) {\n clearNightSessionStart = true;\n}\n\n// =====================================================\n// MANUAL LOCAL CHARGE SEMANTICS\n// IMPORTANT:\n// force_charge_now = bypass fenêtres pour la charge locale,\n// sans imposer le réseau.\n// =====================================================\nconst manualChargeBypassWindows = !!context.manualChargeBypassWindows;\nconst manualChargeLocalRequested = !!context.manualChargeLocalRequested;\n\nconst manualLocalChargeAllowed =\n !!strategy.forceChargeRequested &&\n manualChargeBypassWindows &&\n manualChargeLocalRequested &&\n !safeguards.mustStopHard &&\n !!derived.canRunAtMinCurrent;\n\n// =====================================================\n// EVCS NUMERIC NO-DEMAND FILTER\n// Objectif:\n// détecter \"voiture branchée + session armée + commande claire\n// mais plus de demande réelle\" sans seuils fixes bruts.\n// IMPORTANT:\n// on n'utilise PAS le PV comme critère.\n// =====================================================\nconst prevDeliveryRatioEma = finiteOr(history.evcsDeliveryRatioEma, NaN);\nconst prevDeliveryNoiseEma = finiteOr(history.evcsDeliveryNoiseEma, NaN);\nconst prevNoDemandScoreEma = finiteOr(history.evcsNoDemandScoreEma, NaN);\nconst prevNoDemandSemanticActive = !!history.evcsNoDemandSemanticActive;\nconst prevNoDemandSinceMs = finiteOr(history.evcsNoDemandSinceMs, NaN);\nconst prevNoDemandFilterEvalMs = finiteOr(history.evcsNoDemandFilterEvalMs, NaN);\n\nconst noDemandDtSec =\n Number.isFinite(prevNoDemandFilterEvalMs) && Number.isFinite(nowMs)\n ? clamp((nowMs - prevNoDemandFilterEvalMs) / 1000, 1, 600)\n : 60;\n\n// Constantes dynamiques du filtre = temps de réponse du filtre,\n// pas des seuils métiers bruts.\nconst deliveryTauSec = Math.max(15, finiteOr(params.evcsDeliveryTauSec, 45));\nconst noiseTauSec = Math.max(deliveryTauSec, finiteOr(params.evcsDeliveryNoiseTauSec, 180));\nconst scoreTauSec = Math.max(20, finiteOr(params.evcsNoDemandScoreTauSec, 150));\nconst armTauSec = Math.max(20, finiteOr(params.evcsNoDemandArmTauSec, 180));\n\nconst alphaDelivery = alphaFromTau(noDemandDtSec, deliveryTauSec);\nconst alphaNoise = alphaFromTau(noDemandDtSec, noiseTauSec);\nconst alphaScore = alphaFromTau(noDemandDtSec, scoreTauSec);\n\nconst acVoltageRef = finiteOr(derived.acVoltageRef, 230);\n\nconst commandCurrentA = clamp(\n finiteOr(\n derived.currentReferenceA,\n finiteOr(\n parsed.evcsCommandCurrentA,\n finiteOr(parsed.evcsSetCurrentA, finiteOr(params.currentMinA, 6))\n )\n ),\n 0,\n 32\n);\n\nconst expectedPowerW = Math.max(1, commandCurrentA * acVoltageRef);\n\nconst realCurrentA = Math.abs(\n finiteOr(\n parsed.evcsCurrentRealA,\n acVoltageRef > 180\n ? finiteOr(parsed.evcsAcPowerW, 0) / acVoltageRef\n : 0\n )\n);\n\nconst realPowerW = Math.max(0, finiteOr(parsed.evcsAcPowerW, 0));\n\nconst currentDeliveryRatioRaw = clamp(\n realCurrentA / Math.max(commandCurrentA, 0.1),\n 0,\n 1.25\n);\n\nconst powerDeliveryRatioRaw = clamp(\n realPowerW / expectedPowerW,\n 0,\n 1.25\n);\n\nconst deliveryRatioRaw = clamp(\n Math.max(currentDeliveryRatioRaw, powerDeliveryRatioRaw),\n 0,\n 1\n);\n\nconst deliveryRatioEma = ema(prevDeliveryRatioEma, deliveryRatioRaw, alphaDelivery);\n\nconst deliveryResidualAbs = Math.abs(deliveryRatioRaw - deliveryRatioEma);\nconst deliveryNoiseEma = ema(prevDeliveryNoiseEma, deliveryResidualAbs, alphaNoise);\n\nconst adaptiveQuietBand = clamp(\n 0.02 + 3.0 * deliveryNoiseEma,\n 0.02,\n 0.35\n);\n\nconst noDemandEvidenceRaw = clamp(\n (adaptiveQuietBand - deliveryRatioEma) / adaptiveQuietBand,\n 0,\n 1\n);\n\nconst sessionElapsedSec = Math.max(\n 0,\n finiteOr(lifecycle.minutesSinceStart, 0) * 60\n);\n\nconst sessionArmFactor = 1 - Math.exp(-sessionElapsedSec / armTauSec);\n\nconst currentMinRefA = Math.max(1, finiteOr(params.currentMinA, 6));\nconst commandStrength = commandCurrentA / currentMinRefA;\n\nconst commandFactor = clamp(\n sigmoid((commandStrength - 1.0) * 2.2),\n 0,\n 1\n);\n\n// Source effectivement livrable.\n// IMPORTANT:\n// on évite de conclure \"voiture pleine\" si en réalité la source n'est\n// pas disponible (ex: attente grid takeover).\nconst manualLocalNoDemandEligible =\n manualLocalChargeAllowed &&\n (\n context.sourceMode === 'solar_only' ||\n context.sourceMode === 'solar_potential' ||\n context.sourceMode === 'blocked'\n );\n\nconst sourceDeliverableForNoDemand =\n manualLocalNoDemandEligible\n ? true\n : (context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced')\n ? !!parsed.gridAvailable\n : (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential')\n ? !!conditions.solarHoldOk || !!conditions.solarStartOk\n : context.sourceMode === 'hybrid_probe'\n ? !!conditions.probeHoldGuardOk || !!conditions.probeSuccessNow || !!conditions.probeStartOk\n : context.sourceMode === 'battery_assist_night'\n ? !!conditions.batteryAssistNightHoldOk || !!conditions.batteryAssistNightStartOk\n : false;\n\nconst evcsSessionArmedForNoDemand =\n !!cloud.sessionObservedActive &&\n !!parsed.evcsVehicleConnected &&\n !!parsed.evcsChargingSwitchOn &&\n !!parsed.chargePermissionNow &&\n !!parsed.controlLatch &&\n commandCurrentA > 0 &&\n sourceDeliverableForNoDemand;\n\nconst noDemandScoreRaw = evcsSessionArmedForNoDemand\n ? clamp(\n noDemandEvidenceRaw * sessionArmFactor * commandFactor,\n 0,\n 1\n )\n : 0;\n\nconst noDemandScoreEma = ema(prevNoDemandScoreEma, noDemandScoreRaw, alphaScore);\n\nconst adaptiveDecisionCenter = clamp(\n 0.72 - 0.20 * deliveryNoiseEma,\n 0.55,\n 0.82\n);\n\nconst adaptiveDecisionBand = clamp(\n 0.16 + 0.80 * deliveryNoiseEma,\n 0.12,\n 0.34\n);\n\nconst noDemandScoreOn = clamp(\n adaptiveDecisionCenter + adaptiveDecisionBand / 2,\n 0.60,\n 0.95\n);\n\nconst noDemandScoreOff = clamp(\n adaptiveDecisionCenter - adaptiveDecisionBand / 2,\n 0.15,\n noDemandScoreOn - 0.05\n);\n\nlet evcsVehicleNoDemandSemantic = prevNoDemandSemanticActive;\n\nif (!evcsSessionArmedForNoDemand) {\n evcsVehicleNoDemandSemantic = false;\n} else if (prevNoDemandSemanticActive) {\n evcsVehicleNoDemandSemantic = noDemandScoreEma >= noDemandScoreOff;\n} else {\n evcsVehicleNoDemandSemantic = noDemandScoreEma >= noDemandScoreOn;\n}\n\nconst evcsNoDemandSinceMs =\n evcsVehicleNoDemandSemantic\n ? (\n prevNoDemandSemanticActive && Number.isFinite(prevNoDemandSinceMs)\n ? prevNoDemandSinceMs\n : nowMs\n )\n : null;\n\nconst evcsNoDemandSinceMinutes =\n Number.isFinite(evcsNoDemandSinceMs) && Number.isFinite(nowMs)\n ? Math.max(0, (nowMs - evcsNoDemandSinceMs) / 60000)\n : null;\n\nconst vehicleDemandFilter = {\n dtSec: round3(noDemandDtSec),\n\n commandCurrentA: round3(commandCurrentA),\n realCurrentA: round3(realCurrentA),\n expectedPowerW: round3(expectedPowerW),\n realPowerW: round3(realPowerW),\n\n currentDeliveryRatioRaw: round3(currentDeliveryRatioRaw),\n powerDeliveryRatioRaw: round3(powerDeliveryRatioRaw),\n deliveryRatioRaw: round3(deliveryRatioRaw),\n deliveryRatioEma: round3(deliveryRatioEma),\n deliveryNoiseEma: round3(deliveryNoiseEma),\n\n adaptiveQuietBand: round3(adaptiveQuietBand),\n noDemandEvidenceRaw: round3(noDemandEvidenceRaw),\n\n sessionElapsedSec: round3(sessionElapsedSec),\n sessionArmFactor: round3(sessionArmFactor),\n commandFactor: round3(commandFactor),\n sourceDeliverableForNoDemand,\n evcsSessionArmedForNoDemand,\n\n noDemandScoreRaw: round3(noDemandScoreRaw),\n noDemandScoreEma: round3(noDemandScoreEma),\n\n noDemandScoreOn: round3(noDemandScoreOn),\n noDemandScoreOff: round3(noDemandScoreOff),\n\n evcsVehicleNoDemandSemantic,\n evcsNoDemandSinceMs: Number.isFinite(evcsNoDemandSinceMs) ? evcsNoDemandSinceMs : null,\n evcsNoDemandSinceMinutes: Number.isFinite(evcsNoDemandSinceMinutes)\n ? round3(evcsNoDemandSinceMinutes)\n : null,\n\n nextHistory: {\n evcsDeliveryRatioEma: round3(deliveryRatioEma),\n evcsDeliveryNoiseEma: round3(deliveryNoiseEma),\n evcsNoDemandScoreEma: round3(noDemandScoreEma),\n evcsNoDemandSemanticActive: evcsVehicleNoDemandSemantic,\n evcsNoDemandSinceMs: Number.isFinite(evcsNoDemandSinceMs) ? evcsNoDemandSinceMs : null,\n evcsNoDemandFilterEvalMs: nowMs\n }\n};\n\n// =====================================================\n// DECISION TREE\n// =====================================================\nif (!parsed.controllerEnabled) {\n desiredCharging = parsed.evcsChargingSwitchOn;\n desiredPermission = parsed.chargePermissionNow;\n desiredLatch = parsed.controlLatch;\n desiredCurrentA = clamp(\n derived.currentReferenceA || params.currentMinA,\n params.currentMinA,\n params.currentMaxAutoA\n );\n\n state = 'controller_disabled';\n currentDecision = 'hold';\n dynamicOk = false;\n mustStop = false;\n reason.push('Contrôleur désactivé');\n}\n\nelse if (strategy.isStopMode) {\n setOff({\n stateName: 'stopped_user',\n decisionName: 'stop_stop_mode',\n reasonText: 'Arrêt manuel via Stratégie STOP',\n dynamic: false,\n mustStopFlag: false,\n blockReason: 'manual_stop',\n blockedUntilStamp: now.stamp,\n });\n}\n\nelse if (parsed.evcsModeIsAuto) {\n setOff({\n stateName: 'mode_auto_conflict',\n decisionName: 'stop_auto_mode_conflict',\n reasonText: 'Borne en mode Auto interne',\n dynamic: false,\n mustStopFlag: true,\n blockReason: 'mode_auto_conflict',\n blockedUntilStamp: now.stamp,\n });\n}\n\nelse if (!parsed.evcsDeviceConnected || !parsed.evcsVehicleConnected) {\n setOff({\n stateName: 'vehicle_not_connected',\n decisionName: 'stop_not_connected',\n reasonText: 'Véhicule ou borne non connecté',\n dynamic: false,\n mustStopFlag: false,\n blockReason: 'vehicle_not_connected',\n blockedUntilStamp: now.stamp,\n });\n}\n\nelse if (safeguards.mustStopHard) {\n setOff({\n stateName:\n derived.anyVebusLowBattery ? 'vebus_low_battery_hard_stop' :\n derived.vebusDcVoltageHardLow ? 'vebus_dc_voltage_hard_stop' :\n derived.vebusAcOutHardReached ? 'vebus_ac_hard_stop' :\n safeguards.batteryCurrentHardExceeded ? 'battery_current_hard_stop' :\n 'hard_stop',\n decisionName: 'stop_hard',\n reasonText: 'Hard stop de protection',\n dynamic: false,\n mustStopFlag: true,\n blockReason:\n safeguards.batteryCurrentHardExceeded ? 'battery_current_hard_stop' :\n derived.vebusDcVoltageHardLow ? 'vebus_dc_voltage_stop' :\n derived.anyVebusLowBattery ? 'vebus_low_battery_stop' :\n derived.vebusAcOutHardReached ? 'vebus_ac_hard_stop' :\n 'battery_protection_stop',\n blockedUntilStamp: formatParisStampFromMs(nowMs + 15 * 60000),\n });\n}\n\n// =====================================================\n// ACTIVE SESSION BRANCH\n// =====================================================\nelse if (cloud.sessionObservedActive) {\n if (cloud.enteringCloudDebt) {\n setCloudDebtStartedAt = true;\n }\n\n if (conditions.shouldStopOverloadAtMin) {\n setOff({\n stateName: 'vebus_overload_stop_min_current',\n decisionName: 'stop',\n reasonText: 'Arrêt: surcharge convertisseur persistante au courant mini',\n dynamic: false,\n mustStopFlag: true,\n blockReason: 'vebus_overload_persistent',\n blockedUntilStamp: formatParisStampFromMs(\n nowMs + batteryTune.global.overloadStopAfterSeconds * 1000\n ),\n });\n }\n\n else if (safeguards.gridBatteryProtectionStop) {\n setOff({\n stateName: 'stop_grid_battery_guard',\n decisionName: 'stop',\n reasonText: `Arrêt charge réseau: batterie trop sollicitée (${round1(parsed.batteryCurrentA)}A)`,\n dynamic: false,\n mustStopFlag: true,\n blockReason: 'grid_battery_guard',\n blockedUntilStamp: formatParisStampFromMs(nowMs + 20 * 60000),\n });\n }\n\n else if (conditions.probeFailedNow) {\n setOff({\n stateName: 'probe_failed_lock',\n decisionName: 'stop',\n reasonText: 'Mode sonde échoué',\n dynamic: false,\n mustStopFlag: true,\n blockReason: 'probe_failed',\n blockedUntilStamp: formatParisStampFromMs(nowMs + params.probeRetryMinutes * 60000),\n });\n }\n\n else if (derived.physicalCapA < params.currentMinA) {\n if (lifecycle.minRunElapsed) {\n setOff({\n stateName: 'stop_power_cap',\n decisionName: 'stop_power_cap',\n reasonText: 'Capacité système insuffisante',\n dynamic: false,\n mustStopFlag: true,\n blockReason: 'system_capacity_stop',\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\n });\n } else {\n setOn({\n stateName: 'hold_min_run_power_cap',\n decisionName: 'hold',\n currentA: params.currentMinA,\n reasonText: \"Maintien jusqu'au temps mini ON\",\n dynamic: false,\n mustStopFlag: false,\n });\n }\n }\n\n else if (conditions.probeWindowActive && !conditions.probeSuccessNow) {\n if (!conditions.probeHoldGuardOk) {\n setOff({\n stateName: 'probe_stop_guard',\n decisionName: 'stop',\n reasonText: 'Sonde interrompue: garde-fous non respectés',\n dynamic: false,\n mustStopFlag: true,\n blockReason: 'probe_failed',\n blockedUntilStamp: formatParisStampFromMs(nowMs + params.probeRetryMinutes * 60000),\n });\n } else {\n setOn({\n stateName: 'probe_hold',\n decisionName: 'hold',\n currentA: Math.min(params.probeCurrentA, derived.physicalCapA),\n reasonText: `Mode sonde actif à ${round1(Math.min(params.probeCurrentA, derived.physicalCapA))}A`,\n dynamic: true,\n mustStopFlag: false,\n });\n }\n }\n\n else {\n const activeSourceMode =\n conditions.probeSuccessNow\n ? 'solar_only'\n : context.sourceMode;\n\n const holdConditionsOk =\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced')\n ? conditions.gridHoldOk\n : (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential')\n ? conditions.solarHoldOk\n : activeSourceMode === 'hybrid_probe'\n ? conditions.probeHoldGuardOk\n : activeSourceMode === 'battery_assist_night'\n ? conditions.batteryAssistNightHoldOk\n : false;\n\n const manualLocalHoldOverride =\n manualLocalChargeAllowed &&\n (\n activeSourceMode === 'solar_only' ||\n activeSourceMode === 'solar_potential' ||\n activeSourceMode === 'blocked'\n );\n\n const effectiveHoldConditionsOk =\n holdConditionsOk || manualLocalHoldOverride;\n\n // =================================================\n // VEHICLE FULL / PAUSED - NUMERIC FILTER DECISION\n // IMPORTANT:\n // - uniquement en session active\n // - uniquement si la source est réellement livrable\n // - sans utiliser le PV comme critère\n // =================================================\n if (\n vehicleDemandFilter.evcsVehicleNoDemandSemantic &&\n vehicleDemandFilter.sourceDeliverableForNoDemand\n ) {\n setOff({\n stateName: 'vehicle_full_or_paused',\n decisionName: 'stop_vehicle_full_or_paused',\n reasonText: 'Véhicule branché mais ne demande plus réellement de puissance',\n dynamic: false,\n mustStopFlag: false,\n blockReason: 'none',\n blockedUntilStamp: now.stamp,\n });\n\n reason.push(`DeliveryRatioEma=${vehicleDemandFilter.deliveryRatioEma}`);\n reason.push(`NoiseEma=${vehicleDemandFilter.deliveryNoiseEma}`);\n reason.push(`NoDemandScore=${vehicleDemandFilter.noDemandScoreEma}`);\n reason.push(`ScoreOn=${vehicleDemandFilter.noDemandScoreOn}`);\n reason.push(`ScoreOff=${vehicleDemandFilter.noDemandScoreOff}`);\n\n if (vehicleDemandFilter.evcsNoDemandSinceMinutes !== null) {\n reason.push(`NoDemand depuis ${round1(vehicleDemandFilter.evcsNoDemandSinceMinutes)} min`);\n }\n }\n\n else if (manualLocalHoldOverride && activeSourceMode === 'blocked') {\n setOn({\n stateName: 'charging_hold_manual_local',\n decisionName: 'hold',\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\n reasonText: 'Maintien manuel local hors fenêtre sans réseau',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (activeSourceMode === 'blocked') {\n if (lifecycle.minRunElapsed) {\n setOff({\n stateName: 'stop_source_lost',\n decisionName: 'stop',\n reasonText: 'Source autorisée perdue',\n dynamic: false,\n mustStopFlag: true,\n blockReason: context.profile === 'outside_window' ? 'outside_window' : 'source_unavailable',\n blockedUntilStamp:\n context.profile === 'outside_window' && Number.isFinite(locks.nextAllowedWindowStartMs)\n ? formatParisStampFromMs(locks.nextAllowedWindowStartMs)\n : formatParisStampFromMs(nowMs + params.minStopMinutes * 60000),\n });\n } else {\n setOn({\n stateName: 'hold_min_run_source_lost',\n decisionName: 'hold',\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\n reasonText: \"Source perdue mais maintien jusqu'au mini ON\",\n dynamic: false,\n mustStopFlag: false,\n });\n }\n }\n\n else if (\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&\n !parsed.gridAvailable\n ) {\n if (lifecycle.minRunElapsed) {\n setOff({\n stateName:\n context.profile === 'night_hc'\n ? 'night_wait_grid_takeover_runtime'\n : 'wait_grid_source_runtime',\n decisionName: 'hold',\n reasonText:\n context.profile === 'night_hc'\n ? 'Relais réseau demandé pendant la session nuit EVCS'\n : 'Source réseau demandée mais pas encore disponible',\n dynamic: false,\n mustStopFlag: false,\n blockReason:\n context.profile === 'night_hc' ? 'grid_hc_unavailable' : 'grid_unavailable',\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\n });\n } else {\n setOn({\n stateName: 'hold_min_run_wait_grid_takeover',\n decisionName: 'hold',\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\n reasonText: \"Maintien jusqu'au mini ON en attente du réseau\",\n dynamic: false,\n mustStopFlag: false,\n });\n }\n }\n\n else if (!effectiveHoldConditionsOk) {\n if (lifecycle.minRunElapsed) {\n setOff({\n stateName: 'stop_conditions_lost',\n decisionName: 'stop',\n reasonText: 'Conditions de maintien perdues',\n dynamic: false,\n mustStopFlag: true,\n blockReason: 'hold_conditions_lost',\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\n });\n } else {\n setOn({\n stateName: 'hold_min_run',\n decisionName: 'hold',\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\n reasonText: \"Maintien jusqu'au temps mini ON\",\n dynamic: true,\n mustStopFlag: false,\n });\n }\n }\n\n else if ((derived.currentReferenceA > derived.physicalCapA) && lifecycle.currentCooldownElapsed) {\n setOn({\n stateName: 'charging_step_down_system_cap',\n decisionName: 'step_down',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - Math.max(params.currentStepDownA, 2),\n derived.physicalCapA\n )\n ),\n reasonText: 'Réduction pour respecter la capacité système',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (conditions.shouldStepDownOverload) {\n setOn({\n stateName: 'charging_step_down_overload',\n decisionName: 'step_down_fast',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - Math.max(batteryTune.global.overloadStepDownA, params.currentStepDownA),\n derived.physicalCapA\n )\n ),\n reasonText: 'Baisse rapide: surcharge convertisseur',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (conditions.shouldStepDownVebusPower) {\n setOn({\n stateName: 'charging_step_down_vebus_power',\n decisionName: 'step_down_fast',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - Math.max(params.currentStepDownA + 1, 3),\n derived.physicalCapA\n )\n ),\n reasonText: 'Baisse rapide: puissance VE.Bus élevée',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (conditions.shouldStepDownVebusVoltage) {\n setOn({\n stateName: 'charging_step_down_dc_voltage',\n decisionName: 'step_down_fast',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - Math.max(params.currentStepDownA + 1, 3),\n derived.physicalCapA\n )\n ),\n reasonText: 'Baisse rapide: tension DC basse',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (\n activeSourceMode === 'battery_assist_night' &&\n conditions.batteryAssistNightShouldStepDown\n ) {\n setOn({\n stateName: 'charging_step_down_battery_assist_night',\n decisionName: 'step_down',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - params.currentStepDownA,\n derived.physicalCapA\n )\n ),\n reasonText:\n nightEvcs?.batteryRecoveryUrgent\n ? 'Réduction: approche départ, préparation takeover réseau'\n : 'Réduction: assistance batterie nuit devient trop coûteuse',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&\n conditions.gridShouldStepDownBattery\n ) {\n setOn({\n stateName:\n activeSourceMode === 'grid_forced'\n ? 'charging_step_down_grid_forced_guard'\n : 'charging_step_down_grid_guard',\n decisionName: 'step_down',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - params.currentStepDownA,\n derived.physicalCapA\n )\n ),\n reasonText:\n activeSourceMode === 'grid_forced'\n ? 'Réduction réseau forcé: la batterie commence à participer'\n : 'Réduction réseau: la batterie commence à participer',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (\n (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&\n conditions.solarShouldStepDownCloudDebt\n ) {\n setOn({\n stateName: 'charging_step_down_cloud_debt',\n decisionName: 'step_down',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - Math.max(params.currentStepDownA, 2),\n derived.physicalCapA\n )\n ),\n reasonText: 'Réduction: dette nuage trop élevée',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (\n (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&\n conditions.solarShouldStepDownBattery\n ) {\n setOn({\n stateName: 'charging_step_down_battery',\n decisionName: 'step_down',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - params.currentStepDownA,\n derived.physicalCapA\n )\n ),\n reasonText: 'Réduction: batterie trop sollicitée',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&\n conditions.gridShouldStepUp\n ) {\n setOn({\n stateName:\n activeSourceMode === 'grid_forced'\n ? (\n nightEvcs?.batteryRecoveryUrgent\n ? 'night_departure_deadline_grid_forced'\n : 'charging_step_up_grid_forced'\n )\n : 'charging_step_up_grid_hc',\n decisionName: 'step_up',\n currentA: clamp(\n derived.currentReferenceA + params.currentStepUpA,\n params.currentMinA,\n derived.physicalCapA\n ),\n reasonText:\n activeSourceMode === 'grid_forced'\n ? (\n nightEvcs?.batteryRecoveryUrgent\n ? 'Hausse prudente: priorité départ, réseau forcé'\n : 'Hausse prudente du courant en charge réseau forcée'\n )\n : 'Hausse prudente du courant en HC réseau',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (\n activeSourceMode === 'battery_assist_night' &&\n conditions.batteryAssistNightShouldStepUp\n ) {\n setOn({\n stateName: 'charging_step_up_battery_assist_night',\n decisionName: 'step_up',\n currentA: clamp(\n derived.currentReferenceA + params.currentStepUpA,\n params.currentMinA,\n derived.physicalCapA\n ),\n reasonText: 'Hausse prudente du courant en assistance batterie nuit',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (\n (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&\n conditions.solarShouldStepUp\n ) {\n setOn({\n stateName:\n conditions.probeSuccessNow\n ? 'probe_success_step_up'\n : (modes.batteryPriorityMode\n ? 'charging_step_up_battery'\n : 'charging_step_up_forecast'),\n decisionName: 'step_up',\n currentA: clamp(\n derived.currentReferenceA + params.currentStepUpA,\n params.currentMinA,\n derived.physicalCapA\n ),\n reasonText: 'Hausse prudente du courant',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else {\n setOn({\n stateName:\n manualLocalHoldOverride ? 'charging_hold_manual_local' :\n conditions.probeSuccessNow ? 'probe_success_hold' :\n activeSourceMode === 'battery_assist_night' ? 'charging_hold_battery_assist_night' :\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') ? 'charging_hold_grid' :\n cloud.cloudDebtActive ? 'charging_hold_cloud_tolerance' :\n 'charging_hold',\n decisionName: 'hold',\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\n reasonText:\n manualLocalHoldOverride\n ? 'Maintien manuel local du courant'\n : activeSourceMode === 'battery_assist_night'\n ? 'Maintien du courant en assistance batterie nuit'\n : 'Maintien du courant',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n }\n}\n\n// =====================================================\n// START BRANCH\n// =====================================================\nelse {\n const startConditionsOk =\n (context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced')\n ? conditions.gridStartOk\n : (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential')\n ? conditions.solarStartOk\n : context.sourceMode === 'hybrid_probe'\n ? conditions.probeStartOk\n : context.sourceMode === 'battery_assist_night'\n ? conditions.batteryAssistNightStartOk\n : false;\n\n const manualLocalStartOverride =\n manualLocalChargeAllowed &&\n (\n context.sourceMode === 'solar_only' ||\n context.sourceMode === 'solar_potential' ||\n context.sourceMode === 'blocked'\n );\n\n if (!derived.canRunAtMinCurrent) {\n setOff({\n stateName: 'blocked_by_capacity_min_current',\n decisionName: 'hold',\n reasonText: 'Capacité insuffisante pour courant mini',\n dynamic: false,\n mustStopFlag: false,\n blockReason: 'system_capacity_stop',\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\n });\n }\n\n else if (manualLocalStartOverride) {\n setOn({\n stateName: 'start_manual_local_min_current',\n decisionName: 'start',\n currentA: params.currentMinA,\n reasonText:\n context.profile === 'outside_window'\n ? 'Démarrage manuel local hors fenêtre sans réseau'\n : 'Démarrage manuel local sans réseau',\n dynamic: true,\n mustStopFlag: false,\n });\n\n nextBlockReason = 'none';\n nextBlockedUntilStamp = now.stamp;\n setProbeStartedAt = false;\n }\n\n // P8 : véhicule plein en session nuit → veille auto-entretenue.\n // Le state est relu au cycle suivant (prev_surplus_state) : il maintient\n // vehicleChargeCompleted jusqu'au débranchement ou à la fin de fenêtre.\n else if (context.profile === 'night_hc' && nightEvcs?.vehicleChargeCompleted) {\n setOff({\n stateName: 'night_session_done_idle',\n decisionName: 'hold',\n reasonText: 'Charge véhicule terminée : veille nuit, réseau relâché (recharge batterie si sous cible)',\n dynamic: false,\n mustStopFlag: false,\n blockReason: 'none',\n blockedUntilStamp: now.stamp,\n });\n }\n\n else if (context.sourceMode === 'blocked') {\n setOff({\n stateName:\n context.profile === 'outside_window'\n ? 'blocked_outside_window'\n : (context.profile === 'night_hc'\n ? 'night_no_grid_hc_lock'\n : 'idle_wait_source'),\n decisionName: 'hold',\n reasonText:\n context.profile === 'outside_window'\n ? 'Hors fenêtre préférée sans source suffisante'\n : context.profile === 'night_hc'\n ? 'Nuit sans autorisation HC réseau'\n : 'Source non autorisée ou insuffisante',\n dynamic: false,\n mustStopFlag: false,\n blockReason:\n context.profile === 'outside_window'\n ? 'outside_window'\n : context.profile === 'night_hc'\n ? 'grid_hc_unavailable'\n : 'source_unavailable',\n blockedUntilStamp:\n Number.isFinite(locks.nextAllowedWindowStartMs)\n ? formatParisStampFromMs(locks.nextAllowedWindowStartMs)\n : formatParisStampFromMs(nowMs + 15 * 60000),\n });\n }\n\n else if (\n (context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced') &&\n !parsed.gridAvailable\n ) {\n setOff({\n stateName:\n context.profile === 'night_hc'\n ? 'night_wait_grid_takeover'\n : 'wait_grid_source',\n decisionName: 'hold',\n reasonText:\n context.profile === 'night_hc'\n ? (\n nightEvcs?.batteryRecoveryUrgent\n ? 'Réseau demandé: urgence recharge batterie avant départ'\n : 'Prise de relais réseau demandée pour session nuit EVCS'\n )\n : 'Source réseau demandée mais pas encore disponible',\n dynamic: false,\n mustStopFlag: false,\n blockReason:\n context.profile === 'night_hc'\n ? 'grid_hc_unavailable'\n : 'grid_unavailable',\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\n });\n }\n\n else if (locks.startBlockedByReason) {\n setOff({\n stateName: 'wait_reason_lock',\n decisionName: 'hold',\n reasonText: `Blocage anti-cliquetis: ${history.lastBlockReason}`,\n dynamic: false,\n mustStopFlag: false,\n blockReason: history.lastBlockReason,\n blockedUntilStamp: history.blockedUntilAt || now.stamp,\n });\n }\n\n else if (!startConditionsOk) {\n setOff({\n stateName: 'idle_wait_conditions',\n decisionName: 'hold',\n reasonText:\n context.sourceMode === 'battery_assist_night'\n ? 'Conditions de démarrage nuit batterie non réunies'\n : 'Conditions de démarrage non réunies',\n dynamic: false,\n mustStopFlag: false,\n blockReason: 'none',\n blockedUntilStamp: now.stamp,\n });\n }\n\n else if (!lifecycle.minStopElapsed) {\n setOff({\n stateName: 'wait_min_stop',\n decisionName: 'hold',\n reasonText: 'Temps mini OFF non écoulé',\n dynamic: false,\n mustStopFlag: false,\n blockReason: 'none',\n blockedUntilStamp: now.stamp,\n });\n }\n\n else {\n const startCurrentA =\n context.sourceMode === 'hybrid_probe'\n ? clamp(params.probeCurrentA, params.currentMinA, derived.physicalCapA)\n : params.currentMinA;\n\n setOn({\n stateName:\n context.sourceMode === 'grid_forced' ? (\n nightEvcs?.batteryRecoveryUrgent\n ? 'night_departure_deadline_grid_forced'\n : 'start_grid_forced_min_current'\n ) :\n context.sourceMode === 'grid_hc_allowed' ? 'start_grid_hc_min_current' :\n context.sourceMode === 'battery_assist_night' ? 'start_battery_assist_night_min_current' :\n context.sourceMode === 'hybrid_probe' ? 'probe_start_6a' :\n context.sourceMode === 'solar_potential' ? 'start_forecast_solar_potential' :\n modes.batteryFullHarvestMode ? 'start_min_current_battery_full_harvest' :\n modes.forecastCommitMode ? 'start_min_current_forecast_commit' :\n 'start_min_current',\n decisionName: 'start',\n currentA: startCurrentA,\n reasonText:\n context.sourceMode === 'grid_forced'\n ? (\n nightEvcs?.batteryRecoveryUrgent\n ? 'Démarrage réseau forcé: priorité départ / recharge batterie'\n : 'Démarrage en charge réseau forcée'\n )\n : context.sourceMode === 'grid_hc_allowed'\n ? 'Démarrage en fenêtre HC réseau'\n : context.sourceMode === 'battery_assist_night'\n ? 'Démarrage nuit en assistance batterie contrôlée'\n : context.sourceMode === 'hybrid_probe'\n ? 'Démarrage en mode sonde'\n : context.sourceMode === 'solar_potential'\n ? 'Démarrage par potentiel solaire confirmé'\n : modes.batteryFullHarvestMode\n ? 'Démarrage harvest solaire'\n : modes.forecastCommitMode\n ? 'Démarrage forecast commit'\n : 'Démarrage prudent à courant mini',\n dynamic: true,\n mustStopFlag: false,\n });\n\n nextBlockReason = 'none';\n nextBlockedUntilStamp = now.stamp;\n setProbeStartedAt = context.sourceMode === 'hybrid_probe';\n }\n}\n\n// =====================================================\n// CONTEXT REASONS\n// =====================================================\nif (nightEvcs?.nightSessionActive) {\n reason.push(\n `Nuit EVCS: target=${round1(nightEvcs.batteryMorningTargetPct)}% | trigger réseau=${round1(nightEvcs.gridTakeoverTriggerPct)}%`\n );\n\n if (Number.isFinite(nightEvcs.nightSessionStartSocPct)) {\n reason.push(\n `SOC départ session nuit=${round1(nightEvcs.nightSessionStartSocPct)}% | décharge autorisée=${round1(nightEvcs.maxBatteryDischargePoints)} pts`\n );\n }\n\n if (nightEvcs.batteryRecoveryUrgent) {\n reason.push(\"Urgence départ: il faut anticiper la reprise réseau\");\n }\n}\n\nif (manualLocalChargeAllowed) {\n reason.push(`ManualLocalCharge=on`);\n reason.push(`ManualLocalProfile=${context.profile}`);\n reason.push(`ManualLocalSource=${context.sourceMode}`);\n}\n\nreason.push(`VehicleDemandFilter delivery=${vehicleDemandFilter.deliveryRatioEma}`);\nreason.push(`VehicleDemandFilter noise=${vehicleDemandFilter.deliveryNoiseEma}`);\nreason.push(`VehicleDemandFilter score=${vehicleDemandFilter.noDemandScoreEma}`);\nreason.push(`VehicleDemandFilter noDemand=${vehicleDemandFilter.evcsVehicleNoDemandSemantic ? 'on' : 'off'}`);\n\n// =====================================================\n// OUTPUT\n// =====================================================\nreturn [{\n json: {\n ...prev,\n vehicleDemandFilter,\n decision: {\n desiredCharging,\n desiredPermission,\n desiredLatch,\n desiredCurrentA,\n\n state,\n currentDecision,\n dynamicOk,\n mustStop,\n reason,\n\n nextBlockReason,\n nextBlockedUntilStamp,\n\n setCloudDebtStartedAt,\n setProbeStartedAt,\n\n setNightSessionStart,\n clearNightSessionStart,\n\n notifyWanted,\n notifyTitle,\n notifyMessage,\n }\n }\n}];\n" + "jsCode": "function clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nfunction round1(v) {\n return Math.round(v * 10) / 10;\n}\n\nfunction round3(v) {\n return Math.round(v * 1000) / 1000;\n}\n\nfunction to2(n) {\n return String(n).padStart(2, '0');\n}\n\nfunction finiteOr(v, fallback) {\n return Number.isFinite(v) ? v : fallback;\n}\n\nfunction sigmoid(x) {\n return 1 / (1 + Math.exp(-x));\n}\n\nfunction alphaFromTau(dtSec, tauSec) {\n const dt = Math.max(1, finiteOr(dtSec, 60));\n const tau = Math.max(1, finiteOr(tauSec, 60));\n return Math.min(1, Math.max(0.001, 1 - Math.exp(-dt / tau)));\n}\n\nfunction ema(prev, sample, alpha) {\n if (!Number.isFinite(sample)) return Number.isFinite(prev) ? prev : 0;\n if (!Number.isFinite(prev)) return sample;\n return prev + alpha * (sample - prev);\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 now,\n nowMs,\n parsed,\n params,\n history,\n strategy,\n lifecycle,\n context,\n derived,\n locks,\n cloud,\n modes,\n safeguards,\n conditions,\n batteryTune,\n nightEvcs,\n} = prev;\n\n// =====================================================\n// DECISION INIT\n// =====================================================\nlet desiredCharging = false;\nlet desiredPermission = false;\nlet desiredLatch = false;\nlet desiredCurrentA = params.currentMinA;\n\nlet state = 'idle';\nlet currentDecision = 'hold';\nlet dynamicOk = false;\nlet mustStop = false;\n\nlet reason = [];\n\nlet setCloudDebtStartedAt = false;\nlet setProbeStartedAt = false;\n\n// EVCS night session memory\nlet setNightSessionStart = false;\nlet clearNightSessionStart = false;\n\nlet notifyWanted = false;\nlet notifyTitle = 'Victron EVCS Surplus';\nlet notifyMessage = '';\n\nlet nextBlockReason = 'none';\nlet nextBlockedUntilStamp = now.stamp;\n\n// =====================================================\n// HELPERS\n// =====================================================\nfunction setOff({\n stateName,\n decisionName = 'stop',\n reasonText,\n dynamic = false,\n mustStopFlag = false,\n blockReason = 'none',\n blockedUntilStamp = now.stamp,\n}) {\n desiredCharging = false;\n desiredPermission = false;\n desiredLatch = false;\n desiredCurrentA = params.currentMinA;\n\n state = stateName;\n currentDecision = decisionName;\n dynamicOk = dynamic;\n mustStop = mustStopFlag;\n nextBlockReason = blockReason;\n nextBlockedUntilStamp = blockedUntilStamp;\n\n if (reasonText) reason.push(reasonText);\n}\n\nfunction setOn({\n stateName,\n decisionName = 'hold',\n currentA = params.currentMinA,\n reasonText,\n dynamic = true,\n mustStopFlag = false,\n}) {\n desiredCharging = true;\n desiredPermission = true;\n desiredLatch = true;\n desiredCurrentA = clamp(\n currentA,\n params.currentMinA,\n Math.max(params.currentMinA, derived.physicalCapA || params.currentMinA)\n );\n\n state = stateName;\n currentDecision = decisionName;\n dynamicOk = dynamic;\n mustStop = mustStopFlag;\n\n if (reasonText) reason.push(reasonText);\n}\n\n// =====================================================\n// SESSION MEMORY SIDE EFFECTS\n// =====================================================\nif (nightEvcs?.nightSessionNeedsCapture) {\n setNightSessionStart = true;\n}\n\nif (nightEvcs?.nightSessionNeedsClear) {\n clearNightSessionStart = true;\n}\n\n// =====================================================\n// MANUAL LOCAL CHARGE SEMANTICS\n// IMPORTANT:\n// force_charge_now = bypass fenêtres pour la charge locale,\n// sans imposer le réseau.\n// =====================================================\nconst manualChargeBypassWindows = !!context.manualChargeBypassWindows;\nconst manualChargeLocalRequested = !!context.manualChargeLocalRequested;\n\nconst manualLocalChargeAllowed =\n !!strategy.forceChargeRequested &&\n manualChargeBypassWindows &&\n manualChargeLocalRequested &&\n !safeguards.mustStopHard &&\n !!derived.canRunAtMinCurrent;\n\n// =====================================================\n// EVCS NUMERIC NO-DEMAND FILTER\n// Objectif:\n// détecter \"voiture branchée + session armée + commande claire\n// mais plus de demande réelle\" sans seuils fixes bruts.\n// IMPORTANT:\n// on n'utilise PAS le PV comme critère.\n// =====================================================\nconst prevDeliveryRatioEma = finiteOr(history.evcsDeliveryRatioEma, NaN);\nconst prevDeliveryNoiseEma = finiteOr(history.evcsDeliveryNoiseEma, NaN);\nconst prevNoDemandScoreEma = finiteOr(history.evcsNoDemandScoreEma, NaN);\nconst prevNoDemandSemanticActive = !!history.evcsNoDemandSemanticActive;\nconst prevNoDemandSinceMs = finiteOr(history.evcsNoDemandSinceMs, NaN);\nconst prevNoDemandFilterEvalMs = finiteOr(history.evcsNoDemandFilterEvalMs, NaN);\n\nconst noDemandDtSec =\n Number.isFinite(prevNoDemandFilterEvalMs) && Number.isFinite(nowMs)\n ? clamp((nowMs - prevNoDemandFilterEvalMs) / 1000, 1, 600)\n : 60;\n\n// Constantes dynamiques du filtre = temps de réponse du filtre,\n// pas des seuils métiers bruts.\nconst deliveryTauSec = Math.max(15, finiteOr(params.evcsDeliveryTauSec, 45));\nconst noiseTauSec = Math.max(deliveryTauSec, finiteOr(params.evcsDeliveryNoiseTauSec, 180));\nconst scoreTauSec = Math.max(20, finiteOr(params.evcsNoDemandScoreTauSec, 150));\nconst armTauSec = Math.max(20, finiteOr(params.evcsNoDemandArmTauSec, 180));\n\nconst alphaDelivery = alphaFromTau(noDemandDtSec, deliveryTauSec);\nconst alphaNoise = alphaFromTau(noDemandDtSec, noiseTauSec);\nconst alphaScore = alphaFromTau(noDemandDtSec, scoreTauSec);\n\nconst acVoltageRef = finiteOr(derived.acVoltageRef, 230);\n\nconst commandCurrentA = clamp(\n finiteOr(\n derived.currentReferenceA,\n finiteOr(\n parsed.evcsCommandCurrentA,\n finiteOr(parsed.evcsSetCurrentA, finiteOr(params.currentMinA, 6))\n )\n ),\n 0,\n 32\n);\n\nconst expectedPowerW = Math.max(1, commandCurrentA * acVoltageRef);\n\nconst realCurrentA = Math.abs(\n finiteOr(\n parsed.evcsCurrentRealA,\n acVoltageRef > 180\n ? finiteOr(parsed.evcsAcPowerW, 0) / acVoltageRef\n : 0\n )\n);\n\nconst realPowerW = Math.max(0, finiteOr(parsed.evcsAcPowerW, 0));\n\nconst currentDeliveryRatioRaw = clamp(\n realCurrentA / Math.max(commandCurrentA, 0.1),\n 0,\n 1.25\n);\n\nconst powerDeliveryRatioRaw = clamp(\n realPowerW / expectedPowerW,\n 0,\n 1.25\n);\n\nconst deliveryRatioRaw = clamp(\n Math.max(currentDeliveryRatioRaw, powerDeliveryRatioRaw),\n 0,\n 1\n);\n\nconst deliveryRatioEma = ema(prevDeliveryRatioEma, deliveryRatioRaw, alphaDelivery);\n\nconst deliveryResidualAbs = Math.abs(deliveryRatioRaw - deliveryRatioEma);\nconst deliveryNoiseEma = ema(prevDeliveryNoiseEma, deliveryResidualAbs, alphaNoise);\n\nconst adaptiveQuietBand = clamp(\n 0.02 + 3.0 * deliveryNoiseEma,\n 0.02,\n 0.35\n);\n\nconst noDemandEvidenceRaw = clamp(\n (adaptiveQuietBand - deliveryRatioEma) / adaptiveQuietBand,\n 0,\n 1\n);\n\nconst sessionElapsedSec = Math.max(\n 0,\n finiteOr(lifecycle.minutesSinceStart, 0) * 60\n);\n\nconst sessionArmFactor = 1 - Math.exp(-sessionElapsedSec / armTauSec);\n\nconst currentMinRefA = Math.max(1, finiteOr(params.currentMinA, 6));\nconst commandStrength = commandCurrentA / currentMinRefA;\n\nconst commandFactor = clamp(\n sigmoid((commandStrength - 1.0) * 2.2),\n 0,\n 1\n);\n\n// Source effectivement livrable.\n// IMPORTANT:\n// on évite de conclure \"voiture pleine\" si en réalité la source n'est\n// pas disponible (ex: attente grid takeover).\nconst manualLocalNoDemandEligible =\n manualLocalChargeAllowed &&\n (\n context.sourceMode === 'solar_only' ||\n context.sourceMode === 'solar_potential' ||\n context.sourceMode === 'blocked'\n );\n\nconst sourceDeliverableForNoDemand =\n manualLocalNoDemandEligible\n ? true\n : (context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced')\n ? !!parsed.gridAvailable\n : (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential')\n ? !!conditions.solarHoldOk || !!conditions.solarStartOk\n : context.sourceMode === 'hybrid_probe'\n ? !!conditions.probeHoldGuardOk || !!conditions.probeSuccessNow || !!conditions.probeStartOk\n : context.sourceMode === 'battery_assist_night'\n ? !!conditions.batteryAssistNightHoldOk || !!conditions.batteryAssistNightStartOk\n : false;\n\nconst evcsSessionArmedForNoDemand =\n !!cloud.sessionObservedActive &&\n !!parsed.evcsVehicleConnected &&\n !!parsed.evcsChargingSwitchOn &&\n !!parsed.chargePermissionNow &&\n !!parsed.controlLatch &&\n commandCurrentA > 0 &&\n sourceDeliverableForNoDemand;\n\nconst noDemandScoreRaw = evcsSessionArmedForNoDemand\n ? clamp(\n noDemandEvidenceRaw * sessionArmFactor * commandFactor,\n 0,\n 1\n )\n : 0;\n\nconst noDemandScoreEma = ema(prevNoDemandScoreEma, noDemandScoreRaw, alphaScore);\n\nconst adaptiveDecisionCenter = clamp(\n 0.72 - 0.20 * deliveryNoiseEma,\n 0.55,\n 0.82\n);\n\nconst adaptiveDecisionBand = clamp(\n 0.16 + 0.80 * deliveryNoiseEma,\n 0.12,\n 0.34\n);\n\nconst noDemandScoreOn = clamp(\n adaptiveDecisionCenter + adaptiveDecisionBand / 2,\n 0.60,\n 0.95\n);\n\nconst noDemandScoreOff = clamp(\n adaptiveDecisionCenter - adaptiveDecisionBand / 2,\n 0.15,\n noDemandScoreOn - 0.05\n);\n\nlet evcsVehicleNoDemandSemantic = prevNoDemandSemanticActive;\n\nif (!evcsSessionArmedForNoDemand) {\n evcsVehicleNoDemandSemantic = false;\n} else if (prevNoDemandSemanticActive) {\n evcsVehicleNoDemandSemantic = noDemandScoreEma >= noDemandScoreOff;\n} else {\n evcsVehicleNoDemandSemantic = noDemandScoreEma >= noDemandScoreOn;\n}\n\nconst evcsNoDemandSinceMs =\n evcsVehicleNoDemandSemantic\n ? (\n prevNoDemandSemanticActive && Number.isFinite(prevNoDemandSinceMs)\n ? prevNoDemandSinceMs\n : nowMs\n )\n : null;\n\nconst evcsNoDemandSinceMinutes =\n Number.isFinite(evcsNoDemandSinceMs) && Number.isFinite(nowMs)\n ? Math.max(0, (nowMs - evcsNoDemandSinceMs) / 60000)\n : null;\n\nconst vehicleDemandFilter = {\n dtSec: round3(noDemandDtSec),\n\n commandCurrentA: round3(commandCurrentA),\n realCurrentA: round3(realCurrentA),\n expectedPowerW: round3(expectedPowerW),\n realPowerW: round3(realPowerW),\n\n currentDeliveryRatioRaw: round3(currentDeliveryRatioRaw),\n powerDeliveryRatioRaw: round3(powerDeliveryRatioRaw),\n deliveryRatioRaw: round3(deliveryRatioRaw),\n deliveryRatioEma: round3(deliveryRatioEma),\n deliveryNoiseEma: round3(deliveryNoiseEma),\n\n adaptiveQuietBand: round3(adaptiveQuietBand),\n noDemandEvidenceRaw: round3(noDemandEvidenceRaw),\n\n sessionElapsedSec: round3(sessionElapsedSec),\n sessionArmFactor: round3(sessionArmFactor),\n commandFactor: round3(commandFactor),\n sourceDeliverableForNoDemand,\n evcsSessionArmedForNoDemand,\n\n noDemandScoreRaw: round3(noDemandScoreRaw),\n noDemandScoreEma: round3(noDemandScoreEma),\n\n noDemandScoreOn: round3(noDemandScoreOn),\n noDemandScoreOff: round3(noDemandScoreOff),\n\n evcsVehicleNoDemandSemantic,\n evcsNoDemandSinceMs: Number.isFinite(evcsNoDemandSinceMs) ? evcsNoDemandSinceMs : null,\n evcsNoDemandSinceMinutes: Number.isFinite(evcsNoDemandSinceMinutes)\n ? round3(evcsNoDemandSinceMinutes)\n : null,\n\n nextHistory: {\n evcsDeliveryRatioEma: round3(deliveryRatioEma),\n evcsDeliveryNoiseEma: round3(deliveryNoiseEma),\n evcsNoDemandScoreEma: round3(noDemandScoreEma),\n evcsNoDemandSemanticActive: evcsVehicleNoDemandSemantic,\n evcsNoDemandSinceMs: Number.isFinite(evcsNoDemandSinceMs) ? evcsNoDemandSinceMs : null,\n evcsNoDemandFilterEvalMs: nowMs\n }\n};\n\n// =====================================================\n// DECISION TREE\n// =====================================================\nif (!parsed.controllerEnabled) {\n desiredCharging = parsed.evcsChargingSwitchOn;\n desiredPermission = parsed.chargePermissionNow;\n desiredLatch = parsed.controlLatch;\n desiredCurrentA = clamp(\n derived.currentReferenceA || params.currentMinA,\n params.currentMinA,\n params.currentMaxAutoA\n );\n\n state = 'controller_disabled';\n currentDecision = 'hold';\n dynamicOk = false;\n mustStop = false;\n reason.push('Contrôleur désactivé');\n}\n\nelse if (strategy.isStopMode) {\n setOff({\n stateName: 'stopped_user',\n decisionName: 'stop_stop_mode',\n reasonText: 'Arrêt manuel via Stratégie STOP',\n dynamic: false,\n mustStopFlag: false,\n blockReason: 'manual_stop',\n blockedUntilStamp: now.stamp,\n });\n}\n\nelse if (parsed.evcsModeIsAuto) {\n setOff({\n stateName: 'mode_auto_conflict',\n decisionName: 'stop_auto_mode_conflict',\n reasonText: 'Borne en mode Auto interne',\n dynamic: false,\n mustStopFlag: true,\n blockReason: 'mode_auto_conflict',\n blockedUntilStamp: now.stamp,\n });\n}\n\nelse if (!parsed.evcsDeviceConnected || !parsed.evcsVehicleConnected) {\n setOff({\n stateName: 'vehicle_not_connected',\n decisionName: 'stop_not_connected',\n reasonText: 'Véhicule ou borne non connecté',\n dynamic: false,\n mustStopFlag: false,\n blockReason: 'vehicle_not_connected',\n blockedUntilStamp: now.stamp,\n });\n}\n\nelse if (safeguards.mustStopHard) {\n setOff({\n stateName:\n derived.anyVebusLowBattery ? 'vebus_low_battery_hard_stop' :\n derived.vebusDcVoltageHardLow ? 'vebus_dc_voltage_hard_stop' :\n derived.vebusAcOutHardReached ? 'vebus_ac_hard_stop' :\n safeguards.batteryCurrentHardExceeded ? 'battery_current_hard_stop' :\n 'hard_stop',\n decisionName: 'stop_hard',\n reasonText: 'Hard stop de protection',\n dynamic: false,\n mustStopFlag: true,\n blockReason:\n safeguards.batteryCurrentHardExceeded ? 'battery_current_hard_stop' :\n derived.vebusDcVoltageHardLow ? 'vebus_dc_voltage_stop' :\n derived.anyVebusLowBattery ? 'vebus_low_battery_stop' :\n derived.vebusAcOutHardReached ? 'vebus_ac_hard_stop' :\n 'battery_protection_stop',\n blockedUntilStamp: formatParisStampFromMs(nowMs + 15 * 60000),\n });\n}\n\n// =====================================================\n// ACTIVE SESSION BRANCH\n// =====================================================\nelse if (cloud.sessionObservedActive) {\n if (cloud.enteringCloudDebt) {\n setCloudDebtStartedAt = true;\n }\n\n if (conditions.shouldStopOverloadAtMin) {\n setOff({\n stateName: 'vebus_overload_stop_min_current',\n decisionName: 'stop',\n reasonText: 'Arrêt: surcharge convertisseur persistante au courant mini',\n dynamic: false,\n mustStopFlag: true,\n blockReason: 'vebus_overload_persistent',\n blockedUntilStamp: formatParisStampFromMs(\n nowMs + batteryTune.global.overloadStopAfterSeconds * 1000\n ),\n });\n }\n\n else if (safeguards.gridBatteryProtectionStop) {\n setOff({\n stateName: 'stop_grid_battery_guard',\n decisionName: 'stop',\n reasonText: `Arrêt charge réseau: batterie trop sollicitée (${round1(parsed.batteryCurrentA)}A)`,\n dynamic: false,\n mustStopFlag: true,\n blockReason: 'grid_battery_guard',\n blockedUntilStamp: formatParisStampFromMs(nowMs + 20 * 60000),\n });\n }\n\n else if (conditions.probeFailedNow) {\n setOff({\n stateName: 'probe_failed_lock',\n decisionName: 'stop',\n reasonText: 'Mode sonde échoué',\n dynamic: false,\n mustStopFlag: true,\n blockReason: 'probe_failed',\n blockedUntilStamp: formatParisStampFromMs(nowMs + params.probeRetryMinutes * 60000),\n });\n }\n\n else if (derived.physicalCapA < params.currentMinA) {\n if (lifecycle.minRunElapsed) {\n setOff({\n stateName: 'stop_power_cap',\n decisionName: 'stop_power_cap',\n reasonText: 'Capacité système insuffisante',\n dynamic: false,\n mustStopFlag: true,\n blockReason: 'system_capacity_stop',\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\n });\n } else {\n setOn({\n stateName: 'hold_min_run_power_cap',\n decisionName: 'hold',\n currentA: params.currentMinA,\n reasonText: \"Maintien jusqu'au temps mini ON\",\n dynamic: false,\n mustStopFlag: false,\n });\n }\n }\n\n else if (conditions.probeWindowActive && !conditions.probeSuccessNow) {\n if (!conditions.probeHoldGuardOk) {\n setOff({\n stateName: 'probe_stop_guard',\n decisionName: 'stop',\n reasonText: 'Sonde interrompue: garde-fous non respectés',\n dynamic: false,\n mustStopFlag: true,\n blockReason: 'probe_failed',\n blockedUntilStamp: formatParisStampFromMs(nowMs + params.probeRetryMinutes * 60000),\n });\n } else {\n setOn({\n stateName: 'probe_hold',\n decisionName: 'hold',\n currentA: Math.min(params.probeCurrentA, derived.physicalCapA),\n reasonText: `Mode sonde actif à ${round1(Math.min(params.probeCurrentA, derived.physicalCapA))}A`,\n dynamic: true,\n mustStopFlag: false,\n });\n }\n }\n\n else {\n const activeSourceMode =\n conditions.probeSuccessNow\n ? 'solar_only'\n : context.sourceMode;\n\n const holdConditionsOk =\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced')\n ? conditions.gridHoldOk\n : (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential')\n ? conditions.solarHoldOk\n : activeSourceMode === 'hybrid_probe'\n ? conditions.probeHoldGuardOk\n : activeSourceMode === 'battery_assist_night'\n ? conditions.batteryAssistNightHoldOk\n : false;\n\n const manualLocalHoldOverride =\n manualLocalChargeAllowed &&\n (\n activeSourceMode === 'solar_only' ||\n activeSourceMode === 'solar_potential' ||\n activeSourceMode === 'blocked'\n );\n\n const effectiveHoldConditionsOk =\n holdConditionsOk || manualLocalHoldOverride;\n\n // =================================================\n // VEHICLE FULL / PAUSED - NUMERIC FILTER DECISION\n // IMPORTANT:\n // - uniquement en session active\n // - uniquement si la source est réellement livrable\n // - sans utiliser le PV comme critère\n // =================================================\n if (\n vehicleDemandFilter.evcsVehicleNoDemandSemantic &&\n vehicleDemandFilter.sourceDeliverableForNoDemand\n ) {\n setOff({\n stateName: 'vehicle_full_or_paused',\n decisionName: 'stop_vehicle_full_or_paused',\n reasonText: 'Véhicule branché mais ne demande plus réellement de puissance',\n dynamic: false,\n mustStopFlag: false,\n blockReason: 'none',\n blockedUntilStamp: now.stamp,\n });\n\n reason.push(`DeliveryRatioEma=${vehicleDemandFilter.deliveryRatioEma}`);\n reason.push(`NoiseEma=${vehicleDemandFilter.deliveryNoiseEma}`);\n reason.push(`NoDemandScore=${vehicleDemandFilter.noDemandScoreEma}`);\n reason.push(`ScoreOn=${vehicleDemandFilter.noDemandScoreOn}`);\n reason.push(`ScoreOff=${vehicleDemandFilter.noDemandScoreOff}`);\n\n if (vehicleDemandFilter.evcsNoDemandSinceMinutes !== null) {\n reason.push(`NoDemand depuis ${round1(vehicleDemandFilter.evcsNoDemandSinceMinutes)} min`);\n }\n }\n\n else if (manualLocalHoldOverride && activeSourceMode === 'blocked') {\n setOn({\n stateName: 'charging_hold_manual_local',\n decisionName: 'hold',\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\n reasonText: 'Maintien manuel local hors fenêtre sans réseau',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (activeSourceMode === 'blocked') {\n if (lifecycle.minRunElapsed) {\n setOff({\n stateName: 'stop_source_lost',\n decisionName: 'stop',\n reasonText: 'Source autorisée perdue',\n dynamic: false,\n mustStopFlag: true,\n blockReason: context.profile === 'outside_window' ? 'outside_window' : 'source_unavailable',\n blockedUntilStamp:\n context.profile === 'outside_window' && Number.isFinite(locks.nextAllowedWindowStartMs)\n ? formatParisStampFromMs(locks.nextAllowedWindowStartMs)\n : formatParisStampFromMs(nowMs + params.minStopMinutes * 60000),\n });\n } else {\n setOn({\n stateName: 'hold_min_run_source_lost',\n decisionName: 'hold',\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\n reasonText: \"Source perdue mais maintien jusqu'au mini ON\",\n dynamic: false,\n mustStopFlag: false,\n });\n }\n }\n\n else if (\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&\n !parsed.gridAvailable\n ) {\n if (lifecycle.minRunElapsed) {\n setOff({\n stateName:\n context.profile === 'night_hc'\n ? 'night_wait_grid_takeover_runtime'\n : 'wait_grid_source_runtime',\n decisionName: 'hold',\n reasonText:\n context.profile === 'night_hc'\n ? 'Relais réseau demandé pendant la session nuit EVCS'\n : 'Source réseau demandée mais pas encore disponible',\n dynamic: false,\n mustStopFlag: false,\n blockReason:\n context.profile === 'night_hc' ? 'grid_hc_unavailable' : 'grid_unavailable',\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\n });\n } else {\n setOn({\n stateName: 'hold_min_run_wait_grid_takeover',\n decisionName: 'hold',\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\n reasonText: \"Maintien jusqu'au mini ON en attente du réseau\",\n dynamic: false,\n mustStopFlag: false,\n });\n }\n }\n\n else if (!effectiveHoldConditionsOk) {\n if (lifecycle.minRunElapsed) {\n setOff({\n stateName: 'stop_conditions_lost',\n decisionName: 'stop',\n reasonText: 'Conditions de maintien perdues',\n dynamic: false,\n mustStopFlag: true,\n blockReason: 'hold_conditions_lost',\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\n });\n } else {\n setOn({\n stateName: 'hold_min_run',\n decisionName: 'hold',\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\n reasonText: \"Maintien jusqu'au temps mini ON\",\n dynamic: true,\n mustStopFlag: false,\n });\n }\n }\n\n else if ((derived.currentReferenceA > derived.physicalCapA) && lifecycle.currentCooldownElapsed) {\n setOn({\n stateName: 'charging_step_down_system_cap',\n decisionName: 'step_down',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - Math.max(params.currentStepDownA, 2),\n derived.physicalCapA\n )\n ),\n reasonText: 'Réduction pour respecter la capacité système',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (conditions.shouldStepDownOverload) {\n setOn({\n stateName: 'charging_step_down_overload',\n decisionName: 'step_down_fast',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - Math.max(batteryTune.global.overloadStepDownA, params.currentStepDownA),\n derived.physicalCapA\n )\n ),\n reasonText: 'Baisse rapide: surcharge convertisseur',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (conditions.shouldStepDownVebusPower) {\n setOn({\n stateName: 'charging_step_down_vebus_power',\n decisionName: 'step_down_fast',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - Math.max(params.currentStepDownA + 1, 3),\n derived.physicalCapA\n )\n ),\n reasonText: 'Baisse rapide: puissance VE.Bus élevée',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (conditions.shouldStepDownVebusVoltage) {\n setOn({\n stateName: 'charging_step_down_dc_voltage',\n decisionName: 'step_down_fast',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - Math.max(params.currentStepDownA + 1, 3),\n derived.physicalCapA\n )\n ),\n reasonText: 'Baisse rapide: tension DC basse',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (\n activeSourceMode === 'battery_assist_night' &&\n conditions.batteryAssistNightShouldStepDown\n ) {\n setOn({\n stateName: 'charging_step_down_battery_assist_night',\n decisionName: 'step_down',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - params.currentStepDownA,\n derived.physicalCapA\n )\n ),\n reasonText:\n nightEvcs?.batteryRecoveryUrgent\n ? 'Réduction: approche départ, préparation takeover réseau'\n : 'Réduction: assistance batterie nuit devient trop coûteuse',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&\n conditions.gridShouldStepDownBattery\n ) {\n setOn({\n stateName:\n activeSourceMode === 'grid_forced'\n ? 'charging_step_down_grid_forced_guard'\n : 'charging_step_down_grid_guard',\n decisionName: 'step_down',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - params.currentStepDownA,\n derived.physicalCapA\n )\n ),\n reasonText:\n activeSourceMode === 'grid_forced'\n ? 'Réduction réseau forcé: la batterie commence à participer'\n : 'Réduction réseau: la batterie commence à participer',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (\n (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&\n conditions.solarShouldStepDownCloudDebt\n ) {\n setOn({\n stateName: 'charging_step_down_cloud_debt',\n decisionName: 'step_down',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - Math.max(params.currentStepDownA, 2),\n derived.physicalCapA\n )\n ),\n reasonText: 'Réduction: dette nuage trop élevée',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (\n (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&\n conditions.solarShouldStepDownBattery\n ) {\n setOn({\n stateName: 'charging_step_down_battery',\n decisionName: 'step_down',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - params.currentStepDownA,\n derived.physicalCapA\n )\n ),\n reasonText: 'Réduction: batterie trop sollicitée',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&\n conditions.gridShouldStepUp\n ) {\n setOn({\n stateName:\n activeSourceMode === 'grid_forced'\n ? (\n nightEvcs?.batteryRecoveryUrgent\n ? 'night_departure_deadline_grid_forced'\n : 'charging_step_up_grid_forced'\n )\n : 'charging_step_up_grid_hc',\n decisionName: 'step_up',\n currentA: clamp(\n derived.currentReferenceA + params.currentStepUpA,\n params.currentMinA,\n derived.physicalCapA\n ),\n reasonText:\n activeSourceMode === 'grid_forced'\n ? (\n nightEvcs?.batteryRecoveryUrgent\n ? 'Hausse prudente: priorité départ, réseau forcé'\n : 'Hausse prudente du courant en charge réseau forcée'\n )\n : 'Hausse prudente du courant en HC réseau',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (\n activeSourceMode === 'battery_assist_night' &&\n conditions.batteryAssistNightShouldStepUp\n ) {\n setOn({\n stateName: 'charging_step_up_battery_assist_night',\n decisionName: 'step_up',\n currentA: clamp(\n derived.currentReferenceA + params.currentStepUpA,\n params.currentMinA,\n derived.physicalCapA\n ),\n reasonText: 'Hausse prudente du courant en assistance batterie nuit',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (\n (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&\n conditions.solarShouldStepUp\n ) {\n setOn({\n stateName:\n conditions.probeSuccessNow\n ? 'probe_success_step_up'\n : (modes.batteryPriorityMode\n ? 'charging_step_up_battery'\n : 'charging_step_up_forecast'),\n decisionName: 'step_up',\n currentA: clamp(\n derived.currentReferenceA + params.currentStepUpA,\n params.currentMinA,\n derived.physicalCapA\n ),\n reasonText: 'Hausse prudente du courant',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else {\n setOn({\n stateName:\n manualLocalHoldOverride ? 'charging_hold_manual_local' :\n conditions.probeSuccessNow ? 'probe_success_hold' :\n activeSourceMode === 'battery_assist_night' ? 'charging_hold_battery_assist_night' :\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') ? 'charging_hold_grid' :\n cloud.cloudDebtActive ? 'charging_hold_cloud_tolerance' :\n 'charging_hold',\n decisionName: 'hold',\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\n reasonText:\n manualLocalHoldOverride\n ? 'Maintien manuel local du courant'\n : activeSourceMode === 'battery_assist_night'\n ? 'Maintien du courant en assistance batterie nuit'\n : 'Maintien du courant',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n }\n}\n\n// =====================================================\n// START BRANCH\n// =====================================================\nelse {\n const startConditionsOk =\n (context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced')\n ? conditions.gridStartOk\n : (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential')\n ? conditions.solarStartOk\n : context.sourceMode === 'hybrid_probe'\n ? conditions.probeStartOk\n : context.sourceMode === 'battery_assist_night'\n ? conditions.batteryAssistNightStartOk\n : false;\n\n const manualLocalStartOverride =\n manualLocalChargeAllowed &&\n (\n context.sourceMode === 'solar_only' ||\n context.sourceMode === 'solar_potential' ||\n context.sourceMode === 'blocked'\n );\n\n if (!derived.canRunAtMinCurrent) {\n setOff({\n stateName: 'blocked_by_capacity_min_current',\n decisionName: 'hold',\n reasonText: 'Capacité insuffisante pour courant mini',\n dynamic: false,\n mustStopFlag: false,\n blockReason: 'system_capacity_stop',\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\n });\n }\n\n else if (manualLocalStartOverride) {\n setOn({\n stateName: 'start_manual_local_min_current',\n decisionName: 'start',\n currentA: params.currentMinA,\n reasonText:\n context.profile === 'outside_window'\n ? 'Démarrage manuel local hors fenêtre sans réseau'\n : 'Démarrage manuel local sans réseau',\n dynamic: true,\n mustStopFlag: false,\n });\n\n nextBlockReason = 'none';\n nextBlockedUntilStamp = now.stamp;\n setProbeStartedAt = false;\n }\n\n // P8 : véhicule plein en session nuit → veille auto-entretenue.\n // Le state est relu au cycle suivant (prev_surplus_state) : il maintient\n // vehicleChargeCompleted jusqu'au débranchement ou à la fin de fenêtre.\n // Fix A (08/08/2026) : une fois la recovery batterie terminée (SOC ≥ cible),\n // l'état devient 'night_recovery_done_idle' (collant) → takeover relâché\n // définitivement, plus de rebond EDF autour de la cible.\n else if (context.profile === 'night_hc' && nightEvcs?.vehicleChargeCompleted) {\n setOff({\n stateName: nightEvcs?.nightRecoveryDone\n ? 'night_recovery_done_idle'\n : 'night_session_done_idle',\n decisionName: 'hold',\n reasonText: nightEvcs?.nightRecoveryDone\n ? 'Charge véhicule terminée + batterie à la cible : veille nuit, réseau relâché'\n : 'Charge véhicule terminée : veille nuit, réseau relâché (recharge batterie si sous cible)',\n dynamic: false,\n mustStopFlag: false,\n blockReason: 'none',\n blockedUntilStamp: now.stamp,\n });\n }\n\n else if (context.sourceMode === 'blocked') {\n setOff({\n stateName:\n context.profile === 'outside_window'\n ? 'blocked_outside_window'\n : (context.profile === 'night_hc'\n ? 'night_no_grid_hc_lock'\n : 'idle_wait_source'),\n decisionName: 'hold',\n reasonText:\n context.profile === 'outside_window'\n ? 'Hors fenêtre préférée sans source suffisante'\n : context.profile === 'night_hc'\n ? 'Nuit sans autorisation HC réseau'\n : 'Source non autorisée ou insuffisante',\n dynamic: false,\n mustStopFlag: false,\n blockReason:\n context.profile === 'outside_window'\n ? 'outside_window'\n : context.profile === 'night_hc'\n ? 'grid_hc_unavailable'\n : 'source_unavailable',\n blockedUntilStamp:\n Number.isFinite(locks.nextAllowedWindowStartMs)\n ? formatParisStampFromMs(locks.nextAllowedWindowStartMs)\n : formatParisStampFromMs(nowMs + 15 * 60000),\n });\n }\n\n else if (\n (context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced') &&\n !parsed.gridAvailable\n ) {\n setOff({\n stateName:\n context.profile === 'night_hc'\n ? 'night_wait_grid_takeover'\n : 'wait_grid_source',\n decisionName: 'hold',\n reasonText:\n context.profile === 'night_hc'\n ? (\n nightEvcs?.batteryRecoveryUrgent\n ? 'Réseau demandé: urgence recharge batterie avant départ'\n : 'Prise de relais réseau demandée pour session nuit EVCS'\n )\n : 'Source réseau demandée mais pas encore disponible',\n dynamic: false,\n mustStopFlag: false,\n blockReason:\n context.profile === 'night_hc'\n ? 'grid_hc_unavailable'\n : 'grid_unavailable',\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\n });\n }\n\n else if (locks.startBlockedByReason) {\n setOff({\n stateName: 'wait_reason_lock',\n decisionName: 'hold',\n reasonText: `Blocage anti-cliquetis: ${history.lastBlockReason}`,\n dynamic: false,\n mustStopFlag: false,\n blockReason: history.lastBlockReason,\n blockedUntilStamp: history.blockedUntilAt || now.stamp,\n });\n }\n\n else if (!startConditionsOk) {\n setOff({\n stateName: 'idle_wait_conditions',\n decisionName: 'hold',\n reasonText:\n context.sourceMode === 'battery_assist_night'\n ? 'Conditions de démarrage nuit batterie non réunies'\n : 'Conditions de démarrage non réunies',\n dynamic: false,\n mustStopFlag: false,\n blockReason: 'none',\n blockedUntilStamp: now.stamp,\n });\n }\n\n else if (!lifecycle.minStopElapsed) {\n setOff({\n stateName: 'wait_min_stop',\n decisionName: 'hold',\n reasonText: 'Temps mini OFF non écoulé',\n dynamic: false,\n mustStopFlag: false,\n blockReason: 'none',\n blockedUntilStamp: now.stamp,\n });\n }\n\n else {\n const startCurrentA =\n context.sourceMode === 'hybrid_probe'\n ? clamp(params.probeCurrentA, params.currentMinA, derived.physicalCapA)\n : params.currentMinA;\n\n setOn({\n stateName:\n context.sourceMode === 'grid_forced' ? (\n nightEvcs?.batteryRecoveryUrgent\n ? 'night_departure_deadline_grid_forced'\n : 'start_grid_forced_min_current'\n ) :\n context.sourceMode === 'grid_hc_allowed' ? 'start_grid_hc_min_current' :\n context.sourceMode === 'battery_assist_night' ? 'start_battery_assist_night_min_current' :\n context.sourceMode === 'hybrid_probe' ? 'probe_start_6a' :\n context.sourceMode === 'solar_potential' ? 'start_forecast_solar_potential' :\n modes.batteryFullHarvestMode ? 'start_min_current_battery_full_harvest' :\n modes.forecastCommitMode ? 'start_min_current_forecast_commit' :\n 'start_min_current',\n decisionName: 'start',\n currentA: startCurrentA,\n reasonText:\n context.sourceMode === 'grid_forced'\n ? (\n nightEvcs?.batteryRecoveryUrgent\n ? 'Démarrage réseau forcé: priorité départ / recharge batterie'\n : 'Démarrage en charge réseau forcée'\n )\n : context.sourceMode === 'grid_hc_allowed'\n ? 'Démarrage en fenêtre HC réseau'\n : context.sourceMode === 'battery_assist_night'\n ? 'Démarrage nuit en assistance batterie contrôlée'\n : context.sourceMode === 'hybrid_probe'\n ? 'Démarrage en mode sonde'\n : context.sourceMode === 'solar_potential'\n ? 'Démarrage par potentiel solaire confirmé'\n : modes.batteryFullHarvestMode\n ? 'Démarrage harvest solaire'\n : modes.forecastCommitMode\n ? 'Démarrage forecast commit'\n : 'Démarrage prudent à courant mini',\n dynamic: true,\n mustStopFlag: false,\n });\n\n nextBlockReason = 'none';\n nextBlockedUntilStamp = now.stamp;\n setProbeStartedAt = context.sourceMode === 'hybrid_probe';\n }\n}\n\n// =====================================================\n// CONTEXT REASONS\n// =====================================================\nif (nightEvcs?.nightSessionActive) {\n reason.push(\n `Nuit EVCS: target=${round1(nightEvcs.batteryMorningTargetPct)}% | trigger réseau=${round1(nightEvcs.gridTakeoverTriggerPct)}%`\n );\n\n if (Number.isFinite(nightEvcs.nightSessionStartSocPct)) {\n reason.push(\n `SOC départ session nuit=${round1(nightEvcs.nightSessionStartSocPct)}% | décharge autorisée=${round1(nightEvcs.maxBatteryDischargePoints)} pts`\n );\n }\n\n if (nightEvcs.batteryRecoveryUrgent) {\n reason.push(\"Urgence départ: il faut anticiper la reprise réseau\");\n }\n}\n\nif (manualLocalChargeAllowed) {\n reason.push(`ManualLocalCharge=on`);\n reason.push(`ManualLocalProfile=${context.profile}`);\n reason.push(`ManualLocalSource=${context.sourceMode}`);\n}\n\nreason.push(`VehicleDemandFilter delivery=${vehicleDemandFilter.deliveryRatioEma}`);\nreason.push(`VehicleDemandFilter noise=${vehicleDemandFilter.deliveryNoiseEma}`);\nreason.push(`VehicleDemandFilter score=${vehicleDemandFilter.noDemandScoreEma}`);\nreason.push(`VehicleDemandFilter noDemand=${vehicleDemandFilter.evcsVehicleNoDemandSemantic ? 'on' : 'off'}`);\n\n// =====================================================\n// OUTPUT\n// =====================================================\nreturn [{\n json: {\n ...prev,\n vehicleDemandFilter,\n decision: {\n desiredCharging,\n desiredPermission,\n desiredLatch,\n desiredCurrentA,\n\n state,\n currentDecision,\n dynamicOk,\n mustStop,\n reason,\n\n nextBlockReason,\n nextBlockedUntilStamp,\n\n setCloudDebtStartedAt,\n setProbeStartedAt,\n\n setNightSessionStart,\n clearNightSessionStart,\n\n notifyWanted,\n notifyTitle,\n notifyMessage,\n }\n }\n}];\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2, diff --git a/workflows/victron-grid-hysteresis-v1.json b/workflows/victron-grid-hysteresis-v1.json new file mode 100644 index 0000000..3f3965b --- /dev/null +++ b/workflows/victron-grid-hysteresis-v1.json @@ -0,0 +1,459 @@ +{ + "updatedAt": "2026-07-09T21:45:52.945Z", + "createdAt": "2026-03-17T01:43:29.018Z", + "id": "7iIlu7LLTW7TZA8o", + "name": "Victron - Battery / Grid Hysteresis Fix V1", + "description": null, + "active": true, + "isArchived": false, + "nodes": [ + { + "parameters": { + "rule": { + "interval": [ + { + "field": "minutes", + "minutesInterval": 1 + } + ] + } + }, + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1.3, + "position": [ + 0, + 0 + ], + "id": "0b9ad256-b51b-4921-b81b-5ba8a2113412", + "name": "Schedule Trigger" + }, + { + "parameters": { + "method": "POST", + "url": "=http://10.10.10.17:8123/api/template\n", + "authentication": "genericCredentialType", + "genericAuthType": "httpBearerAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + }, + { + "name": "Accept", + "value": "text/plain" + } + ] + }, + "sendBody": true, + "bodyParameters": { + "parameters": [ + { + "name": "template", + "value": "{{ {\n \"soc_percent\": states(\"sensor.victron_battery_soc\"),\n \"grid_state\": states(\"switch.edf_general_ok_switch\"),\n \"pv_power_w\": states(\"sensor.victron_pv_power_system\"),\n\n \"house_ac_power_w\": states(\"sensor.victron_consumption_power_l1\"),\n \"grid_power_w\": states(\"sensor.victron_grid_power_l1\"),\n\n \"evcs_ac_power_w\": states(\"sensor.victron_evcs_ac_power\"),\n \"evcs_charging\": states(\"switch.victron_evcs_charging\"),\n \"evcs_status_raw\": states(\"sensor.victron_evcs_status_raw\"),\n\n \"hysteresis_enabled\": states(\"input_boolean.victron_grid_hysteresis_enabled\"),\n \"notifications_enabled\": states(\"input_boolean.victron_grid_hysteresis_notifications\"),\n \"grid_latch\": states(\"input_boolean.victron_grid_hysteresis_latch\"),\n\n \"grid_soc_on\": states(\"input_number.victron_grid_soc_on\"),\n \"grid_soc_off\": states(\"input_number.victron_grid_soc_off\"),\n \"grid_min_on_minutes\": states(\"input_number.victron_grid_min_on_minutes\"),\n \"grid_cooldown_minutes\": states(\"input_number.victron_grid_cooldown_minutes\"),\n \"ac_loads_max_safe_w\": states(\"input_number.victron_ac_loads_max_safe_w\"),\n\n \"last_on_at\": states(\"input_datetime.victron_grid_last_on_at\"),\n \"last_off_at\": states(\"input_datetime.victron_grid_last_off_at\"),\n \"last_signature\": states(\"input_text.victron_grid_control_last_signature\"),\n\n \"evcs_priority_active\": states(\"input_boolean.victron_evcs_priority_active\"),\n \"night_grid_takeover_needed\": states(\"input_boolean.victron_evcs_night_grid_takeover_needed\"),\n \"evcs_source_mode\": states(\"input_text.victron_evcs_source_mode\"),\n \"user_strategy\": states(\"input_select.victron_evcs_strategy\"),\n \"evcs_surplus_state\": states(\"input_text.victron_evcs_surplus_state\")\n} | tojson }}\n" + } + ] + }, + "options": {} + }, + "id": "577c8308-a790-44f8-a9dc-098811bd5e0b", + "name": "HA Snapshot Grid Hysteresis", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 224, + 0 + ], + "credentials": { + "httpBearerAuth": { + "id": "uv3sknYYigxBkaVN", + "name": "Bearer Auth account 2" + } + } + }, + { + "parameters": { + "jsCode": "function num(v, fallback = NaN) {\n const n = Number.parseFloat(v);\n return Number.isFinite(n) ? n : fallback;\n}\n\nfunction low(v) {\n return (v ?? '').toString().trim().toLowerCase();\n}\n\nfunction parseMaybeJson(v) {\n if (typeof v !== 'string') return v;\n try {\n return JSON.parse(v);\n } catch {\n return v;\n }\n}\n\nfunction extractSnapshot(input) {\n if (typeof input === 'string') return parseMaybeJson(input);\n if (input?.data && typeof input.data === 'string') return parseMaybeJson(input.data);\n if (input?.body && typeof input.body === 'string') return parseMaybeJson(input.body);\n if (input?.data && typeof input.data === 'object') return input.data;\n if (input?.body && typeof input.body === 'object') return input.body;\n return input;\n}\n\nfunction round1(v) {\n return Math.round(v * 10) / 10;\n}\n\nfunction clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nfunction pickDefined(...values) {\n for (const v of values) {\n if (v === undefined || v === null) continue;\n if (typeof v === 'string' && v.trim() === '') continue;\n return v;\n }\n return undefined;\n}\n\nfunction isOnLike(v) {\n const s = low(v);\n return s === 'on' || s === 'true' || s === '1' || s === 'yes';\n}\n\nfunction getParisNowParts() {\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())\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const date = `${parts.year}-${parts.month}-${parts.day}`;\n const stamp = `${date} ${parts.hour}:${parts.minute}:${parts.second}`;\n\n return {\n year: Number(parts.year),\n month: Number(parts.month),\n day: Number(parts.day),\n hour: Number(parts.hour),\n minute: Number(parts.minute),\n second: Number(parts.second),\n date,\n stamp,\n };\n}\n\nfunction parseLocalStampToMs(s) {\n const text = (s ?? '').toString().trim();\n const m = text.match(/^(\\d{4})-(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2})$/);\n if (!m) return null;\n\n const y = Number(m[1]);\n const mo = Number(m[2]);\n const d = Number(m[3]);\n const h = Number(m[4]);\n const mi = Number(m[5]);\n const se = Number(m[6]);\n\n if (![y, mo, d, h, mi, se].every(Number.isFinite)) return null;\n\n return new Date(y, mo - 1, d, h, mi, se).getTime();\n}\n\nconst cfg = {\n mobileNotifyDomain: 'notify',\n mobileNotifyService: 'mobile_app_iphone_16_de_tt',\n};\n\nconst raw = $input.first().json;\nconst input = extractSnapshot(raw);\nconst now = getParisNowParts();\nconst nowMs = parseLocalStampToMs(now.stamp);\n\n// ======================================================\n// INPUTS PRINCIPAUX\n// ======================================================\nconst soc = clamp(num(input.soc_percent, 0), 0, 100);\nconst gridStateRaw = low(input.grid_state || 'off');\nconst gridIsOn = gridStateRaw === 'on';\nconst gridSwitchUnavailable = gridStateRaw === 'unavailable' || gridStateRaw === 'unknown' || gridStateRaw === 'none';\nconst pvPowerW = Math.max(0, num(input.pv_power_w, 0));\n\nconst houseAcPowerW = Math.max(0, num(input.house_ac_power_w, 0));\nconst gridPowerW = num(input.grid_power_w, 0);\n\nconst evcsAcPowerW = Math.max(0, num(input.evcs_ac_power_w, 0));\nconst evcsCharging = low(input.evcs_charging || 'off') === 'on';\nconst evcsStatusRaw = num(input.evcs_status_raw, NaN);\n\nconst hysteresisEnabled = low(input.hysteresis_enabled || 'off') === 'on';\nconst notificationsEnabled = low(input.notifications_enabled || 'off') === 'on';\nconst latchIsOn = low(input.grid_latch || 'off') === 'on';\n\nconst gridSocOn = clamp(num(input.grid_soc_on, 22), 0, 100);\nconst gridSocOff = clamp(num(input.grid_soc_off, 35), 0, 100);\nconst gridMinOnMinutes = Math.max(0, num(input.grid_min_on_minutes, 45));\nconst gridCooldownMinutes = Math.max(0, num(input.grid_cooldown_minutes, 15));\nconst acLoadsMaxSafeW = Math.max(0, num(input.ac_loads_max_safe_w, 7800));\n\nconst lastOnAt = (input.last_on_at ?? '').toString().trim();\nconst lastOffAt = (input.last_off_at ?? '').toString().trim();\nconst lastSignature = (input.last_signature ?? '').toString().trim();\n\nconst lastOnMs = parseLocalStampToMs(lastOnAt);\nconst lastOffMs = parseLocalStampToMs(lastOffAt);\n\nconst minutesSinceLastOn =\n lastOnMs !== null && nowMs !== null ? Math.max(0, (nowMs - lastOnMs) / 60000) : null;\n\nconst minutesSinceLastOff =\n lastOffMs !== null && nowMs !== null ? Math.max(0, (nowMs - lastOffMs) / 60000) : null;\n\nconst minOnElapsed =\n minutesSinceLastOn === null ? true : minutesSinceLastOn >= gridMinOnMinutes;\n\nconst cooldownElapsed =\n minutesSinceLastOff === null ? true : minutesSinceLastOff >= gridCooldownMinutes;\n\nconst socBand =\n soc <= gridSocOn ? 'low' :\n soc < gridSocOff ? 'mid' :\n 'high';\n\nconst evcsSeemsActive = evcsCharging || evcsAcPowerW > 300;\nconst acOverSafeLimit = houseAcPowerW >= acLoadsMaxSafeW;\n\n// ======================================================\n// EVCS OVERRIDE INPUTS\n// ======================================================\nconst evcsPriorityActive = isOnLike(\n pickDefined(\n input.evcs_priority_active,\n input.victron_evcs_priority_active,\n input['input_boolean.victron_evcs_priority_active']\n )\n);\n\nconst evcsNightGridTakeoverNeeded = isOnLike(\n pickDefined(\n input.night_grid_takeover_needed,\n input.victron_evcs_night_grid_takeover_needed,\n input['input_boolean.victron_evcs_night_grid_takeover_needed']\n )\n);\n\nconst evcsSourceModeRaw = (\n pickDefined(\n input.evcs_source_mode,\n input.victron_evcs_source_mode,\n input['input_text.victron_evcs_source_mode']\n ) ?? ''\n).toString().trim();\n\nconst evcsSourceMode = low(evcsSourceModeRaw);\n\nconst userStrategyRaw = (\n pickDefined(\n input.user_strategy,\n input.victron_evcs_user_strategy,\n input['input_select.victron_evcs_user_strategy']\n ) ?? ''\n).toString().trim();\n\nconst userStrategy = low(userStrategyRaw);\n\n// ======================================================\n// EVCS SEMANTIC STATE\n// Ici on ne détecte PAS la voiture pleine par seuils.\n// On consomme seulement l'état sémantique déjà produit\n// par le workflow EVCS.\n// ======================================================\nconst evcsSurplusStateRaw = (\n pickDefined(\n input.evcs_surplus_state,\n input.victron_evcs_surplus_state,\n input['input_text.victron_evcs_surplus_state']\n ) ?? ''\n).toString().trim();\n\nconst evcsSurplusState = low(evcsSurplusStateRaw);\n\nconst evcsVehicleNoDemandSemantic =\n evcsSurplusState === 'vehicle_full_or_paused' ||\n evcsSurplusState === 'vehicle_not_requesting_power' ||\n evcsSurplusState === 'vehicle_full' ||\n evcsSurplusState === 'charge_complete' ||\n evcsSurplusState.includes('full_or_paused') ||\n evcsSurplusState.includes('not_requesting_power') ||\n evcsSurplusState.includes('charge_complete') ||\n evcsSurplusState.includes('no_demand');\n\n// ======================================================\n// OVERRIDE EVCS VERS GRID\n// CORRECTION:\n// - evcsPriorityActive seul ne doit PLUS suffire à allumer EDF\n// - seuls les cas explicitement réseau peuvent forcer le grid\n// - ainsi \"Réseau HC midi = OFF\" redevient réellement respecté\n// ======================================================\nconst evcsExplicitGridRequest =\n userStrategy === 'force_grid_now' ||\n userStrategy === 'force_charge_now' ||\n evcsNightGridTakeoverNeeded ||\n evcsSourceMode === 'réseau forcé' ||\n evcsSourceMode === 'réseau hc autorisé' ||\n evcsSourceMode === 'grid_forced' ||\n evcsSourceMode === 'grid_hc_allowed';\n\nconst evcsGridOverride =\n !evcsVehicleNoDemandSemantic &&\n evcsExplicitGridRequest;\n\n// ======================================================\n// DÉCISION\n// ======================================================\nlet state = 'observe_only';\nlet reason = [];\nlet desiredGrid = gridIsOn ? 'on' : 'off';\nlet desiredLatch = latchIsOn ? 'on' : 'off';\n\n// ======================================================\n// FORCE_GRID + VEHICLE FULL IMMEDIATE CUT\n// FIX V2.5: Si force_grid_now actif et voiture pleine,\n// couper EDF immédiatement sans attendre minOnRuntime.\n// ======================================================\nlet forcedCut = false;\nif (\n userStrategy === 'force_grid_now' &&\n evcsVehicleNoDemandSemantic &&\n latchIsOn\n) {\n desiredGrid = 'off';\n desiredLatch = 'off';\n state = 'turn_off_force_grid_vehicle_full';\n reason.push('Force grid + voiture pleine : coupure immediate du reseau');\n forcedCut = true;\n}\n\n// ======================================================\n// FIX C (08/08/2026): VEHICULE DEBRANCHE -> COUPURE IMMEDIATE\n// Incident nuit 07-08/08 : apres debranchement du vehicule, EDF restait\n// maintenu ON 25 min (hold_on_min_runtime) alors que plus rien ne le\n// justifiait (SOC >= seuil OFF). Ici : coupure immediate, bypass min_on.\n// ======================================================\nif (\n !forcedCut &&\n latchIsOn &&\n gridIsOn &&\n !evcsGridOverride &&\n evcsSurplusState === 'vehicle_not_connected' &&\n soc >= gridSocOff\n) {\n desiredGrid = 'off';\n desiredLatch = 'off';\n state = 'turn_off_vehicle_unplugged';\n reason.push('Vehicule debranche + SOC >= seuil OFF : coupure immediate du reseau (bypass min_on)');\n forcedCut = true;\n}\n\n\n// ======================================================\n// CORE HYSTERESIS LOGIC\n// ======================================================\nif (gridSwitchUnavailable) {\n // Switch EDF hors ligne : mode sûr. Aucune action, aucun timestamp,\n // on garde le latch tel quel et on réévaluera au retour du switch.\n state = 'switch_unavailable';\n desiredGrid = gridIsOn ? 'on' : 'off';\n desiredLatch = latchIsOn ? 'on' : 'off';\n reason.push('Switch EDF indisponible : aucune action, attente du retour');\n\n} else if (forcedCut) {\n // Décision déjà prise plus haut (force_grid + voiture pleine) :\n // on ne laisse plus la chaîne principale l'écraser.\n\n} else if (evcsGridOverride) {\n desiredGrid = 'on';\n desiredLatch = 'on';\n\n if (!gridIsOn) {\n state = 'turn_on_evcs_override';\n reason.push('Override EVCS actif : activation EDF forcée');\n } else if (!latchIsOn) {\n state = 'adopt_existing_grid_on_evcs_override';\n reason.push('Override EVCS actif : EDF déjà ON, adoption par latch');\n } else {\n state = 'hold_on_evcs_override';\n reason.push('Override EVCS actif : maintien EDF ON');\n }\n\n if (userStrategy === 'force_grid_now') {\n reason.push('Cause override : force_grid_now');\n } else if (userStrategy === 'force_charge_now') {\n reason.push('Cause override : force_charge_now');\n } else if (evcsNightGridTakeoverNeeded) {\n reason.push('Cause override : night_grid_takeover_needed');\n } else {\n reason.push(`Cause override : source_mode=${evcsSourceModeRaw || 'n/a'}`);\n }\n\n} else if (!hysteresisEnabled) {\n state = 'hysteresis_disabled';\n desiredGrid = gridIsOn ? 'on' : 'off';\n desiredLatch = latchIsOn ? 'on' : 'off';\n reason.push('Automatisme hystérésis réseau désactivé');\n\n} else if (soc <= gridSocOn) {\n desiredGrid = 'on';\n desiredLatch = 'on';\n\n if (!cooldownElapsed && !gridIsOn) {\n state = 'turn_on_low_soc_cooldown_bypassed';\n reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : activation EDF malgré cooldown`);\n } else if (!gridIsOn) {\n state = 'turn_on_low_soc';\n reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : activation EDF`);\n } else if (!latchIsOn) {\n state = 'adopt_existing_grid_on_low_soc';\n reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : EDF déjà ON, prise de contrôle par latch`);\n } else {\n state = 'hold_on_low_soc';\n reason.push(`SOC encore bas ${round1(soc)}% : maintien EDF ON`);\n }\n\n} else if (gridIsOn && !latchIsOn) {\n if (soc < gridSocOff) {\n desiredGrid = 'on';\n desiredLatch = 'on';\n state = 'adopt_existing_grid_on';\n reason.push(`EDF déjà ON avec latch OFF et SOC ${round1(soc)}% < seuil OFF ${gridSocOff}% : adoption de l'état ON`);\n } else {\n desiredGrid = 'off';\n desiredLatch = 'off';\n state = 'turn_off_stray_grid_on';\n reason.push(`EDF ON avec latch OFF et SOC ${round1(soc)}% >= seuil OFF ${gridSocOff}% : coupure de reprise de contrôle`);\n }\n\n} else if (latchIsOn) {\n if (!minOnElapsed) {\n desiredGrid = 'on';\n desiredLatch = 'on';\n state = 'hold_on_min_runtime';\n reason.push(`EDF maintenu ON : durée mini ${gridMinOnMinutes} min non atteinte (${round1(minutesSinceLastOn || 0)} min)`);\n } else if (soc < gridSocOff) {\n desiredGrid = 'on';\n desiredLatch = 'on';\n state = 'hold_on_until_soc_off';\n reason.push(`EDF maintenu ON : SOC ${round1(soc)}% < seuil OFF ${gridSocOff}%`);\n } else {\n desiredGrid = 'off';\n desiredLatch = 'off';\n state = 'turn_off_recovered';\n reason.push(`SOC ${round1(soc)}% >= seuil OFF ${gridSocOff}% : coupure EDF`);\n }\n\n} else {\n desiredGrid = 'off';\n desiredLatch = 'off';\n state = 'observe_only';\n reason.push('Aucune action : hors zone d’hystérésis');\n}\n\n// ======================================================\n// COMPARE DESIRED VS CURRENT\n// ======================================================\nconst gridAction =\n desiredGrid === (gridIsOn ? 'on' : 'off')\n ? 'none'\n : desiredGrid === 'on'\n ? 'turn_on'\n : 'turn_off';\n\nconst latchAction =\n desiredLatch === (latchIsOn ? 'on' : 'off')\n ? 'none'\n : desiredLatch === 'on'\n ? 'turn_on'\n : 'turn_off';\n\n// ======================================================\n// TIMESTAMPS\n// ======================================================\nconst setLastOnAt =\n (gridAction === 'turn_on' && latchAction === 'turn_on') ||\n state === 'adopt_existing_grid_on' ||\n state === 'adopt_existing_grid_on_low_soc' ||\n state === 'adopt_existing_grid_on_evcs_override';\n\nconst setLastOffAt =\n gridAction === 'turn_off';\n\n// ======================================================\n// CONTEXT / ALERTS\n// ======================================================\nreason.push(`Grid actuel ${gridIsOn ? 'ON' : 'OFF'}`);\nreason.push(`Latch actuel ${latchIsOn ? 'ON' : 'OFF'}`);\nreason.push(`SOC ${round1(soc)}% | seuils ${gridSocOn}%/${gridSocOff}%`);\nreason.push(`PV ${Math.round(pvPowerW)}W`);\nreason.push(`Charge AC maison ${Math.round(houseAcPowerW)}W`);\nreason.push(`Puissance réseau ${Math.round(gridPowerW)}W`);\nreason.push(`EVCS ${evcsSeemsActive ? 'actif' : 'inactif'} | ${Math.round(evcsAcPowerW)}W | status_raw=${Number.isFinite(evcsStatusRaw) ? evcsStatusRaw : 'n/a'}`);\n\nif (minutesSinceLastOn !== null) {\n reason.push(`Dernier ON il y a ${round1(minutesSinceLastOn)} min`);\n}\nif (minutesSinceLastOff !== null) {\n reason.push(`Dernier OFF il y a ${round1(minutesSinceLastOff)} min`);\n}\n\nif (acOverSafeLimit) {\n reason.push(`⚠️ Charge AC maison ${Math.round(houseAcPowerW)}W >= limite sûre ${Math.round(acLoadsMaxSafeW)}W`);\n}\n\nreason.push(`EVCS override ${evcsGridOverride ? 'ON' : 'OFF'}`);\nreason.push(`EVCS explicit grid request ${evcsExplicitGridRequest ? 'ON' : 'OFF'}`);\nreason.push(`EVCS priority ${evcsPriorityActive ? 'ON' : 'OFF'}`);\nreason.push(`EVCS night takeover ${evcsNightGridTakeoverNeeded ? 'ON' : 'OFF'}`);\nreason.push(`EVCS source ${evcsSourceModeRaw || 'n/a'}`);\nreason.push(`EVCS strategy ${userStrategyRaw || 'n/a'}`);\nreason.push(`EVCS surplus state ${evcsSurplusStateRaw || 'n/a'}`);\nreason.push(`EVCS no-demand semantic ${evcsVehicleNoDemandSemantic ? 'ON' : 'OFF'}`);\n\n// ======================================================\n// NOTIFICATIONS\n// ======================================================\nlet notifyTitle = 'Victron Grid Hysteresis';\nlet notifyMessage = '';\nlet notifyWanted = false;\n\nif (\n state === 'turn_on_evcs_override' ||\n state === 'adopt_existing_grid_on_evcs_override' ||\n state === 'hold_on_evcs_override'\n) {\n notifyTitle = '🚗 EDF forcé par EVCS';\n notifyMessage = `Priorité EVCS active. EDF maintenu ON pour la recharge véhicule. Cause: ${userStrategyRaw || evcsSourceModeRaw || 'override EVCS'}.`;\n notifyWanted = true;\n\n} else if (state === 'turn_on_low_soc' || state === 'turn_on_low_soc_cooldown_bypassed') {\n notifyTitle = '⚡ EDF activé sur SOC bas';\n notifyMessage = `SOC ${round1(soc)}% <= ${gridSocOn}%. EDF activé et maintenu jusqu'à au moins ${gridSocOff}% (min ${gridMinOnMinutes} min).`;\n notifyWanted = true;\n\n} else if (state === 'adopt_existing_grid_on_low_soc' || state === 'adopt_existing_grid_on') {\n notifyTitle = '🔁 EDF ON repris par hystérésis';\n notifyMessage = `EDF était déjà ON. Le workflow reprend la main et le maintient jusqu'à ${gridSocOff}% (min ${gridMinOnMinutes} min).`;\n notifyWanted = true;\n\n} else if (state === 'turn_off_vehicle_unplugged') {\n notifyTitle = '✅ EDF coupé (véhicule débranché)';\n notifyMessage = `Véhicule débranché et SOC ${round1(soc)}% >= ${gridSocOff}%. Coupure immédiate du réseau.`;\n notifyWanted = true;\n\n} else if (state === 'turn_off_recovered') {\n notifyTitle = '✅ EDF coupé après recharge';\n notifyMessage = `SOC ${round1(soc)}% >= ${gridSocOff}%. Retour hors réseau autorisé.`;\n notifyWanted = true;\n\n} else if (state === 'turn_off_stray_grid_on') {\n notifyTitle = '✅ EDF coupé (reprise de contrôle)';\n notifyMessage = `EDF était ON sans latch. SOC ${round1(soc)}% >= ${gridSocOff}%, coupure réseau appliquée.`;\n notifyWanted = true;\n\n} else if (acOverSafeLimit && !gridIsOn) {\n notifyTitle = '🚨 Charge AC élevée hors réseau';\n notifyMessage = `Charge AC maison ${Math.round(houseAcPowerW)}W au-dessus de la limite sûre ${Math.round(acLoadsMaxSafeW)}W alors que EDF est OFF.`;\n notifyWanted = true;\n\n} else if (acOverSafeLimit && gridIsOn) {\n notifyTitle = '⚠️ Charge AC élevée';\n notifyMessage = `Charge AC maison ${Math.round(houseAcPowerW)}W proche/au-dessus de la limite sûre ${Math.round(acLoadsMaxSafeW)}W.`;\n notifyWanted = true;\n}\n\nconst shortReason = reason.join(' | ').slice(0, 255);\n\nconst signature = [\n now.date,\n state,\n `dg=${desiredGrid}`,\n `dl=${desiredLatch}`,\n `sb=${socBand}`,\n `ac=${acOverSafeLimit ? '1' : '0'}`,\n `ov=${evcsGridOverride ? '1' : '0'}`,\n `eg=${evcsExplicitGridRequest ? '1' : '0'}`,\n `ep=${evcsPriorityActive ? '1' : '0'}`,\n `es=${evcsSurplusState || 'na'}`\n].join('|');\n\nconst signatureChanged = signature !== lastSignature;\n\n// ======================================================\n// OUTPUT\n// ======================================================\nconst result = {\n timestamp_paris: now.stamp,\n\n inputs: {\n soc_percent: soc,\n grid_state: gridIsOn ? 'on' : 'off',\n pv_power_w: pvPowerW,\n house_ac_power_w: houseAcPowerW,\n grid_power_w: gridPowerW,\n evcs_ac_power_w: evcsAcPowerW,\n evcs_charging: evcsCharging,\n evcs_status_raw: Number.isFinite(evcsStatusRaw) ? evcsStatusRaw : null,\n\n hysteresis_enabled: hysteresisEnabled,\n notifications_enabled: notificationsEnabled,\n grid_latch: latchIsOn,\n grid_soc_on: gridSocOn,\n grid_soc_off: gridSocOff,\n grid_min_on_minutes: gridMinOnMinutes,\n grid_cooldown_minutes: gridCooldownMinutes,\n ac_loads_max_safe_w: acLoadsMaxSafeW,\n\n last_on_at: lastOnAt || null,\n last_off_at: lastOffAt || null,\n last_signature: lastSignature || null,\n\n evcs_priority_active: evcsPriorityActive,\n night_grid_takeover_needed: evcsNightGridTakeoverNeeded,\n evcs_source_mode: evcsSourceModeRaw || null,\n user_strategy: userStrategyRaw || null,\n evcs_surplus_state: evcsSurplusStateRaw || null\n },\n\n computed: {\n desired_grid: desiredGrid,\n desired_latch: desiredLatch,\n minutes_since_last_on: minutesSinceLastOn !== null ? round1(minutesSinceLastOn) : null,\n minutes_since_last_off: minutesSinceLastOff !== null ? round1(minutesSinceLastOff) : null,\n min_on_elapsed: minOnElapsed,\n cooldown_elapsed: cooldownElapsed,\n ac_over_safe_limit: acOverSafeLimit,\n evcs_seems_active: evcsSeemsActive,\n\n evcs_grid_override: evcsGridOverride,\n evcs_explicit_grid_request: evcsExplicitGridRequest,\n evcs_priority_active: evcsPriorityActive,\n evcs_night_grid_takeover_needed: evcsNightGridTakeoverNeeded,\n evcs_source_mode: evcsSourceModeRaw || null,\n evcs_user_strategy: userStrategyRaw || null,\n evcs_surplus_state: evcsSurplusStateRaw || null,\n evcs_vehicle_no_demand_semantic: evcsVehicleNoDemandSemantic\n },\n\n decision: {\n state,\n grid_action: gridAction,\n latch_action: latchAction,\n set_last_on_at: setLastOnAt,\n set_last_off_at: setLastOffAt,\n should_notify: notificationsEnabled && signatureChanged && notifyWanted,\n signature,\n signature_changed: signatureChanged,\n reason,\n short_reason: shortReason\n },\n\n ha_service_calls: {\n helper_state: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_grid_control_state',\n value: state\n }\n },\n\n helper_reason: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_grid_control_reason',\n value: shortReason || 'Aucune raison'\n }\n },\n\n helper_signature: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_grid_control_last_signature',\n value: signature\n }\n },\n\n helper_latch: {\n domain: 'input_boolean',\n service: desiredLatch === 'on' ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_grid_hysteresis_latch'\n }\n },\n\n helper_last_on_at: {\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_grid_last_on_at',\n datetime: now.stamp\n }\n },\n\n helper_last_off_at: {\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_grid_last_off_at',\n datetime: now.stamp\n }\n },\n\n grid: {\n domain: 'switch',\n service: desiredGrid === 'on' ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'switch.edf_general_ok_switch'\n }\n },\n\n logbook: {\n domain: 'logbook',\n service: 'log',\n data: {\n name: 'Victron Grid Hysteresis',\n message: `${state} | Grid=${desiredGrid} | Latch=${desiredLatch} | SOC=${round1(soc)}% | AC=${Math.round(houseAcPowerW)}W | ${shortReason}`,\n entity_id: 'switch.edf_general_ok_switch'\n }\n },\n\n mobile_notification: {\n domain: cfg.mobileNotifyDomain,\n service: cfg.mobileNotifyService,\n data: {\n title: notifyTitle,\n message: notifyMessage,\n data: {\n push: {\n 'interruption-level': 'time-sensitive',\n sound: { name: 'default' }\n },\n ttl: 0,\n priority: 'high'\n }\n }\n }\n }\n};\n\nreturn [{ json: result }];\n" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 448, + 0 + ], + "id": "dde6d41d-3212-4371-83b3-b58d0d70c21e", + "name": "Compute Grid Hysteresis" + }, + { + "parameters": { + "jsCode": "const root = $input.first().json || {};\nconst decision = root.decision || {};\nconst calls = root.ha_service_calls || {};\nconst actions = [];\n\nfunction pushAction(action, call, enabled = true) {\n if (!enabled) return;\n if (!call) return;\n\n actions.push({\n json: {\n action,\n ...call\n }\n });\n}\n\n// ======================================================\n// HELPERS TOUJOURS MIS À JOUR\n// ======================================================\npushAction('helper_state', calls.helper_state, true);\npushAction('helper_reason', calls.helper_reason, true);\n\n// ======================================================\n// SIGNATURE SI CHANGEMENT\n// ======================================================\nif (decision.signature_changed) {\n pushAction('helper_signature', calls.helper_signature, true);\n}\n\n// ======================================================\n// SÉQUENCEMENT ACTIONS RÉSEAU / LATCH\n//\n// IMPORTANT :\n// - pour ALLUMER : latch ON d'abord, puis grid ON\n// - pour ÉTEINDRE : grid OFF d'abord, puis latch OFF\n// ======================================================\n\n// ---------- CAS 1 : LATCH ON avant GRID ON ----------\nif (decision.latch_action === 'turn_on') {\n pushAction('helper_latch', calls.helper_latch, true);\n}\n\nif (decision.grid_action === 'turn_on') {\n pushAction('grid', calls.grid, true);\n}\n\n// ---------- CAS 2 : GRID OFF avant LATCH OFF ----------\nif (decision.grid_action === 'turn_off') {\n pushAction('grid', calls.grid, true);\n}\n\nif (decision.latch_action === 'turn_off') {\n pushAction('helper_latch', calls.helper_latch, true);\n}\n\n// ======================================================\n// TIMESTAMPS SI NÉCESSAIRE\n// ======================================================\nif (decision.set_last_on_at) {\n pushAction('helper_last_on_at', calls.helper_last_on_at, true);\n}\n\nif (decision.set_last_off_at) {\n pushAction('helper_last_off_at', calls.helper_last_off_at, true);\n}\n\n// ======================================================\n// LOGBOOK SI CHANGEMENT DE SIGNATURE\n// ======================================================\nif (decision.signature_changed) {\n pushAction('logbook', calls.logbook, true);\n}\n\n// ======================================================\n// NOTIFICATION SI UTILE\n// ======================================================\nif (decision.should_notify) {\n pushAction('mobile_notification', calls.mobile_notification, true);\n}\n\nreturn actions;\n" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 672, + 0 + ], + "id": "d3728055-9f5b-4362-a275-247cc91c5f41", + "name": "Build Grid Hysteresis Actions" + }, + { + "parameters": { + "method": "POST", + "url": "={{ 'http://10.10.10.17:8123/api/services/' + $json.domain + '/' + $json.service }}\n", + "authentication": "genericCredentialType", + "genericAuthType": "httpBearerAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + }, + { + "name": "accept", + "value": "application/json" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $json.data }}", + "options": {} + }, + "id": "7d38f7cf-ad84-4481-a201-d21dd0dce321", + "name": "Execute HA Service", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 896, + 0 + ], + "alwaysOutputData": true, + "credentials": { + "httpBearerAuth": { + "id": "uv3sknYYigxBkaVN", + "name": "Bearer Auth account 2" + } + } + } + ], + "connections": { + "Schedule Trigger": { + "main": [ + [ + { + "node": "HA Snapshot Grid Hysteresis", + "type": "main", + "index": 0 + } + ] + ] + }, + "HA Snapshot Grid Hysteresis": { + "main": [ + [ + { + "node": "Compute Grid Hysteresis", + "type": "main", + "index": 0 + } + ] + ] + }, + "Compute Grid Hysteresis": { + "main": [ + [ + { + "node": "Build Grid Hysteresis Actions", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Grid Hysteresis Actions": { + "main": [ + [ + { + "node": "Execute HA Service", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1", + "binaryMode": "separate", + "availableInMCP": true, + "timeSavedMode": "fixed", + "callerPolicy": "workflowsFromSameOwner", + "saveDataErrorExecution": "all" + }, + "staticData": { + "node:Schedule Trigger": { + "recurrenceRules": [ + null + ], + "recurrenceRuleSignatures": [ + null + ] + } + }, + "meta": null, + "nodeGroups": [], + "pinData": {}, + "versionId": "ecba2d4a-5f6f-4024-951f-3bd01905cd03", + "activeVersionId": "ecba2d4a-5f6f-4024-951f-3bd01905cd03", + "versionCounter": 87, + "triggerCount": 1, + "sourceWorkflowId": null, + "shared": [ + { + "updatedAt": "2026-03-17T01:43:29.021Z", + "createdAt": "2026-03-17T01:43:29.021Z", + "role": "workflow:owner", + "workflowId": "7iIlu7LLTW7TZA8o", + "projectId": "1q2yd8IyTc8fb25V", + "project": { + "updatedAt": "2026-02-06T16:27:24.469Z", + "createdAt": "2026-02-06T16:23:40.444Z", + "id": "1q2yd8IyTc8fb25V", + "name": "thierry tallagrand ", + "type": "personal", + "icon": null, + "description": null, + "customTelemetryTags": [], + "creatorId": "8b9e9552-cd74-4863-82a3-174b3b3befa2" + } + } + ], + "tags": [], + "activeVersion": { + "updatedAt": "2026-07-09T01:00:10.890Z", + "createdAt": "2026-07-09T01:00:10.890Z", + "versionId": "ecba2d4a-5f6f-4024-951f-3bd01905cd03", + "workflowId": "7iIlu7LLTW7TZA8o", + "nodes": [ + { + "parameters": { + "rule": { + "interval": [ + { + "field": "minutes", + "minutesInterval": 1 + } + ] + } + }, + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1.3, + "position": [ + 0, + 0 + ], + "id": "0b9ad256-b51b-4921-b81b-5ba8a2113412", + "name": "Schedule Trigger" + }, + { + "parameters": { + "method": "POST", + "url": "=http://10.10.10.17:8123/api/template\n", + "authentication": "genericCredentialType", + "genericAuthType": "httpBearerAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + }, + { + "name": "Accept", + "value": "text/plain" + } + ] + }, + "sendBody": true, + "bodyParameters": { + "parameters": [ + { + "name": "template", + "value": "{{ {\n \"soc_percent\": states(\"sensor.victron_battery_soc\"),\n \"grid_state\": states(\"switch.edf_general_ok_switch\"),\n \"pv_power_w\": states(\"sensor.victron_pv_power_system\"),\n\n \"house_ac_power_w\": states(\"sensor.victron_consumption_power_l1\"),\n \"grid_power_w\": states(\"sensor.victron_grid_power_l1\"),\n\n \"evcs_ac_power_w\": states(\"sensor.victron_evcs_ac_power\"),\n \"evcs_charging\": states(\"switch.victron_evcs_charging\"),\n \"evcs_status_raw\": states(\"sensor.victron_evcs_status_raw\"),\n\n \"hysteresis_enabled\": states(\"input_boolean.victron_grid_hysteresis_enabled\"),\n \"notifications_enabled\": states(\"input_boolean.victron_grid_hysteresis_notifications\"),\n \"grid_latch\": states(\"input_boolean.victron_grid_hysteresis_latch\"),\n\n \"grid_soc_on\": states(\"input_number.victron_grid_soc_on\"),\n \"grid_soc_off\": states(\"input_number.victron_grid_soc_off\"),\n \"grid_min_on_minutes\": states(\"input_number.victron_grid_min_on_minutes\"),\n \"grid_cooldown_minutes\": states(\"input_number.victron_grid_cooldown_minutes\"),\n \"ac_loads_max_safe_w\": states(\"input_number.victron_ac_loads_max_safe_w\"),\n\n \"last_on_at\": states(\"input_datetime.victron_grid_last_on_at\"),\n \"last_off_at\": states(\"input_datetime.victron_grid_last_off_at\"),\n \"last_signature\": states(\"input_text.victron_grid_control_last_signature\"),\n\n \"evcs_priority_active\": states(\"input_boolean.victron_evcs_priority_active\"),\n \"night_grid_takeover_needed\": states(\"input_boolean.victron_evcs_night_grid_takeover_needed\"),\n \"evcs_source_mode\": states(\"input_text.victron_evcs_source_mode\"),\n \"user_strategy\": states(\"input_select.victron_evcs_strategy\"),\n \"evcs_surplus_state\": states(\"input_text.victron_evcs_surplus_state\")\n} | tojson }}\n" + } + ] + }, + "options": {} + }, + "id": "577c8308-a790-44f8-a9dc-098811bd5e0b", + "name": "HA Snapshot Grid Hysteresis", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 224, + 0 + ], + "credentials": { + "httpBearerAuth": { + "id": "uv3sknYYigxBkaVN", + "name": "Bearer Auth account 2" + } + } + }, + { + "parameters": { + "jsCode": "function num(v, fallback = NaN) {\n const n = Number.parseFloat(v);\n return Number.isFinite(n) ? n : fallback;\n}\n\nfunction low(v) {\n return (v ?? '').toString().trim().toLowerCase();\n}\n\nfunction parseMaybeJson(v) {\n if (typeof v !== 'string') return v;\n try {\n return JSON.parse(v);\n } catch {\n return v;\n }\n}\n\nfunction extractSnapshot(input) {\n if (typeof input === 'string') return parseMaybeJson(input);\n if (input?.data && typeof input.data === 'string') return parseMaybeJson(input.data);\n if (input?.body && typeof input.body === 'string') return parseMaybeJson(input.body);\n if (input?.data && typeof input.data === 'object') return input.data;\n if (input?.body && typeof input.body === 'object') return input.body;\n return input;\n}\n\nfunction round1(v) {\n return Math.round(v * 10) / 10;\n}\n\nfunction clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nfunction pickDefined(...values) {\n for (const v of values) {\n if (v === undefined || v === null) continue;\n if (typeof v === 'string' && v.trim() === '') continue;\n return v;\n }\n return undefined;\n}\n\nfunction isOnLike(v) {\n const s = low(v);\n return s === 'on' || s === 'true' || s === '1' || s === 'yes';\n}\n\nfunction getParisNowParts() {\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())\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const date = `${parts.year}-${parts.month}-${parts.day}`;\n const stamp = `${date} ${parts.hour}:${parts.minute}:${parts.second}`;\n\n return {\n year: Number(parts.year),\n month: Number(parts.month),\n day: Number(parts.day),\n hour: Number(parts.hour),\n minute: Number(parts.minute),\n second: Number(parts.second),\n date,\n stamp,\n };\n}\n\nfunction parseLocalStampToMs(s) {\n const text = (s ?? '').toString().trim();\n const m = text.match(/^(\\d{4})-(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2})$/);\n if (!m) return null;\n\n const y = Number(m[1]);\n const mo = Number(m[2]);\n const d = Number(m[3]);\n const h = Number(m[4]);\n const mi = Number(m[5]);\n const se = Number(m[6]);\n\n if (![y, mo, d, h, mi, se].every(Number.isFinite)) return null;\n\n return new Date(y, mo - 1, d, h, mi, se).getTime();\n}\n\nconst cfg = {\n mobileNotifyDomain: 'notify',\n mobileNotifyService: 'mobile_app_iphone_16_de_tt',\n};\n\nconst raw = $input.first().json;\nconst input = extractSnapshot(raw);\nconst now = getParisNowParts();\nconst nowMs = parseLocalStampToMs(now.stamp);\n\n// ======================================================\n// INPUTS PRINCIPAUX\n// ======================================================\nconst soc = clamp(num(input.soc_percent, 0), 0, 100);\nconst gridStateRaw = low(input.grid_state || 'off');\nconst gridIsOn = gridStateRaw === 'on';\nconst gridSwitchUnavailable = gridStateRaw === 'unavailable' || gridStateRaw === 'unknown' || gridStateRaw === 'none';\nconst pvPowerW = Math.max(0, num(input.pv_power_w, 0));\n\nconst houseAcPowerW = Math.max(0, num(input.house_ac_power_w, 0));\nconst gridPowerW = num(input.grid_power_w, 0);\n\nconst evcsAcPowerW = Math.max(0, num(input.evcs_ac_power_w, 0));\nconst evcsCharging = low(input.evcs_charging || 'off') === 'on';\nconst evcsStatusRaw = num(input.evcs_status_raw, NaN);\n\nconst hysteresisEnabled = low(input.hysteresis_enabled || 'off') === 'on';\nconst notificationsEnabled = low(input.notifications_enabled || 'off') === 'on';\nconst latchIsOn = low(input.grid_latch || 'off') === 'on';\n\nconst gridSocOn = clamp(num(input.grid_soc_on, 22), 0, 100);\nconst gridSocOff = clamp(num(input.grid_soc_off, 35), 0, 100);\nconst gridMinOnMinutes = Math.max(0, num(input.grid_min_on_minutes, 45));\nconst gridCooldownMinutes = Math.max(0, num(input.grid_cooldown_minutes, 15));\nconst acLoadsMaxSafeW = Math.max(0, num(input.ac_loads_max_safe_w, 7800));\n\nconst lastOnAt = (input.last_on_at ?? '').toString().trim();\nconst lastOffAt = (input.last_off_at ?? '').toString().trim();\nconst lastSignature = (input.last_signature ?? '').toString().trim();\n\nconst lastOnMs = parseLocalStampToMs(lastOnAt);\nconst lastOffMs = parseLocalStampToMs(lastOffAt);\n\nconst minutesSinceLastOn =\n lastOnMs !== null && nowMs !== null ? Math.max(0, (nowMs - lastOnMs) / 60000) : null;\n\nconst minutesSinceLastOff =\n lastOffMs !== null && nowMs !== null ? Math.max(0, (nowMs - lastOffMs) / 60000) : null;\n\nconst minOnElapsed =\n minutesSinceLastOn === null ? true : minutesSinceLastOn >= gridMinOnMinutes;\n\nconst cooldownElapsed =\n minutesSinceLastOff === null ? true : minutesSinceLastOff >= gridCooldownMinutes;\n\nconst socBand =\n soc <= gridSocOn ? 'low' :\n soc < gridSocOff ? 'mid' :\n 'high';\n\nconst evcsSeemsActive = evcsCharging || evcsAcPowerW > 300;\nconst acOverSafeLimit = houseAcPowerW >= acLoadsMaxSafeW;\n\n// ======================================================\n// EVCS OVERRIDE INPUTS\n// ======================================================\nconst evcsPriorityActive = isOnLike(\n pickDefined(\n input.evcs_priority_active,\n input.victron_evcs_priority_active,\n input['input_boolean.victron_evcs_priority_active']\n )\n);\n\nconst evcsNightGridTakeoverNeeded = isOnLike(\n pickDefined(\n input.night_grid_takeover_needed,\n input.victron_evcs_night_grid_takeover_needed,\n input['input_boolean.victron_evcs_night_grid_takeover_needed']\n )\n);\n\nconst evcsSourceModeRaw = (\n pickDefined(\n input.evcs_source_mode,\n input.victron_evcs_source_mode,\n input['input_text.victron_evcs_source_mode']\n ) ?? ''\n).toString().trim();\n\nconst evcsSourceMode = low(evcsSourceModeRaw);\n\nconst userStrategyRaw = (\n pickDefined(\n input.user_strategy,\n input.victron_evcs_user_strategy,\n input['input_select.victron_evcs_user_strategy']\n ) ?? ''\n).toString().trim();\n\nconst userStrategy = low(userStrategyRaw);\n\n// ======================================================\n// EVCS SEMANTIC STATE\n// Ici on ne détecte PAS la voiture pleine par seuils.\n// On consomme seulement l'état sémantique déjà produit\n// par le workflow EVCS.\n// ======================================================\nconst evcsSurplusStateRaw = (\n pickDefined(\n input.evcs_surplus_state,\n input.victron_evcs_surplus_state,\n input['input_text.victron_evcs_surplus_state']\n ) ?? ''\n).toString().trim();\n\nconst evcsSurplusState = low(evcsSurplusStateRaw);\n\nconst evcsVehicleNoDemandSemantic =\n evcsSurplusState === 'vehicle_full_or_paused' ||\n evcsSurplusState === 'vehicle_not_requesting_power' ||\n evcsSurplusState === 'vehicle_full' ||\n evcsSurplusState === 'charge_complete' ||\n evcsSurplusState.includes('full_or_paused') ||\n evcsSurplusState.includes('not_requesting_power') ||\n evcsSurplusState.includes('charge_complete') ||\n evcsSurplusState.includes('no_demand');\n\n// ======================================================\n// OVERRIDE EVCS VERS GRID\n// CORRECTION:\n// - evcsPriorityActive seul ne doit PLUS suffire à allumer EDF\n// - seuls les cas explicitement réseau peuvent forcer le grid\n// - ainsi \"Réseau HC midi = OFF\" redevient réellement respecté\n// ======================================================\nconst evcsExplicitGridRequest =\n userStrategy === 'force_grid_now' ||\n userStrategy === 'force_charge_now' ||\n evcsNightGridTakeoverNeeded ||\n evcsSourceMode === 'réseau forcé' ||\n evcsSourceMode === 'réseau hc autorisé' ||\n evcsSourceMode === 'grid_forced' ||\n evcsSourceMode === 'grid_hc_allowed';\n\nconst evcsGridOverride =\n !evcsVehicleNoDemandSemantic &&\n evcsExplicitGridRequest;\n\n// ======================================================\n// DÉCISION\n// ======================================================\nlet state = 'observe_only';\nlet reason = [];\nlet desiredGrid = gridIsOn ? 'on' : 'off';\nlet desiredLatch = latchIsOn ? 'on' : 'off';\n\n// ======================================================\n// FORCE_GRID + VEHICLE FULL IMMEDIATE CUT\n// FIX V2.5: Si force_grid_now actif et voiture pleine,\n// couper EDF immédiatement sans attendre minOnRuntime.\n// ======================================================\nlet forcedCut = false;\nif (\n userStrategy === 'force_grid_now' &&\n evcsVehicleNoDemandSemantic &&\n latchIsOn\n) {\n desiredGrid = 'off';\n desiredLatch = 'off';\n state = 'turn_off_force_grid_vehicle_full';\n reason.push('Force grid + voiture pleine : coupure immediate du reseau');\n forcedCut = true;\n}\n\n\n// ======================================================\n// CORE HYSTERESIS LOGIC\n// ======================================================\nif (gridSwitchUnavailable) {\n // Switch EDF hors ligne : mode sûr. Aucune action, aucun timestamp,\n // on garde le latch tel quel et on réévaluera au retour du switch.\n state = 'switch_unavailable';\n desiredGrid = gridIsOn ? 'on' : 'off';\n desiredLatch = latchIsOn ? 'on' : 'off';\n reason.push('Switch EDF indisponible : aucune action, attente du retour');\n\n} else if (forcedCut) {\n // Décision déjà prise plus haut (force_grid + voiture pleine) :\n // on ne laisse plus la chaîne principale l'écraser.\n\n} else if (evcsGridOverride) {\n desiredGrid = 'on';\n desiredLatch = 'on';\n\n if (!gridIsOn) {\n state = 'turn_on_evcs_override';\n reason.push('Override EVCS actif : activation EDF forcée');\n } else if (!latchIsOn) {\n state = 'adopt_existing_grid_on_evcs_override';\n reason.push('Override EVCS actif : EDF déjà ON, adoption par latch');\n } else {\n state = 'hold_on_evcs_override';\n reason.push('Override EVCS actif : maintien EDF ON');\n }\n\n if (userStrategy === 'force_grid_now') {\n reason.push('Cause override : force_grid_now');\n } else if (userStrategy === 'force_charge_now') {\n reason.push('Cause override : force_charge_now');\n } else if (evcsNightGridTakeoverNeeded) {\n reason.push('Cause override : night_grid_takeover_needed');\n } else {\n reason.push(`Cause override : source_mode=${evcsSourceModeRaw || 'n/a'}`);\n }\n\n} else if (!hysteresisEnabled) {\n state = 'hysteresis_disabled';\n desiredGrid = gridIsOn ? 'on' : 'off';\n desiredLatch = latchIsOn ? 'on' : 'off';\n reason.push('Automatisme hystérésis réseau désactivé');\n\n} else if (soc <= gridSocOn) {\n desiredGrid = 'on';\n desiredLatch = 'on';\n\n if (!cooldownElapsed && !gridIsOn) {\n state = 'turn_on_low_soc_cooldown_bypassed';\n reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : activation EDF malgré cooldown`);\n } else if (!gridIsOn) {\n state = 'turn_on_low_soc';\n reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : activation EDF`);\n } else if (!latchIsOn) {\n state = 'adopt_existing_grid_on_low_soc';\n reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : EDF déjà ON, prise de contrôle par latch`);\n } else {\n state = 'hold_on_low_soc';\n reason.push(`SOC encore bas ${round1(soc)}% : maintien EDF ON`);\n }\n\n} else if (gridIsOn && !latchIsOn) {\n if (soc < gridSocOff) {\n desiredGrid = 'on';\n desiredLatch = 'on';\n state = 'adopt_existing_grid_on';\n reason.push(`EDF déjà ON avec latch OFF et SOC ${round1(soc)}% < seuil OFF ${gridSocOff}% : adoption de l'état ON`);\n } else {\n desiredGrid = 'off';\n desiredLatch = 'off';\n state = 'turn_off_stray_grid_on';\n reason.push(`EDF ON avec latch OFF et SOC ${round1(soc)}% >= seuil OFF ${gridSocOff}% : coupure de reprise de contrôle`);\n }\n\n} else if (latchIsOn) {\n if (!minOnElapsed) {\n desiredGrid = 'on';\n desiredLatch = 'on';\n state = 'hold_on_min_runtime';\n reason.push(`EDF maintenu ON : durée mini ${gridMinOnMinutes} min non atteinte (${round1(minutesSinceLastOn || 0)} min)`);\n } else if (soc < gridSocOff) {\n desiredGrid = 'on';\n desiredLatch = 'on';\n state = 'hold_on_until_soc_off';\n reason.push(`EDF maintenu ON : SOC ${round1(soc)}% < seuil OFF ${gridSocOff}%`);\n } else {\n desiredGrid = 'off';\n desiredLatch = 'off';\n state = 'turn_off_recovered';\n reason.push(`SOC ${round1(soc)}% >= seuil OFF ${gridSocOff}% : coupure EDF`);\n }\n\n} else {\n desiredGrid = 'off';\n desiredLatch = 'off';\n state = 'observe_only';\n reason.push('Aucune action : hors zone d’hystérésis');\n}\n\n// ======================================================\n// COMPARE DESIRED VS CURRENT\n// ======================================================\nconst gridAction =\n desiredGrid === (gridIsOn ? 'on' : 'off')\n ? 'none'\n : desiredGrid === 'on'\n ? 'turn_on'\n : 'turn_off';\n\nconst latchAction =\n desiredLatch === (latchIsOn ? 'on' : 'off')\n ? 'none'\n : desiredLatch === 'on'\n ? 'turn_on'\n : 'turn_off';\n\n// ======================================================\n// TIMESTAMPS\n// ======================================================\nconst setLastOnAt =\n (gridAction === 'turn_on' && latchAction === 'turn_on') ||\n state === 'adopt_existing_grid_on' ||\n state === 'adopt_existing_grid_on_low_soc' ||\n state === 'adopt_existing_grid_on_evcs_override';\n\nconst setLastOffAt =\n gridAction === 'turn_off';\n\n// ======================================================\n// CONTEXT / ALERTS\n// ======================================================\nreason.push(`Grid actuel ${gridIsOn ? 'ON' : 'OFF'}`);\nreason.push(`Latch actuel ${latchIsOn ? 'ON' : 'OFF'}`);\nreason.push(`SOC ${round1(soc)}% | seuils ${gridSocOn}%/${gridSocOff}%`);\nreason.push(`PV ${Math.round(pvPowerW)}W`);\nreason.push(`Charge AC maison ${Math.round(houseAcPowerW)}W`);\nreason.push(`Puissance réseau ${Math.round(gridPowerW)}W`);\nreason.push(`EVCS ${evcsSeemsActive ? 'actif' : 'inactif'} | ${Math.round(evcsAcPowerW)}W | status_raw=${Number.isFinite(evcsStatusRaw) ? evcsStatusRaw : 'n/a'}`);\n\nif (minutesSinceLastOn !== null) {\n reason.push(`Dernier ON il y a ${round1(minutesSinceLastOn)} min`);\n}\nif (minutesSinceLastOff !== null) {\n reason.push(`Dernier OFF il y a ${round1(minutesSinceLastOff)} min`);\n}\n\nif (acOverSafeLimit) {\n reason.push(`⚠️ Charge AC maison ${Math.round(houseAcPowerW)}W >= limite sûre ${Math.round(acLoadsMaxSafeW)}W`);\n}\n\nreason.push(`EVCS override ${evcsGridOverride ? 'ON' : 'OFF'}`);\nreason.push(`EVCS explicit grid request ${evcsExplicitGridRequest ? 'ON' : 'OFF'}`);\nreason.push(`EVCS priority ${evcsPriorityActive ? 'ON' : 'OFF'}`);\nreason.push(`EVCS night takeover ${evcsNightGridTakeoverNeeded ? 'ON' : 'OFF'}`);\nreason.push(`EVCS source ${evcsSourceModeRaw || 'n/a'}`);\nreason.push(`EVCS strategy ${userStrategyRaw || 'n/a'}`);\nreason.push(`EVCS surplus state ${evcsSurplusStateRaw || 'n/a'}`);\nreason.push(`EVCS no-demand semantic ${evcsVehicleNoDemandSemantic ? 'ON' : 'OFF'}`);\n\n// ======================================================\n// NOTIFICATIONS\n// ======================================================\nlet notifyTitle = 'Victron Grid Hysteresis';\nlet notifyMessage = '';\nlet notifyWanted = false;\n\nif (\n state === 'turn_on_evcs_override' ||\n state === 'adopt_existing_grid_on_evcs_override' ||\n state === 'hold_on_evcs_override'\n) {\n notifyTitle = '🚗 EDF forcé par EVCS';\n notifyMessage = `Priorité EVCS active. EDF maintenu ON pour la recharge véhicule. Cause: ${userStrategyRaw || evcsSourceModeRaw || 'override EVCS'}.`;\n notifyWanted = true;\n\n} else if (state === 'turn_on_low_soc' || state === 'turn_on_low_soc_cooldown_bypassed') {\n notifyTitle = '⚡ EDF activé sur SOC bas';\n notifyMessage = `SOC ${round1(soc)}% <= ${gridSocOn}%. EDF activé et maintenu jusqu'à au moins ${gridSocOff}% (min ${gridMinOnMinutes} min).`;\n notifyWanted = true;\n\n} else if (state === 'adopt_existing_grid_on_low_soc' || state === 'adopt_existing_grid_on') {\n notifyTitle = '🔁 EDF ON repris par hystérésis';\n notifyMessage = `EDF était déjà ON. Le workflow reprend la main et le maintient jusqu'à ${gridSocOff}% (min ${gridMinOnMinutes} min).`;\n notifyWanted = true;\n\n} else if (state === 'turn_off_recovered') {\n notifyTitle = '✅ EDF coupé après recharge';\n notifyMessage = `SOC ${round1(soc)}% >= ${gridSocOff}%. Retour hors réseau autorisé.`;\n notifyWanted = true;\n\n} else if (state === 'turn_off_stray_grid_on') {\n notifyTitle = '✅ EDF coupé (reprise de contrôle)';\n notifyMessage = `EDF était ON sans latch. SOC ${round1(soc)}% >= ${gridSocOff}%, coupure réseau appliquée.`;\n notifyWanted = true;\n\n} else if (acOverSafeLimit && !gridIsOn) {\n notifyTitle = '🚨 Charge AC élevée hors réseau';\n notifyMessage = `Charge AC maison ${Math.round(houseAcPowerW)}W au-dessus de la limite sûre ${Math.round(acLoadsMaxSafeW)}W alors que EDF est OFF.`;\n notifyWanted = true;\n\n} else if (acOverSafeLimit && gridIsOn) {\n notifyTitle = '⚠️ Charge AC élevée';\n notifyMessage = `Charge AC maison ${Math.round(houseAcPowerW)}W proche/au-dessus de la limite sûre ${Math.round(acLoadsMaxSafeW)}W.`;\n notifyWanted = true;\n}\n\nconst shortReason = reason.join(' | ').slice(0, 255);\n\nconst signature = [\n now.date,\n state,\n `dg=${desiredGrid}`,\n `dl=${desiredLatch}`,\n `sb=${socBand}`,\n `ac=${acOverSafeLimit ? '1' : '0'}`,\n `ov=${evcsGridOverride ? '1' : '0'}`,\n `eg=${evcsExplicitGridRequest ? '1' : '0'}`,\n `ep=${evcsPriorityActive ? '1' : '0'}`,\n `es=${evcsSurplusState || 'na'}`\n].join('|');\n\nconst signatureChanged = signature !== lastSignature;\n\n// ======================================================\n// OUTPUT\n// ======================================================\nconst result = {\n timestamp_paris: now.stamp,\n\n inputs: {\n soc_percent: soc,\n grid_state: gridIsOn ? 'on' : 'off',\n pv_power_w: pvPowerW,\n house_ac_power_w: houseAcPowerW,\n grid_power_w: gridPowerW,\n evcs_ac_power_w: evcsAcPowerW,\n evcs_charging: evcsCharging,\n evcs_status_raw: Number.isFinite(evcsStatusRaw) ? evcsStatusRaw : null,\n\n hysteresis_enabled: hysteresisEnabled,\n notifications_enabled: notificationsEnabled,\n grid_latch: latchIsOn,\n grid_soc_on: gridSocOn,\n grid_soc_off: gridSocOff,\n grid_min_on_minutes: gridMinOnMinutes,\n grid_cooldown_minutes: gridCooldownMinutes,\n ac_loads_max_safe_w: acLoadsMaxSafeW,\n\n last_on_at: lastOnAt || null,\n last_off_at: lastOffAt || null,\n last_signature: lastSignature || null,\n\n evcs_priority_active: evcsPriorityActive,\n night_grid_takeover_needed: evcsNightGridTakeoverNeeded,\n evcs_source_mode: evcsSourceModeRaw || null,\n user_strategy: userStrategyRaw || null,\n evcs_surplus_state: evcsSurplusStateRaw || null\n },\n\n computed: {\n desired_grid: desiredGrid,\n desired_latch: desiredLatch,\n minutes_since_last_on: minutesSinceLastOn !== null ? round1(minutesSinceLastOn) : null,\n minutes_since_last_off: minutesSinceLastOff !== null ? round1(minutesSinceLastOff) : null,\n min_on_elapsed: minOnElapsed,\n cooldown_elapsed: cooldownElapsed,\n ac_over_safe_limit: acOverSafeLimit,\n evcs_seems_active: evcsSeemsActive,\n\n evcs_grid_override: evcsGridOverride,\n evcs_explicit_grid_request: evcsExplicitGridRequest,\n evcs_priority_active: evcsPriorityActive,\n evcs_night_grid_takeover_needed: evcsNightGridTakeoverNeeded,\n evcs_source_mode: evcsSourceModeRaw || null,\n evcs_user_strategy: userStrategyRaw || null,\n evcs_surplus_state: evcsSurplusStateRaw || null,\n evcs_vehicle_no_demand_semantic: evcsVehicleNoDemandSemantic\n },\n\n decision: {\n state,\n grid_action: gridAction,\n latch_action: latchAction,\n set_last_on_at: setLastOnAt,\n set_last_off_at: setLastOffAt,\n should_notify: notificationsEnabled && signatureChanged && notifyWanted,\n signature,\n signature_changed: signatureChanged,\n reason,\n short_reason: shortReason\n },\n\n ha_service_calls: {\n helper_state: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_grid_control_state',\n value: state\n }\n },\n\n helper_reason: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_grid_control_reason',\n value: shortReason || 'Aucune raison'\n }\n },\n\n helper_signature: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_grid_control_last_signature',\n value: signature\n }\n },\n\n helper_latch: {\n domain: 'input_boolean',\n service: desiredLatch === 'on' ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_grid_hysteresis_latch'\n }\n },\n\n helper_last_on_at: {\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_grid_last_on_at',\n datetime: now.stamp\n }\n },\n\n helper_last_off_at: {\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_grid_last_off_at',\n datetime: now.stamp\n }\n },\n\n grid: {\n domain: 'switch',\n service: desiredGrid === 'on' ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'switch.edf_general_ok_switch'\n }\n },\n\n logbook: {\n domain: 'logbook',\n service: 'log',\n data: {\n name: 'Victron Grid Hysteresis',\n message: `${state} | Grid=${desiredGrid} | Latch=${desiredLatch} | SOC=${round1(soc)}% | AC=${Math.round(houseAcPowerW)}W | ${shortReason}`,\n entity_id: 'switch.edf_general_ok_switch'\n }\n },\n\n mobile_notification: {\n domain: cfg.mobileNotifyDomain,\n service: cfg.mobileNotifyService,\n data: {\n title: notifyTitle,\n message: notifyMessage,\n data: {\n push: {\n 'interruption-level': 'time-sensitive',\n sound: { name: 'default' }\n },\n ttl: 0,\n priority: 'high'\n }\n }\n }\n }\n};\n\nreturn [{ json: result }];\n" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 448, + 0 + ], + "id": "dde6d41d-3212-4371-83b3-b58d0d70c21e", + "name": "Compute Grid Hysteresis" + }, + { + "parameters": { + "jsCode": "const root = $input.first().json || {};\nconst decision = root.decision || {};\nconst calls = root.ha_service_calls || {};\nconst actions = [];\n\nfunction pushAction(action, call, enabled = true) {\n if (!enabled) return;\n if (!call) return;\n\n actions.push({\n json: {\n action,\n ...call\n }\n });\n}\n\n// ======================================================\n// HELPERS TOUJOURS MIS À JOUR\n// ======================================================\npushAction('helper_state', calls.helper_state, true);\npushAction('helper_reason', calls.helper_reason, true);\n\n// ======================================================\n// SIGNATURE SI CHANGEMENT\n// ======================================================\nif (decision.signature_changed) {\n pushAction('helper_signature', calls.helper_signature, true);\n}\n\n// ======================================================\n// SÉQUENCEMENT ACTIONS RÉSEAU / LATCH\n//\n// IMPORTANT :\n// - pour ALLUMER : latch ON d'abord, puis grid ON\n// - pour ÉTEINDRE : grid OFF d'abord, puis latch OFF\n// ======================================================\n\n// ---------- CAS 1 : LATCH ON avant GRID ON ----------\nif (decision.latch_action === 'turn_on') {\n pushAction('helper_latch', calls.helper_latch, true);\n}\n\nif (decision.grid_action === 'turn_on') {\n pushAction('grid', calls.grid, true);\n}\n\n// ---------- CAS 2 : GRID OFF avant LATCH OFF ----------\nif (decision.grid_action === 'turn_off') {\n pushAction('grid', calls.grid, true);\n}\n\nif (decision.latch_action === 'turn_off') {\n pushAction('helper_latch', calls.helper_latch, true);\n}\n\n// ======================================================\n// TIMESTAMPS SI NÉCESSAIRE\n// ======================================================\nif (decision.set_last_on_at) {\n pushAction('helper_last_on_at', calls.helper_last_on_at, true);\n}\n\nif (decision.set_last_off_at) {\n pushAction('helper_last_off_at', calls.helper_last_off_at, true);\n}\n\n// ======================================================\n// LOGBOOK SI CHANGEMENT DE SIGNATURE\n// ======================================================\nif (decision.signature_changed) {\n pushAction('logbook', calls.logbook, true);\n}\n\n// ======================================================\n// NOTIFICATION SI UTILE\n// ======================================================\nif (decision.should_notify) {\n pushAction('mobile_notification', calls.mobile_notification, true);\n}\n\nreturn actions;\n" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 672, + 0 + ], + "id": "d3728055-9f5b-4362-a275-247cc91c5f41", + "name": "Build Grid Hysteresis Actions" + }, + { + "parameters": { + "method": "POST", + "url": "={{ 'http://10.10.10.17:8123/api/services/' + $json.domain + '/' + $json.service }}\n", + "authentication": "genericCredentialType", + "genericAuthType": "httpBearerAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + }, + { + "name": "accept", + "value": "application/json" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $json.data }}", + "options": {} + }, + "id": "7d38f7cf-ad84-4481-a201-d21dd0dce321", + "name": "Execute HA Service", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 896, + 0 + ], + "alwaysOutputData": true, + "credentials": { + "httpBearerAuth": { + "id": "uv3sknYYigxBkaVN", + "name": "Bearer Auth account 2" + } + } + } + ], + "connections": { + "Schedule Trigger": { + "main": [ + [ + { + "node": "HA Snapshot Grid Hysteresis", + "type": "main", + "index": 0 + } + ] + ] + }, + "HA Snapshot Grid Hysteresis": { + "main": [ + [ + { + "node": "Compute Grid Hysteresis", + "type": "main", + "index": 0 + } + ] + ] + }, + "Compute Grid Hysteresis": { + "main": [ + [ + { + "node": "Build Grid Hysteresis Actions", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Grid Hysteresis Actions": { + "main": [ + [ + { + "node": "Execute HA Service", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "nodeGroups": [], + "authors": "thierry tallagrand", + "name": null, + "description": null, + "autosaved": false, + "workflowPublishHistory": [ + { + "createdAt": "2026-07-09T01:00:10.991Z", + "id": 162, + "workflowId": "7iIlu7LLTW7TZA8o", + "versionId": "ecba2d4a-5f6f-4024-951f-3bd01905cd03", + "event": "activated", + "userId": "8b9e9552-cd74-4863-82a3-174b3b3befa2" + }, + { + "createdAt": "2026-07-09T21:45:52.998Z", + "id": 188, + "workflowId": "7iIlu7LLTW7TZA8o", + "versionId": "ecba2d4a-5f6f-4024-951f-3bd01905cd03", + "event": "activated", + "userId": "8b9e9552-cd74-4863-82a3-174b3b3befa2" + }, + { + "createdAt": "2026-07-09T01:00:10.961Z", + "id": 161, + "workflowId": "7iIlu7LLTW7TZA8o", + "versionId": "ecba2d4a-5f6f-4024-951f-3bd01905cd03", + "event": "deactivated", + "userId": "8b9e9552-cd74-4863-82a3-174b3b3befa2" + }, + { + "createdAt": "2026-07-09T21:45:52.981Z", + "id": 187, + "workflowId": "7iIlu7LLTW7TZA8o", + "versionId": "ecba2d4a-5f6f-4024-951f-3bd01905cd03", + "event": "deactivated", + "userId": "8b9e9552-cd74-4863-82a3-174b3b3befa2" + } + ] + } +}