diff --git a/HANDOFF.md b/HANDOFF.md index 8937e5c..11fecc8 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1242,3 +1242,71 @@ branché mi-session) --- *Document mis à jour le : 22/08/2026 (~18:05) — Fix G déployé et validé (syntaxe + golden-hysteresis 7/7 + PUT OK), à confirmer en conditions réelles à la prochaine fermeture de fenêtre HC mi-session + +## 25. Session du 24/08/2026 — Fix G retiré + diagnostic des deux oscillateurs + +### Fix G retiré de la production (déployé 24/08 12:18, golden-hysteresis 7/7) +Fix G (§24) a produit en production un yoyo EDF de 2-4 min la nuit du 23-24/08, +véhicule débranché : coupure à SOC 21 % (Fix G, bypass min_on) puis rallumage à +SOC 20 % (branche `hold_on_low_soc`, bypass cooldown), MPPT qui flappe à chaque +bascule. Logbook : `turn_off_evcs_override_released` 06:43:03 → `hold_on_low_soc` +06:45:03 → `turn_on_low_soc_cooldown_bypassed` 06:52:03. + +**Erreur de conception** : le garde `soc > gridSocOn` ne protégeait que l'INSTANT +de l'engagement sur SOC bas (`soc <= 20`), pas la PHASE DE REMONTÉE 20 → 30 qui +est 100 % de la durée de vie de ce cas. Fix G a donc remplacé l'hystérésis 20/30 +par une hystérésis 20/20,1. + +**Erreur de test** : le fixture `tests/golden-hysteresis/` portait `grid_soc_on: +"22"` / `grid_soc_off: "35"` — les *valeurs de repli du code*, pas la production +(vérifiée via MCP : **20 / 30**, inchangées depuis le 07/08). Et le scénario de +non-régression `g-baseline-low-soc-no-override` utilisait SOC 18 %, c'est-à-dire +le seul point que Fix G épargnait. → **Règle : figer un fixture de test sur des +valeurs lues en live via MCP, jamais sur les fallbacks du code.** + +Retrait : `tools/patch-20260824-revert-g.mjs` (rejouable) — le jsCode résultant +est byte-identique à la version pré-Fix-G (commit `0419c74`), vérifié. +Suite réécrite (7 scénarios, seuils réels 20/30) avec les garde-fous manquants +`h-recovery-band-no-cut-21` / `-25` : la remontée batterie sur réseau ne doit +JAMAIS être interrompue avant `grid_soc_off`. Test de mutation : Fix G réappliqué +→ 4/7 échecs, retiré → 7/7. Le filet attrape désormais la régression. + +### Diagnostic : deux oscillateurs, même pathologie +Détail complet dans `docs/MANUEL_COMPLET.md` §11.8. Résumé : + +**A — Yoyo EDF.** Le problème de fond n'est pas le réglage de `grid_soc_off` +mais le fait qu'il n'a aucun sens physique : le 24/08, la PV ne couvre la conso +maison qu'à partir de ~09:00 (0,07 → 0,63 → 2,74 kWh/h contre 0,75 kWh/h de +conso). Couper EDF à 06:43 à 21 % rend le yoyo inévitable quels que soient les +seuils. Le workflow hystérésis lit 25 champs et **aucune prévision solaire**. +Cible : session réseau unique dimensionnée jusqu'au croisement PV/conso, +arbitrée par le conseiller IA. NB : mesurer la vraie capacité PV impose de +couper EDF (sinon le MPPT est bridé et on ne voit rien). + +**B — Oscillation charge EVCS en plein soleil** (24/08 ~11:58, PV 7,9 kW). +Ce n'est PAS une limite 5 kVA : aucun `ve_bus_overload` depuis le 20/08, +`ac_loads_max_safe_w` = 9000, AC-out L1 à 5335 W sans overload, plafond calculé +~20 A. Causes réelles : +1. `battery_discharge_soft_a` = 0 A et `battery_neutral_band_a` = 0 A annulent + la tolérance de −12 A de la bande SOC 40-60 % (`profileBatteryDischargeFloorA + = max(profil, global)`) → bande morte nulle, toute pointe de conso casse + `solarHoldOk` ; avec `min_run_minutes` = 3 → cycle de 3 à 6 min. +2. En `outside_window` le plancher de démarrage est `soc_start_min` = 80 % pour + un SOC de 49 % : seul l'override d'abondance P6 autorise le départ, et il + exige `batteryCurrentA >= +20 A` — que la charge du véhicule fait justement + tomber. **L'action détruit sa propre condition d'autorisation.** + +### Décisions de conception validées avec l'utilisateur +- La conso se **lit**, elle ne se devine pas ; filtrage numérique dynamique des + pointes (improbables la nuit hors charge EVCS ; pas de moyenne quand aucun + véhicule n'est branché). +- SOC : lissage pour les décisions de confort, **valeur instantanée conservée + pour la sécurité** (le risque prime). +- Les seuils fixes doivent devenir dynamiques (saison, prévision) et être + arbitrés par le conseiller IA : « un automatisme c'est trop rigide pour + traiter tous ces cas tordus ». Ne plus empiler de branches codées en dur. +- Exploiter les messages d'état réels du Victron et de la borne au lieu de + s'auto-limiter à l'aveugle sur un proxy. + +--- +*Document mis à jour le : 24/08/2026 (~12:20) — Fix G retiré, golden-hysteresis 7/7 (seuils réels 20/30), diagnostic des deux oscillateurs* diff --git a/docs/MANUEL_COMPLET.md b/docs/MANUEL_COMPLET.md index 6aa5461..9321d26 100644 --- a/docs/MANUEL_COMPLET.md +++ b/docs/MANUEL_COMPLET.md @@ -445,13 +445,7 @@ Y1 EDF ON + latch │ (turn_off_vehicle_unplugged, bypass min_on — Fix C) │ v4: es ∈ {night_recovery_done_idle, departure_done_idle} + SOC ≥ seuil OFF │ → coupure immédiate (turn_off_recovery_done, bypass min_on — E2+P9) - │ v5: override EVCS disparu (evcsGridOverride → false) ET SOC > seuil ON - │ (PAS seuil OFF : ne touche pas au cas indépendant "SOC bas") - │ → coupure immédiate (turn_off_evcs_override_released, bypass - │ min_on — Fix G, 22/08/2026). Contrairement à v3/v4, ne dépend PAS - │ d'un nom exact de `surplus_state` — couvre tout état transitoire - │ (y compris ceux non encore nommés). Garde anti-glitch : ignoré si - │ la lecture `evcs_source_mode` est vide/unavailable/unknown. + │ (v5 "Fix G" a existé du 22 au 24/08/2026 puis a été RETIRÉ : voir §11.8) └─▶ retour Y0 (cooldown 15 min) ``` @@ -488,8 +482,12 @@ retesté. Remises à zéro : débranchement, session fraîche (P11), ou force_*. terminé) → `surplus_state` retombe sur un état générique transitoire (`stop_source_lost`/`idle_wait_source`) qu'aucune règle v3/v4 ne reconnaît → EDF maintenu des heures par la boucle générique jusqu'à `gridSocOff`, PV - plafonné ~100 W par le MPPT. → Fix G (v5 : coupure générique dès que - l'override EVCS disparaît, sans dépendre du nom d'état `surplus_state`). + plafonné ~100 W par le MPPT. → Fix G (v5), **retiré le 24/08** : mauvais + diagnostic, le sujet réel est le bilan énergétique (§11.8). +- **Nuit 23-24/08 (yoyo EDF de 2-4 min)** : Fix G coupait à SOC 21 % ce que la + branche « SOC bas » venait de rallumer à 20 % → hystérésis 20/30 réduite à + 20/20,1, MPPT qui flappe à chaque bascule. → Fix G retiré, garde-fous + anti-yoyo ajoutés à `tests/golden-hysteresis/` (§11.8). ### 11.7 Conseiller IA — mode ombre et câblages réels @@ -505,6 +503,71 @@ retesté. Remises à zéro : débranchement, session fraîche (P11), ou force_*. --- +### 11.8 Les deux oscillateurs identifiés le 24/08/2026 + +Deux boucles de rétroaction distinctes, de même nature : **une décision prise +sur un seuil que l'action elle-même vient détruire**, sans bande morte. + +#### A. Yoyo EDF (workflow hystérésis) — Fix G retiré + +Fix G coupait EDF dès que l'override EVCS disparaissait et que +`soc > grid_soc_on`. Or la branche « SOC bas » allume EDF à `grid_soc_on` +(20 %) et doit tenir jusqu'à `grid_soc_off` (30 %) : pendant TOUTE cette +remontée, `soc > grid_soc_on` est vrai. Fix G coupait donc à 21 % ce que la +branche basse rallumait à 20 %, les deux en bypass de leur amortisseur +(min_on 25 min d'un côté, cooldown 15 min de l'autre) → oscillateur libre de +2 à 4 minutes, MPPT qui flappe à chaque bascule. Retiré le 24/08. + +**Le problème de fond n'est pas le seuil mais son absence de sens physique.** +Le 24/08, conso maison ≈ 0,75 kWh/h de 07:00 à 09:00 pour une production +solaire de 0,07 puis 0,63 kWh/h : rien ne peut relayer EDF avant ~09:00, où la +PV passe à 2,74 kWh/h et la batterie remonte seule de 21 à 31 %. Couper EDF à +06:43 à 21 % rend le yoyo **mécaniquement inévitable, quels que soient les +seuils**. Double peine : EDF ON bride le MPPT (ESS sans injection), donc la +seule ressource capable de sortir du yoyo est étouffée par le yoyo. + +Constat structurel : le snapshot du workflow hystérésis lit 25 champs et +**aucune prévision solaire, aucune conso projetée, aucun helper advisor**. Il +décide sur un point de SOC instantané comparé à deux nombres fixes. `30 %` +n'est pas mal réglé, il est hors sujet : la bonne valeur dépend de l'heure, de +la saison et de la courbe du jour (plus haute en saison des pluies, plus basse +par forte prévision). Cible retenue : une **session réseau unique et +dimensionnée** (charger en une fois jusqu'au niveau qui permet de tenir +jusqu'au croisement PV/conso), arbitrée par le conseiller IA, les règles codées +restant un filet de sécurité borné. + +#### B. Oscillation de la charge EVCS en plein soleil + +Observée le 24/08 vers 11:58-12:00 avec PV 7,7-7,9 kW : la borne alterne +`Waiting for start` / 0 A et `Charging` / 7 A. **Ce n'est pas une limite +5 kVA** — aucun `binary_sensor.victron_ve_bus_overload` depuis le 20/08, +`ac_loads_max_safe_w` = 9000 W, AC-out L1 mesuré à 5335 W sans overload (le +parallèle maître/esclave tient bien au-delà de 5 kVA), et le plafond calculé +par `WINDOWS_SOURCEV2` ressort à ~20 A. Deux causes réelles : + +1. **Bande morte nulle.** `battery_discharge_soft_a` = 0 A et + `battery_neutral_band_a` = 0 A (réglés à 0 le 17/07/2026). Comme + `profileBatteryDischargeFloorA = max(seuil du profil, réglage global)`, la + tolérance de −12 A prévue pour la bande SOC 40-60 % est **annulée** : la + moindre pointe de conso (ballon, lave-vaisselle) fait passer le courant + batterie sous 0 A et casse `solarHoldOk`. Avec `min_run_minutes` = 3, la + période du cycle est de 3 à 6 minutes. +2. **Condition de démarrage auto-destructrice (P6).** En `outside_window` + (11:00-12:24) le plancher de démarrage est `soc_start_min` = 80 % alors que + le SOC est à 49 %. Le seul chemin autorisant le départ est l'override + d'abondance P6, qui exige `pvPowerAvg10m ≥ 4000 W` **et** + `batteryCurrentA ≥ +20 A`. Or brancher la voiture détourne le surplus et + fait tomber le courant batterie sous +20 A : l'action détruit sa propre + condition d'autorisation. Système bistable → oscillateur. + +Direction retenue (même logique qu'en A) : lire la conso au lieu de la +deviner, la filtrer numériquement (les pointes nocturnes sont improbables hors +charge EVCS), lisser le SOC pour les décisions de confort en gardant +l'instantané pour la sécurité, et exploiter les messages d'état réels du +Victron et de la borne plutôt que de s'auto-limiter à l'aveugle sur un proxy. + +--- + ## 12. Configuration opencode.json ```json diff --git a/tests/golden-hysteresis/fixture-snapshot.json b/tests/golden-hysteresis/fixture-snapshot.json index 66c5e1f..4c464be 100644 --- a/tests/golden-hysteresis/fixture-snapshot.json +++ b/tests/golden-hysteresis/fixture-snapshot.json @@ -14,8 +14,8 @@ "notifications_enabled": "on", "grid_latch": "off", - "grid_soc_on": "22", - "grid_soc_off": "35", + "grid_soc_on": "20", + "grid_soc_off": "30", "grid_min_on_minutes": "25", "grid_cooldown_minutes": "15", "ac_loads_max_safe_w": "7800", diff --git a/tests/golden-hysteresis/scenarios.mjs b/tests/golden-hysteresis/scenarios.mjs index 683b507..f20b24b 100644 --- a/tests/golden-hysteresis/scenarios.mjs +++ b/tests/golden-hysteresis/scenarios.mjs @@ -1,14 +1,22 @@ // Scénarios golden pour le node "Compute Grid Hysteresis" // (workflow "Victron - Battery / Grid Hysteresis Fix V1"). // -// Contexte (Fix G, 22/08/2026) : incident nuit 21-22/08 — la fenêtre HC nuit -// se ferme pendant que le véhicule est encore en session active (pas encore -// flag "charge terminée"). Le contrôleur EVCS relâche correctement son -// override (helper night_grid_takeover_needed -> off, source_mode -> Bloqué) -// mais émet un état transitoire générique (stop_source_lost / idle_wait_source) -// qu'aucune règle de coupure immédiate existante (Fix C/E2) ne reconnaît. -// EDF restait alors maintenu par la boucle générique jusqu'au seuil -// grid_soc_off (35%), potentiellement des heures. +// Seuils du fixture = valeurs RÉELLES de production (vérifiées via MCP +// ha-get-state le 24/08/2026) : grid_soc_on = 20, grid_soc_off = 30, +// min_on = 25 min, cooldown = 15 min. Ne PAS utiliser les valeurs de repli +// codées dans le node (22/35) : elles ne correspondent à rien en production +// et masquent les bugs de bande. +// +// Historique : cette suite a été créée le 22/08/2026 pour valider "Fix G" +// (coupure immédiate dès que l'override EVCS disparaissait et que +// SOC > grid_soc_on). Fix G a été RETIRÉ le 24/08/2026 : son garde SOC ne +// protégeait que l'instant de l'engagement sur SOC bas, pas la phase de +// remontée grid_soc_on -> grid_soc_off, ce qui transformait l'hystérésis +// 20/30 en hystérésis 20/20,1 et produisait un yoyo EDF de 2 à 4 minutes. +// Les scénarios `h-recovery-band-*` ci-dessous sont les garde-fous qui +// auraient attrapé cette régression : ils vérifient que la remontée de la +// batterie sur réseau n'est JAMAIS interrompue tant que grid_soc_off n'est +// pas atteint. function stamp(ms) { const p = new Intl.DateTimeFormat('fr-FR', { @@ -23,63 +31,31 @@ function minutesAgo(fixedMs, minutes) { return stamp(fixedMs - minutes * 60000); } -const T = '2026-08-22T06:15:00+02:00'; // fenêtre HC (01:24-07:24) fermée mi-session +const T = '2026-08-24T06:45:00+02:00'; // heure de l'incident de yoyo du 24/08 const T_MS = new Date(T).getTime(); +// Base commune : EDF ON sous latch, aucun override EVCS (véhicule débranché), +// lecture HA saine. C'est la situation "remontée batterie sur réseau". +const recoveryBase = { + grid_latch: 'on', + grid_state: 'on', + night_grid_takeover_needed: 'off', + evcs_priority_active: 'off', + evcs_source_mode: 'Bloqué', + evcs_surplus_state: 'vehicle_not_connected', + user_strategy: 'auto_solar_plus', +}; + export const scenarios = [ { - id: 'g-incident-gap-mid-session', - label: 'Fenêtre fermée mi-session (stop_source_lost) — min_on NON écoulé', + id: 'h-engage-low-soc', + label: 'SOC 18% <= seuil ON 20% : EDF maintenu (engagement batterie basse)', time: T, overrides: { - grid_latch: 'on', - grid_state: 'on', - soc_percent: '30', - night_grid_takeover_needed: 'off', - evcs_source_mode: 'Bloqué', - evcs_surplus_state: 'stop_source_lost', - user_strategy: 'auto_solar_plus', - last_on_at: minutesAgo(T_MS, 5), // << 25 min de min_on - }, - expect: { - state: 'turn_off_evcs_override_released', - gridAction: 'turn_off', - latchAction: 'turn_off', - evcsGridOverride: false, - }, - }, - { - id: 'g-incident-gap-idle-wait-source', - label: 'Fenêtre fermée mi-session (idle_wait_source) — même bypass générique', - time: T, - overrides: { - grid_latch: 'on', - grid_state: 'on', - soc_percent: '30', - night_grid_takeover_needed: 'off', - evcs_source_mode: 'Bloqué', - evcs_surplus_state: 'idle_wait_source', - user_strategy: 'auto_solar_plus', - last_on_at: minutesAgo(T_MS, 5), - }, - expect: { - state: 'turn_off_evcs_override_released', - gridAction: 'turn_off', - latchAction: 'turn_off', - }, - }, - { - id: 'g-baseline-low-soc-no-override', - label: 'SOC bas indépendant (18% <= seuil ON 22%) — Fix G ne doit PAS agir', - time: T, - overrides: { - grid_latch: 'on', - grid_state: 'on', - soc_percent: '18', - night_grid_takeover_needed: 'off', - evcs_source_mode: '', + ...recoveryBase, evcs_surplus_state: '', - user_strategy: 'auto_solar_plus', + evcs_source_mode: '', + soc_percent: '18', last_on_at: minutesAgo(T_MS, 5), }, expect: { @@ -89,17 +65,83 @@ export const scenarios = [ }, }, { - id: 'g-regression-fixc-unplugged', - label: 'Véhicule débranché — Fix C doit toujours gagner (pas absorbé par Fix G)', + id: 'h-recovery-band-no-cut-21', + label: 'ANTI-YOYO : SOC 21% (juste au-dessus du seuil ON), min_on non écoulé — aucune coupure', time: T, overrides: { - grid_latch: 'on', - grid_state: 'on', + ...recoveryBase, + // véhicule débranché mais SOC < seuil OFF : Fix C ne doit pas couper, + // et rien d'autre ne doit couper non plus. C'est LE point exact où + // Fix G coupait et provoquait le yoyo de 2-4 min du 24/08. + soc_percent: '21', + last_on_at: minutesAgo(T_MS, 5), + }, + expect: { + state: 'hold_on_min_runtime', + gridAction: 'none', + latchAction: 'none', + evcsGridOverride: false, + }, + }, + { + id: 'h-recovery-band-no-cut-25', + label: 'ANTI-YOYO : SOC 25% en pleine bande, min_on écoulé — la remontée doit continuer', + time: T, + overrides: { + ...recoveryBase, + soc_percent: '25', + last_on_at: minutesAgo(T_MS, 120), // min_on largement écoulé + }, + expect: { + state: 'hold_on_until_soc_off', + gridAction: 'none', + latchAction: 'none', + evcsGridOverride: false, + }, + }, + { + id: 'h-recovery-complete', + label: 'SOC 30% = seuil OFF, min_on écoulé : coupure normale de fin de remontée', + time: T, + overrides: { + ...recoveryBase, + // véhicule branché mais au repos : on teste la coupure GÉNÉRIQUE de fin + // de remontée, pas le raccourci Fix C (débranchement). + evcs_surplus_state: 'idle_wait_conditions', + soc_percent: '30', + last_on_at: minutesAgo(T_MS, 120), + }, + expect: { + state: 'turn_off_recovered', + gridAction: 'turn_off', + latchAction: 'turn_off', + }, + }, + { + id: 'h-override-released-soc-ok', + label: 'Override EVCS relâché avec SOC 34% >= seuil OFF : la boucle générique coupe déjà (aucun fix dédié requis)', + time: T, + overrides: { + ...recoveryBase, + // Cas de l'incident du 21-22/08 tel qu'il aurait dû se présenter : + // état transitoire non énuméré, mais SOC au-dessus du seuil OFF. + evcs_surplus_state: 'stop_source_lost', + soc_percent: '34', + last_on_at: minutesAgo(T_MS, 120), + }, + expect: { + state: 'turn_off_recovered', + gridAction: 'turn_off', + latchAction: 'turn_off', + }, + }, + { + id: 'g-regression-fixc-unplugged', + label: 'Véhicule débranché + SOC 40% >= seuil OFF : Fix C coupe immédiatement (bypass min_on)', + time: T, + overrides: { + ...recoveryBase, soc_percent: '40', - night_grid_takeover_needed: 'off', - evcs_source_mode: 'Bloqué', - evcs_surplus_state: 'vehicle_not_connected', - user_strategy: 'auto_solar_plus', last_on_at: minutesAgo(T_MS, 5), }, expect: { @@ -109,16 +151,12 @@ export const scenarios = [ }, { id: 'g-regression-fixe2-recovery-done', - label: 'Recovery done — Fix E2 doit toujours gagner (pas absorbé par Fix G)', + label: 'Recovery done + SOC 40% : Fix E2 coupe immédiatement (bypass min_on)', time: T, overrides: { - grid_latch: 'on', - grid_state: 'on', - soc_percent: '40', - night_grid_takeover_needed: 'off', - evcs_source_mode: 'Bloqué', + ...recoveryBase, evcs_surplus_state: 'night_recovery_done_idle', - user_strategy: 'auto_solar_plus', + soc_percent: '40', last_on_at: minutesAgo(T_MS, 5), }, expect: { @@ -126,43 +164,4 @@ export const scenarios = [ gridAction: 'turn_off', }, }, - { - id: 'g-incident-replay-hc-boundary', - label: 'Rejeu incident complet : min_on déjà écoulé aussi — pas seulement un bypass min_on', - time: T, - overrides: { - grid_latch: 'on', - grid_state: 'on', - soc_percent: '30', - night_grid_takeover_needed: 'off', - evcs_source_mode: 'Bloqué', - evcs_surplus_state: 'stop_source_lost', - user_strategy: 'auto_solar_plus', - last_on_at: minutesAgo(T_MS, 120), // min_on largement écoulé - }, - expect: { - state: 'turn_off_evcs_override_released', - gridAction: 'turn_off', - latchAction: 'turn_off', - }, - }, - { - id: 'g-glitch-guard-unavailable', - label: 'Lecture HA source_mode invalide — garde anti-glitch, Fix G ne doit PAS agir', - time: T, - overrides: { - grid_latch: 'on', - grid_state: 'on', - soc_percent: '30', - night_grid_takeover_needed: 'off', - evcs_source_mode: 'unavailable', - evcs_surplus_state: 'stop_source_lost', - user_strategy: 'auto_solar_plus', - last_on_at: minutesAgo(T_MS, 5), - }, - expect: { - state: 'hold_on_min_runtime', - gridAction: 'none', - }, - }, ]; diff --git a/tools/patch-20260824-revert-g.mjs b/tools/patch-20260824-revert-g.mjs new file mode 100644 index 0000000..0c81f22 --- /dev/null +++ b/tools/patch-20260824-revert-g.mjs @@ -0,0 +1,67 @@ +#!/usr/bin/env node +// Patch 24/08/2026 — REVERT de FIX G (22/08/2026). +// +// Pourquoi : Fix G coupait EDF dès que l'override EVCS disparaissait ET que +// SOC > gridSocOn. Le garde `soc > gridSocOn` ne protégeait que l'INSTANT de +// l'engagement sur SOC bas (soc <= 20), pas la PHASE DE REMONTÉE 20 -> 30 qui +// est 100 % de la durée de vie de ce cas. Résultat en production la nuit du +// 23-24/08, véhicule débranché : +// SOC 20 -> branche low_soc : EDF ON (bypass cooldown) +// SOC 21 -> Fix G : EDF OFF (bypass min_on) +// ... cycle de 2 à 4 minutes, MPPT qui flappe à chaque bascule. +// Fix G avait de fait remplacé l'hystérésis 20/30 par une hystérésis 20/20,1. +// +// De plus, sa prémisse était fausse : l'incident du 21-22/08 qui l'avait +// motivé n'est pas un problème d'override EVCS mais un problème de bilan +// énergétique (couper EDF à 21 % à 06:43 alors que la PV ne couvre la conso +// maison qu'à partir de ~09:00 rend le yoyo mécaniquement inévitable, quels +// que soient les seuils). Ce sujet est traité séparément. +// +// Cible : workflows/victron-grid-hysteresis-v1.json (node "Compute Grid Hysteresis"). +// Rejouable : détecte si déjà retiré. + +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_evcs_override_released')) { + console.log('Fix G déjà retiré — rien à faire.'); + process.exit(0); +} + +// --- 1. Bloc de décision ------------------------------------------------- +const decisionStart = code.indexOf('\n// ======================================================\n// FIX G (22/08/2026)'); +if (decisionStart === -1) throw new Error('En-tête du bloc de décision Fix G introuvable.'); + +const decisionEndMarker = " forcedCut = true;\n}\n"; +const decisionEnd = code.indexOf(decisionEndMarker, decisionStart); +if (decisionEnd === -1) throw new Error('Fin du bloc de décision Fix G introuvable.'); + +code = code.slice(0, decisionStart) + code.slice(decisionEnd + decisionEndMarker.length); + +// --- 2. Branche notification -------------------------------------------- +const notifStart = code.indexOf("} else if (state === 'turn_off_evcs_override_released') {"); +if (notifStart === -1) throw new Error('Branche notification Fix G introuvable.'); + +const notifEndMarker = " notifyWanted = true;\n\n"; +const notifEnd = code.indexOf(notifEndMarker, notifStart); +if (notifEnd === -1) throw new Error('Fin de la branche notification Fix G introuvable.'); + +code = code.slice(0, notifStart) + code.slice(notifEnd + notifEndMarker.length); + +if (code.includes('turn_off_evcs_override_released')) { + throw new Error('Résidus de Fix G après retrait — vérifier manuellement.'); +} + +node.parameters.jsCode = code; +fs.writeFileSync(FILE, JSON.stringify(wf, null, 2) + '\n', 'utf8'); +console.log('Fix G retiré de', FILE, `(jsCode: ${code.length} caractères)`); diff --git a/workflows/victron-grid-hysteresis-v1.json b/workflows/victron-grid-hysteresis-v1.json index 6d7e8bf..edb4d5e 100644 --- a/workflows/victron-grid-hysteresis-v1.json +++ b/workflows/victron-grid-hysteresis-v1.json @@ -74,7 +74,7 @@ }, { "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// FIX E2 (09/08/2026): RECOVERY DONE -> COUPURE IMMEDIATE\n// 'night_recovery_done_idle' est un etat COLLANT du controleur EVCS :\n// charge terminee ET batterie a la cible, le takeover ne reviendra pas\n// (sauf SOC <= cible-2, qui repasserait par l'override). Attendre le\n// min_on 25 min ne protege ici de rien : coupure immediate.\n// ======================================================\nif (\n !forcedCut &&\n latchIsOn &&\n gridIsOn &&\n !evcsGridOverride &&\n (\n evcsSurplusState === 'night_recovery_done_idle' ||\n evcsSurplusState === 'departure_done_idle'\n ) &&\n soc >= gridSocOff\n) {\n desiredGrid = 'off';\n desiredLatch = 'off';\n state = 'turn_off_recovery_done';\n reason.push('Charge terminee + batterie a la cible : coupure immediate du reseau (bypass min_on)');\n forcedCut = true;\n}\n\n// ======================================================\n// FIX G (22/08/2026): OVERRIDE EVCS DISPARU -> COUPURE IMMEDIATE\n// Incident nuit 21-22/08 : a la fermeture de la fenetre HC (~06-07h), le\n// controleur EVCS remet correctement son override a false MAIS le vehicule\n// n'est pas encore flag \"charge terminee\" -> DECISION_ENGINEV2 retombe sur\n// des etats generiques transitoires (stop_source_lost, idle_wait_source,\n// et potentiellement d'autres non enumeres ici). Fix C/E2 ne matchent\n// aucun de ces noms -> EDF restait maintenu ON par la boucle generique\n// jusqu'a gridSocOff (35%), potentiellement des heures.\n//\n// Regle GENERIQUE (pas un whitelist de plus) : des que evcsGridOverride\n// redevient false ET SOC > gridSocOn (22%, PAS gridSocOff 35% : on ne\n// touche pas au cas independant \"SOC bas\" qui doit tenir jusqu'a\n// gridSocOff), on coupe immediatement.\n//\n// Garde anti-glitch : n'agit pas si la lecture evcs_source_mode est\n// vide/unavailable/unknown (panne HA transitoire) - dans ce cas la boucle\n// generique gere normalement plutot que de couper sur une lecture non fiable.\n// ======================================================\nconst evcsSourceModeLooksValid =\n evcsSourceModeRaw !== '' &&\n !['unavailable', 'unknown'].includes(low(evcsSourceModeRaw));\n\nif (\n !forcedCut &&\n latchIsOn &&\n gridIsOn &&\n !evcsGridOverride &&\n soc > gridSocOn &&\n evcsSourceModeLooksValid\n) {\n desiredGrid = 'off';\n desiredLatch = 'off';\n state = 'turn_off_evcs_override_released';\n reason.push(`Override EVCS disparu (source=${evcsSourceModeRaw || 'n/a'}, surplus_state=${evcsSurplusStateRaw || 'n/a'}) + SOC ${round1(soc)}% > seuil ON ${gridSocOn}% : coupure immediate du reseau (bypass min_on, libere le solaire)`);\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_recovery_done') {\n notifyTitle = '✅ EDF coupé (charge terminée)';\n notifyMessage = `Charge VE terminée et batterie à la cible (SOC ${round1(soc)}%). Coupure immédiate du réseau.`;\n notifyWanted = true;\n\n} else if (state === 'turn_off_evcs_override_released') {\n notifyTitle = '✅ EDF coupé (override EVCS disparu)';\n notifyMessage = `L'override EVCS a disparu (source=${evcsSourceModeRaw || 'n/a'}) et SOC ${round1(soc)}% > seuil ON ${gridSocOn}%. Coupure immédiate du réseau pour libérer le solaire.`;\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" + "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// FIX E2 (09/08/2026): RECOVERY DONE -> COUPURE IMMEDIATE\n// 'night_recovery_done_idle' est un etat COLLANT du controleur EVCS :\n// charge terminee ET batterie a la cible, le takeover ne reviendra pas\n// (sauf SOC <= cible-2, qui repasserait par l'override). Attendre le\n// min_on 25 min ne protege ici de rien : coupure immediate.\n// ======================================================\nif (\n !forcedCut &&\n latchIsOn &&\n gridIsOn &&\n !evcsGridOverride &&\n (\n evcsSurplusState === 'night_recovery_done_idle' ||\n evcsSurplusState === 'departure_done_idle'\n ) &&\n soc >= gridSocOff\n) {\n desiredGrid = 'off';\n desiredLatch = 'off';\n state = 'turn_off_recovery_done';\n reason.push('Charge terminee + batterie a la cible : 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_recovery_done') {\n notifyTitle = '✅ EDF coupé (charge terminée)';\n notifyMessage = `Charge VE terminée et batterie à la cible (SOC ${round1(soc)}%). 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,