diff --git a/HANDOFF.md b/HANDOFF.md index 1d69985..8937e5c 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1109,5 +1109,136 @@ p11-changement-vehicule-nuit (prev=night_recovery_done_idle + charging_time 15s 2. CHANGEMENT DE VÉHICULE / nouvelle connexion (charging_time ≤ 90 s, P11) ; 3. force_charge_now / force_grid_now (échappatoires manuelles). +## Diagnostic en attente de vérification (Qwen, 22/08) +Voici mon analyse et plan de correction : +Diagnostic +Le bug est dans HA_SERVICE_CALLS_OUTPUT.js:987-996 : +edf_general_ok_on: { + execute: ['grid_forced', 'grid_hc_allowed'].includes(context.sourceMode) && !parsed.gridAvailable, + ... +} +Il n'existe pas de edf_general_ok_off. Le workflow ne coupe JAMAIS activement EDF. Il ne fait que le rallumer quand sourceMode est grid ET que le réseau n'est pas encore disponible. Quand sourceMode revient à solar_only/blocked, le switch reste ON dans l'état qu'il avait. +Conséquence : après la fenêtre HC nocturne (~06:00), EDF reste allumé. La présence du réseau bloque la production PV (Victron plafonne à ~100 W). Le débranchement du véhicule résout parce que c'est le seul mécanisme qui coupe physiquement le switch. +Plan de correction +Fichier : src/nodes/HA_SERVICE_CALLS_OUTPUT.js +Ajouter un call edf_general_ok_off juste après edf_general_ok_on (ligne 996) : +edf_general_ok_off: { + execute: + !['grid_forced', 'grid_hc_allowed'].includes(context.sourceMode) && + parsed.gridAvailable &parsed.evcsVehicle + +**Verdict (Claude, 22/08) : diagnostic ÉCARTÉ, voir §24 ci-dessous.** Le +symptôme est correctement identifié (EDF ne se coupe jamais activement après +la fenêtre HC) mais la cause et le correctif proposés sont faux : le bug n'est +pas dans `HA_SERVICE_CALLS_OUTPUT.js` (qui n'a jamais eu vocation à couper EDF +— la coupure est le rôle exclusif du workflow séparé "Victron - Battery / Grid +Hysteresis Fix V1"), et ajouter un `edf_general_ok_off` côté EVCS créerait un +deuxième écrivain non coordonné sur `switch.edf_general_ok_switch` (courses +30s/1min entre les deux workflows, le hysteresis reprenant la main au cycle +suivant via son propre `latchIsOn` et rallumant probablement EDF quand même). +Le vrai bug et son correctif (Fix G, côté hystérésis) sont documentés en §24. + --- -*Document mis à jour le : 12/08/2026 (~05:05) — P11 déployé et validé live, golden 39/39* +*Document mis à jour le : 22/08/2026 (~18:00) — Fix G déployé (hystérésis), diagnostic Qwen écarté, golden-hysteresis 7/7* + +## 24. Session du 22/08/2026 — Fix G : coupure EDF quand l'override EVCS disparaît mi-session (hystérésis) + +### Incident (nuit 21-22/08) +Le takeover nuit (P7) a correctement rechargé la batterie à ~35 % via EDF. +Quand la fenêtre HC s'est fermée (~06:00-07:24) avec le véhicule **encore +branché en session active** (pas encore flag "charge terminée"), EDF est resté +allumé — le réseau a plafonné le MPPT Victron à ~100 W, privant la maison de +600-4000 W de solaire disponible (elle tirait ~1000 W d'EDF). Seul le +débranchement du véhicule a libéré EDF (2179 W de PV immédiatement après). + +### Root cause +Côté EVCS (`WINDOWS_SOURCEV2.js`/`DECISION_ENGINEV2.js`) : **pas le bug**. +Chaque cycle, `nightSessionActive` et tout le moteur nuit sont activement +recalculés à `false`/`'blocked'` dès la fermeture de fenêtre — `helper +night_grid_takeover_needed` passe à `off` et `source_mode` à `'Bloqué'` +correctement et immédiatement, même mi-session. MAIS les deux branches +"collantes" de fin de session (Fix F, exige `profile === 'night_hc'` ; P10 +`day_vehicle_full_idle`, exige que le `surplus_state` précédent soit déjà un +pattern "terminé") ne se déclenchent PAS sur le cycle exact où la fenêtre se +ferme mi-session non-terminée. Le système retombe sur des états génériques +transitoires (`stop_source_lost` puis `idle_wait_source`). + +Côté **Grid Hysteresis** (`workflows/victron-grid-hysteresis-v1.json`, node +"Compute Grid Hysteresis") : **c'est là le bug**. Les règles de coupure +immédiate existantes (Fix C `turn_off_vehicle_unplugged`, Fix E2 +`turn_off_recovery_done`) ne matchent que des noms exacts de `surplus_state` +(`vehicle_not_connected`, `night_recovery_done_idle`, `departure_done_idle`) — +aucune ne matche `stop_source_lost`/`idle_wait_source` (ni `day_vehicle_full_idle` +lui-même). Sans matche, le code retombe sur la boucle générique qui maintient +EDF jusqu'au seuil statique `grid_soc_off` (35 %, `input_number.victron_grid_soc_off`), +totalement découplé de la cible dynamique de l'EVCS — potentiellement des +heures, d'autant qu'EDF-ON lui-même bride la recharge solaire organique. + +### Fix G (déployé 22/08 ~18:00, golden-hysteresis 7/7) +Au lieu d'ajouter encore un nom d'état à une liste blanche fragile, nouvelle +règle **générique** dans "Compute Grid Hysteresis", basée sur `evcsGridOverride` +(déjà fiable) plutôt que sur le nom de `surplus_state` — couvre donc aussi +automatiquement tout futur nom d'état non énuméré : +```javascript +if ( + !forcedCut && latchIsOn && gridIsOn && + !evcsGridOverride && + soc > gridSocOn && // 22%, PAS gridSocOff 35% — protège le cas + // indépendant "SOC bas" qui doit tenir + // jusqu'à gridSocOff + evcsSourceModeLooksValid // garde anti-glitch (lecture HA vide/ + // unavailable/unknown → pas d'action) +) { + desiredGrid = 'off'; desiredLatch = 'off'; + state = 'turn_off_evcs_override_released'; + forcedCut = true; +} +``` +Coupure immédiate, bypass min_on (25 min), comme Fix C/E2. Décision validée +avec l'utilisateur : vitesse de libération prioritaire sur le risque résiduel +(un glitch de lecture HA sur un seul cycle pourrait causer une coupure/ +rallumage isolé, auto-diagnosticable dans le logbook — pas de chatter soutenu +car il faudrait une lecture fausse à *chaque* cycle). + +### Nouvelle suite de tests : `tests/golden-hysteresis/` (7 scénarios, première +suite pour ce workflow — 0 → 7) +`g-incident-gap-mid-session`/`g-incident-gap-idle-wait-source` (les deux noms +d'état zombies de l'incident, min_on non écoulé) · `g-incident-replay-hc-boundary` +(rejeu complet, min_on déjà écoulé aussi — prouve que ce n'est pas seulement un +bypass min_on) · `g-baseline-low-soc-no-override` (garde non-régression : SOC +bas indépendant doit continuer à tenir) · `g-regression-fixc-unplugged` / +`g-regression-fixe2-recovery-done` (Fix C/E2 toujours prioritaires, pas +absorbés par Fix G) · `g-glitch-guard-unavailable` (garde anti-glitch). +Lancée AVANT patch : 3 échecs (les scénarios d'incident, stuck sur +`hold_on_min_runtime`/`hold_on_until_soc_off`) + 4 succès (gardes déjà bonnes) +— confirme que le harnais détecte vraiment le bug. Après patch : 7/7. +Câblée dans `tools/n8n-push.mjs` (nouveau `isHysteresisWorkflow`), bloquante +au push comme les 2 autres workflows. + +### Outillage +`tools/patch-20260822-g-override-released-cut.mjs` (rejouable, même patron que +`patch-20260809-e2-recovery-done-cut.mjs`) · `tests/golden-hysteresis/{fixture-snapshot.json,run.mjs,scenarios.mjs}`. + +### Déploiement +`node tools/n8n-push.mjs workflows/victron-grid-hysteresis-v1.json` → backup +serveur auto (`modified/prepush-7iIlu7LLTW7TZA8o-*.json`) → syntaxe OK → +golden-hysteresis 7/7 → PUT OK. `grid_min_on_minutes` réel vérifié via MCP +`ha-get-state` avant de figer le fixture : **25** (pas le fallback 45 du code). + +### Hors périmètre (volontairement) +Aucun changement côté `src/nodes/*` (EVCS V2.6) — le fix est entièrement côté +hystérésis. Amélioration cosmétique future possible, non incluse ici : +remplacer les noms zombies `stop_source_lost`/`idle_wait_source` par un état de +transition dédié pour la lisibilité dashboard/logbook. + +### À surveiller (prochaine fermeture de fenêtre HC avec véhicule encore +branché mi-session) +1. Logbook doit montrer `turn_off_evcs_override_released` peu après la + fermeture de fenêtre si le véhicule n'est pas encore flag "terminé" ; +2. La PV doit remonter (MPPT non-plafonné) immédiatement après ; +3. Aucune coupure/rallumage répété (`turn_off_evcs_override_released` suivi de + près par `turn_on_evcs_override` répétitivement) — signe d'un glitch de + lecture HA à investiguer si observé. + +--- +*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 diff --git a/docs/MANUEL_COMPLET.md b/docs/MANUEL_COMPLET.md index 0e9d237..6aa5461 100644 --- a/docs/MANUEL_COMPLET.md +++ b/docs/MANUEL_COMPLET.md @@ -1,7 +1,7 @@ # Manuel Utilisateur - Système EVCS Solaire -> Document généré le 17/07/2026 — dernière mise à jour le 09/08/2026 -> (fixes anti-rebond A→F, câblage advisor cible SOC, P9 départ, véhicules VW/JEEP) +> Document généré le 17/07/2026 — dernière mise à jour le 22/08/2026 +> (fixes anti-rebond A→G, câblage advisor cible SOC, P9 départ, véhicules VW/JEEP) > Projet: n8n / Home Assistant / EVCS Solaire sans injection réseau --- @@ -445,6 +445,13 @@ 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. └─▶ retour Y0 (cooldown 15 min) ``` @@ -477,6 +484,12 @@ retesté. Remises à zéro : débranchement, session fraîche (P11), ou force_*. - **Matin 09/08 (VW pleine rebranchée)** : micro-charges du véhicule sur batterie → hard stop ÉCRASAIT l'état collant → re-P7. → Fix F (mémoire prioritaire sur les gardes, réécrite chaque cycle). +- **Nuit 21-22/08** : fenêtre HC fermée mi-session (véhicule pas encore flag + 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`). ### 11.7 Conseiller IA — mode ombre et câblages réels @@ -586,4 +599,4 @@ git commit && git push --- -*Document généré le 17/07/2026 — mis à jour le 09/08/2026 (HANDOFF §17-21 : fixes A→F, advisor, P9, véhicules)* \ No newline at end of file +*Document généré le 17/07/2026 — mis à jour le 22/08/2026 (HANDOFF §17-24 : fixes A→G, advisor, P9, véhicules)* \ No newline at end of file diff --git a/tests/golden-hysteresis/fixture-snapshot.json b/tests/golden-hysteresis/fixture-snapshot.json new file mode 100644 index 0000000..66c5e1f --- /dev/null +++ b/tests/golden-hysteresis/fixture-snapshot.json @@ -0,0 +1,32 @@ +{ + "soc_percent": "50", + "grid_state": "off", + "pv_power_w": "2000", + + "house_ac_power_w": "700", + "grid_power_w": "0", + + "evcs_ac_power_w": "0", + "evcs_charging": "off", + "evcs_status_raw": "6", + + "hysteresis_enabled": "on", + "notifications_enabled": "on", + "grid_latch": "off", + + "grid_soc_on": "22", + "grid_soc_off": "35", + "grid_min_on_minutes": "25", + "grid_cooldown_minutes": "15", + "ac_loads_max_safe_w": "7800", + + "last_on_at": "", + "last_off_at": "", + "last_signature": "", + + "evcs_priority_active": "off", + "night_grid_takeover_needed": "off", + "evcs_source_mode": "Bloqué", + "user_strategy": "auto_solar_plus", + "evcs_surplus_state": "idle_wait_conditions" +} diff --git a/tests/golden-hysteresis/run.mjs b/tests/golden-hysteresis/run.mjs new file mode 100644 index 0000000..dabeadf --- /dev/null +++ b/tests/golden-hysteresis/run.mjs @@ -0,0 +1,127 @@ +#!/usr/bin/env node +// Suite de tests GOLDEN du workflow "Victron - Battery / Grid Hysteresis Fix V1". +// Exécute le VRAI code du node "Compute Grid Hysteresis" sur un snapshot HA réel +// modifié par scénario, avec horloge simulée. +// +// Usage: +// node tests/golden-hysteresis/run.mjs # assertions (exit 1 si échec) +// node tests/golden-hysteresis/run.mjs --print # affiche les résumés (pour figer les attendus) +// node tests/golden-hysteresis/run.mjs --only=incident # filtre par id +// +// RÈGLE : cette suite DOIT passer avant tout push du workflow hystérésis +// (intégrée à tools/n8n-push.mjs). + +import fs from 'fs'; +import vm from 'vm'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { scenarios } from './scenarios.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '../..'); +const WORKFLOW_FILE = path.join(ROOT, 'workflows/victron-grid-hysteresis-v1.json'); +const FIXTURE_FILE = path.join(__dirname, 'fixture-snapshot.json'); + +const PRINT = process.argv.includes('--print'); +const JSON_OUT = process.argv.includes('--json'); +const only = (process.argv.find(a => a.startsWith('--only=')) || '').slice(7); + +const wf = JSON.parse(fs.readFileSync(WORKFLOW_FILE, 'utf8')); +const fixture = JSON.parse(fs.readFileSync(FIXTURE_FILE, 'utf8')); + +const nodeCode = wf.nodes.find(n => n.name === 'Compute Grid Hysteresis').parameters.jsCode; + +// Horloge simulée : Date épinglée à l'instant du scénario dans la sandbox. +// Le node utilise new Date() (zéro argument) pour "maintenant" et +// new Date(y, mo-1, d, h, mi, se) (multi-arguments) pour reparser last_on_at +// /last_off_at — seuls les appels zéro-argument doivent être épinglés. +function makeFakeDate(fixedMs) { + return class FakeDate extends Date { + constructor(...args) { + if (args.length === 0) super(fixedMs); + else super(...args); + } + static now() { return fixedMs; } + }; +} + +function runNode(jsCode, inputJson, fixedMs) { + const ctx = { + $input: { first: () => ({ json: inputJson }) }, + console, Intl, Math, Number, Object, String, JSON, Array, Boolean, + Date: makeFakeDate(fixedMs), + }; + vm.createContext(ctx); + const out = vm.runInContext(`(function(){\n${jsCode}\n})()`, ctx); + return out[0].json; +} + +function summarize(out) { + const c = out.computed || {}; + const d = out.decision || {}; + return { + state: d.state, + gridAction: d.grid_action, + latchAction: d.latch_action, + shouldNotify: !!d.should_notify, + evcsGridOverride: !!c.evcs_grid_override, + }; +} + +const jsonResults = {}; +let failures = 0, ran = 0; +for (const sc of scenarios) { + if (only && !sc.id.includes(only)) continue; + ran++; + const fixedMs = new Date(sc.time).getTime(); + if (!Number.isFinite(fixedMs)) throw new Error(`${sc.id}: time invalide`); + + const raw = { ...fixture, ...sc.overrides }; + const payload = { data: JSON.stringify(raw) }; + let out; + try { + out = runNode(nodeCode, payload, fixedMs); + } catch (e) { + failures++; + console.log(`💥 ${sc.id} : CRASH — ${e.message}`); + continue; + } + const got = summarize(out); + + if (JSON_OUT) { + jsonResults[sc.id] = got; + continue; + } + + if (PRINT) { + console.log(`\n─── ${sc.id} | ${sc.label}`); + console.log(' expect:', JSON.stringify(got)); + if (out.decision?.short_reason) console.log(' raison:', out.decision.short_reason); + continue; + } + + const diffs = []; + for (const [k, v] of Object.entries(sc.expect || {})) { + if (JSON.stringify(got[k]) !== JSON.stringify(v)) diffs.push(`${k}: attendu ${JSON.stringify(v)}, obtenu ${JSON.stringify(got[k])}`); + } + if (diffs.length) { + failures++; + console.log(`❌ ${sc.id} | ${sc.label}`); + for (const d of diffs) console.log(` ${d}`); + if (out.decision?.short_reason) console.log(` raison: ${out.decision.short_reason}`); + } else { + console.log(`✅ ${sc.id}`); + } +} + +if (JSON_OUT) { + console.log(JSON.stringify(jsonResults, null, 2)); + process.exit(0); +} + +if (!PRINT) { + console.log(failures + ? `\n${failures}/${ran} ÉCHEC(S) — NE PAS POUSSER EN PRODUCTION` + : `\nGOLDEN HYSTERESIS OK — ${ran}/${ran} scénarios conformes`); + process.exit(failures ? 1 : 0); +} diff --git a/tests/golden-hysteresis/scenarios.mjs b/tests/golden-hysteresis/scenarios.mjs new file mode 100644 index 0000000..683b507 --- /dev/null +++ b/tests/golden-hysteresis/scenarios.mjs @@ -0,0 +1,168 @@ +// 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. + +function stamp(ms) { + const p = new Intl.DateTimeFormat('fr-FR', { + timeZone: 'Europe/Paris', year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, + }).formatToParts(new Date(ms)); + const g = t => p.find(x => x.type === t).value; + return `${g('year')}-${g('month')}-${g('day')} ${g('hour')}:${g('minute')}:${g('second')}`; +} + +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_MS = new Date(T).getTime(); + +export const scenarios = [ + { + id: 'g-incident-gap-mid-session', + label: 'Fenêtre fermée mi-session (stop_source_lost) — min_on NON écoulé', + 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: '', + evcs_surplus_state: '', + user_strategy: 'auto_solar_plus', + last_on_at: minutesAgo(T_MS, 5), + }, + expect: { + state: 'hold_on_low_soc', + gridAction: 'none', + latchAction: 'none', + }, + }, + { + id: 'g-regression-fixc-unplugged', + label: 'Véhicule débranché — Fix C doit toujours gagner (pas absorbé par Fix G)', + time: T, + overrides: { + grid_latch: 'on', + grid_state: 'on', + 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: { + state: 'turn_off_vehicle_unplugged', + gridAction: 'turn_off', + }, + }, + { + id: 'g-regression-fixe2-recovery-done', + label: 'Recovery done — Fix E2 doit toujours gagner (pas absorbé par Fix G)', + time: T, + overrides: { + grid_latch: 'on', + grid_state: 'on', + soc_percent: '40', + night_grid_takeover_needed: 'off', + evcs_source_mode: 'Bloqué', + evcs_surplus_state: 'night_recovery_done_idle', + user_strategy: 'auto_solar_plus', + last_on_at: minutesAgo(T_MS, 5), + }, + expect: { + state: 'turn_off_recovery_done', + 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/n8n-push.mjs b/tools/n8n-push.mjs index 55a9eb4..9027956 100644 --- a/tools/n8n-push.mjs +++ b/tools/n8n-push.mjs @@ -108,10 +108,12 @@ console.log('Syntaxe OK sur tous les nodes jsCode.'); // 3bis. Suites de tests (R3) : bloquantes par workflow, sauf --skip-golden const isEvcsWorkflow = path.basename(INPUT) === 'evcs-surplus-controller-v2.6.json'; const isAdvisorWorkflow = path.basename(INPUT) === 'evcs-advisor-shadow-v1.json'; +const isHysteresisWorkflow = path.basename(INPUT) === 'victron-grid-hysteresis-v1.json'; const skipGolden = process.argv.includes('--skip-golden'); const testSuite = isEvcsWorkflow && fs.existsSync('tests/golden/run.mjs') ? 'tests/golden/run.mjs' : isAdvisorWorkflow && fs.existsSync('tests/advisor/run.mjs') ? 'tests/advisor/run.mjs' : + isHysteresisWorkflow && fs.existsSync('tests/golden-hysteresis/run.mjs') ? 'tests/golden-hysteresis/run.mjs' : null; if (testSuite) { if (skipGolden) { diff --git a/tools/patch-20260822-g-override-released-cut.mjs b/tools/patch-20260822-g-override-released-cut.mjs new file mode 100644 index 0000000..bded35f --- /dev/null +++ b/tools/patch-20260822-g-override-released-cut.mjs @@ -0,0 +1,99 @@ +#!/usr/bin/env node +// Patch 22/08/2026 — FIX G (relais) : coupure EDF immédiate, générique, dès +// que l'override EVCS disparaît (evcsGridOverride === false) et que le SOC +// est au-dessus du seuil bas ON (gridSocOn, PAS gridSocOff) — au lieu de +// dépendre d'une liste blanche de noms exacts de surplus_state (Fix C/E2), +// qui ratait les états transitoires génériques (stop_source_lost, +// idle_wait_source) émis quand la fenêtre HC nuit se ferme mi-session. +// Incident : nuit 21-22/08/2026, EDF resté ON des heures après la fermeture +// de la fenêtre HC, PV plafonné ~100W par le MPPT. +// 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_evcs_override_released')) { + console.log('Déjà appliqué — rien à faire.'); + process.exit(0); +} + +const anchor = ` state = 'turn_off_recovery_done'; + reason.push('Charge terminee + batterie a la cible : coupure immediate du reseau (bypass min_on)'); + forcedCut = true; +} +`; + +if (!code.includes(anchor)) { + throw new Error('Ancre Fix E2 introuvable — le node a peut-être changé, vérifier manuellement.'); +} + +const insertion = `${anchor} +// ====================================================== +// FIX G (22/08/2026): OVERRIDE EVCS DISPARU -> COUPURE IMMEDIATE +// Incident nuit 21-22/08 : a la fermeture de la fenetre HC (~06-07h), le +// controleur EVCS remet correctement son override a false MAIS le vehicule +// n'est pas encore flag "charge terminee" -> DECISION_ENGINEV2 retombe sur +// des etats generiques transitoires (stop_source_lost, idle_wait_source, +// et potentiellement d'autres non enumeres ici). Fix C/E2 ne matchent +// aucun de ces noms -> EDF restait maintenu ON par la boucle generique +// jusqu'a gridSocOff (35%), potentiellement des heures. +// +// Regle GENERIQUE (pas un whitelist de plus) : des que evcsGridOverride +// redevient false ET SOC > gridSocOn (22%, PAS gridSocOff 35% : on ne +// touche pas au cas independant "SOC bas" qui doit tenir jusqu'a +// gridSocOff), on coupe immediatement. +// +// Garde anti-glitch : n'agit pas si la lecture evcs_source_mode est +// vide/unavailable/unknown (panne HA transitoire) - dans ce cas la boucle +// generique gere normalement plutot que de couper sur une lecture non fiable. +// ====================================================== +const evcsSourceModeLooksValid = + evcsSourceModeRaw !== '' && + !['unavailable', 'unknown'].includes(low(evcsSourceModeRaw)); + +if ( + !forcedCut && + latchIsOn && + gridIsOn && + !evcsGridOverride && + soc > gridSocOn && + evcsSourceModeLooksValid +) { + desiredGrid = 'off'; + desiredLatch = 'off'; + state = 'turn_off_evcs_override_released'; + 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)\`); + forcedCut = true; +} +`; + +code = code.replace(anchor, insertion); + +const notifAnchor = `} else if (state === 'turn_off_recovered') {`; +if (!code.includes(notifAnchor)) { + throw new Error('Ancre notification turn_off_recovered introuvable.'); +} + +const notifInsertion = `} else if (state === 'turn_off_evcs_override_released') { + notifyTitle = '✅ EDF coupé (override EVCS disparu)'; + 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.\`; + notifyWanted = true; + +${notifAnchor}`; + +code = code.replace(notifAnchor, notifInsertion); + +node.parameters.jsCode = code; +fs.writeFileSync(FILE, JSON.stringify(wf, null, 2) + '\n', 'utf8'); +console.log('Fix G appliqué à', FILE); diff --git a/workflows/victron-grid-hysteresis-v1.json b/workflows/victron-grid-hysteresis-v1.json index edb4d5e..6d7e8bf 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// ======================================================\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" + "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" }, "type": "n8n-nodes-base.code", "typeVersion": 2,