From 1ae81296587bcf6df3f907801059bbb0af932fab Mon Sep 17 00:00:00 2001 From: Tallagrand Thierry Date: Fri, 7 Aug 2026 21:03:52 +0200 Subject: [PATCH] =?UTF-8?q?R2:=20git=20=3D=20source=20de=20v=C3=A9rit?= =?UTF-8?q?=C3=A9=20=E2=80=94=20src/nodes/=20+=20build/extract=20int=C3=A9?= =?UTF-8?q?gr=C3=A9s=20au=20push?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/nodes/*.js (6 nodes + template snapshot) = source de vérité unique. - n8n-push.mjs reconstruit le JSON depuis src/ avant syntaxe+golden+PUT (src gagne toujours; n8n-extract.mjs pour resynchroniser si édition UI). - n8n-build.mjs --check pour vérifier la synchro. - Copies historiques déplacées dans archive/ (mortes, dérivées de la prod). - .gitignore: backups locaux de rollback. - AGENTS.md: règle R2 pour toutes les futures sessions (dont Qwen local). - Validé: idempotence, mutation/restauration, pipeline push complet en réel. --- .gitignore | 6 + AGENTS.md | 14 + HANDOFF.md | 37 + .../BATTERY}/BATTERY_CLOUD_LOCKS.js | 0 .../BATTERY}/BATTERY_CLOUD_LOCKSP1.js | 0 .../BATTERY}/BATTERY_CLOUD_LOCKSP2.js | 0 .../BATTERY}/BATTERY_CLOUD_LOCKSP3.js | 0 ...y_cloud_locks_new_profil_sans_injection.js | 0 .../DECISION}/DECISION_ENGINEV2.js | 0 .../DECISION}/decision_engine.js | 0 .../DECISION}/decision_engineP1.js | 0 .../DECISION}/decision_engineP2.js | 0 .../DECISION}/decision_engineP3.js | 0 .../DECISION}/decision_engineP4.js | 0 EVCSV2.2.json => archive/EVCSV2.2.json | 0 {HYSTERESIS => archive/HYSTERESIS}/COMPUTE.js | 0 .../HYSTERESIS}/COMPUTEP1.js | 0 .../HYSTERESIS}/COMPUTEP2.js | 0 .../HYSTERESIS}/COMPUTEP3.js | 0 archive/README.md | 11 + .../WINDOWS}/WINDOW_SOURCEV2.js | 0 .../WINDOWS}/manage_windows.js | 0 .../WINDOWS}/manage_windowsP1.js | 0 .../WINDOWS}/manage_windowsP2.js | 0 agents.old => archive/agents.old | 0 build.js => archive/build.js | 0 .../decoupesurplus}/BATTERY_CLOUD_LOCKS.js | 0 .../decoupesurplus}/DECISION_ENGINE.js | 0 .../decoupesurplus}/DECISION_ENGINE_4.1.js | 0 .../decoupesurplus}/DECISION_ENGINE_4.2.js | 0 .../decoupesurplus}/DECISION_ENGINE_4.3.js | 0 .../HA_SERVICE_CALLS_OUTPUT.js | 0 .../decoupesurplus}/INPUT_PARSE.js | 0 .../decoupesurplus}/V2/DEC.js | 0 .../decoupesurplus}/V2/DEC1.js | 0 .../decoupesurplus}/V2/DEC2.js | 0 .../decoupesurplus}/V2/DEC3.js | 0 .../decoupesurplus}/V2/DECISION/decision.js | 0 .../decoupesurplus}/V2/DECISION/decisionP1.js | 0 .../decoupesurplus}/V2/DECISION/decisionP2.js | 0 .../decoupesurplus}/V2/DECISION/decisionP3.js | 0 .../decoupesurplus}/V2/DECISION/decisionP4.js | 0 .../decoupesurplus}/V2/DECISION/decisionP5.js | 0 .../decoupesurplus}/V2/HASERVICEOUTPUT.js | 0 .../decoupesurplus}/V2/HASERVICEOUTPUTP1.js | 0 .../decoupesurplus}/V2/HASERVICEOUTPUTP2.js | 0 .../decoupesurplus}/V2/HASERVICEOUTPUTP3.js | 0 .../decoupesurplus}/V2/HASERVICEOUTPUTP4.js | 0 .../V2/INPUTPARSE/inputparse.js | 0 .../V2/INPUTPARSE/inputparseP1.js | 0 .../V2/INPUTPARSE/inputparseP2.js | 0 .../decoupesurplus}/V2/batcloud.js | 0 .../decoupesurplus}/V2/batcloudP1.js | 0 .../decoupesurplus}/V2/batcloudP11.js | 0 .../decoupesurplus}/V2/batcloudP12.js | 0 .../decoupesurplus}/V2/batcloudP13.js | 0 .../decoupesurplus}/V2/batcloudP14.js | 0 .../decoupesurplus}/V2/batcloudP2.js | 0 .../decoupesurplus}/V2/gridhyst.js | 0 .../decoupesurplus}/V2/gridhystP1.js | 0 .../decoupesurplus}/V2/gridhystP2.js | 0 .../decoupesurplus}/V2/gridhystact.js | 0 .../decoupesurplus}/WINDOWS_SOURCE.js | 0 helperv2.txt => archive/helperv2.txt | 0 legacy.txt => archive/legacy.txt | 0 snapshot.js => archive/snapshot.js | 0 surplus.js => archive/surplus.js | 0 src/nodes/BATTERY_CLOUD_LOCKS.js | 897 ++++++++++++ src/nodes/BUILD_ACTIONS.js | 223 +++ src/nodes/DECISION_ENGINEV2.js | 1291 +++++++++++++++++ src/nodes/HA_SERVICE_CALLS_OUTPUT.js | 1060 ++++++++++++++ src/nodes/HA_SNAPSHOT_TEMPLATE.json.tpl | 212 +++ src/nodes/INPUT_PARSE.js | 590 ++++++++ src/nodes/WINDOWS_SOURCEV2.js | 685 +++++++++ tools/lib-node-map.mjs | 32 + tools/n8n-build.mjs | 38 + tools/n8n-extract.mjs | 21 + tools/n8n-push.mjs | 15 + 78 files changed, 5132 insertions(+) create mode 100644 .gitignore rename {BATTERY => archive/BATTERY}/BATTERY_CLOUD_LOCKS.js (100%) rename {BATTERY => archive/BATTERY}/BATTERY_CLOUD_LOCKSP1.js (100%) rename {BATTERY => archive/BATTERY}/BATTERY_CLOUD_LOCKSP2.js (100%) rename {BATTERY => archive/BATTERY}/BATTERY_CLOUD_LOCKSP3.js (100%) rename {BATTERY => archive/BATTERY}/battery_cloud_locks_new_profil_sans_injection.js (100%) rename {DECISION => archive/DECISION}/DECISION_ENGINEV2.js (100%) rename {DECISION => archive/DECISION}/decision_engine.js (100%) rename {DECISION => archive/DECISION}/decision_engineP1.js (100%) rename {DECISION => archive/DECISION}/decision_engineP2.js (100%) rename {DECISION => archive/DECISION}/decision_engineP3.js (100%) rename {DECISION => archive/DECISION}/decision_engineP4.js (100%) rename EVCSV2.2.json => archive/EVCSV2.2.json (100%) rename {HYSTERESIS => archive/HYSTERESIS}/COMPUTE.js (100%) rename {HYSTERESIS => archive/HYSTERESIS}/COMPUTEP1.js (100%) rename {HYSTERESIS => archive/HYSTERESIS}/COMPUTEP2.js (100%) rename {HYSTERESIS => archive/HYSTERESIS}/COMPUTEP3.js (100%) create mode 100644 archive/README.md rename {WINDOWS => archive/WINDOWS}/WINDOW_SOURCEV2.js (100%) rename {WINDOWS => archive/WINDOWS}/manage_windows.js (100%) rename {WINDOWS => archive/WINDOWS}/manage_windowsP1.js (100%) rename {WINDOWS => archive/WINDOWS}/manage_windowsP2.js (100%) rename agents.old => archive/agents.old (100%) rename build.js => archive/build.js (100%) rename {decoupesurplus => archive/decoupesurplus}/BATTERY_CLOUD_LOCKS.js (100%) rename {decoupesurplus => archive/decoupesurplus}/DECISION_ENGINE.js (100%) rename {decoupesurplus => archive/decoupesurplus}/DECISION_ENGINE_4.1.js (100%) rename {decoupesurplus => archive/decoupesurplus}/DECISION_ENGINE_4.2.js (100%) rename {decoupesurplus => archive/decoupesurplus}/DECISION_ENGINE_4.3.js (100%) rename {decoupesurplus => archive/decoupesurplus}/HA_SERVICE_CALLS_OUTPUT.js (100%) rename {decoupesurplus => archive/decoupesurplus}/INPUT_PARSE.js (100%) rename {decoupesurplus => archive/decoupesurplus}/V2/DEC.js (100%) rename {decoupesurplus => archive/decoupesurplus}/V2/DEC1.js (100%) rename {decoupesurplus => archive/decoupesurplus}/V2/DEC2.js (100%) rename {decoupesurplus => archive/decoupesurplus}/V2/DEC3.js (100%) rename {decoupesurplus => archive/decoupesurplus}/V2/DECISION/decision.js (100%) rename {decoupesurplus => archive/decoupesurplus}/V2/DECISION/decisionP1.js (100%) rename {decoupesurplus => archive/decoupesurplus}/V2/DECISION/decisionP2.js (100%) rename {decoupesurplus => archive/decoupesurplus}/V2/DECISION/decisionP3.js (100%) rename {decoupesurplus => archive/decoupesurplus}/V2/DECISION/decisionP4.js (100%) rename {decoupesurplus => archive/decoupesurplus}/V2/DECISION/decisionP5.js (100%) rename {decoupesurplus => archive/decoupesurplus}/V2/HASERVICEOUTPUT.js (100%) rename {decoupesurplus => archive/decoupesurplus}/V2/HASERVICEOUTPUTP1.js (100%) rename {decoupesurplus => archive/decoupesurplus}/V2/HASERVICEOUTPUTP2.js (100%) rename {decoupesurplus => archive/decoupesurplus}/V2/HASERVICEOUTPUTP3.js (100%) rename {decoupesurplus => archive/decoupesurplus}/V2/HASERVICEOUTPUTP4.js (100%) rename {decoupesurplus => archive/decoupesurplus}/V2/INPUTPARSE/inputparse.js (100%) rename {decoupesurplus => archive/decoupesurplus}/V2/INPUTPARSE/inputparseP1.js (100%) rename {decoupesurplus => archive/decoupesurplus}/V2/INPUTPARSE/inputparseP2.js (100%) rename {decoupesurplus => archive/decoupesurplus}/V2/batcloud.js (100%) rename {decoupesurplus => archive/decoupesurplus}/V2/batcloudP1.js (100%) rename {decoupesurplus => archive/decoupesurplus}/V2/batcloudP11.js (100%) rename {decoupesurplus => archive/decoupesurplus}/V2/batcloudP12.js (100%) rename {decoupesurplus => archive/decoupesurplus}/V2/batcloudP13.js (100%) rename {decoupesurplus => archive/decoupesurplus}/V2/batcloudP14.js (100%) rename {decoupesurplus => archive/decoupesurplus}/V2/batcloudP2.js (100%) rename {decoupesurplus => archive/decoupesurplus}/V2/gridhyst.js (100%) rename {decoupesurplus => archive/decoupesurplus}/V2/gridhystP1.js (100%) rename {decoupesurplus => archive/decoupesurplus}/V2/gridhystP2.js (100%) rename {decoupesurplus => archive/decoupesurplus}/V2/gridhystact.js (100%) rename {decoupesurplus => archive/decoupesurplus}/WINDOWS_SOURCE.js (100%) rename helperv2.txt => archive/helperv2.txt (100%) rename legacy.txt => archive/legacy.txt (100%) rename snapshot.js => archive/snapshot.js (100%) rename surplus.js => archive/surplus.js (100%) create mode 100644 src/nodes/BATTERY_CLOUD_LOCKS.js create mode 100644 src/nodes/BUILD_ACTIONS.js create mode 100644 src/nodes/DECISION_ENGINEV2.js create mode 100644 src/nodes/HA_SERVICE_CALLS_OUTPUT.js create mode 100644 src/nodes/HA_SNAPSHOT_TEMPLATE.json.tpl create mode 100644 src/nodes/INPUT_PARSE.js create mode 100644 src/nodes/WINDOWS_SOURCEV2.js create mode 100644 tools/lib-node-map.mjs create mode 100644 tools/n8n-build.mjs create mode 100644 tools/n8n-extract.mjs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d198356 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# Backups locaux de workflows (rollback) — jamais dans git +backup-workflow-*.json +modified/prepush-*.json + +# Sorties temporaires +node_modules/ diff --git a/AGENTS.md b/AGENTS.md index 35e2123..c7deb19 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,6 +25,20 @@ ## 📂 LIRE UN WORKFLOW n8n (méthode obligatoire) +### ⭐ R2 (07/08/2026) — SOURCE DE VÉRITÉ = src/nodes/ +Le code des nodes du workflow EVCS V2.6 vit dans **`src/nodes/*.js`** +(+ `HA_SNAPSHOT_TEMPLATE.json.tpl`). RÈGLES : +- Pour MODIFIER un node : éditer `src/nodes/.js`, puis + `node tools/n8n-push.mjs workflows/evcs-surplus-controller-v2.6.json` + (build src→JSON + syntaxe + suite golden 19 scénarios + backup + PUT, tout automatique). +- Pour LIRE un node : lire `src/nodes/.js` directement (PAS le MCP, pas le JSON). +- JAMAIS éditer le JSON du workflow ni l'UI n8n directement : le prochain push + écrase avec src/. (Si ça arrive quand même : `node tools/n8n-extract.mjs` resynchronise.) +- Tests : `node tests/golden/run.mjs` (19 scénarios figés). Toute évolution voulue + du comportement → mettre à jour `tests/golden/scenarios.mjs` dans le même commit. +- Les dossiers `archive/` (ex decoupesurplus/, WINDOWS/, BATTERY/...) sont des + copies mortes : NE PLUS UTILISER. + Le MCP `n8n-mcp_get_workflow` renvoie le workflow ENTIER (~357K, trop gros pour le contexte). opencode l'écrit alors dans un fichier `~/.local/share/opencode/tool-output/tool_*` et te le tronque. diff --git a/HANDOFF.md b/HANDOFF.md index d969ac5..67c0189 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -625,3 +625,40 @@ Effets quand vehicleChargeCompleted : 3. Batterie sous cible matin (35 été) : EDF maintenu + recharge ACM jusqu'à cible 4. 07:24 : fin de fenêtre → retour profil jour +## 15. Session du 07/08/2026 (soir) — R2 : Git = source de vérité du code des nodes + +### Nouveau layout +- **`src/nodes/`** = SOURCE DE VÉRITÉ : INPUT_PARSE.js, WINDOWS_SOURCEV2.js, + BATTERY_CLOUD_LOCKS.js, DECISION_ENGINEV2.js, HA_SERVICE_CALLS_OUTPUT.js, + BUILD_ACTIONS.js, HA_SNAPSHOT_TEMPLATE.json.tpl. +- `workflows/evcs-surplus-controller-v2.6.json` = artefact déployable, reconstruit + automatiquement depuis src/ à chaque push (src GAGNE toujours). +- `tools/lib-node-map.mjs` (cartographie), `tools/n8n-extract.mjs` (JSON→src, + uniquement pour resynchroniser après une édition UI n8n — à éviter), + `tools/n8n-build.mjs [--check]` (src→JSON / vérif synchro). +- `archive/` : toutes les copies historiques (decoupesurplus/, WINDOWS/, BATTERY/, + DECISION/, HYSTERESIS/, EVCSV2.2.json, surplus.js...) — copies mortes, dérivées. +- `.gitignore` : backup-workflow-*.json + modified/prepush-*.json (rollbacks locaux). + +### WORKFLOW DE MODIFICATION DÉSORMAIS (règle inscrite dans AGENTS.md) +``` +éditer src/nodes/.js +node tools/n8n-push.mjs workflows/evcs-surplus-controller-v2.6.json + → build src→JSON → node --check → GOLDEN 19/19 (bloquant) → backup serveur → PUT +git commit (src + json + scénarios si évolution voulue) && git push +``` +JAMAIS d'édition directe du JSON ou de l'UI n8n (écrasée au prochain push). + +### Validations effectuées +- Extract/build idempotents (extract → build → --check : zéro diff). +- Test mutation : édition src → build injecte 1 node → restauration git+extract OK. +- Pipeline complet de push exécuté en réel : build (no-op) → golden 19/19 → PUT OK. +- Nota : tailles `ls` ≠ longueur de code (octets UTF-8 vs caractères, accents FR). + +### Plan de maintenabilité : état +- R1 ✅ déduplication moteur nuit (07/08) +- R3 ✅ suite golden 19 scénarios bloquante (07/08) +- R2 ✅ git source de vérité + build/push (07/08) +- Suivant : agent IA planificateur en MODE OMBRE (jamais dans la boucle 30 s) — + helpers advisor_*, validation bornée des sorties, kill-switch, cf. HANDOFF §13/§14. + diff --git a/BATTERY/BATTERY_CLOUD_LOCKS.js b/archive/BATTERY/BATTERY_CLOUD_LOCKS.js similarity index 100% rename from BATTERY/BATTERY_CLOUD_LOCKS.js rename to archive/BATTERY/BATTERY_CLOUD_LOCKS.js diff --git a/BATTERY/BATTERY_CLOUD_LOCKSP1.js b/archive/BATTERY/BATTERY_CLOUD_LOCKSP1.js similarity index 100% rename from BATTERY/BATTERY_CLOUD_LOCKSP1.js rename to archive/BATTERY/BATTERY_CLOUD_LOCKSP1.js diff --git a/BATTERY/BATTERY_CLOUD_LOCKSP2.js b/archive/BATTERY/BATTERY_CLOUD_LOCKSP2.js similarity index 100% rename from BATTERY/BATTERY_CLOUD_LOCKSP2.js rename to archive/BATTERY/BATTERY_CLOUD_LOCKSP2.js diff --git a/BATTERY/BATTERY_CLOUD_LOCKSP3.js b/archive/BATTERY/BATTERY_CLOUD_LOCKSP3.js similarity index 100% rename from BATTERY/BATTERY_CLOUD_LOCKSP3.js rename to archive/BATTERY/BATTERY_CLOUD_LOCKSP3.js diff --git a/BATTERY/battery_cloud_locks_new_profil_sans_injection.js b/archive/BATTERY/battery_cloud_locks_new_profil_sans_injection.js similarity index 100% rename from BATTERY/battery_cloud_locks_new_profil_sans_injection.js rename to archive/BATTERY/battery_cloud_locks_new_profil_sans_injection.js diff --git a/DECISION/DECISION_ENGINEV2.js b/archive/DECISION/DECISION_ENGINEV2.js similarity index 100% rename from DECISION/DECISION_ENGINEV2.js rename to archive/DECISION/DECISION_ENGINEV2.js diff --git a/DECISION/decision_engine.js b/archive/DECISION/decision_engine.js similarity index 100% rename from DECISION/decision_engine.js rename to archive/DECISION/decision_engine.js diff --git a/DECISION/decision_engineP1.js b/archive/DECISION/decision_engineP1.js similarity index 100% rename from DECISION/decision_engineP1.js rename to archive/DECISION/decision_engineP1.js diff --git a/DECISION/decision_engineP2.js b/archive/DECISION/decision_engineP2.js similarity index 100% rename from DECISION/decision_engineP2.js rename to archive/DECISION/decision_engineP2.js diff --git a/DECISION/decision_engineP3.js b/archive/DECISION/decision_engineP3.js similarity index 100% rename from DECISION/decision_engineP3.js rename to archive/DECISION/decision_engineP3.js diff --git a/DECISION/decision_engineP4.js b/archive/DECISION/decision_engineP4.js similarity index 100% rename from DECISION/decision_engineP4.js rename to archive/DECISION/decision_engineP4.js diff --git a/EVCSV2.2.json b/archive/EVCSV2.2.json similarity index 100% rename from EVCSV2.2.json rename to archive/EVCSV2.2.json diff --git a/HYSTERESIS/COMPUTE.js b/archive/HYSTERESIS/COMPUTE.js similarity index 100% rename from HYSTERESIS/COMPUTE.js rename to archive/HYSTERESIS/COMPUTE.js diff --git a/HYSTERESIS/COMPUTEP1.js b/archive/HYSTERESIS/COMPUTEP1.js similarity index 100% rename from HYSTERESIS/COMPUTEP1.js rename to archive/HYSTERESIS/COMPUTEP1.js diff --git a/HYSTERESIS/COMPUTEP2.js b/archive/HYSTERESIS/COMPUTEP2.js similarity index 100% rename from HYSTERESIS/COMPUTEP2.js rename to archive/HYSTERESIS/COMPUTEP2.js diff --git a/HYSTERESIS/COMPUTEP3.js b/archive/HYSTERESIS/COMPUTEP3.js similarity index 100% rename from HYSTERESIS/COMPUTEP3.js rename to archive/HYSTERESIS/COMPUTEP3.js diff --git a/archive/README.md b/archive/README.md new file mode 100644 index 0000000..8c064b1 --- /dev/null +++ b/archive/README.md @@ -0,0 +1,11 @@ +# Archive — copies historiques (NE PLUS UTILISER) + +Depuis R2 (07/08/2026), la source de vérité du code des nodes du workflow +"HA Snapshot EVCS Surplus Controller V2.6" est **`src/nodes/`** (un fichier +par node), injectée automatiquement dans `workflows/*.json` par +`tools/n8n-build.mjs` à chaque `tools/n8n-push.mjs`. + +Tout ce qui se trouve ici (decoupesurplus/, WINDOWS/, BATTERY/, DECISION/, +HYSTERESIS/, versions P1/P2/P3...) sont des copies de travail des sessions +2026-05→2026-07, qui ont TOUTES dérivé de la production. Conservées uniquement +pour l'archéologie. diff --git a/WINDOWS/WINDOW_SOURCEV2.js b/archive/WINDOWS/WINDOW_SOURCEV2.js similarity index 100% rename from WINDOWS/WINDOW_SOURCEV2.js rename to archive/WINDOWS/WINDOW_SOURCEV2.js diff --git a/WINDOWS/manage_windows.js b/archive/WINDOWS/manage_windows.js similarity index 100% rename from WINDOWS/manage_windows.js rename to archive/WINDOWS/manage_windows.js diff --git a/WINDOWS/manage_windowsP1.js b/archive/WINDOWS/manage_windowsP1.js similarity index 100% rename from WINDOWS/manage_windowsP1.js rename to archive/WINDOWS/manage_windowsP1.js diff --git a/WINDOWS/manage_windowsP2.js b/archive/WINDOWS/manage_windowsP2.js similarity index 100% rename from WINDOWS/manage_windowsP2.js rename to archive/WINDOWS/manage_windowsP2.js diff --git a/agents.old b/archive/agents.old similarity index 100% rename from agents.old rename to archive/agents.old diff --git a/build.js b/archive/build.js similarity index 100% rename from build.js rename to archive/build.js diff --git a/decoupesurplus/BATTERY_CLOUD_LOCKS.js b/archive/decoupesurplus/BATTERY_CLOUD_LOCKS.js similarity index 100% rename from decoupesurplus/BATTERY_CLOUD_LOCKS.js rename to archive/decoupesurplus/BATTERY_CLOUD_LOCKS.js diff --git a/decoupesurplus/DECISION_ENGINE.js b/archive/decoupesurplus/DECISION_ENGINE.js similarity index 100% rename from decoupesurplus/DECISION_ENGINE.js rename to archive/decoupesurplus/DECISION_ENGINE.js diff --git a/decoupesurplus/DECISION_ENGINE_4.1.js b/archive/decoupesurplus/DECISION_ENGINE_4.1.js similarity index 100% rename from decoupesurplus/DECISION_ENGINE_4.1.js rename to archive/decoupesurplus/DECISION_ENGINE_4.1.js diff --git a/decoupesurplus/DECISION_ENGINE_4.2.js b/archive/decoupesurplus/DECISION_ENGINE_4.2.js similarity index 100% rename from decoupesurplus/DECISION_ENGINE_4.2.js rename to archive/decoupesurplus/DECISION_ENGINE_4.2.js diff --git a/decoupesurplus/DECISION_ENGINE_4.3.js b/archive/decoupesurplus/DECISION_ENGINE_4.3.js similarity index 100% rename from decoupesurplus/DECISION_ENGINE_4.3.js rename to archive/decoupesurplus/DECISION_ENGINE_4.3.js diff --git a/decoupesurplus/HA_SERVICE_CALLS_OUTPUT.js b/archive/decoupesurplus/HA_SERVICE_CALLS_OUTPUT.js similarity index 100% rename from decoupesurplus/HA_SERVICE_CALLS_OUTPUT.js rename to archive/decoupesurplus/HA_SERVICE_CALLS_OUTPUT.js diff --git a/decoupesurplus/INPUT_PARSE.js b/archive/decoupesurplus/INPUT_PARSE.js similarity index 100% rename from decoupesurplus/INPUT_PARSE.js rename to archive/decoupesurplus/INPUT_PARSE.js diff --git a/decoupesurplus/V2/DEC.js b/archive/decoupesurplus/V2/DEC.js similarity index 100% rename from decoupesurplus/V2/DEC.js rename to archive/decoupesurplus/V2/DEC.js diff --git a/decoupesurplus/V2/DEC1.js b/archive/decoupesurplus/V2/DEC1.js similarity index 100% rename from decoupesurplus/V2/DEC1.js rename to archive/decoupesurplus/V2/DEC1.js diff --git a/decoupesurplus/V2/DEC2.js b/archive/decoupesurplus/V2/DEC2.js similarity index 100% rename from decoupesurplus/V2/DEC2.js rename to archive/decoupesurplus/V2/DEC2.js diff --git a/decoupesurplus/V2/DEC3.js b/archive/decoupesurplus/V2/DEC3.js similarity index 100% rename from decoupesurplus/V2/DEC3.js rename to archive/decoupesurplus/V2/DEC3.js diff --git a/decoupesurplus/V2/DECISION/decision.js b/archive/decoupesurplus/V2/DECISION/decision.js similarity index 100% rename from decoupesurplus/V2/DECISION/decision.js rename to archive/decoupesurplus/V2/DECISION/decision.js diff --git a/decoupesurplus/V2/DECISION/decisionP1.js b/archive/decoupesurplus/V2/DECISION/decisionP1.js similarity index 100% rename from decoupesurplus/V2/DECISION/decisionP1.js rename to archive/decoupesurplus/V2/DECISION/decisionP1.js diff --git a/decoupesurplus/V2/DECISION/decisionP2.js b/archive/decoupesurplus/V2/DECISION/decisionP2.js similarity index 100% rename from decoupesurplus/V2/DECISION/decisionP2.js rename to archive/decoupesurplus/V2/DECISION/decisionP2.js diff --git a/decoupesurplus/V2/DECISION/decisionP3.js b/archive/decoupesurplus/V2/DECISION/decisionP3.js similarity index 100% rename from decoupesurplus/V2/DECISION/decisionP3.js rename to archive/decoupesurplus/V2/DECISION/decisionP3.js diff --git a/decoupesurplus/V2/DECISION/decisionP4.js b/archive/decoupesurplus/V2/DECISION/decisionP4.js similarity index 100% rename from decoupesurplus/V2/DECISION/decisionP4.js rename to archive/decoupesurplus/V2/DECISION/decisionP4.js diff --git a/decoupesurplus/V2/DECISION/decisionP5.js b/archive/decoupesurplus/V2/DECISION/decisionP5.js similarity index 100% rename from decoupesurplus/V2/DECISION/decisionP5.js rename to archive/decoupesurplus/V2/DECISION/decisionP5.js diff --git a/decoupesurplus/V2/HASERVICEOUTPUT.js b/archive/decoupesurplus/V2/HASERVICEOUTPUT.js similarity index 100% rename from decoupesurplus/V2/HASERVICEOUTPUT.js rename to archive/decoupesurplus/V2/HASERVICEOUTPUT.js diff --git a/decoupesurplus/V2/HASERVICEOUTPUTP1.js b/archive/decoupesurplus/V2/HASERVICEOUTPUTP1.js similarity index 100% rename from decoupesurplus/V2/HASERVICEOUTPUTP1.js rename to archive/decoupesurplus/V2/HASERVICEOUTPUTP1.js diff --git a/decoupesurplus/V2/HASERVICEOUTPUTP2.js b/archive/decoupesurplus/V2/HASERVICEOUTPUTP2.js similarity index 100% rename from decoupesurplus/V2/HASERVICEOUTPUTP2.js rename to archive/decoupesurplus/V2/HASERVICEOUTPUTP2.js diff --git a/decoupesurplus/V2/HASERVICEOUTPUTP3.js b/archive/decoupesurplus/V2/HASERVICEOUTPUTP3.js similarity index 100% rename from decoupesurplus/V2/HASERVICEOUTPUTP3.js rename to archive/decoupesurplus/V2/HASERVICEOUTPUTP3.js diff --git a/decoupesurplus/V2/HASERVICEOUTPUTP4.js b/archive/decoupesurplus/V2/HASERVICEOUTPUTP4.js similarity index 100% rename from decoupesurplus/V2/HASERVICEOUTPUTP4.js rename to archive/decoupesurplus/V2/HASERVICEOUTPUTP4.js diff --git a/decoupesurplus/V2/INPUTPARSE/inputparse.js b/archive/decoupesurplus/V2/INPUTPARSE/inputparse.js similarity index 100% rename from decoupesurplus/V2/INPUTPARSE/inputparse.js rename to archive/decoupesurplus/V2/INPUTPARSE/inputparse.js diff --git a/decoupesurplus/V2/INPUTPARSE/inputparseP1.js b/archive/decoupesurplus/V2/INPUTPARSE/inputparseP1.js similarity index 100% rename from decoupesurplus/V2/INPUTPARSE/inputparseP1.js rename to archive/decoupesurplus/V2/INPUTPARSE/inputparseP1.js diff --git a/decoupesurplus/V2/INPUTPARSE/inputparseP2.js b/archive/decoupesurplus/V2/INPUTPARSE/inputparseP2.js similarity index 100% rename from decoupesurplus/V2/INPUTPARSE/inputparseP2.js rename to archive/decoupesurplus/V2/INPUTPARSE/inputparseP2.js diff --git a/decoupesurplus/V2/batcloud.js b/archive/decoupesurplus/V2/batcloud.js similarity index 100% rename from decoupesurplus/V2/batcloud.js rename to archive/decoupesurplus/V2/batcloud.js diff --git a/decoupesurplus/V2/batcloudP1.js b/archive/decoupesurplus/V2/batcloudP1.js similarity index 100% rename from decoupesurplus/V2/batcloudP1.js rename to archive/decoupesurplus/V2/batcloudP1.js diff --git a/decoupesurplus/V2/batcloudP11.js b/archive/decoupesurplus/V2/batcloudP11.js similarity index 100% rename from decoupesurplus/V2/batcloudP11.js rename to archive/decoupesurplus/V2/batcloudP11.js diff --git a/decoupesurplus/V2/batcloudP12.js b/archive/decoupesurplus/V2/batcloudP12.js similarity index 100% rename from decoupesurplus/V2/batcloudP12.js rename to archive/decoupesurplus/V2/batcloudP12.js diff --git a/decoupesurplus/V2/batcloudP13.js b/archive/decoupesurplus/V2/batcloudP13.js similarity index 100% rename from decoupesurplus/V2/batcloudP13.js rename to archive/decoupesurplus/V2/batcloudP13.js diff --git a/decoupesurplus/V2/batcloudP14.js b/archive/decoupesurplus/V2/batcloudP14.js similarity index 100% rename from decoupesurplus/V2/batcloudP14.js rename to archive/decoupesurplus/V2/batcloudP14.js diff --git a/decoupesurplus/V2/batcloudP2.js b/archive/decoupesurplus/V2/batcloudP2.js similarity index 100% rename from decoupesurplus/V2/batcloudP2.js rename to archive/decoupesurplus/V2/batcloudP2.js diff --git a/decoupesurplus/V2/gridhyst.js b/archive/decoupesurplus/V2/gridhyst.js similarity index 100% rename from decoupesurplus/V2/gridhyst.js rename to archive/decoupesurplus/V2/gridhyst.js diff --git a/decoupesurplus/V2/gridhystP1.js b/archive/decoupesurplus/V2/gridhystP1.js similarity index 100% rename from decoupesurplus/V2/gridhystP1.js rename to archive/decoupesurplus/V2/gridhystP1.js diff --git a/decoupesurplus/V2/gridhystP2.js b/archive/decoupesurplus/V2/gridhystP2.js similarity index 100% rename from decoupesurplus/V2/gridhystP2.js rename to archive/decoupesurplus/V2/gridhystP2.js diff --git a/decoupesurplus/V2/gridhystact.js b/archive/decoupesurplus/V2/gridhystact.js similarity index 100% rename from decoupesurplus/V2/gridhystact.js rename to archive/decoupesurplus/V2/gridhystact.js diff --git a/decoupesurplus/WINDOWS_SOURCE.js b/archive/decoupesurplus/WINDOWS_SOURCE.js similarity index 100% rename from decoupesurplus/WINDOWS_SOURCE.js rename to archive/decoupesurplus/WINDOWS_SOURCE.js diff --git a/helperv2.txt b/archive/helperv2.txt similarity index 100% rename from helperv2.txt rename to archive/helperv2.txt diff --git a/legacy.txt b/archive/legacy.txt similarity index 100% rename from legacy.txt rename to archive/legacy.txt diff --git a/snapshot.js b/archive/snapshot.js similarity index 100% rename from snapshot.js rename to archive/snapshot.js diff --git a/surplus.js b/archive/surplus.js similarity index 100% rename from surplus.js rename to archive/surplus.js diff --git a/src/nodes/BATTERY_CLOUD_LOCKS.js b/src/nodes/BATTERY_CLOUD_LOCKS.js new file mode 100644 index 0000000..522ec60 --- /dev/null +++ b/src/nodes/BATTERY_CLOUD_LOCKS.js @@ -0,0 +1,897 @@ +function clamp(v, min, max) { + return Math.min(Math.max(v, min), max); +} + +function to2(n) { + return String(n).padStart(2, '0'); +} + +function getParisParts(ms = Date.now()) { + const fmt = new Intl.DateTimeFormat('fr-FR', { + timeZone: 'Europe/Paris', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); + + const parts = Object.fromEntries( + fmt.formatToParts(new Date(ms)) + .filter(p => p.type !== 'literal') + .map(p => [p.type, p.value]) + ); + + const hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour); + const date = `${parts.year}-${parts.month}-${parts.day}`; + + return { + date, + hour, + minute: Number(parts.minute), + second: Number(parts.second), + }; +} + +function getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') { + const dtf = new Intl.DateTimeFormat('en-US', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); + + const parts = Object.fromEntries( + dtf.formatToParts(new Date(utcTs)) + .filter(p => p.type !== 'literal') + .map(p => [p.type, p.value]) + ); + + const asIfUtc = Date.UTC( + Number(parts.year), + Number(parts.month) - 1, + Number(parts.day), + Number(parts.hour) === 24 ? 0 : Number(parts.hour), + Number(parts.minute), + Number(parts.second) + ); + + const d = new Date(utcTs); + const actualUtc = Date.UTC( + d.getUTCFullYear(), + d.getUTCMonth(), + d.getUTCDate(), + d.getUTCHours(), + d.getUTCMinutes(), + d.getUTCSeconds() + ); + + return asIfUtc - actualUtc; +} + +function parseParisStampToMs(s) { + const text = (s ?? '').toString().trim(); + const m = text.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/); + if (!m) return null; + + const y = Number(m[1]); + const mo = Number(m[2]); + const d = Number(m[3]); + const h = Number(m[4]); + const mi = Number(m[5]); + const se = Number(m[6]); + + const guessUtc = Date.UTC(y, mo - 1, d, h, mi, se); + const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris'); + let ts = guessUtc - off1; + const off2 = getTzOffsetMs(ts, 'Europe/Paris'); + + if (off2 !== off1) ts = guessUtc - off2; + return ts; +} + +function hhmmssFromMinutes(minOfDay) { + const mm = ((minOfDay % 1440) + 1440) % 1440; + const hh = Math.floor(mm / 60); + const mi = mm % 60; + return `${to2(hh)}:${to2(mi)}:00`; +} + +function nextDailyOccurrenceMs(startMin, refMs) { + if (!Number.isFinite(startMin)) return null; + + const today = getParisParts(refMs).date; + const todayStamp = `${today} ${hhmmssFromMinutes(startMin)}`; + const todayMs = parseParisStampToMs(todayStamp); + + if (todayMs !== null && todayMs > refMs + 1000) return todayMs; + + const tomorrow = getParisParts(refMs + 36 * 3600 * 1000).date; + const tomorrowStamp = `${tomorrow} ${hhmmssFromMinutes(startMin)}`; + return parseParisStampToMs(tomorrowStamp); +} + +const prev = $input.first().json; + +const { + nowMs, + cfg, + parsed, + params, + history, + batteryTune, + strategy, + derived, + context, + lifecycle, +} = prev; + +// ===================================================== +// SESSION / SOURCE +// ===================================================== +const sessionObservedActive = + parsed.evcsChargingSwitchOn || + parsed.controlLatch || + parsed.chargePermissionNow; + +const isGridSource = + context.sourceMode === 'grid_hc_allowed' || + context.sourceMode === 'grid_forced'; + +const isLocalSolarSource = + context.sourceMode === 'solar_only' || + context.sourceMode === 'solar_potential'; + +// ===================================================== +// SOC BAND / BATTERY DYNAMIC POLICY +// ===================================================== +const socBand = + parsed.soc > 90 ? 'gt90' : + parsed.soc > 80 ? '80_90' : + parsed.soc > 60 ? '60_80' : + parsed.soc > 40 ? '40_60' : + parsed.soc > 25 ? '25_40' : + 'lt25'; + +const band = batteryTune.bands[socBand]; + +const dischargeModeFactor = + strategy.forceChargeRequested ? 1.2 : + strategy.forceGridRequested ? 0.7 : + strategy.userStrategy === 'auto_solar' ? 0.8 : + 1.0; + +const chargeReserveModeFactor = + strategy.forceChargeRequested ? 0.8 : + strategy.forceGridRequested ? 1.1 : + strategy.userStrategy === 'auto_solar' ? 1.1 : + 1.0; + +const toleranceModeFactor = + strategy.forceChargeRequested ? 1.25 : + strategy.forceGridRequested ? 0.8 : + strategy.userStrategy === 'auto_solar' ? 0.8 : + 1.0; + +const batteryDischargeSoftDynamicA = band.dischargeSoftA * dischargeModeFactor; +const batteryDischargeHardDynamicA = band.dischargeHardA * dischargeModeFactor; +const batteryChargeReserveDynamicA = Math.max(0, band.chargeReserveA * chargeReserveModeFactor); +const batterySoftToleranceMinutes = band.softToleranceMin * toleranceModeFactor; +const batteryHardToleranceSeconds = band.hardToleranceSec; +const batteryRecoveryMinutes = band.recoveryMin; + +// ===================================================== +// CONTEXT HELPERS +// ===================================================== +const houseSpikeThresholdW = 1500; +const houseSpikeDetected = + (parsed.houseAcPowerW - parsed.houseAcPowerPrevW) >= houseSpikeThresholdW; + +const solarPotentialContext = + context.sourceMode === 'solar_potential' || + ( + parsed.soc >= 80 && + derived.forecastBufferKwh > 0 && + parsed.pvPowerW < context.effectivePvStartMinW && + derived.houseNonEvcsW < 1200 + ); + +// ===================================================== +// OUTSIDE-WINDOW LOCAL BYPASS +// IMPORTANT: +// en auto_solar_plus ou force_charge_now, un contexte local solaire +// ne doit plus rester bloqué juste parce qu'on est hors fenêtre. +// ===================================================== +const outsideWindowLocalBypass = + context.profile === 'outside_window' && + !strategy.forceGridRequested && + ( + strategy.forceChargeRequested || + strategy.userStrategy === 'auto_solar_plus' + ) && + isLocalSolarSource; + +// ===================================================== +// REASON-BASED LOCKS +// ===================================================== +const nextWindowCandidates = [ + nextDailyOccurrenceMs(params.morningStartMin, nowMs), + nextDailyOccurrenceMs(params.hcMiddayStartMin, nowMs), + nextDailyOccurrenceMs(params.eveningStartMin, nowMs), + nextDailyOccurrenceMs(params.hcNightStartMin, nowMs), +].filter(v => Number.isFinite(v)); + +const nextAllowedWindowStartMs = + nextWindowCandidates.length > 0 ? Math.min(...nextWindowCandidates) : null; + +const blockActive = + history.blockedUntilMs !== null && + history.blockedUntilMs > nowMs + 1000; + +const blockStillRelevant = + history.lastBlockReason === 'outside_window' + ? (context.profile === 'outside_window' && !outsideWindowLocalBypass) + : history.lastBlockReason === 'probe_failed' + ? context.sourceMode === 'hybrid_probe' + : history.lastBlockReason === 'battery_protection_stop' + ? ( + parsed.batteryCurrentA < -params.batteryNeutralBandA || + derived.anyVebusLowBattery || + derived.vebusDcVoltageSoftLow + ) + : history.lastBlockReason === 'grid_battery_guard' + ? isGridSource + : history.lastBlockReason === 'vebus_overload_persistent' + ? derived.anyVebusOverload + : false; + +const startBlockedByReason = blockActive && blockStillRelevant; + +// ===================================================== +// CLOUD DEBT +// ===================================================== +const batteryNetPowerW = parsed.batteryCurrentA * derived.batteryVoltageRef; +let cloudDebtNextKwh = params.cloudDebtPrevKwh; + +if (sessionObservedActive) { + if (batteryNetPowerW < 0 && !isGridSource) { + cloudDebtNextKwh += (Math.abs(batteryNetPowerW) * cfg.sampleSeconds) / 3600000; + } else if (batteryNetPowerW > 0) { + cloudDebtNextKwh -= (batteryNetPowerW * cfg.sampleSeconds) / 3600000; + } +} else if (batteryNetPowerW > 0) { + cloudDebtNextKwh -= (batteryNetPowerW * cfg.sampleSeconds) / 3600000; +} + +cloudDebtNextKwh = clamp(cloudDebtNextKwh, 0, params.cloudDebtMaxKwh * 4); + +const cloudDebtMeaningful = + cloudDebtNextKwh > cfg.cloudDebtMeaningfulThresholdKwh; + +const cloudEventDischargingNow = + sessionObservedActive && + !isGridSource && + parsed.batteryCurrentA < -Math.max(1, params.batteryNeutralBandA) && + cloudDebtMeaningful; + +const continuingCloudEvent = + cloudEventDischargingNow && + history.cloudDebtStartedMsPrev !== null && + params.cloudDebtPrevKwh > cfg.cloudDebtRecoveryThresholdKwh; + +const enteringCloudDebt = + cloudEventDischargingNow && !continuingCloudEvent; + +const cloudDebtActive = cloudEventDischargingNow; + +const cloudDebtStartedMs = + cloudEventDischargingNow + ? (continuingCloudEvent ? history.cloudDebtStartedMsPrev : nowMs) + : null; + +const cloudDebtDurationMinutes = + cloudDebtActive && cloudDebtStartedMs !== null + ? Math.max(0, (nowMs - cloudDebtStartedMs) / 60000) + : 0; + +const effectiveCloudToleranceMinutes = + params.cloudToleranceMinutes + + (solarPotentialContext ? batteryTune.global.solarPotentialGraceMinutes : 0) + + (houseSpikeDetected ? batteryTune.global.houseSpikeGraceMinutes : 0); + +const cloudToleranceOk = + cloudDebtNextKwh <= params.cloudDebtMaxKwh && + ( + !cloudDebtActive || + cloudDebtDurationMinutes <= effectiveCloudToleranceMinutes + ); + +// ===================================================== +// LEGACY / FORECAST MODES +// ===================================================== +const batteryFullHarvestMode = + parsed.forecastModeEnabled && + context.profile !== 'night_hc' && + context.profile !== 'outside_window' && + parsed.soc >= params.batteryFullSoc && + parsed.todayRemainingKwh >= params.batteryFullForecastMinKwh && + derived.projectedEndSocAfterCharge >= (derived.endDayGuardSoc - 2); + +const forecastCommitMode = + parsed.forecastModeEnabled && + context.profile !== 'night_hc' && + context.profile !== 'outside_window' && + parsed.soc >= params.forecastCommitSocFloor && + parsed.todayRemainingKwh >= params.forecastCommitMinKwh && + derived.projectedEndSocAfterCharge >= derived.endDayGuardSoc; + +const batteryPriorityMode = + !batteryFullHarvestMode && !forecastCommitMode; + +// ===================================================== +// EVCS NIGHT ENGINE — R1 (07/08/2026) +// Le moteur nuit est calculé UNE SEULE FOIS dans WINDOWS_SOURCEV2 +// (payload nightEvcsPreview). Ce node CONSOMME, il ne recalcule plus. +// TOUTE évolution de la logique nuit se fait dans WINDOWS_SOURCEV2. +// Fallback sûr si preview absent : session inactive / 'blocked'. +// ===================================================== +const nightPrev = prev.nightEvcsPreview || {}; + +const nightSessionActive = !!nightPrev.nightSessionActive; +const nightSessionNeedsCapture = !!nightPrev.nightSessionNeedsCapture; +const nightSessionNeedsClear = !!nightPrev.nightSessionNeedsClear; +const nightSessionStartSocPct = + Number.isFinite(nightPrev.nightSessionStartSocPct) ? nightPrev.nightSessionStartSocPct : null; +const nightSessionStartAt = nightPrev.nightSessionStartAt || ''; +const nightSessionStartMs = + Number.isFinite(nightPrev.nightSessionStartMs) ? nightPrev.nightSessionStartMs : null; + +const batteryHardFloorPct = + Number.isFinite(nightPrev.batteryHardFloorPct) + ? nightPrev.batteryHardFloorPct + : params.evcsNightBatteryHardFloorPct; +const batteryMorningTargetPct = + Number.isFinite(nightPrev.batteryMorningTargetPct) + ? nightPrev.batteryMorningTargetPct + : params.evcsNightTargetWeakForecastPct; +const maxBatteryDischargePoints = + Number.isFinite(nightPrev.maxBatteryDischargePoints) ? nightPrev.maxBatteryDischargePoints : 0; +const gridTakeoverTriggerPct = + Number.isFinite(nightPrev.gridTakeoverTriggerPct) + ? nightPrev.gridTakeoverTriggerPct + : Math.max(batteryHardFloorPct, batteryMorningTargetPct); + +const batteryBelowHardFloor = !!nightPrev.batteryBelowHardFloor; +const batteryBelowMorningTarget = !!nightPrev.batteryBelowMorningTarget; +const batteryAtOrBelowGridTakeoverTrigger = !!nightPrev.batteryAtOrBelowGridTakeoverTrigger; + +const batteryKwhMissingToTarget = + Number.isFinite(nightPrev.batteryKwhMissingToTarget) ? nightPrev.batteryKwhMissingToTarget : 0; +const batteryRecoveryMinutesNeeded = + Number.isFinite(nightPrev.batteryRecoveryMinutesNeeded) ? nightPrev.batteryRecoveryMinutesNeeded : 0; +const batteryRecoveryUrgent = !!nightPrev.batteryRecoveryUrgent; +const batteryRecoveryAllowedNow = !!nightPrev.batteryRecoveryAllowedNow; +const batteryTargetReached = !!nightPrev.batteryTargetReached; + +const batteryAssistAllowed = !!nightPrev.batteryAssistAllowed; +const nightGridTakeoverNeeded = !!nightPrev.nightGridTakeoverNeeded; +const nightTakeoverLatched = !!nightPrev.nightTakeoverLatched; +const vehicleChargeCompleted = !!nightPrev.vehicleChargeCompleted; +const nightPreferredSourceMode = nightPrev.nightPreferredSourceMode || 'blocked'; + +// ===================================================== +// PROFILE-BASED THRESHOLDS +// ===================================================== +const profileSocStartFloor = + context.profile === 'morning_solar' ? params.morningSocMin : + context.profile === 'midday_hc_or_solar' ? params.middaySocMin : + context.profile === 'evening_high_soc' ? params.eveningSocStartMin : + params.socStartMin; + +// P6 ABONDANCE : PV moyen élevé + batterie en charge franche => le plancher SOC +// de démarrage perd son sens (la batterie continue de monter même avec l'EVCS à 6 A). +// Jours nuageux ou batterie peu chargée par le PV : rien ne change. +const PV_ABUNDANCE_AVG_MIN_W = 4000; +const ABUNDANCE_BATTERY_CHARGE_MIN_A = 20; +const ABUNDANCE_SOC_FLOOR_PCT = 40; +const solarAbundance = + parsed.pvPowerAvg10mW >= PV_ABUNDANCE_AVG_MIN_W && + parsed.batteryCurrentA >= ABUNDANCE_BATTERY_CHARGE_MIN_A; +const effectiveSocStartFloor = solarAbundance + ? Math.min(profileSocStartFloor, ABUNDANCE_SOC_FLOOR_PCT) + : profileSocStartFloor; + +const profileSocHoldFloor = + context.profile === 'morning_solar' ? Math.max(params.socStopMin, params.morningSocMin - 10) : + context.profile === 'midday_hc_or_solar' ? Math.max(params.socStopMin, params.middaySocMin - 15) : + context.profile === 'evening_high_soc' ? params.eveningSocStopMin : + params.socStopMin; + +const profileSpecificDischargeFloorA = + context.profile === 'morning_solar' ? params.morningBatteryMaxDischargeA : + context.profile === 'midday_hc_or_solar' ? params.middayBatteryMaxDischargeA : + context.profile === 'evening_high_soc' ? params.eveningBatteryMaxDischargeA : + params.batteryDischargeSoftA; + +const profileBatteryDischargeFloorA = + Math.max(profileSpecificDischargeFloorA, batteryDischargeSoftDynamicA); + +// ===================================================== +// GRID RELAX IN force_charge_now +// ===================================================== +// Assouplissement uniquement pour la garde réseau, +// sans toucher aux sécurités dures globales. +const gridForceChargeRelaxSoftA = + strategy.forceChargeRequested && isGridSource + ? ( + parsed.soc >= 90 ? 28 : + parsed.soc >= 80 ? 16 : + parsed.soc >= 60 ? 8 : + parsed.soc >= 40 ? 4 : + 0 + ) + : 0; + +const gridForceChargeRelaxHardA = + strategy.forceChargeRequested && isGridSource + ? ( + parsed.soc >= 90 ? 39 : + parsed.soc >= 80 ? 29 : + parsed.soc >= 60 ? 18 : + parsed.soc >= 40 ? 8 : + 0 + ) + : 0; + +const gridBatterySoftDynamicABase = + Math.max(params.gridBatterySoftA, batteryDischargeSoftDynamicA); + +const gridBatteryHardDynamicABase = + Math.max(params.gridBatteryHardA, batteryDischargeHardDynamicA); + +let gridBatterySoftDynamicA = + gridBatterySoftDynamicABase - gridForceChargeRelaxSoftA; + +let gridBatteryHardDynamicA = + gridBatteryHardDynamicABase - gridForceChargeRelaxHardA; + +// Garantit que le hard reste plus permissif que le soft d'au moins 3 A +if (gridBatteryHardDynamicA > (gridBatterySoftDynamicA - 3)) { + gridBatteryHardDynamicA = gridBatterySoftDynamicA - 3; +} + +// ===================================================== +// BASE SAFETY +// ===================================================== +const commonSafetyStartOk = + derived.canRunAtMinCurrent && + !derived.anyVebusOverload && + !derived.anyVebusLowBattery && + !derived.vebusDcVoltageSoftLow && + !derived.vebusAcOutSoftReached && + parsed.evcsDeviceConnected && + parsed.evcsVehicleConnected && + !parsed.evcsModeIsAuto; + +const commonSafetyHoldOk = + derived.canRunAtMinCurrent && + !derived.anyVebusLowBattery && + !derived.vebusDcVoltageHardLow && + parsed.evcsDeviceConnected && + parsed.evcsVehicleConnected && + !parsed.evcsModeIsAuto; + +// ===================================================== +// BATTERY-ASSISTED SOLAR START/HOLD +// IMPORTANT: +// Installation sans injection: +// le PV visible peut rester bas tant que l'EVCS ne démarre pas. +// On autorise donc un démarrage/maintien local si la batterie est haute +// et que la dette nuage reste faible. +// ===================================================== +const batteryAssistSolarPolicyEligible = + !strategy.forceGridRequested && + ( + strategy.forceChargeRequested || + strategy.userStrategy === 'auto_solar_plus' + ) && + isLocalSolarSource; + +const batteryAssistSolarStartSocMin = 90; +const batteryAssistSolarHoldSocMin = 85; + +const batteryAssistSolarDebtStartMaxKwh = Math.max( + cfg.cloudDebtRecoveryThresholdKwh, + Math.min(params.cloudDebtMaxKwh, 0.05) +); + +const batteryAssistSolarStartOk = + batteryAssistSolarPolicyEligible && + commonSafetyStartOk && + !isGridSource && + parsed.soc >= Math.max(profileSocStartFloor, batteryAssistSolarStartSocMin) && + parsed.soc > batteryAssistSolarHoldSocMin && + parsed.batteryCurrentA >= profileBatteryDischargeFloorA && + cloudDebtNextKwh <= batteryAssistSolarDebtStartMaxKwh && + !cloudDebtActive; + +const batteryAssistSolarHoldOk = + batteryAssistSolarPolicyEligible && + commonSafetyHoldOk && + !isGridSource && + parsed.soc >= Math.max(profileSocHoldFloor, batteryAssistSolarHoldSocMin) && + parsed.batteryCurrentA >= profileBatteryDischargeFloorA && + cloudToleranceOk; + +// ===================================================== +// START / HOLD CONDITIONS +// ===================================================== +const rawSolarStartOk = + (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') && + commonSafetyStartOk && + parsed.soc >= effectiveSocStartFloor && + parsed.batteryCurrentA >= profileBatteryDischargeFloorA && + ( + context.sourceMode === 'solar_potential' || + parsed.pvPowerW >= context.effectivePvStartMinW + ); + +const solarStartOk = + rawSolarStartOk || batteryAssistSolarStartOk; + +const rawSolarHoldOk = + (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') && + commonSafetyHoldOk && + parsed.soc >= profileSocHoldFloor && + parsed.batteryCurrentA >= profileBatteryDischargeFloorA && + ( + parsed.pvPowerW >= params.pvHoldMinW || + cloudToleranceOk + ); + +const solarHoldOk = + rawSolarHoldOk || batteryAssistSolarHoldOk; + +// FIX V2.5: Garde SOC - pas de grid pour EVCS si SOC > 35% sauf force_grid_now +const gridAllowedBySoc = + strategy.forceGridRequested || + context.profile === 'night_hc' || + parsed.soc <= 35; + +const gridStartOk = + gridAllowedBySoc && + isGridSource && + commonSafetyStartOk && + parsed.gridAvailable && + parsed.batteryCurrentA >= gridBatterySoftDynamicA; + +const gridHoldOk = + gridAllowedBySoc && + isGridSource && + commonSafetyHoldOk && + parsed.gridAvailable && + parsed.batteryCurrentA >= gridBatteryHardDynamicA; + +const batteryAssistNightStartOk = + context.sourceMode === 'battery_assist_night' && + commonSafetyStartOk && + nightSessionActive && + batteryAssistAllowed && + parsed.soc > batteryHardFloorPct && + parsed.soc > gridTakeoverTriggerPct && + parsed.batteryCurrentA >= profileBatteryDischargeFloorA; + +const batteryAssistNightHoldOk = + context.sourceMode === 'battery_assist_night' && + commonSafetyHoldOk && + nightSessionActive && + batteryAssistAllowed && + parsed.soc > batteryHardFloorPct && + parsed.soc > gridTakeoverTriggerPct && + parsed.batteryCurrentA >= batteryDischargeHardDynamicA; + +// ===================================================== +// PROBE LOGIC — STRICTEMENT LIMITÉE AU MODE PROBE +// ===================================================== +const probeModeActive = context.sourceMode === 'hybrid_probe'; + +const probeStartOk = + probeModeActive && + commonSafetyStartOk && + (context.morningProbeEligible || context.middayProbeEligible || context.afternoonProbeEligible); + +const probeHoldGuardOk = + probeModeActive && + commonSafetyHoldOk && + parsed.batteryCurrentA >= profileBatteryDischargeFloorA; + +const probeWindowActive = + probeModeActive && + sessionObservedActive && + history.probeStartedMs !== null && + lifecycle.minutesSinceProbeStart !== null && + lifecycle.minutesSinceProbeStart < params.probeMinutes; + +const probeElapsedReached = + probeModeActive && + sessionObservedActive && + history.probeStartedMs !== null && + lifecycle.minutesSinceProbeStart !== null && + lifecycle.minutesSinceProbeStart >= params.probeMinutes; + +const probeSuccessNow = + probeModeActive && + sessionObservedActive && + history.probeStartedMs !== null && + ( + parsed.pvPowerW >= params.pvHoldMinW && + parsed.batteryCurrentA >= Math.min(0, batteryDischargeSoftDynamicA) + ); + +const probeFailedNow = + probeModeActive && + probeElapsedReached && + !probeSuccessNow; + +// ===================================================== +// STEP-UP LOGIC +// ===================================================== +const stepUpByBatteryReserve = + batteryPriorityMode && + parsed.batteryCurrentA >= batteryChargeReserveDynamicA; + +const stepUpByForecastMode = + !batteryPriorityMode && + ( + parsed.pvPowerW >= params.pvHoldMinW || + parsed.pvPowerAvg10mW >= params.pvHoldMinW + ) && + derived.forecastBufferKwh >= -1 && + derived.projectedEndSocAfterCharge >= (derived.endDayGuardSoc - 5) && + cloudToleranceOk; + +const solarShouldStepUp = + lifecycle.currentCooldownElapsed && + derived.currentReferenceA < derived.physicalCapA && + derived.physicalCapA >= params.currentMinA && + !derived.vebusAcOutSoftReached && + !derived.vebusDcVoltageSoftLow && + !derived.houseLoadHighVsHeadroom && + ( + stepUpByBatteryReserve || + stepUpByForecastMode || + parsed.pvPowerW >= (context.effectivePvStartMinW + 400) + ); + +const gridShouldStepUp = + lifecycle.currentCooldownElapsed && + derived.currentReferenceA < derived.physicalCapA && + derived.physicalCapA >= params.currentMinA && + !derived.vebusAcOutSoftReached && + !derived.vebusDcVoltageSoftLow && + parsed.batteryCurrentA >= gridBatterySoftDynamicA && + !derived.houseLoadHighVsHeadroom; + +const batteryAssistNightShouldStepUp = + context.sourceMode === 'battery_assist_night' && + lifecycle.currentCooldownElapsed && + derived.currentReferenceA < derived.physicalCapA && + derived.physicalCapA >= params.currentMinA && + !derived.vebusAcOutSoftReached && + !derived.vebusDcVoltageSoftLow && + !derived.houseLoadHighVsHeadroom && + parsed.batteryCurrentA >= batteryChargeReserveDynamicA && + batteryAssistAllowed; + +// ===================================================== +// STEP-DOWN LOGIC +// ===================================================== +const solarShouldStepDownBattery = + parsed.batteryCurrentA <= batteryDischargeSoftDynamicA && + lifecycle.currentCooldownElapsed && + derived.currentReferenceA > params.currentMinA; + +const solarShouldStepDownCloudDebt = + !cloudToleranceOk && + lifecycle.currentCooldownElapsed && + derived.currentReferenceA > params.currentMinA; + +const gridShouldStepDownBattery = + parsed.batteryCurrentA < gridBatterySoftDynamicA && + lifecycle.currentCooldownElapsed && + derived.currentReferenceA > params.currentMinA; + +const batteryAssistNightShouldStepDown = + context.sourceMode === 'battery_assist_night' && + lifecycle.currentCooldownElapsed && + derived.currentReferenceA > params.currentMinA && + ( + parsed.batteryCurrentA <= batteryDischargeSoftDynamicA || + parsed.soc <= gridTakeoverTriggerPct || + batteryRecoveryUrgent + ); + +const shouldStepDownVebusPower = + derived.vebusAcOutSoftReached && + lifecycle.currentCooldownElapsed && + derived.currentReferenceA > params.currentMinA; + +const shouldStepDownVebusVoltage = + derived.vebusDcVoltageSoftLow && + lifecycle.currentCooldownElapsed && + derived.currentReferenceA > params.currentMinA; + +const shouldStepDownOverload = + derived.anyVebusOverload && + lifecycle.currentCooldownElapsed && + derived.currentReferenceA > params.currentMinA; + +const shouldStopOverloadAtMin = + derived.anyVebusOverload && + derived.currentReferenceA <= params.currentMinA && + sessionObservedActive; + +// ===================================================== +// HARD STOPS / PROTECTIONS +// ===================================================== +const batteryCurrentHardExceeded = + parsed.batteryCurrentA <= batteryDischargeHardDynamicA; + +// overload volontairement exclu du hard stop immédiat +const mustStopHard = + derived.anyVebusLowBattery || + derived.vebusDcVoltageHardLow || + derived.vebusAcOutHardReached || + batteryCurrentHardExceeded; + +const gridBatteryProtectionStop = + sessionObservedActive && + isGridSource && + parsed.batteryCurrentA < gridBatteryHardDynamicA; + +// ===================================================== +// OUTPUT +// ===================================================== +return [{ + json: { + ...prev, + + batteryPolicy: { + socBand, + batteryDischargeSoftDynamicA, + batteryDischargeHardDynamicA, + batteryChargeReserveDynamicA, + batterySoftToleranceMinutes, + batteryHardToleranceSeconds, + batteryRecoveryMinutes, + + gridBatterySoftDynamicA, + gridBatteryHardDynamicA, + gridForceChargeRelaxSoftA, + gridForceChargeRelaxHardA, + + batteryAssistSolarStartSocMin, + batteryAssistSolarHoldSocMin, + batteryAssistSolarDebtStartMaxKwh, + + houseSpikeDetected, + solarPotentialContext, + outsideWindowLocalBypass, + }, + + locks: { + nextAllowedWindowStartMs, + blockActive, + blockStillRelevant, + startBlockedByReason, + }, + + cloud: { + sessionObservedActive, + batteryNetPowerW, + cloudDebtNextKwh, + cloudDebtMeaningful, + cloudEventDischargingNow, + continuingCloudEvent, + enteringCloudDebt, + cloudDebtActive, + cloudDebtStartedMs, + cloudDebtDurationMinutes, + effectiveCloudToleranceMinutes, + cloudToleranceOk, + }, + + modes: { + batteryFullHarvestMode, + forecastCommitMode, + batteryPriorityMode, + }, + + nightEvcs: { + nightSessionActive, + nightSessionNeedsCapture, + nightSessionNeedsClear, + nightSessionStartSocPct, + nightSessionStartAt, + nightSessionStartMs, + + batteryHardFloorPct, + batteryMorningTargetPct, + maxBatteryDischargePoints, + gridTakeoverTriggerPct, + + batteryBelowHardFloor, + batteryBelowMorningTarget, + batteryAtOrBelowGridTakeoverTrigger, + + batteryKwhMissingToTarget, + batteryRecoveryMinutesNeeded, + batteryRecoveryUrgent, + batteryRecoveryAllowedNow, + batteryTargetReached, + + batteryAssistAllowed, + nightGridTakeoverNeeded, + nightTakeoverLatched, + vehicleChargeCompleted, + nightPreferredSourceMode, + }, + + safeguards: { + isGridSource, + commonSafetyStartOk, + commonSafetyHoldOk, + profileSocStartFloor, + effectiveSocStartFloor, + solarAbundance, + profileSocHoldFloor, + profileBatteryDischargeFloorA, + batteryCurrentHardExceeded, + mustStopHard, + gridBatteryProtectionStop, + }, + + conditions: { + rawSolarStartOk, + rawSolarHoldOk, + batteryAssistSolarPolicyEligible, + batteryAssistSolarStartOk, + batteryAssistSolarHoldOk, + + solarStartOk, + solarHoldOk, + gridStartOk, + gridHoldOk, + + batteryAssistNightStartOk, + batteryAssistNightHoldOk, + + probeStartOk, + probeHoldGuardOk, + probeWindowActive, + probeElapsedReached, + probeSuccessNow, + probeFailedNow, + + solarShouldStepUp, + gridShouldStepUp, + batteryAssistNightShouldStepUp, + + solarShouldStepDownBattery, + solarShouldStepDownCloudDebt, + gridShouldStepDownBattery, + batteryAssistNightShouldStepDown, + + shouldStepDownVebusPower, + shouldStepDownVebusVoltage, + shouldStepDownOverload, + shouldStopOverloadAtMin, + } + } +}]; diff --git a/src/nodes/BUILD_ACTIONS.js b/src/nodes/BUILD_ACTIONS.js new file mode 100644 index 0000000..784ba20 --- /dev/null +++ b/src/nodes/BUILD_ACTIONS.js @@ -0,0 +1,223 @@ +const root = $input.first().json || {}; +const decision = root.decision || {}; +const calls = root.ha_service_calls || {}; +const context = root.context || {}; +const actions = []; + +function pushAction(action, call, enabled = true) { + if (!enabled) return; + if (!call) return; + if (call.execute === false) return; + + const { execute, ...rest } = call; + + actions.push({ + json: { + action, + ...rest + } + }); +} + +// ===================================================== +// HELPERS TOUJOURS MIS À JOUR +// ===================================================== +[ + 'helper_state', + 'helper_current_decision', + 'helper_reason', + 'helper_operating_mode', + 'helper_source_mode', + 'helper_target_current', + 'helper_budget_remaining', + 'helper_projected_soc', + 'helper_cloud_debt', + 'helper_dynamic_ok', + 'helper_must_stop', + 'helper_evcs_priority_active', + 'helper_energy_priority_mode', + 'helper_energy_priority_reason', + 'helper_evcs_priority_until', + + // =================================================== + // NIGHT EVCS HELPERS + // =================================================== + 'helper_night_target_soc', + 'helper_night_grid_trigger_soc', + 'helper_night_preferred_source_mode', + 'helper_night_grid_takeover_needed', + 'helper_night_battery_recovery_urgent', + 'helper_night_session_active', + + // =================================================== + // VEHICLE DEMAND FILTER HISTORY HELPERS + // =================================================== + 'helper_evcs_delivery_ratio_ema', + 'helper_evcs_delivery_noise_ema', + 'helper_evcs_no_demand_score_ema', + 'helper_evcs_no_demand_semantic_active', +].forEach(name => pushAction(name, calls[name], true)); + +// ===================================================== +// HELPERS / TIMESTAMPS CONDITIONNELS +// ===================================================== +pushAction( + 'helper_cloud_debt_started_at', + calls.helper_cloud_debt_started_at, + !!decision.set_cloud_debt_started_at +); + +pushAction( + 'helper_block_reason', + calls.helper_block_reason, + !!decision.set_block_reason +); + +pushAction( + 'helper_blocked_until', + calls.helper_blocked_until, + !!decision.set_blocked_until +); + +pushAction( + 'helper_probe_started_at', + calls.helper_probe_started_at, + !!decision.set_probe_started_at +); + +// ===================================================== +// NIGHT SESSION MEMORY +// ===================================================== +pushAction( + 'helper_night_session_start_soc', + calls.helper_night_session_start_soc, + !!decision.set_night_session_start || !!decision.clear_night_session_start +); + +pushAction( + 'helper_night_session_start_at', + calls.helper_night_session_start_at, + !!decision.set_night_session_start || !!decision.clear_night_session_start +); + +// ===================================================== +// VEHICLE DEMAND FILTER TIMESTAMPS +// ===================================================== +pushAction( + 'helper_evcs_no_demand_since_at', + calls.helper_evcs_no_demand_since_at, + !!calls.helper_evcs_no_demand_since_at?.execute +); + +pushAction( + 'helper_evcs_no_demand_filter_eval_at', + calls.helper_evcs_no_demand_filter_eval_at, + !!calls.helper_evcs_no_demand_filter_eval_at?.execute +); + +// ===================================================== +// DEMANDE RÉSEAU EDF SI SOURCE GRID +// ===================================================== +pushAction( + 'edf_general_ok_on', + calls.edf_general_ok_on, + context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced' +); + +// ===================================================== +// AUTO START OFF SI BESOIN +// ===================================================== +pushAction( + 'evcs_auto_start_off', + calls.evcs_auto_start_off, + decision.auto_start_action === 'turn_off' +); + +// ===================================================== +// SI ON ARRÊTE : CHARGING OFF D'ABORD +// ===================================================== +pushAction( + 'evcs_charging', + calls.evcs_charging, + decision.charging_action === 'turn_off' +); + +// ===================================================== +// PERMISSION / LATCH +// ===================================================== +pushAction( + 'helper_permission', + calls.helper_permission, + decision.permission_action !== 'none' +); + +pushAction( + 'helper_latch', + calls.helper_latch, + decision.latch_action !== 'none' +); + +// ===================================================== +// COURANT SI NÉCESSAIRE +// ===================================================== +pushAction( + 'evcs_set_current', + calls.evcs_set_current, + decision.current_action === 'set' +); + +pushAction( + 'helper_last_current_change_at', + calls.helper_last_current_change_at, + !!decision.set_last_current_change_at +); + +// ===================================================== +// SI ON DÉMARRE : CHARGING ON À LA FIN +// ===================================================== +pushAction( + 'evcs_charging', + calls.evcs_charging, + decision.charging_action === 'turn_on' +); + +// ===================================================== +// TIMESTAMPS START / STOP +// ===================================================== +pushAction( + 'helper_last_start_at', + calls.helper_last_start_at, + !!decision.set_last_start_at +); + +pushAction( + 'helper_last_stop_at', + calls.helper_last_stop_at, + !!decision.set_last_stop_at +); + +// ===================================================== +// SIGNATURE + LOGBOOK SI CHANGEMENT +// ===================================================== +pushAction( + 'helper_signature', + calls.helper_signature, + !!decision.signature_changed +); + +pushAction( + 'logbook', + calls.logbook, + !!decision.signature_changed +); + +// ===================================================== +// NOTIFICATIONS +// ===================================================== +pushAction( + 'mobile_notification', + calls.mobile_notification, + !!decision.should_notify +); + +return actions; diff --git a/src/nodes/DECISION_ENGINEV2.js b/src/nodes/DECISION_ENGINEV2.js new file mode 100644 index 0000000..9ae7bba --- /dev/null +++ b/src/nodes/DECISION_ENGINEV2.js @@ -0,0 +1,1291 @@ +function clamp(v, min, max) { + return Math.min(Math.max(v, min), max); +} + +function round1(v) { + return Math.round(v * 10) / 10; +} + +function round3(v) { + return Math.round(v * 1000) / 1000; +} + +function to2(n) { + return String(n).padStart(2, '0'); +} + +function finiteOr(v, fallback) { + return Number.isFinite(v) ? v : fallback; +} + +function sigmoid(x) { + return 1 / (1 + Math.exp(-x)); +} + +function alphaFromTau(dtSec, tauSec) { + const dt = Math.max(1, finiteOr(dtSec, 60)); + const tau = Math.max(1, finiteOr(tauSec, 60)); + return Math.min(1, Math.max(0.001, 1 - Math.exp(-dt / tau))); +} + +function ema(prev, sample, alpha) { + if (!Number.isFinite(sample)) return Number.isFinite(prev) ? prev : 0; + if (!Number.isFinite(prev)) return sample; + return prev + alpha * (sample - prev); +} + +function formatParisStampFromMs(ms) { + const fmt = new Intl.DateTimeFormat('fr-FR', { + timeZone: 'Europe/Paris', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); + + const parts = Object.fromEntries( + fmt.formatToParts(new Date(ms)) + .filter(p => p.type !== 'literal') + .map(p => [p.type, p.value]) + ); + + const hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour); + return `${parts.year}-${parts.month}-${parts.day} ${to2(hour)}:${parts.minute}:${parts.second}`; +} + +const prev = $input.first().json; + +const { + now, + nowMs, + parsed, + params, + history, + strategy, + lifecycle, + context, + derived, + locks, + cloud, + modes, + safeguards, + conditions, + batteryTune, + nightEvcs, +} = prev; + +// ===================================================== +// DECISION INIT +// ===================================================== +let desiredCharging = false; +let desiredPermission = false; +let desiredLatch = false; +let desiredCurrentA = params.currentMinA; + +let state = 'idle'; +let currentDecision = 'hold'; +let dynamicOk = false; +let mustStop = false; + +let reason = []; + +let setCloudDebtStartedAt = false; +let setProbeStartedAt = false; + +// EVCS night session memory +let setNightSessionStart = false; +let clearNightSessionStart = false; + +let notifyWanted = false; +let notifyTitle = 'Victron EVCS Surplus'; +let notifyMessage = ''; + +let nextBlockReason = 'none'; +let nextBlockedUntilStamp = now.stamp; + +// ===================================================== +// HELPERS +// ===================================================== +function setOff({ + stateName, + decisionName = 'stop', + reasonText, + dynamic = false, + mustStopFlag = false, + blockReason = 'none', + blockedUntilStamp = now.stamp, +}) { + desiredCharging = false; + desiredPermission = false; + desiredLatch = false; + desiredCurrentA = params.currentMinA; + + state = stateName; + currentDecision = decisionName; + dynamicOk = dynamic; + mustStop = mustStopFlag; + nextBlockReason = blockReason; + nextBlockedUntilStamp = blockedUntilStamp; + + if (reasonText) reason.push(reasonText); +} + +function setOn({ + stateName, + decisionName = 'hold', + currentA = params.currentMinA, + reasonText, + dynamic = true, + mustStopFlag = false, +}) { + desiredCharging = true; + desiredPermission = true; + desiredLatch = true; + desiredCurrentA = clamp( + currentA, + params.currentMinA, + Math.max(params.currentMinA, derived.physicalCapA || params.currentMinA) + ); + + state = stateName; + currentDecision = decisionName; + dynamicOk = dynamic; + mustStop = mustStopFlag; + + if (reasonText) reason.push(reasonText); +} + +// ===================================================== +// SESSION MEMORY SIDE EFFECTS +// ===================================================== +if (nightEvcs?.nightSessionNeedsCapture) { + setNightSessionStart = true; +} + +if (nightEvcs?.nightSessionNeedsClear) { + clearNightSessionStart = true; +} + +// ===================================================== +// MANUAL LOCAL CHARGE SEMANTICS +// IMPORTANT: +// force_charge_now = bypass fenêtres pour la charge locale, +// sans imposer le réseau. +// ===================================================== +const manualChargeBypassWindows = !!context.manualChargeBypassWindows; +const manualChargeLocalRequested = !!context.manualChargeLocalRequested; + +const manualLocalChargeAllowed = + !!strategy.forceChargeRequested && + manualChargeBypassWindows && + manualChargeLocalRequested && + !safeguards.mustStopHard && + !!derived.canRunAtMinCurrent; + +// ===================================================== +// EVCS NUMERIC NO-DEMAND FILTER +// Objectif: +// détecter "voiture branchée + session armée + commande claire +// mais plus de demande réelle" sans seuils fixes bruts. +// IMPORTANT: +// on n'utilise PAS le PV comme critère. +// ===================================================== +const prevDeliveryRatioEma = finiteOr(history.evcsDeliveryRatioEma, NaN); +const prevDeliveryNoiseEma = finiteOr(history.evcsDeliveryNoiseEma, NaN); +const prevNoDemandScoreEma = finiteOr(history.evcsNoDemandScoreEma, NaN); +const prevNoDemandSemanticActive = !!history.evcsNoDemandSemanticActive; +const prevNoDemandSinceMs = finiteOr(history.evcsNoDemandSinceMs, NaN); +const prevNoDemandFilterEvalMs = finiteOr(history.evcsNoDemandFilterEvalMs, NaN); + +const noDemandDtSec = + Number.isFinite(prevNoDemandFilterEvalMs) && Number.isFinite(nowMs) + ? clamp((nowMs - prevNoDemandFilterEvalMs) / 1000, 1, 600) + : 60; + +// Constantes dynamiques du filtre = temps de réponse du filtre, +// pas des seuils métiers bruts. +const deliveryTauSec = Math.max(15, finiteOr(params.evcsDeliveryTauSec, 45)); +const noiseTauSec = Math.max(deliveryTauSec, finiteOr(params.evcsDeliveryNoiseTauSec, 180)); +const scoreTauSec = Math.max(20, finiteOr(params.evcsNoDemandScoreTauSec, 150)); +const armTauSec = Math.max(20, finiteOr(params.evcsNoDemandArmTauSec, 180)); + +const alphaDelivery = alphaFromTau(noDemandDtSec, deliveryTauSec); +const alphaNoise = alphaFromTau(noDemandDtSec, noiseTauSec); +const alphaScore = alphaFromTau(noDemandDtSec, scoreTauSec); + +const acVoltageRef = finiteOr(derived.acVoltageRef, 230); + +const commandCurrentA = clamp( + finiteOr( + derived.currentReferenceA, + finiteOr( + parsed.evcsCommandCurrentA, + finiteOr(parsed.evcsSetCurrentA, finiteOr(params.currentMinA, 6)) + ) + ), + 0, + 32 +); + +const expectedPowerW = Math.max(1, commandCurrentA * acVoltageRef); + +const realCurrentA = Math.abs( + finiteOr( + parsed.evcsCurrentRealA, + acVoltageRef > 180 + ? finiteOr(parsed.evcsAcPowerW, 0) / acVoltageRef + : 0 + ) +); + +const realPowerW = Math.max(0, finiteOr(parsed.evcsAcPowerW, 0)); + +const currentDeliveryRatioRaw = clamp( + realCurrentA / Math.max(commandCurrentA, 0.1), + 0, + 1.25 +); + +const powerDeliveryRatioRaw = clamp( + realPowerW / expectedPowerW, + 0, + 1.25 +); + +const deliveryRatioRaw = clamp( + Math.max(currentDeliveryRatioRaw, powerDeliveryRatioRaw), + 0, + 1 +); + +const deliveryRatioEma = ema(prevDeliveryRatioEma, deliveryRatioRaw, alphaDelivery); + +const deliveryResidualAbs = Math.abs(deliveryRatioRaw - deliveryRatioEma); +const deliveryNoiseEma = ema(prevDeliveryNoiseEma, deliveryResidualAbs, alphaNoise); + +const adaptiveQuietBand = clamp( + 0.02 + 3.0 * deliveryNoiseEma, + 0.02, + 0.35 +); + +const noDemandEvidenceRaw = clamp( + (adaptiveQuietBand - deliveryRatioEma) / adaptiveQuietBand, + 0, + 1 +); + +const sessionElapsedSec = Math.max( + 0, + finiteOr(lifecycle.minutesSinceStart, 0) * 60 +); + +const sessionArmFactor = 1 - Math.exp(-sessionElapsedSec / armTauSec); + +const currentMinRefA = Math.max(1, finiteOr(params.currentMinA, 6)); +const commandStrength = commandCurrentA / currentMinRefA; + +const commandFactor = clamp( + sigmoid((commandStrength - 1.0) * 2.2), + 0, + 1 +); + +// Source effectivement livrable. +// IMPORTANT: +// on évite de conclure "voiture pleine" si en réalité la source n'est +// pas disponible (ex: attente grid takeover). +const manualLocalNoDemandEligible = + manualLocalChargeAllowed && + ( + context.sourceMode === 'solar_only' || + context.sourceMode === 'solar_potential' || + context.sourceMode === 'blocked' + ); + +const sourceDeliverableForNoDemand = + manualLocalNoDemandEligible + ? true + : (context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced') + ? !!parsed.gridAvailable + : (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') + ? !!conditions.solarHoldOk || !!conditions.solarStartOk + : context.sourceMode === 'hybrid_probe' + ? !!conditions.probeHoldGuardOk || !!conditions.probeSuccessNow || !!conditions.probeStartOk + : context.sourceMode === 'battery_assist_night' + ? !!conditions.batteryAssistNightHoldOk || !!conditions.batteryAssistNightStartOk + : false; + +const evcsSessionArmedForNoDemand = + !!cloud.sessionObservedActive && + !!parsed.evcsVehicleConnected && + !!parsed.evcsChargingSwitchOn && + !!parsed.chargePermissionNow && + !!parsed.controlLatch && + commandCurrentA > 0 && + sourceDeliverableForNoDemand; + +const noDemandScoreRaw = evcsSessionArmedForNoDemand + ? clamp( + noDemandEvidenceRaw * sessionArmFactor * commandFactor, + 0, + 1 + ) + : 0; + +const noDemandScoreEma = ema(prevNoDemandScoreEma, noDemandScoreRaw, alphaScore); + +const adaptiveDecisionCenter = clamp( + 0.72 - 0.20 * deliveryNoiseEma, + 0.55, + 0.82 +); + +const adaptiveDecisionBand = clamp( + 0.16 + 0.80 * deliveryNoiseEma, + 0.12, + 0.34 +); + +const noDemandScoreOn = clamp( + adaptiveDecisionCenter + adaptiveDecisionBand / 2, + 0.60, + 0.95 +); + +const noDemandScoreOff = clamp( + adaptiveDecisionCenter - adaptiveDecisionBand / 2, + 0.15, + noDemandScoreOn - 0.05 +); + +let evcsVehicleNoDemandSemantic = prevNoDemandSemanticActive; + +if (!evcsSessionArmedForNoDemand) { + evcsVehicleNoDemandSemantic = false; +} else if (prevNoDemandSemanticActive) { + evcsVehicleNoDemandSemantic = noDemandScoreEma >= noDemandScoreOff; +} else { + evcsVehicleNoDemandSemantic = noDemandScoreEma >= noDemandScoreOn; +} + +const evcsNoDemandSinceMs = + evcsVehicleNoDemandSemantic + ? ( + prevNoDemandSemanticActive && Number.isFinite(prevNoDemandSinceMs) + ? prevNoDemandSinceMs + : nowMs + ) + : null; + +const evcsNoDemandSinceMinutes = + Number.isFinite(evcsNoDemandSinceMs) && Number.isFinite(nowMs) + ? Math.max(0, (nowMs - evcsNoDemandSinceMs) / 60000) + : null; + +const vehicleDemandFilter = { + dtSec: round3(noDemandDtSec), + + commandCurrentA: round3(commandCurrentA), + realCurrentA: round3(realCurrentA), + expectedPowerW: round3(expectedPowerW), + realPowerW: round3(realPowerW), + + currentDeliveryRatioRaw: round3(currentDeliveryRatioRaw), + powerDeliveryRatioRaw: round3(powerDeliveryRatioRaw), + deliveryRatioRaw: round3(deliveryRatioRaw), + deliveryRatioEma: round3(deliveryRatioEma), + deliveryNoiseEma: round3(deliveryNoiseEma), + + adaptiveQuietBand: round3(adaptiveQuietBand), + noDemandEvidenceRaw: round3(noDemandEvidenceRaw), + + sessionElapsedSec: round3(sessionElapsedSec), + sessionArmFactor: round3(sessionArmFactor), + commandFactor: round3(commandFactor), + sourceDeliverableForNoDemand, + evcsSessionArmedForNoDemand, + + noDemandScoreRaw: round3(noDemandScoreRaw), + noDemandScoreEma: round3(noDemandScoreEma), + + noDemandScoreOn: round3(noDemandScoreOn), + noDemandScoreOff: round3(noDemandScoreOff), + + evcsVehicleNoDemandSemantic, + evcsNoDemandSinceMs: Number.isFinite(evcsNoDemandSinceMs) ? evcsNoDemandSinceMs : null, + evcsNoDemandSinceMinutes: Number.isFinite(evcsNoDemandSinceMinutes) + ? round3(evcsNoDemandSinceMinutes) + : null, + + nextHistory: { + evcsDeliveryRatioEma: round3(deliveryRatioEma), + evcsDeliveryNoiseEma: round3(deliveryNoiseEma), + evcsNoDemandScoreEma: round3(noDemandScoreEma), + evcsNoDemandSemanticActive: evcsVehicleNoDemandSemantic, + evcsNoDemandSinceMs: Number.isFinite(evcsNoDemandSinceMs) ? evcsNoDemandSinceMs : null, + evcsNoDemandFilterEvalMs: nowMs + } +}; + +// ===================================================== +// DECISION TREE +// ===================================================== +if (!parsed.controllerEnabled) { + desiredCharging = parsed.evcsChargingSwitchOn; + desiredPermission = parsed.chargePermissionNow; + desiredLatch = parsed.controlLatch; + desiredCurrentA = clamp( + derived.currentReferenceA || params.currentMinA, + params.currentMinA, + params.currentMaxAutoA + ); + + state = 'controller_disabled'; + currentDecision = 'hold'; + dynamicOk = false; + mustStop = false; + reason.push('Contrôleur désactivé'); +} + +else if (strategy.isStopMode) { + setOff({ + stateName: 'stopped_user', + decisionName: 'stop_stop_mode', + reasonText: 'Arrêt manuel via Stratégie STOP', + dynamic: false, + mustStopFlag: false, + blockReason: 'manual_stop', + blockedUntilStamp: now.stamp, + }); +} + +else if (parsed.evcsModeIsAuto) { + setOff({ + stateName: 'mode_auto_conflict', + decisionName: 'stop_auto_mode_conflict', + reasonText: 'Borne en mode Auto interne', + dynamic: false, + mustStopFlag: true, + blockReason: 'mode_auto_conflict', + blockedUntilStamp: now.stamp, + }); +} + +else if (!parsed.evcsDeviceConnected || !parsed.evcsVehicleConnected) { + setOff({ + stateName: 'vehicle_not_connected', + decisionName: 'stop_not_connected', + reasonText: 'Véhicule ou borne non connecté', + dynamic: false, + mustStopFlag: false, + blockReason: 'vehicle_not_connected', + blockedUntilStamp: now.stamp, + }); +} + +else if (safeguards.mustStopHard) { + setOff({ + stateName: + derived.anyVebusLowBattery ? 'vebus_low_battery_hard_stop' : + derived.vebusDcVoltageHardLow ? 'vebus_dc_voltage_hard_stop' : + derived.vebusAcOutHardReached ? 'vebus_ac_hard_stop' : + safeguards.batteryCurrentHardExceeded ? 'battery_current_hard_stop' : + 'hard_stop', + decisionName: 'stop_hard', + reasonText: 'Hard stop de protection', + dynamic: false, + mustStopFlag: true, + blockReason: + safeguards.batteryCurrentHardExceeded ? 'battery_current_hard_stop' : + derived.vebusDcVoltageHardLow ? 'vebus_dc_voltage_stop' : + derived.anyVebusLowBattery ? 'vebus_low_battery_stop' : + derived.vebusAcOutHardReached ? 'vebus_ac_hard_stop' : + 'battery_protection_stop', + blockedUntilStamp: formatParisStampFromMs(nowMs + 15 * 60000), + }); +} + +// ===================================================== +// ACTIVE SESSION BRANCH +// ===================================================== +else if (cloud.sessionObservedActive) { + if (cloud.enteringCloudDebt) { + setCloudDebtStartedAt = true; + } + + if (conditions.shouldStopOverloadAtMin) { + setOff({ + stateName: 'vebus_overload_stop_min_current', + decisionName: 'stop', + reasonText: 'Arrêt: surcharge convertisseur persistante au courant mini', + dynamic: false, + mustStopFlag: true, + blockReason: 'vebus_overload_persistent', + blockedUntilStamp: formatParisStampFromMs( + nowMs + batteryTune.global.overloadStopAfterSeconds * 1000 + ), + }); + } + + else if (safeguards.gridBatteryProtectionStop) { + setOff({ + stateName: 'stop_grid_battery_guard', + decisionName: 'stop', + reasonText: `Arrêt charge réseau: batterie trop sollicitée (${round1(parsed.batteryCurrentA)}A)`, + dynamic: false, + mustStopFlag: true, + blockReason: 'grid_battery_guard', + blockedUntilStamp: formatParisStampFromMs(nowMs + 20 * 60000), + }); + } + + else if (conditions.probeFailedNow) { + setOff({ + stateName: 'probe_failed_lock', + decisionName: 'stop', + reasonText: 'Mode sonde échoué', + dynamic: false, + mustStopFlag: true, + blockReason: 'probe_failed', + blockedUntilStamp: formatParisStampFromMs(nowMs + params.probeRetryMinutes * 60000), + }); + } + + else if (derived.physicalCapA < params.currentMinA) { + if (lifecycle.minRunElapsed) { + setOff({ + stateName: 'stop_power_cap', + decisionName: 'stop_power_cap', + reasonText: 'Capacité système insuffisante', + dynamic: false, + mustStopFlag: true, + blockReason: 'system_capacity_stop', + blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000), + }); + } else { + setOn({ + stateName: 'hold_min_run_power_cap', + decisionName: 'hold', + currentA: params.currentMinA, + reasonText: "Maintien jusqu'au temps mini ON", + dynamic: false, + mustStopFlag: false, + }); + } + } + + else if (conditions.probeWindowActive && !conditions.probeSuccessNow) { + if (!conditions.probeHoldGuardOk) { + setOff({ + stateName: 'probe_stop_guard', + decisionName: 'stop', + reasonText: 'Sonde interrompue: garde-fous non respectés', + dynamic: false, + mustStopFlag: true, + blockReason: 'probe_failed', + blockedUntilStamp: formatParisStampFromMs(nowMs + params.probeRetryMinutes * 60000), + }); + } else { + setOn({ + stateName: 'probe_hold', + decisionName: 'hold', + currentA: Math.min(params.probeCurrentA, derived.physicalCapA), + reasonText: `Mode sonde actif à ${round1(Math.min(params.probeCurrentA, derived.physicalCapA))}A`, + dynamic: true, + mustStopFlag: false, + }); + } + } + + else { + const activeSourceMode = + conditions.probeSuccessNow + ? 'solar_only' + : context.sourceMode; + + const holdConditionsOk = + (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') + ? conditions.gridHoldOk + : (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') + ? conditions.solarHoldOk + : activeSourceMode === 'hybrid_probe' + ? conditions.probeHoldGuardOk + : activeSourceMode === 'battery_assist_night' + ? conditions.batteryAssistNightHoldOk + : false; + + const manualLocalHoldOverride = + manualLocalChargeAllowed && + ( + activeSourceMode === 'solar_only' || + activeSourceMode === 'solar_potential' || + activeSourceMode === 'blocked' + ); + + const effectiveHoldConditionsOk = + holdConditionsOk || manualLocalHoldOverride; + + // ================================================= + // VEHICLE FULL / PAUSED - NUMERIC FILTER DECISION + // IMPORTANT: + // - uniquement en session active + // - uniquement si la source est réellement livrable + // - sans utiliser le PV comme critère + // ================================================= + if ( + vehicleDemandFilter.evcsVehicleNoDemandSemantic && + vehicleDemandFilter.sourceDeliverableForNoDemand + ) { + setOff({ + stateName: 'vehicle_full_or_paused', + decisionName: 'stop_vehicle_full_or_paused', + reasonText: 'Véhicule branché mais ne demande plus réellement de puissance', + dynamic: false, + mustStopFlag: false, + blockReason: 'none', + blockedUntilStamp: now.stamp, + }); + + reason.push(`DeliveryRatioEma=${vehicleDemandFilter.deliveryRatioEma}`); + reason.push(`NoiseEma=${vehicleDemandFilter.deliveryNoiseEma}`); + reason.push(`NoDemandScore=${vehicleDemandFilter.noDemandScoreEma}`); + reason.push(`ScoreOn=${vehicleDemandFilter.noDemandScoreOn}`); + reason.push(`ScoreOff=${vehicleDemandFilter.noDemandScoreOff}`); + + if (vehicleDemandFilter.evcsNoDemandSinceMinutes !== null) { + reason.push(`NoDemand depuis ${round1(vehicleDemandFilter.evcsNoDemandSinceMinutes)} min`); + } + } + + else if (manualLocalHoldOverride && activeSourceMode === 'blocked') { + setOn({ + stateName: 'charging_hold_manual_local', + decisionName: 'hold', + currentA: Math.min(derived.currentReferenceA, derived.physicalCapA), + reasonText: 'Maintien manuel local hors fenêtre sans réseau', + dynamic: true, + mustStopFlag: false, + }); + } + + else if (activeSourceMode === 'blocked') { + if (lifecycle.minRunElapsed) { + setOff({ + stateName: 'stop_source_lost', + decisionName: 'stop', + reasonText: 'Source autorisée perdue', + dynamic: false, + mustStopFlag: true, + blockReason: context.profile === 'outside_window' ? 'outside_window' : 'source_unavailable', + blockedUntilStamp: + context.profile === 'outside_window' && Number.isFinite(locks.nextAllowedWindowStartMs) + ? formatParisStampFromMs(locks.nextAllowedWindowStartMs) + : formatParisStampFromMs(nowMs + params.minStopMinutes * 60000), + }); + } else { + setOn({ + stateName: 'hold_min_run_source_lost', + decisionName: 'hold', + currentA: Math.min(derived.currentReferenceA, derived.physicalCapA), + reasonText: "Source perdue mais maintien jusqu'au mini ON", + dynamic: false, + mustStopFlag: false, + }); + } + } + + else if ( + (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') && + !parsed.gridAvailable + ) { + if (lifecycle.minRunElapsed) { + setOff({ + stateName: + context.profile === 'night_hc' + ? 'night_wait_grid_takeover_runtime' + : 'wait_grid_source_runtime', + decisionName: 'hold', + reasonText: + context.profile === 'night_hc' + ? 'Relais réseau demandé pendant la session nuit EVCS' + : 'Source réseau demandée mais pas encore disponible', + dynamic: false, + mustStopFlag: false, + blockReason: + context.profile === 'night_hc' ? 'grid_hc_unavailable' : 'grid_unavailable', + blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000), + }); + } else { + setOn({ + stateName: 'hold_min_run_wait_grid_takeover', + decisionName: 'hold', + currentA: Math.min(derived.currentReferenceA, derived.physicalCapA), + reasonText: "Maintien jusqu'au mini ON en attente du réseau", + dynamic: false, + mustStopFlag: false, + }); + } + } + + else if (!effectiveHoldConditionsOk) { + if (lifecycle.minRunElapsed) { + setOff({ + stateName: 'stop_conditions_lost', + decisionName: 'stop', + reasonText: 'Conditions de maintien perdues', + dynamic: false, + mustStopFlag: true, + blockReason: 'hold_conditions_lost', + blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000), + }); + } else { + setOn({ + stateName: 'hold_min_run', + decisionName: 'hold', + currentA: Math.min(derived.currentReferenceA, derived.physicalCapA), + reasonText: "Maintien jusqu'au temps mini ON", + dynamic: true, + mustStopFlag: false, + }); + } + } + + else if ((derived.currentReferenceA > derived.physicalCapA) && lifecycle.currentCooldownElapsed) { + setOn({ + stateName: 'charging_step_down_system_cap', + decisionName: 'step_down', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - Math.max(params.currentStepDownA, 2), + derived.physicalCapA + ) + ), + reasonText: 'Réduction pour respecter la capacité système', + dynamic: true, + mustStopFlag: false, + }); + } + + else if (conditions.shouldStepDownOverload) { + setOn({ + stateName: 'charging_step_down_overload', + decisionName: 'step_down_fast', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - Math.max(batteryTune.global.overloadStepDownA, params.currentStepDownA), + derived.physicalCapA + ) + ), + reasonText: 'Baisse rapide: surcharge convertisseur', + dynamic: true, + mustStopFlag: false, + }); + } + + else if (conditions.shouldStepDownVebusPower) { + setOn({ + stateName: 'charging_step_down_vebus_power', + decisionName: 'step_down_fast', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - Math.max(params.currentStepDownA + 1, 3), + derived.physicalCapA + ) + ), + reasonText: 'Baisse rapide: puissance VE.Bus élevée', + dynamic: true, + mustStopFlag: false, + }); + } + + else if (conditions.shouldStepDownVebusVoltage) { + setOn({ + stateName: 'charging_step_down_dc_voltage', + decisionName: 'step_down_fast', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - Math.max(params.currentStepDownA + 1, 3), + derived.physicalCapA + ) + ), + reasonText: 'Baisse rapide: tension DC basse', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + activeSourceMode === 'battery_assist_night' && + conditions.batteryAssistNightShouldStepDown + ) { + setOn({ + stateName: 'charging_step_down_battery_assist_night', + decisionName: 'step_down', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - params.currentStepDownA, + derived.physicalCapA + ) + ), + reasonText: + nightEvcs?.batteryRecoveryUrgent + ? 'Réduction: approche départ, préparation takeover réseau' + : 'Réduction: assistance batterie nuit devient trop coûteuse', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') && + conditions.gridShouldStepDownBattery + ) { + setOn({ + stateName: + activeSourceMode === 'grid_forced' + ? 'charging_step_down_grid_forced_guard' + : 'charging_step_down_grid_guard', + decisionName: 'step_down', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - params.currentStepDownA, + derived.physicalCapA + ) + ), + reasonText: + activeSourceMode === 'grid_forced' + ? 'Réduction réseau forcé: la batterie commence à participer' + : 'Réduction réseau: la batterie commence à participer', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') && + conditions.solarShouldStepDownCloudDebt + ) { + setOn({ + stateName: 'charging_step_down_cloud_debt', + decisionName: 'step_down', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - Math.max(params.currentStepDownA, 2), + derived.physicalCapA + ) + ), + reasonText: 'Réduction: dette nuage trop élevée', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') && + conditions.solarShouldStepDownBattery + ) { + setOn({ + stateName: 'charging_step_down_battery', + decisionName: 'step_down', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - params.currentStepDownA, + derived.physicalCapA + ) + ), + reasonText: 'Réduction: batterie trop sollicitée', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') && + conditions.gridShouldStepUp + ) { + setOn({ + stateName: + activeSourceMode === 'grid_forced' + ? ( + nightEvcs?.batteryRecoveryUrgent + ? 'night_departure_deadline_grid_forced' + : 'charging_step_up_grid_forced' + ) + : 'charging_step_up_grid_hc', + decisionName: 'step_up', + currentA: clamp( + derived.currentReferenceA + params.currentStepUpA, + params.currentMinA, + derived.physicalCapA + ), + reasonText: + activeSourceMode === 'grid_forced' + ? ( + nightEvcs?.batteryRecoveryUrgent + ? 'Hausse prudente: priorité départ, réseau forcé' + : 'Hausse prudente du courant en charge réseau forcée' + ) + : 'Hausse prudente du courant en HC réseau', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + activeSourceMode === 'battery_assist_night' && + conditions.batteryAssistNightShouldStepUp + ) { + setOn({ + stateName: 'charging_step_up_battery_assist_night', + decisionName: 'step_up', + currentA: clamp( + derived.currentReferenceA + params.currentStepUpA, + params.currentMinA, + derived.physicalCapA + ), + reasonText: 'Hausse prudente du courant en assistance batterie nuit', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') && + conditions.solarShouldStepUp + ) { + setOn({ + stateName: + conditions.probeSuccessNow + ? 'probe_success_step_up' + : (modes.batteryPriorityMode + ? 'charging_step_up_battery' + : 'charging_step_up_forecast'), + decisionName: 'step_up', + currentA: clamp( + derived.currentReferenceA + params.currentStepUpA, + params.currentMinA, + derived.physicalCapA + ), + reasonText: 'Hausse prudente du courant', + dynamic: true, + mustStopFlag: false, + }); + } + + else { + setOn({ + stateName: + manualLocalHoldOverride ? 'charging_hold_manual_local' : + conditions.probeSuccessNow ? 'probe_success_hold' : + activeSourceMode === 'battery_assist_night' ? 'charging_hold_battery_assist_night' : + (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') ? 'charging_hold_grid' : + cloud.cloudDebtActive ? 'charging_hold_cloud_tolerance' : + 'charging_hold', + decisionName: 'hold', + currentA: Math.min(derived.currentReferenceA, derived.physicalCapA), + reasonText: + manualLocalHoldOverride + ? 'Maintien manuel local du courant' + : activeSourceMode === 'battery_assist_night' + ? 'Maintien du courant en assistance batterie nuit' + : 'Maintien du courant', + dynamic: true, + mustStopFlag: false, + }); + } + } +} + +// ===================================================== +// START BRANCH +// ===================================================== +else { + const startConditionsOk = + (context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced') + ? conditions.gridStartOk + : (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') + ? conditions.solarStartOk + : context.sourceMode === 'hybrid_probe' + ? conditions.probeStartOk + : context.sourceMode === 'battery_assist_night' + ? conditions.batteryAssistNightStartOk + : false; + + const manualLocalStartOverride = + manualLocalChargeAllowed && + ( + context.sourceMode === 'solar_only' || + context.sourceMode === 'solar_potential' || + context.sourceMode === 'blocked' + ); + + if (!derived.canRunAtMinCurrent) { + setOff({ + stateName: 'blocked_by_capacity_min_current', + decisionName: 'hold', + reasonText: 'Capacité insuffisante pour courant mini', + dynamic: false, + mustStopFlag: false, + blockReason: 'system_capacity_stop', + blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000), + }); + } + + else if (manualLocalStartOverride) { + setOn({ + stateName: 'start_manual_local_min_current', + decisionName: 'start', + currentA: params.currentMinA, + reasonText: + context.profile === 'outside_window' + ? 'Démarrage manuel local hors fenêtre sans réseau' + : 'Démarrage manuel local sans réseau', + dynamic: true, + mustStopFlag: false, + }); + + nextBlockReason = 'none'; + nextBlockedUntilStamp = now.stamp; + setProbeStartedAt = false; + } + + // P8 : véhicule plein en session nuit → veille auto-entretenue. + // Le state est relu au cycle suivant (prev_surplus_state) : il maintient + // vehicleChargeCompleted jusqu'au débranchement ou à la fin de fenêtre. + else if (context.profile === 'night_hc' && nightEvcs?.vehicleChargeCompleted) { + setOff({ + stateName: 'night_session_done_idle', + decisionName: 'hold', + reasonText: 'Charge véhicule terminée : veille nuit, réseau relâché (recharge batterie si sous cible)', + dynamic: false, + mustStopFlag: false, + blockReason: 'none', + blockedUntilStamp: now.stamp, + }); + } + + else if (context.sourceMode === 'blocked') { + setOff({ + stateName: + context.profile === 'outside_window' + ? 'blocked_outside_window' + : (context.profile === 'night_hc' + ? 'night_no_grid_hc_lock' + : 'idle_wait_source'), + decisionName: 'hold', + reasonText: + context.profile === 'outside_window' + ? 'Hors fenêtre préférée sans source suffisante' + : context.profile === 'night_hc' + ? 'Nuit sans autorisation HC réseau' + : 'Source non autorisée ou insuffisante', + dynamic: false, + mustStopFlag: false, + blockReason: + context.profile === 'outside_window' + ? 'outside_window' + : context.profile === 'night_hc' + ? 'grid_hc_unavailable' + : 'source_unavailable', + blockedUntilStamp: + Number.isFinite(locks.nextAllowedWindowStartMs) + ? formatParisStampFromMs(locks.nextAllowedWindowStartMs) + : formatParisStampFromMs(nowMs + 15 * 60000), + }); + } + + else if ( + (context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced') && + !parsed.gridAvailable + ) { + setOff({ + stateName: + context.profile === 'night_hc' + ? 'night_wait_grid_takeover' + : 'wait_grid_source', + decisionName: 'hold', + reasonText: + context.profile === 'night_hc' + ? ( + nightEvcs?.batteryRecoveryUrgent + ? 'Réseau demandé: urgence recharge batterie avant départ' + : 'Prise de relais réseau demandée pour session nuit EVCS' + ) + : 'Source réseau demandée mais pas encore disponible', + dynamic: false, + mustStopFlag: false, + blockReason: + context.profile === 'night_hc' + ? 'grid_hc_unavailable' + : 'grid_unavailable', + blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000), + }); + } + + else if (locks.startBlockedByReason) { + setOff({ + stateName: 'wait_reason_lock', + decisionName: 'hold', + reasonText: `Blocage anti-cliquetis: ${history.lastBlockReason}`, + dynamic: false, + mustStopFlag: false, + blockReason: history.lastBlockReason, + blockedUntilStamp: history.blockedUntilAt || now.stamp, + }); + } + + else if (!startConditionsOk) { + setOff({ + stateName: 'idle_wait_conditions', + decisionName: 'hold', + reasonText: + context.sourceMode === 'battery_assist_night' + ? 'Conditions de démarrage nuit batterie non réunies' + : 'Conditions de démarrage non réunies', + dynamic: false, + mustStopFlag: false, + blockReason: 'none', + blockedUntilStamp: now.stamp, + }); + } + + else if (!lifecycle.minStopElapsed) { + setOff({ + stateName: 'wait_min_stop', + decisionName: 'hold', + reasonText: 'Temps mini OFF non écoulé', + dynamic: false, + mustStopFlag: false, + blockReason: 'none', + blockedUntilStamp: now.stamp, + }); + } + + else { + const startCurrentA = + context.sourceMode === 'hybrid_probe' + ? clamp(params.probeCurrentA, params.currentMinA, derived.physicalCapA) + : params.currentMinA; + + setOn({ + stateName: + context.sourceMode === 'grid_forced' ? ( + nightEvcs?.batteryRecoveryUrgent + ? 'night_departure_deadline_grid_forced' + : 'start_grid_forced_min_current' + ) : + context.sourceMode === 'grid_hc_allowed' ? 'start_grid_hc_min_current' : + context.sourceMode === 'battery_assist_night' ? 'start_battery_assist_night_min_current' : + context.sourceMode === 'hybrid_probe' ? 'probe_start_6a' : + context.sourceMode === 'solar_potential' ? 'start_forecast_solar_potential' : + modes.batteryFullHarvestMode ? 'start_min_current_battery_full_harvest' : + modes.forecastCommitMode ? 'start_min_current_forecast_commit' : + 'start_min_current', + decisionName: 'start', + currentA: startCurrentA, + reasonText: + context.sourceMode === 'grid_forced' + ? ( + nightEvcs?.batteryRecoveryUrgent + ? 'Démarrage réseau forcé: priorité départ / recharge batterie' + : 'Démarrage en charge réseau forcée' + ) + : context.sourceMode === 'grid_hc_allowed' + ? 'Démarrage en fenêtre HC réseau' + : context.sourceMode === 'battery_assist_night' + ? 'Démarrage nuit en assistance batterie contrôlée' + : context.sourceMode === 'hybrid_probe' + ? 'Démarrage en mode sonde' + : context.sourceMode === 'solar_potential' + ? 'Démarrage par potentiel solaire confirmé' + : modes.batteryFullHarvestMode + ? 'Démarrage harvest solaire' + : modes.forecastCommitMode + ? 'Démarrage forecast commit' + : 'Démarrage prudent à courant mini', + dynamic: true, + mustStopFlag: false, + }); + + nextBlockReason = 'none'; + nextBlockedUntilStamp = now.stamp; + setProbeStartedAt = context.sourceMode === 'hybrid_probe'; + } +} + +// ===================================================== +// CONTEXT REASONS +// ===================================================== +if (nightEvcs?.nightSessionActive) { + reason.push( + `Nuit EVCS: target=${round1(nightEvcs.batteryMorningTargetPct)}% | trigger réseau=${round1(nightEvcs.gridTakeoverTriggerPct)}%` + ); + + if (Number.isFinite(nightEvcs.nightSessionStartSocPct)) { + reason.push( + `SOC départ session nuit=${round1(nightEvcs.nightSessionStartSocPct)}% | décharge autorisée=${round1(nightEvcs.maxBatteryDischargePoints)} pts` + ); + } + + if (nightEvcs.batteryRecoveryUrgent) { + reason.push("Urgence départ: il faut anticiper la reprise réseau"); + } +} + +if (manualLocalChargeAllowed) { + reason.push(`ManualLocalCharge=on`); + reason.push(`ManualLocalProfile=${context.profile}`); + reason.push(`ManualLocalSource=${context.sourceMode}`); +} + +reason.push(`VehicleDemandFilter delivery=${vehicleDemandFilter.deliveryRatioEma}`); +reason.push(`VehicleDemandFilter noise=${vehicleDemandFilter.deliveryNoiseEma}`); +reason.push(`VehicleDemandFilter score=${vehicleDemandFilter.noDemandScoreEma}`); +reason.push(`VehicleDemandFilter noDemand=${vehicleDemandFilter.evcsVehicleNoDemandSemantic ? 'on' : 'off'}`); + +// ===================================================== +// OUTPUT +// ===================================================== +return [{ + json: { + ...prev, + vehicleDemandFilter, + decision: { + desiredCharging, + desiredPermission, + desiredLatch, + desiredCurrentA, + + state, + currentDecision, + dynamicOk, + mustStop, + reason, + + nextBlockReason, + nextBlockedUntilStamp, + + setCloudDebtStartedAt, + setProbeStartedAt, + + setNightSessionStart, + clearNightSessionStart, + + notifyWanted, + notifyTitle, + notifyMessage, + } + } +}]; diff --git a/src/nodes/HA_SERVICE_CALLS_OUTPUT.js b/src/nodes/HA_SERVICE_CALLS_OUTPUT.js new file mode 100644 index 0000000..8877879 --- /dev/null +++ b/src/nodes/HA_SERVICE_CALLS_OUTPUT.js @@ -0,0 +1,1060 @@ +function round1(v) { + return Math.round(v * 10) / 10; +} + +function round2(v) { + return Math.round(v * 100) / 100; +} + +function round3(v) { + return Math.round(v * 1000) / 1000; +} + +function clamp(v, min, max) { + return Math.min(Math.max(v, min), max); +} + +function to2(n) { + return String(n).padStart(2, '0'); +} + +function formatParisStampFromMs(ms) { + const fmt = new Intl.DateTimeFormat('fr-FR', { + timeZone: 'Europe/Paris', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); + + const parts = Object.fromEntries( + fmt.formatToParts(new Date(ms)) + .filter(p => p.type !== 'literal') + .map(p => [p.type, p.value]) + ); + + const hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour); + return `${parts.year}-${parts.month}-${parts.day} ${to2(hour)}:${parts.minute}:${parts.second}`; +} + +const prev = $input.first().json; + +const { + input, + now, + nowMs, + cfg, + parsed, + params, + history, + strategy, + windows, + lifecycle, + context, + derived, + batteryTune, + batteryPolicy, + locks, + cloud, + modes, + safeguards, + conditions, + decision, + nightEvcs, + vehicleDemandFilter, +} = prev; + +// ===================================================== +// SAFE ACCESS NIGHT EVCS +// ===================================================== +const night = nightEvcs ?? { + nightSessionActive: false, + nightSessionNeedsCapture: false, + nightSessionNeedsClear: false, + nightSessionStartSocPct: null, + nightSessionStartAt: '', + batteryHardFloorPct: null, + batteryMorningTargetPct: null, + maxBatteryDischargePoints: null, + gridTakeoverTriggerPct: null, + batteryRecoveryUrgent: false, + nightGridTakeoverNeeded: false, + nightPreferredSourceMode: 'blocked', +}; + +// ===================================================== +// SAFE ACCESS VEHICLE DEMAND FILTER +// ===================================================== +const vdf = vehicleDemandFilter ?? { + dtSec: null, + commandCurrentA: null, + realCurrentA: null, + expectedPowerW: null, + realPowerW: null, + currentDeliveryRatioRaw: null, + powerDeliveryRatioRaw: null, + deliveryRatioRaw: null, + deliveryRatioEma: null, + deliveryNoiseEma: null, + adaptiveQuietBand: null, + noDemandEvidenceRaw: null, + sessionElapsedSec: null, + sessionArmFactor: null, + commandFactor: null, + sourceDeliverableForNoDemand: false, + evcsSessionArmedForNoDemand: false, + noDemandScoreRaw: null, + noDemandScoreEma: null, + noDemandScoreOn: null, + noDemandScoreOff: null, + evcsVehicleNoDemandSemantic: false, + evcsNoDemandSinceMs: null, + evcsNoDemandSinceMinutes: null, + nextHistory: { + evcsDeliveryRatioEma: null, + evcsDeliveryNoiseEma: null, + evcsNoDemandScoreEma: null, + evcsNoDemandSemanticActive: false, + evcsNoDemandSinceMs: null, + evcsNoDemandFilterEvalMs: null + } +}; + +// ===================================================== +// FINAL ACTIONS +// ===================================================== +let desiredCurrentA = decision.desiredCurrentA; + +desiredCurrentA = decision.desiredCharging + ? clamp( + desiredCurrentA, + params.currentMinA, + Math.max(params.currentMinA, derived.physicalCapA || params.currentMinA) + ) + : params.currentMinA; + +const chargingAction = + decision.desiredCharging === parsed.evcsChargingSwitchOn + ? 'none' + : decision.desiredCharging + ? 'turn_on' + : 'turn_off'; + +const permissionAction = + decision.desiredPermission === parsed.chargePermissionNow + ? 'none' + : decision.desiredPermission + ? 'turn_on' + : 'turn_off'; + +const latchAction = + decision.desiredLatch === parsed.controlLatch + ? 'none' + : decision.desiredLatch + ? 'turn_on' + : 'turn_off'; + +const autoStartAction = + parsed.controllerEnabled && + parsed.evcsVehicleConnected && + parsed.evcsAutoStartSwitchOn + ? 'turn_off' + : 'none'; + +const currentAction = + decision.desiredCharging && + ( + !Number.isFinite(derived.currentReferenceA) || + Math.abs(desiredCurrentA - derived.currentReferenceA) >= 0.5 + ) + ? 'set' + : 'none'; + +const setLastStartAt = chargingAction === 'turn_on'; +const setLastStopAt = chargingAction === 'turn_off'; +const setLastCurrentChangeAt = currentAction === 'set'; + +// ===================================================== +// CLEAN BLOCKING LOGIC +// ===================================================== +const blockedUntilToWrite = + decision.nextBlockReason === 'none' + ? now.stamp + : decision.nextBlockedUntilStamp; + +const needsBlockReasonUpdate = + decision.nextBlockReason !== history.lastBlockReason; + +// ===================================================== +// GLOBAL EVCS PRIORITY +// AUTO + OVERRIDE MANUEL +// ===================================================== +const forceWindowMinutes = Math.max( + 30, + batteryTune?.global?.forceModeAutoRevertMinutes ?? 120 +); + +const activeChargeIntent = + decision.desiredCharging || + ['start', 'hold', 'step_up', 'step_down', 'step_down_fast'].includes(decision.currentDecision); + +const evcsVehicleNotRequestingPowerState = + decision.state === 'vehicle_full_or_paused' || + decision.currentDecision === 'stop_vehicle_full_or_paused'; + +// ------------------------- +// PRIORITÉ AUTO +// ------------------------- +let autoEvcsPriorityActive = false; +let autoEnergyPriorityMode = 'normal'; +let autoEnergyPriorityReason = 'Aucune priorité EVCS'; +let autoEvcsPriorityUntil = now.stamp; + +if ( + parsed.evcsVehicleConnected && + !strategy.isStopMode && + !evcsVehicleNotRequestingPowerState +) { + if (strategy.forceChargeRequested || strategy.forceGridRequested) { + autoEvcsPriorityActive = true; + autoEnergyPriorityMode = 'evcs_priority'; + autoEnergyPriorityReason = + strategy.forceGridRequested + ? 'EVCS prioritaire : force_grid_now' + : 'EVCS prioritaire : force_charge_now'; + autoEvcsPriorityUntil = formatParisStampFromMs(nowMs + forceWindowMinutes * 60000); + } + + else if (night.nightSessionActive) { + autoEvcsPriorityActive = true; + autoEnergyPriorityMode = 'evcs_priority'; + autoEnergyPriorityReason = + night.batteryRecoveryUrgent + ? 'EVCS prioritaire : session nuit + urgence départ' + : night.nightGridTakeoverNeeded + ? 'EVCS prioritaire : session nuit + takeover réseau' + : 'EVCS prioritaire : session nuit active'; + + autoEvcsPriorityUntil = + Number.isFinite(lifecycle.minutesUntilDeparture) + ? formatParisStampFromMs( + nowMs + Math.max(1, lifecycle.minutesUntilDeparture) * 60000 + ) + : formatParisStampFromMs(nowMs + forceWindowMinutes * 60000); + } + + else if (context.departurePriorityActive && Number.isFinite(lifecycle.minutesUntilDeparture)) { + autoEvcsPriorityActive = true; + autoEnergyPriorityMode = 'evcs_priority'; + autoEnergyPriorityReason = `EVCS prioritaire : départ dans ${Math.round(lifecycle.minutesUntilDeparture)} min`; + autoEvcsPriorityUntil = formatParisStampFromMs( + nowMs + Math.max(1, lifecycle.minutesUntilDeparture) * 60000 + ); + } + + else if (activeChargeIntent && context.profile === 'midday_hc_or_solar') { + autoEvcsPriorityActive = true; + autoEnergyPriorityMode = 'evcs_priority'; + autoEnergyPriorityReason = 'EVCS prioritaire : recharge active fenêtre midi'; + autoEvcsPriorityUntil = formatParisStampFromMs(nowMs + 90 * 60000); + } +} + +// ------------------------- +// OVERRIDE MANUEL +// ------------------------- +const manualPriorityOverrideActive = + parsed.evcsPriorityManualOverride && + parsed.evcsPriorityManualMode === 'evcs_priority' && + history.evcsPriorityManualUntilMs !== null && + history.evcsPriorityManualUntilMs > nowMs + 1000; + +let evcsPriorityActive = autoEvcsPriorityActive; +let energyPriorityMode = autoEnergyPriorityMode; +let energyPriorityReason = autoEnergyPriorityReason; +let evcsPriorityUntil = autoEvcsPriorityUntil; +let evcsPrioritySource = 'auto'; + +if (manualPriorityOverrideActive && !evcsVehicleNotRequestingPowerState) { + evcsPriorityActive = true; + energyPriorityMode = 'evcs_priority'; + energyPriorityReason = + parsed.evcsPriorityManualReason || 'EVCS prioritaire : override manuel'; + evcsPriorityUntil = + history.evcsPriorityManualUntilAt || autoEvcsPriorityUntil; + evcsPrioritySource = 'manual'; +} + +// ===================================================== +// REASONS / LABELS / SIGNATURE +// ===================================================== +const reason = [...decision.reason]; + +reason.push(`Stratégie=${strategy.userStrategy}`); +reason.push(`Profil=${context.profile}`); +reason.push(`Source=${context.sourceMode}`); +reason.push( + `Mode=${ + modes.batteryFullHarvestMode + ? 'battery_full_harvest_mode' + : modes.forecastCommitMode + ? 'forecast_commit_mode' + : 'battery_priority_mode' + }` +); +reason.push(`SOC=${round1(parsed.soc)}%`); +reason.push(`Batt=${round1(parsed.batteryCurrentA)}A`); +reason.push(`PV=${Math.round(parsed.pvPowerW)}W`); +reason.push(`PV10=${Math.round(parsed.pvPowerAvg10mW)}W`); +reason.push(`Grid=${parsed.gridAvailable ? 'on' : 'off'} (${Math.round(parsed.gridPowerW)}W)`); +reason.push(`Cap=${derived.physicalCapA}A`); +reason.push(`Ref=${round1(derived.currentReferenceA)}A`); +reason.push(`Target=${round1(desiredCurrentA)}A`); +reason.push(`Debt=${round2(cloud.cloudDebtNextKwh)}kWh`); +reason.push(`Vehicle=${parsed.evcsVehicleConnected ? 'on' : 'off'}`); +reason.push(`EVCS mode=${parsed.evcsModeRaw}`); +reason.push(`VE.Bus AC=${Math.round(parsed.vebusAcOutPowerW)}W`); +reason.push( + `VE.Bus DC=${ + Number.isFinite(parsed.vebusDcVoltageV) + ? round1(parsed.vebusDcVoltageV) + : 'n/a' + }V` +); +reason.push(`PrioEVCS=${evcsPriorityActive ? 'on' : 'off'}(${evcsPrioritySource})`); +reason.push(`VDF delivery=${Number.isFinite(vdf.deliveryRatioEma) ? round3(vdf.deliveryRatioEma) : 'na'}`); +reason.push(`VDF noise=${Number.isFinite(vdf.deliveryNoiseEma) ? round3(vdf.deliveryNoiseEma) : 'na'}`); +reason.push(`VDF score=${Number.isFinite(vdf.noDemandScoreEma) ? round3(vdf.noDemandScoreEma) : 'na'}`); +reason.push(`VDF noDemand=${vdf.evcsVehicleNoDemandSemantic ? 'on' : 'off'}`); + +if (night.nightSessionActive) { + reason.push(`NightTarget=${round1(night.batteryMorningTargetPct ?? 0)}%`); + reason.push(`NightTrigger=${round1(night.gridTakeoverTriggerPct ?? 0)}%`); + + if (Number.isFinite(night.nightSessionStartSocPct)) { + reason.push(`StartSOC=${round1(night.nightSessionStartSocPct)}%`); + reason.push(`AllowDrop=${round1(night.maxBatteryDischargePoints ?? 0)}pts`); + } + + reason.push(`NightPreferred=${night.nightPreferredSourceMode ?? 'blocked'}`); + reason.push(`NightTakeover=${night.nightGridTakeoverNeeded ? 'on' : 'off'}`); + reason.push(`NightUrgent=${night.batteryRecoveryUrgent ? 'on' : 'off'}`); +} + +const shortReason = reason.join(' | ').slice(0, 255); + +const sourceModeLabel = + context.sourceMode === 'blocked' ? 'Bloqué' : + context.sourceMode === 'solar_only' ? 'Solaire' : + context.sourceMode === 'solar_potential' ? 'Potentiel solaire' : + context.sourceMode === 'hybrid_probe' ? 'Probe hybride' : + context.sourceMode === 'grid_hc_allowed' ? 'Réseau HC autorisé' : + context.sourceMode === 'grid_forced' ? 'Réseau forcé' : + context.sourceMode === 'battery_assist_night' ? 'Assistance batterie nuit' : + context.sourceMode; + +const signature = [ + decision.state, + `profile=${context.profile}`, + `source=${context.sourceMode}`, + `chg=${decision.desiredCharging ? '1' : '0'}`, + `perm=${decision.desiredPermission ? '1' : '0'}`, + `l=${decision.desiredLatch ? '1' : '0'}`, + `cur=${round1(desiredCurrentA)}`, + `block=${decision.nextBlockReason}`, + `stop=${decision.mustStop ? '1' : '0'}`, + `prio=${evcsPriorityActive ? '1' : '0'}`, + `night=${night.nightSessionActive ? '1' : '0'}`, + `ntrg=${Number.isFinite(night.gridTakeoverTriggerPct) ? round1(night.gridTakeoverTriggerPct) : 'na'}`, + `nurg=${night.batteryRecoveryUrgent ? '1' : '0'}`, + `vdf=${vdf.evcsVehicleNoDemandSemantic ? '1' : '0'}`, + `vdfs=${Number.isFinite(vdf.noDemandScoreEma) ? round3(vdf.noDemandScoreEma) : 'na'}` +].join('|'); + +const signatureChanged = signature !== history.lastSignature; + +// ===================================================== +// NOTIFICATIONS +// ===================================================== +let notifyWanted = false; +let notifyTitle = 'Victron EVCS Surplus'; +let notifyMessage = shortReason; + +if (parsed.notificationsEnabled && signatureChanged) { + if (decision.currentDecision === 'start') { + notifyWanted = true; + notifyTitle = '⚡ EVCS démarrage'; + } else if (decision.currentDecision === 'step_up') { + notifyWanted = true; + notifyTitle = '⬆️ EVCS hausse courant'; + } else if ( + decision.currentDecision === 'step_down' || + decision.currentDecision === 'step_down_fast' + ) { + notifyWanted = true; + notifyTitle = '⬇️ EVCS baisse courant'; + } else if ( + decision.mustStop || + decision.currentDecision === 'stop' || + decision.currentDecision === 'stop_hard' || + decision.currentDecision === 'stop_power_cap' + ) { + notifyWanted = true; + notifyTitle = '⛔ EVCS arrêt'; + } else if (decision.state === 'mode_auto_conflict') { + notifyWanted = true; + notifyTitle = '⚠️ EVCS mode Auto'; + } else if (decision.state === 'vehicle_full_or_paused') { + notifyWanted = true; + notifyTitle = '🚗 EVCS véhicule plein ou en pause'; + } +} + +// ===================================================== +// TIMESTAMPS TO WRITE +// ===================================================== +const cloudDebtStartedAtToWrite = + cloud.cloudDebtStartedMs + ? formatParisStampFromMs(cloud.cloudDebtStartedMs) + : now.stamp; + +const probeStartedAtToWrite = now.stamp; +const lastStartAtToWrite = now.stamp; +const lastStopAtToWrite = now.stamp; +const lastCurrentChangeAtToWrite = now.stamp; + +// ===================================================== +// NIGHT SESSION MEMORY +// ===================================================== +const nightSessionStartSocToWrite = + decision.clearNightSessionStart + ? '' + : decision.setNightSessionStart + ? String(round1(parsed.soc)) + : ( + Number.isFinite(history.nightSessionStartSocPct) + ? String(round1(history.nightSessionStartSocPct)) + : '' + ); + +const nightSessionStartAtToWrite = + decision.clearNightSessionStart + ? '' + : decision.setNightSessionStart + ? now.stamp + : (history.nightSessionStartAt || ''); + +// ===================================================== +// OUTPUT +// ===================================================== +return [{ + json: { + timestamp_paris: now.stamp, + inputs: input, + + computed: { + operating_mode: context.operatingMode, + profile: context.profile, + source_mode: context.sourceMode, + source_mode_label: sourceModeLabel, + user_strategy: strategy.userStrategy, + + in_night_hc_window: windows.inNightHcWindow, + in_morning_window: windows.inMorningWindow, + in_midday_window: windows.inMiddayWindow, + in_evening_window: windows.inEveningWindow, + + departure_priority_active: context.departurePriorityActive, + minutes_until_departure: lifecycle.minutesUntilDeparture, + + battery_priority_mode: modes.batteryPriorityMode, + battery_full_harvest_mode: modes.batteryFullHarvestMode, + forecast_commit_mode: modes.forecastCommitMode, + + physical_cap_current_a: derived.physicalCapA, + can_run_at_min_current: derived.canRunAtMinCurrent, + + forecast_buffer_kwh: round2(derived.forecastBufferKwh), + projected_end_soc_after_charge: round1(derived.projectedEndSocAfterCharge), + + cloud_debt_next_kwh: round2(cloud.cloudDebtNextKwh), + cloud_debt_duration_minutes: round1(cloud.cloudDebtDurationMinutes), + cloud_debt_active: cloud.cloudDebtActive, + cloud_tolerance_ok: cloud.cloudToleranceOk, + + start_blocked_by_reason: locks.startBlockedByReason, + + probe_window_active: conditions.probeWindowActive, + probe_success_now: conditions.probeSuccessNow, + probe_failed_now: conditions.probeFailedNow, + + any_vebus_overload: derived.anyVebusOverload, + any_vebus_low_battery: derived.anyVebusLowBattery, + vebus_ac_out_soft_reached: derived.vebusAcOutSoftReached, + vebus_ac_out_hard_reached: derived.vebusAcOutHardReached, + vebus_dc_voltage_soft_low: derived.vebusDcVoltageSoftLow, + vebus_dc_voltage_hard_low: derived.vebusDcVoltageHardLow, + + soc_band: batteryPolicy.socBand, + batt_discharge_soft_dynamic_a: round1(batteryPolicy.batteryDischargeSoftDynamicA), + batt_discharge_hard_dynamic_a: round1(batteryPolicy.batteryDischargeHardDynamicA), + batt_charge_reserve_dynamic_a: round1(batteryPolicy.batteryChargeReserveDynamicA), + batt_soft_tolerance_min: round1(batteryPolicy.batterySoftToleranceMinutes), + batt_hard_tolerance_sec: round1(batteryPolicy.batteryHardToleranceSeconds), + batt_recovery_min: round1(batteryPolicy.batteryRecoveryMinutes), + grid_battery_soft_dynamic_a: round1(batteryPolicy.gridBatterySoftDynamicA), + grid_battery_hard_dynamic_a: round1(batteryPolicy.gridBatteryHardDynamicA), + house_spike_detected: batteryPolicy.houseSpikeDetected, + solar_potential_context: batteryPolicy.solarPotentialContext, + + evcs_priority_active: evcsPriorityActive, + energy_priority_mode: energyPriorityMode, + energy_priority_reason: energyPriorityReason, + evcs_priority_until: evcsPriorityUntil, + evcs_priority_source: evcsPrioritySource, + evcs_priority_manual_override_active: manualPriorityOverrideActive, + + night_session_active: night.nightSessionActive, + night_session_needs_capture: !!night.nightSessionNeedsCapture, + night_session_needs_clear: !!night.nightSessionNeedsClear, + night_session_start_soc_pct: Number.isFinite(night.nightSessionStartSocPct) + ? round1(night.nightSessionStartSocPct) + : null, + night_session_start_at: night.nightSessionStartAt || null, + + battery_hard_floor_pct: Number.isFinite(night.batteryHardFloorPct) + ? round1(night.batteryHardFloorPct) + : null, + battery_morning_target_pct: Number.isFinite(night.batteryMorningTargetPct) + ? round1(night.batteryMorningTargetPct) + : null, + max_battery_discharge_points: Number.isFinite(night.maxBatteryDischargePoints) + ? round1(night.maxBatteryDischargePoints) + : null, + grid_takeover_trigger_pct: Number.isFinite(night.gridTakeoverTriggerPct) + ? round1(night.gridTakeoverTriggerPct) + : null, + + battery_recovery_urgent: !!night.batteryRecoveryUrgent, + night_grid_takeover_needed: !!night.nightGridTakeoverNeeded, + night_preferred_source_mode: night.nightPreferredSourceMode || 'blocked', + + evcs_delivery_ratio_ema: Number.isFinite(vdf.deliveryRatioEma) ? round3(vdf.deliveryRatioEma) : null, + evcs_delivery_noise_ema: Number.isFinite(vdf.deliveryNoiseEma) ? round3(vdf.deliveryNoiseEma) : null, + evcs_no_demand_score_ema: Number.isFinite(vdf.noDemandScoreEma) ? round3(vdf.noDemandScoreEma) : null, + evcs_vehicle_no_demand_semantic: !!vdf.evcsVehicleNoDemandSemantic, + evcs_no_demand_since_minutes: Number.isFinite(vdf.evcsNoDemandSinceMinutes) ? round3(vdf.evcsNoDemandSinceMinutes) : null, + evcs_source_deliverable_for_no_demand: !!vdf.sourceDeliverableForNoDemand, + evcs_session_armed_for_no_demand: !!vdf.evcsSessionArmedForNoDemand, + evcs_no_demand_score_on: Number.isFinite(vdf.noDemandScoreOn) ? round3(vdf.noDemandScoreOn) : null, + evcs_no_demand_score_off: Number.isFinite(vdf.noDemandScoreOff) ? round3(vdf.noDemandScoreOff) : null + }, + + decision: { + state: decision.state, + current_decision: decision.currentDecision, + desired_charging: decision.desiredCharging, + desired_permission: decision.desiredPermission, + desired_latch: decision.desiredLatch, + desired_current_a: round1(desiredCurrentA), + + charging_action: chargingAction, + permission_action: permissionAction, + latch_action: latchAction, + current_action: currentAction, + auto_start_action: autoStartAction, + + set_last_start_at: setLastStartAt, + set_last_stop_at: setLastStopAt, + set_last_current_change_at: setLastCurrentChangeAt, + set_cloud_debt_started_at: decision.setCloudDebtStartedAt, + set_probe_started_at: decision.setProbeStartedAt, + + set_night_session_start: !!decision.setNightSessionStart, + clear_night_session_start: !!decision.clearNightSessionStart, + + set_block_reason: needsBlockReasonUpdate || !parsed.controllerEnabled, + block_reason: decision.nextBlockReason, + + set_blocked_until: true, + blocked_until: blockedUntilToWrite, + + dynamic_ok: decision.dynamicOk, + must_stop: decision.mustStop, + + short_reason: shortReason, + reason, + signature, + signature_changed: signatureChanged, + + should_notify: notifyWanted, + notify_title: notifyTitle, + notify_message: notifyMessage, + + evcs_priority_active: evcsPriorityActive, + energy_priority_mode: energyPriorityMode, + energy_priority_reason: energyPriorityReason, + evcs_priority_until: evcsPriorityUntil, + evcs_priority_source: evcsPrioritySource + }, + + ha_service_calls: { + helper_state: { + execute: true, + domain: 'input_text', + service: 'set_value', + data: { + entity_id: 'input_text.victron_evcs_surplus_state', + value: decision.state + } + }, + + helper_current_decision: { + execute: true, + domain: 'input_text', + service: 'set_value', + data: { + entity_id: 'input_text.victron_evcs_surplus_current_decision', + value: decision.currentDecision + } + }, + + helper_reason: { + execute: true, + domain: 'input_text', + service: 'set_value', + data: { + entity_id: 'input_text.victron_evcs_surplus_reason', + value: shortReason + } + }, + + helper_operating_mode: { + execute: true, + domain: 'input_text', + service: 'set_value', + data: { + entity_id: 'input_text.victron_evcs_operating_mode', + value: context.operatingMode + } + }, + + helper_source_mode: { + execute: true, + domain: 'input_text', + service: 'set_value', + data: { + entity_id: 'input_text.victron_evcs_source_mode', + value: sourceModeLabel + } + }, + + helper_target_current: { + execute: true, + domain: 'input_number', + service: 'set_value', + data: { + entity_id: 'input_number.victron_evcs_target_current_auto', + value: round1(desiredCurrentA) + } + }, + + helper_budget_remaining: { + execute: true, + domain: 'input_number', + service: 'set_value', + data: { + entity_id: 'input_number.victron_evcs_budget_remaining_kwh', + value: round2(derived.forecastBufferKwh) + } + }, + + helper_projected_soc: { + execute: true, + domain: 'input_number', + service: 'set_value', + data: { + entity_id: 'input_number.victron_evcs_projected_end_soc_after_charge', + value: round1(derived.projectedEndSocAfterCharge) + } + }, + + helper_cloud_debt: { + execute: true, + domain: 'input_number', + service: 'set_value', + data: { + entity_id: 'input_number.victron_evcs_cloud_debt_kwh', + value: round2(cloud.cloudDebtNextKwh) + } + }, + + helper_dynamic_ok: { + execute: true, + domain: 'input_boolean', + service: decision.dynamicOk ? 'turn_on' : 'turn_off', + data: { + entity_id: 'input_boolean.victron_evcs_dynamic_ok' + } + }, + + helper_must_stop: { + execute: true, + domain: 'input_boolean', + service: decision.mustStop ? 'turn_on' : 'turn_off', + data: { + entity_id: 'input_boolean.victron_evcs_must_stop' + } + }, + + helper_cloud_debt_started_at: { + execute: true, + domain: 'input_datetime', + service: 'set_datetime', + data: { + entity_id: 'input_datetime.victron_evcs_cloud_debt_started_at', + datetime: cloudDebtStartedAtToWrite + } + }, + + helper_last_current_change_at: { + execute: setLastCurrentChangeAt, + domain: 'input_datetime', + service: 'set_datetime', + data: { + entity_id: 'input_datetime.victron_evcs_last_current_change_at', + datetime: lastCurrentChangeAtToWrite + } + }, + + helper_last_start_at: { + execute: setLastStartAt, + domain: 'input_datetime', + service: 'set_datetime', + data: { + entity_id: 'input_datetime.victron_evcs_last_start_at', + datetime: lastStartAtToWrite + } + }, + + helper_last_stop_at: { + execute: setLastStopAt, + domain: 'input_datetime', + service: 'set_datetime', + data: { + entity_id: 'input_datetime.victron_evcs_last_stop_at', + datetime: lastStopAtToWrite + } + }, + + helper_signature: { + execute: true, + domain: 'input_text', + service: 'set_value', + data: { + entity_id: 'input_text.victron_evcs_control_last_signature', + value: signature + } + }, + + helper_block_reason: { + execute: needsBlockReasonUpdate || !parsed.controllerEnabled, + domain: 'input_text', + service: 'set_value', + data: { + entity_id: 'input_text.victron_evcs_last_block_reason', + value: decision.nextBlockReason + } + }, + + helper_blocked_until: { + execute: true, + domain: 'input_datetime', + service: 'set_datetime', + data: { + entity_id: 'input_datetime.victron_evcs_blocked_until', + datetime: blockedUntilToWrite + } + }, + + helper_probe_started_at: { + execute: decision.setProbeStartedAt, + domain: 'input_datetime', + service: 'set_datetime', + data: { + entity_id: 'input_datetime.victron_evcs_probe_started_at', + datetime: probeStartedAtToWrite + } + }, + + helper_evcs_priority_active: { + execute: true, + domain: 'input_boolean', + service: evcsPriorityActive ? 'turn_on' : 'turn_off', + data: { + entity_id: 'input_boolean.victron_evcs_priority_active' + } + }, + + helper_energy_priority_mode: { + execute: true, + domain: 'input_select', + service: 'select_option', + data: { + entity_id: 'input_select.victron_energy_priority_mode', + option: energyPriorityMode + } + }, + + helper_energy_priority_reason: { + execute: true, + domain: 'input_text', + service: 'set_value', + data: { + entity_id: 'input_text.victron_energy_priority_reason', + value: energyPriorityReason + } + }, + + helper_evcs_priority_until: { + execute: true, + domain: 'input_datetime', + service: 'set_datetime', + data: { + entity_id: 'input_datetime.victron_evcs_priority_until', + datetime: evcsPriorityUntil + } + }, + + helper_night_target_soc: { + execute: true, + domain: 'input_number', + service: 'set_value', + data: { + entity_id: 'input_number.victron_evcs_night_target_soc', + value: round1( + Number.isFinite(night.batteryMorningTargetPct) + ? night.batteryMorningTargetPct + : 0 + ) + } + }, + + helper_night_grid_trigger_soc: { + execute: true, + domain: 'input_number', + service: 'set_value', + data: { + entity_id: 'input_number.victron_evcs_night_grid_trigger_soc', + value: round1( + Number.isFinite(night.gridTakeoverTriggerPct) + ? night.gridTakeoverTriggerPct + : 0 + ) + } + }, + + helper_night_preferred_source_mode: { + execute: true, + domain: 'input_text', + service: 'set_value', + data: { + entity_id: 'input_text.victron_evcs_night_preferred_source_mode', + value: night.nightPreferredSourceMode || 'blocked' + } + }, + + helper_night_grid_takeover_needed: { + execute: true, + domain: 'input_boolean', + service: night.nightGridTakeoverNeeded ? 'turn_on' : 'turn_off', + data: { + entity_id: 'input_boolean.victron_evcs_night_grid_takeover_needed' + } + }, + + helper_night_battery_recovery_urgent: { + execute: true, + domain: 'input_boolean', + service: night.batteryRecoveryUrgent ? 'turn_on' : 'turn_off', + data: { + entity_id: 'input_boolean.victron_evcs_night_battery_recovery_urgent' + } + }, + + helper_night_session_active: { + execute: true, + domain: 'input_boolean', + service: night.nightSessionActive ? 'turn_on' : 'turn_off', + data: { + entity_id: 'input_boolean.victron_evcs_night_session_active' + } + }, + + helper_night_session_start_soc: { + execute: decision.setNightSessionStart || decision.clearNightSessionStart, + domain: 'input_text', + service: 'set_value', + data: { + entity_id: 'input_text.victron_evcs_night_session_start_soc', + value: nightSessionStartSocToWrite + } + }, + + helper_night_session_start_at: { + execute: decision.setNightSessionStart || decision.clearNightSessionStart, + domain: 'input_text', + service: 'set_value', + data: { + entity_id: 'input_text.victron_evcs_night_session_start_at', + value: nightSessionStartAtToWrite + } + }, + + // ================================================= + // VEHICLE DEMAND FILTER HISTORY PERSISTENCE + // ================================================= + helper_evcs_delivery_ratio_ema: { + execute: Number.isFinite(vdf?.nextHistory?.evcsDeliveryRatioEma), + domain: 'input_number', + service: 'set_value', + data: { + entity_id: 'input_number.victron_evcs_delivery_ratio_ema', + value: round3(vdf.nextHistory.evcsDeliveryRatioEma) + } + }, + + helper_evcs_delivery_noise_ema: { + execute: Number.isFinite(vdf?.nextHistory?.evcsDeliveryNoiseEma), + domain: 'input_number', + service: 'set_value', + data: { + entity_id: 'input_number.victron_evcs_delivery_noise_ema', + value: round3(vdf.nextHistory.evcsDeliveryNoiseEma) + } + }, + + helper_evcs_no_demand_score_ema: { + execute: Number.isFinite(vdf?.nextHistory?.evcsNoDemandScoreEma), + domain: 'input_number', + service: 'set_value', + data: { + entity_id: 'input_number.victron_evcs_no_demand_score_ema', + value: round3(vdf.nextHistory.evcsNoDemandScoreEma) + } + }, + + helper_evcs_no_demand_semantic_active: { + execute: true, + domain: 'input_boolean', + service: vdf?.nextHistory?.evcsNoDemandSemanticActive ? 'turn_on' : 'turn_off', + data: { + entity_id: 'input_boolean.victron_evcs_no_demand_semantic_active' + } + }, + + helper_evcs_no_demand_since_at: { + execute: Number.isFinite(vdf?.nextHistory?.evcsNoDemandSinceMs), + domain: 'input_datetime', + service: 'set_datetime', + data: { + entity_id: 'input_datetime.victron_evcs_no_demand_since_at', + datetime: formatParisStampFromMs(vdf.nextHistory.evcsNoDemandSinceMs) + } + }, + + helper_evcs_no_demand_filter_eval_at: { + execute: Number.isFinite(vdf?.nextHistory?.evcsNoDemandFilterEvalMs), + domain: 'input_datetime', + service: 'set_datetime', + data: { + entity_id: 'input_datetime.victron_evcs_no_demand_filter_eval_at', + datetime: formatParisStampFromMs(vdf.nextHistory.evcsNoDemandFilterEvalMs) + } + }, + + // ================================================= + // PHYSICAL EDF GRID TAKEOVER + // ================================================= + edf_general_ok_on: { + execute: + ['grid_forced', 'grid_hc_allowed'].includes(context.sourceMode) && + !parsed.gridAvailable, + domain: 'switch', + service: 'turn_on', + data: { + entity_id: 'switch.edf_general_ok_switch' + } + }, + + evcs_auto_start_off: { + execute: autoStartAction === 'turn_off', + domain: 'switch', + service: 'turn_off', + data: { + entity_id: 'switch.victron_evcs_auto_start' + } + }, + + evcs_charging: { + execute: chargingAction !== 'none', + domain: 'switch', + service: decision.desiredCharging ? 'turn_on' : 'turn_off', + data: { + entity_id: 'switch.victron_evcs_charging' + } + }, + + helper_permission: { + execute: permissionAction !== 'none', + domain: 'input_boolean', + service: decision.desiredPermission ? 'turn_on' : 'turn_off', + data: { + entity_id: 'input_boolean.victron_evcs_charge_permission_now' + } + }, + + helper_latch: { + execute: latchAction !== 'none', + domain: 'input_boolean', + service: decision.desiredLatch ? 'turn_on' : 'turn_off', + data: { + entity_id: 'input_boolean.victron_evcs_control_latch' + } + }, + + evcs_set_current: { + execute: currentAction === 'set', + domain: 'number', + service: 'set_value', + data: { + entity_id: 'number.victron_evcs_set_current_command', + value: round1(desiredCurrentA) + } + }, + + logbook: { + execute: signatureChanged, + domain: 'logbook', + service: 'log', + data: { + name: 'Victron EVCS Surplus', + message: shortReason + } + }, + + mobile_notification: { + execute: notifyWanted, + domain: cfg.mobileNotifyDomain, + service: cfg.mobileNotifyService, + data: { + title: notifyTitle, + message: notifyMessage || shortReason + } + } + } + } +}]; diff --git a/src/nodes/HA_SNAPSHOT_TEMPLATE.json.tpl b/src/nodes/HA_SNAPSHOT_TEMPLATE.json.tpl new file mode 100644 index 0000000..ce8ae3a --- /dev/null +++ b/src/nodes/HA_SNAPSHOT_TEMPLATE.json.tpl @@ -0,0 +1,212 @@ +{{ { + "soc_percent": states("sensor.victron_battery_soc"), + "battery_current_a": states("sensor.victron_battery_current"), + + "pv_power_w": states("sensor.victron_pv_power_system"), + "pv_power_10m_avg_w": states("sensor.victron_pv_power_10m_avg"), + "pv_trend_w": states("sensor.victron_pv_power_trend_w"), + + "house_ac_power_w": states("sensor.victron_consumption_power_l1"), + "house_ac_power_prev_w": states("sensor.victron_consumption_power_l1"), + + "grid_power_w": states("sensor.victron_grid_power_l1"), + "grid_state": states("switch.edf_general_ok_switch"), + "victron_acin1_available": states("sensor.victron_ac_in1_available"), + + "today_remaining_kwh": states("sensor.energy_production_today_remaining"), + "tomorrow_kwh": states("sensor.energy_production_tomorrow"), + + "evcs_device_connected": states("binary_sensor.victron_evcs_device_connected"), + "evcs_vehicle_connected": states("binary_sensor.victron_evcs_vehicle_connected"), + "evcs_status_raw": states("sensor.victron_evcs_status_raw"), + "evcs_mode_sensor": states("sensor.victron_evcs_mode"), + "evcs_mode_fallback": states("input_text.sensor_victron_evcs_mode"), + "evcs_set_current_a": states("sensor.victron_evcs_set_current"), + "evcs_command_current_a": states("number.victron_evcs_set_current_command"), + "evcs_current_a": states("sensor.victron_evcs_current"), + "evcs_ac_power_w": states("sensor.victron_evcs_ac_power"), + "evcs_charging_switch": states("switch.victron_evcs_charging"), + "evcs_auto_start_switch": states("switch.victron_evcs_auto_start"), + + "controller_enabled": states("input_boolean.victron_evcs_surplus_auto_enabled"), + "notifications_enabled": states("input_boolean.victron_evcs_surplus_notifications"), + "hard_stop_enabled": states("input_boolean.victron_evcs_hard_stop_enabled"), + "charge_permission_now": states("input_boolean.victron_evcs_charge_permission_now"), + "control_latch": states("input_boolean.victron_evcs_control_latch"), + "departure_priority_enabled": states("input_boolean.victron_evcs_departure_priority_enabled"), + "forecast_mode_enabled": states("input_boolean.victron_evcs_forecast_mode_enabled"), + + "target_current_auto_a": states("input_number.victron_evcs_target_current_auto"), + "budget_remaining_kwh": states("input_number.victron_evcs_budget_remaining_kwh"), + "projected_end_soc_after_charge": states("input_number.victron_evcs_projected_end_soc_after_charge"), + + "pv_start_min_w": states("input_number.victron_evcs_pv_start_min_w"), + "pv_hold_min_w": states("input_number.victron_evcs_pv_hold_min_w"), + "house_ac_headroom_w": states("input_number.victron_evcs_house_ac_headroom_w"), + "battery_discharge_soft_a": states("input_number.victron_evcs_battery_discharge_soft_a"), + "battery_discharge_hard_a": states("input_number.victron_evcs_battery_discharge_hard_a"), + "battery_reserve_up_a": states("input_number.victron_evcs_battery_reserve_up_a"), + "battery_neutral_band_a": states("input_number.victron_evcs_battery_neutral_band_a"), + "soc_start_min": states("input_number.victron_evcs_soc_start_min"), + "soc_stop_min": states("input_number.victron_evcs_soc_stop_min"), + "current_min_a": states("input_number.victron_evcs_current_min_a"), + "current_max_auto_a": states("input_number.victron_evcs_current_max_auto_a"), + "grid_current_max_a": states("input_number.evcs_plafond_courant_edf_on"), + "current_step_up_a": states("input_number.victron_evcs_current_step_up_a"), + "current_step_down_a": states("input_number.victron_evcs_current_step_down_a"), + "current_change_cooldown_seconds": states("input_number.victron_evcs_current_change_cooldown_seconds"), + "min_run_minutes": states("input_number.victron_evcs_min_run_minutes"), + "min_stop_minutes": states("input_number.victron_evcs_min_stop_minutes"), + + "required_energy_kwh": states("input_number.victron_evcs_required_energy_kwh"), + "soc_comfort_target": states("input_number.victron_evcs_soc_comfort_target"), + "end_of_day_soc_guard": states("input_number.victron_evcs_end_of_day_soc_guard"), + + "base_load_remaining_kwh": states("input_number.victron_base_load_remaining_kwh"), + "flexible_safety_margin_kwh": states("input_number.victron_flexible_safety_margin_kwh"), + "end_of_day_soc_target": states("input_number.victron_end_of_day_soc_target"), + "ac_loads_max_safe_w": states("input_number.victron_ac_loads_max_safe_w"), + + "battery_full_soc": states("input_number.victron_evcs_battery_full_soc"), + "battery_full_forecast_min_kwh": states("input_number.victron_evcs_battery_full_forecast_min_kwh"), + "forecast_commit_soc_floor": states("input_number.victron_evcs_forecast_commit_soc_floor"), + "forecast_commit_min_kwh": states("input_number.victron_evcs_forecast_commit_min_kwh"), + + "cloud_debt_kwh": states("input_number.victron_evcs_cloud_debt_kwh"), + "cloud_debt_max_kwh": states("input_number.victron_evcs_cloud_debt_max_kwh"), + "cloud_tolerance_minutes": states("input_number.victron_evcs_cloud_tolerance_minutes"), + "cloud_debt_started_at": states("input_datetime.victron_evcs_cloud_debt_started_at"), + "operating_mode_helper": states("input_text.victron_evcs_operating_mode"), + + "departure_time": states("input_datetime.victron_evcs_departure_time"), + "last_start_at": states("input_datetime.victron_evcs_last_start_at"), + "last_stop_at": states("input_datetime.victron_evcs_last_stop_at"), + "last_current_change_at": states("input_datetime.victron_evcs_last_current_change_at"), + + "day_start_time": states("input_datetime.victron_flexible_day_start_time"), + "day_stop_time": states("input_datetime.victron_flexible_day_stop_time"), + + "vebus_overload": states("binary_sensor.victron_ve_bus_overload"), + "vebus_l1_overload": states("binary_sensor.victron_ve_bus_l1_overload"), + "vebus_low_battery": states("binary_sensor.victron_ve_bus_low_battery"), + "vebus_l1_low_battery": states("binary_sensor.victron_ve_bus_l1_low_battery"), + + "vebus_ac_out_l1_power_w": states("sensor.victron_ve_bus_ac_out_l1_power"), + "vebus_ac_out_l1_current_a": states("sensor.victron_ve_bus_ac_out_l1_current"), + "vebus_ac_out_l1_voltage_v": states("sensor.victron_ve_bus_ac_out_l1_voltage"), + + "vebus_dc_voltage_v": states("sensor.victron_ve_bus_dc_voltage"), + "vebus_dc_current_a": states("sensor.victron_ve_bus_dc_current"), + "vebus_dc_power_w": states("sensor.victron_ve_bus_dc_power_system"), + + "vebus_state": states("sensor.victron_ve_bus_state"), + "vebus_main_state": states("sensor.victron_ve_bus_main_state"), + + "last_signature": states("input_text.victron_evcs_control_last_signature"), + + "hc_night_start_time": states("input_datetime.victron_evcs_hc_night_start"), + "hc_night_stop_time": states("input_datetime.victron_evcs_hc_night_stop"), + "morning_start_time": states("input_datetime.victron_evcs_morning_start"), + "morning_stop_time": states("input_datetime.victron_evcs_morning_stop"), + "hc_midday_start_time": states("input_datetime.victron_evcs_hc_midday_start"), + "hc_midday_stop_time": states("input_datetime.victron_evcs_hc_midday_stop"), + "evening_start_time": states("input_datetime.victron_evcs_evening_start"), + "evening_stop_time": states("input_datetime.victron_evcs_evening_stop"), + + "allow_grid_hc_night": states("input_boolean.victron_evcs_allow_grid_hc_night"), + "night_takeover_prev": states("input_boolean.victron_evcs_night_grid_takeover_needed"), + "prev_surplus_state": states("input_text.victron_evcs_surplus_state"), + "allow_grid_hc_midday": states("input_boolean.victron_evcs_allow_grid_hc_midday"), + "probe_enabled": states("input_boolean.victron_evcs_probe_enabled"), + + "probe_minutes": states("input_number.victron_evcs_probe_minutes"), + "probe_retry_minutes": states("input_number.victron_evcs_probe_retry_minutes"), + "probe_current_a": states("input_number.victron_evcs_probe_current_a"), + + "grid_battery_soft_a": states("input_number.victron_evcs_grid_battery_soft_a"), + "grid_battery_hard_a": states("input_number.victron_evcs_grid_battery_hard_a"), + + "morning_probe_pv_avg_min_w": states("input_number.victron_evcs_morning_probe_pv_avg_min_w"), + "morning_probe_pv_trend_min_w": states("input_number.victron_evcs_morning_probe_pv_trend_min_w"), + "midday_probe_pv_avg_min_w": states("input_number.victron_evcs_midday_probe_pv_avg_min_w"), + "afternoon_probe_pv_avg_min_w": states("input_number.victron_evcs_afternoon_probe_pv_avg_min_w"), + "afternoon_probe_soc_min": states("input_number.victron_evcs_afternoon_probe_soc_min"), + "afternoon_battery_max_discharge_a": states("input_number.victron_evcs_afternoon_battery_max_discharge_a"), + "morning_soc_min": states("input_number.victron_evcs_morning_soc_min"), + "midday_soc_min": states("input_number.victron_evcs_midday_soc_min"), + "evening_soc_start_min": states("input_number.victron_evcs_evening_soc_start_min"), + "evening_soc_stop_min": states("input_number.victron_evcs_evening_soc_stop_min"), + + "morning_battery_max_discharge_a": states("input_number.victron_evcs_morning_battery_max_discharge_a"), + "midday_battery_max_discharge_a": states("input_number.victron_evcs_midday_battery_max_discharge_a"), + "evening_battery_max_discharge_a": states("input_number.victron_evcs_evening_battery_max_discharge_a"), + + "last_block_reason": states("input_text.victron_evcs_last_block_reason"), + "blocked_until": states("input_datetime.victron_evcs_blocked_until"), + "probe_started_at": states("input_datetime.victron_evcs_probe_started_at"), + + "victron_evcs_strategy": states("input_select.victron_evcs_strategy"), + "evcs_priority_manual_override": states("input_boolean.victron_evcs_priority_manual_override"), + "evcs_priority_manual_mode": states("input_select.victron_evcs_priority_manual_mode"), + "evcs_priority_manual_reason": states("input_text.victron_evcs_priority_manual_reason"), + "evcs_priority_manual_until": states("input_datetime.victron_evcs_priority_manual_until"), + + "house_spike_grace_minutes": states("input_number.victron_evcs_house_spike_grace_minutes"), + "solar_potential_grace_minutes": states("input_number.victron_evcs_solar_potential_grace_minutes"), + "step_up_cooldown_seconds": states("input_number.victron_evcs_step_up_cooldown_seconds"), + "step_down_cooldown_seconds": states("input_number.victron_evcs_step_down_cooldown_seconds"), + "force_mode_auto_revert_minutes": states("input_number.victron_evcs_force_mode_auto_revert_minutes"), + + "overload_step_down_a": states("input_number.victron_evcs_overload_step_down_a"), + "overload_hold_seconds": states("input_number.victron_evcs_overload_hold_seconds"), + "overload_stop_after_seconds": states("input_number.victron_evcs_overload_stop_after_seconds"), + + "batt_discharge_soft_gt90_a": states("input_number.victron_evcs_batt_discharge_soft_gt90_a"), + "batt_discharge_hard_gt90_a": states("input_number.victron_evcs_batt_discharge_hard_gt90_a"), + "batt_charge_reserve_gt90_a": states("input_number.victron_evcs_batt_charge_reserve_gt90_a"), + "batt_soft_tol_gt90_min": states("input_number.victron_evcs_batt_soft_tol_gt90_min"), + "batt_hard_tol_gt90_sec": states("input_number.victron_evcs_batt_hard_tol_gt90_sec"), + "batt_recovery_gt90_min": states("input_number.victron_evcs_batt_recovery_gt90_min"), + + "batt_discharge_soft_80_90_a": states("input_number.victron_evcs_batt_discharge_soft_80_90_a"), + "batt_discharge_hard_80_90_a": states("input_number.victron_evcs_batt_discharge_hard_80_90_a"), + "batt_charge_reserve_80_90_a": states("input_number.victron_evcs_batt_charge_reserve_80_90_a"), + "batt_soft_tol_80_90_min": states("input_number.victron_evcs_batt_soft_tol_80_90_min"), + "batt_hard_tol_80_90_sec": states("input_number.victron_evcs_batt_hard_tol_80_90_sec"), + "batt_recovery_80_90_min": states("input_number.victron_evcs_batt_recovery_80_90_min"), + + "batt_discharge_soft_60_80_a": states("input_number.victron_evcs_batt_discharge_soft_60_80_a"), + "batt_discharge_hard_60_80_a": states("input_number.victron_evcs_batt_discharge_hard_60_80_a"), + "batt_charge_reserve_60_80_a": states("input_number.victron_evcs_batt_charge_reserve_60_80_a"), + "batt_soft_tol_60_80_min": states("input_number.victron_evcs_batt_soft_tol_60_80_min"), + "batt_hard_tol_60_80_sec": states("input_number.victron_evcs_batt_hard_tol_60_80_sec"), + "batt_recovery_60_80_min": states("input_number.victron_evcs_batt_recovery_60_80_min"), + + "batt_discharge_soft_40_60_a": states("input_number.victron_evcs_batt_discharge_soft_40_60_a"), + "batt_discharge_hard_40_60_a": states("input_number.victron_evcs_batt_discharge_hard_40_60_a"), + "batt_charge_reserve_40_60_a": states("input_number.victron_evcs_batt_charge_reserve_40_60_a"), + "batt_soft_tol_40_60_min": states("input_number.victron_evcs_batt_soft_tol_40_60_min"), + "batt_hard_tol_40_60_sec": states("input_number.victron_evcs_batt_hard_tol_40_60_sec"), + "batt_recovery_40_60_min": states("input_number.victron_evcs_batt_recovery_40_60_min"), + + "batt_discharge_soft_25_40_a": states("input_number.victron_evcs_batt_discharge_soft_25_40_a"), + "batt_discharge_hard_25_40_a": states("input_number.victron_evcs_batt_discharge_hard_25_40_a"), + "batt_charge_reserve_25_40_a": states("input_number.victron_evcs_batt_charge_reserve_25_40_a"), + "batt_soft_tol_25_40_min": states("input_number.victron_evcs_batt_soft_tol_25_40_min"), + "batt_hard_tol_25_40_sec": states("input_number.victron_evcs_batt_hard_tol_25_40_sec"), + "batt_recovery_25_40_min": states("input_number.victron_evcs_batt_recovery_25_40_min"), + + "batt_discharge_soft_lt25_a": states("input_number.victron_evcs_batt_discharge_soft_lt25_a"), + "batt_discharge_hard_lt25_a": states("input_number.victron_evcs_batt_discharge_hard_lt25_a"), + "batt_charge_reserve_lt25_a": states("input_number.victron_evcs_batt_charge_reserve_lt25_a"), + "batt_soft_tol_lt25_min": states("input_number.victron_evcs_batt_soft_tol_lt25_min"), + "batt_hard_tol_lt25_sec": states("input_number.victron_evcs_batt_hard_tol_lt25_sec"), + "batt_recovery_lt25_min": states("input_number.victron_evcs_batt_recovery_lt25_min"), + + "evcs_delivery_ratio_ema": states("input_number.victron_evcs_delivery_ratio_ema"), + "evcs_delivery_noise_ema": states("input_number.victron_evcs_delivery_noise_ema"), + "evcs_no_demand_score_ema": states("input_number.victron_evcs_no_demand_score_ema"), + "evcs_no_demand_semantic_active": states("input_boolean.victron_evcs_no_demand_semantic_active"), + "evcs_no_demand_since_at": states("input_datetime.victron_evcs_no_demand_since_at"), + "evcs_no_demand_filter_eval_at": states("input_datetime.victron_evcs_no_demand_filter_eval_at") +} | tojson }} diff --git a/src/nodes/INPUT_PARSE.js b/src/nodes/INPUT_PARSE.js new file mode 100644 index 0000000..92247fd --- /dev/null +++ b/src/nodes/INPUT_PARSE.js @@ -0,0 +1,590 @@ +function num(v, fallback = NaN) { + const n = Number.parseFloat(v); + return Number.isFinite(n) ? n : fallback; +} + +function low(v) { + return (v ?? '').toString().trim().toLowerCase(); +} + +function clamp(v, min, max) { + return Math.min(Math.max(v, min), max); +} + +function round1(v) { + return Math.round(v * 10) / 10; +} + +function round2(v) { + return Math.round(v * 100) / 100; +} + +function to2(n) { + return String(n).padStart(2, '0'); +} + +function parseMaybeJson(v) { + if (typeof v !== 'string') return v; + try { + return JSON.parse(v); + } catch { + return v; + } +} + +function extractSnapshot(input) { + if (typeof input === 'string') return parseMaybeJson(input); + if (input?.data && typeof input.data === 'string') return parseMaybeJson(input.data); + if (input?.body && typeof input.body === 'string') return parseMaybeJson(input.body); + if (input?.data && typeof input.data === 'object') return input.data; + if (input?.body && typeof input.body === 'object') return input.body; + return input; +} + +function parseTimeToMinutes(v, fallback = null) { + const s = (v ?? '').toString().trim(); + const m = s.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/); + if (!m) return fallback; + + const hh = Number(m[1]); + const mm = Number(m[2]); + + if (!Number.isFinite(hh) || !Number.isFinite(mm)) return fallback; + return (hh % 24) * 60 + mm; +} + +function getParisParts(ms = Date.now()) { + const fmt = new Intl.DateTimeFormat('fr-FR', { + timeZone: 'Europe/Paris', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); + + const parts = Object.fromEntries( + fmt.formatToParts(new Date(ms)) + .filter(p => p.type !== 'literal') + .map(p => [p.type, p.value]) + ); + + const hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour); + const date = `${parts.year}-${parts.month}-${parts.day}`; + const stamp = `${date} ${to2(hour)}:${parts.minute}:${parts.second}`; + + return { + year: Number(parts.year), + month: Number(parts.month), + day: Number(parts.day), + hour, + minute: Number(parts.minute), + second: Number(parts.second), + date, + stamp, + minNow: hour * 60 + Number(parts.minute), + }; +} + +function getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') { + const dtf = new Intl.DateTimeFormat('en-US', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); + + const parts = Object.fromEntries( + dtf.formatToParts(new Date(utcTs)) + .filter(p => p.type !== 'literal') + .map(p => [p.type, p.value]) + ); + + const asIfUtc = Date.UTC( + Number(parts.year), + Number(parts.month) - 1, + Number(parts.day), + Number(parts.hour) === 24 ? 0 : Number(parts.hour), + Number(parts.minute), + Number(parts.second) + ); + + const d = new Date(utcTs); + const actualUtc = Date.UTC( + d.getUTCFullYear(), + d.getUTCMonth(), + d.getUTCDate(), + d.getUTCHours(), + d.getUTCMinutes(), + d.getUTCSeconds() + ); + + return asIfUtc - actualUtc; +} + +function parseParisStampToMs(s) { + const text = (s ?? '').toString().trim(); + const m = text.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/); + if (!m) return null; + + const y = Number(m[1]); + const mo = Number(m[2]); + const d = Number(m[3]); + const h = Number(m[4]); + const mi = Number(m[5]); + const se = Number(m[6]); + + if (![y, mo, d, h, mi, se].every(Number.isFinite)) return null; + + const guessUtc = Date.UTC(y, mo - 1, d, h, mi, se); + const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris'); + let ts = guessUtc - off1; + const off2 = getTzOffsetMs(ts, 'Europe/Paris'); + + if (off2 !== off1) ts = guessUtc - off2; + return ts; +} + +function cleanText(v, fallback = '') { + const s = (v ?? '').toString().trim(); + const x = s.toLowerCase(); + if (['unknown', 'unavailable', 'none', 'null', 'undefined'].includes(x)) return fallback; + return s; +} + +function finiteOrNull(v) { + return Number.isFinite(v) ? v : null; +} + +const raw = $input.first().json; +const input = extractSnapshot(raw); + +const nowMs = Date.now(); +const now = getParisParts(nowMs); + +const CFG = { + mobileNotifyDomain: 'notify', + mobileNotifyService: 'mobile_app_iphone_16_de_tt', + + batteryUsableCapacityKwh: 17.5, + nominalAcVoltageV: 230, + evcsDedicatedMaxW: 4900, + systemReserveW: 300, + + vebusDcVoltageSoftFloorV: 49.0, + vebusDcVoltageHardFloorV: 48.6, + + departurePriorityWindowMinutes: 240, + sampleSeconds: 30, + + cloudDebtRecoveryThresholdKwh: 0.05, + cloudDebtMeaningfulThresholdKwh: 0.01, +}; + +// ===================================================== +// PARSED SNAPSHOT +// ===================================================== +const parsed = { + soc: clamp(num(input.soc_percent, 0), 0, 100), + batteryCurrentA: num(input.battery_current_a, 0), + + pvPowerW: Math.max(0, num(input.pv_power_w, 0)), + pvPowerAvg10mW: Math.max(0, num(input.pv_power_10m_avg_w, num(input.pv_power_w, 0))), + pvTrendW: num(input.pv_trend_w, 0), + + houseAcPowerW: Math.max(0, num(input.house_ac_power_w, 0)), + houseAcPowerPrevW: Math.max(0, num(input.house_ac_power_prev_w, num(input.house_ac_power_w, 0))), + gridPowerW: num(input.grid_power_w, 0), + + gridIsOn: low(input.grid_state || 'off') === 'on', + acInAvailable: num(input.victron_acin1_available ?? input.ac_in1_available, 0) === 1, + gridAvailable: + (low(input.grid_state || 'off') === 'on') || + (num(input.victron_acin1_available ?? input.ac_in1_available, 0) === 1), + + todayRemainingKwh: Math.max(0, num(input.today_remaining_kwh, 0)), + tomorrowKwh: Math.max(0, num(input.tomorrow_kwh, 0)), + + evcsDeviceConnected: low(input.evcs_device_connected || 'off') === 'on', + evcsVehicleConnected: low(input.evcs_vehicle_connected || 'off') === 'on', + evcsStatusRaw: cleanText(input.evcs_status_raw), + evcsModeRaw: [ + input.evcs_mode_sensor, + input.evcs_mode_fallback, + input.evcs_mode + ] + .map(v => cleanText(v)) + .find(v => v) || 'unknown', + + evcsSetCurrentA: num(input.evcs_set_current_a, NaN), + evcsCommandCurrentA: num(input.evcs_command_current_a, NaN), + + // Mesure réelle côté borne + evcsCurrentA: Math.max(0, num(input.evcs_current_a, 0)), + evcsCurrentRealA: Math.max(0, num(input.evcs_current_a, 0)), + + evcsAcPowerW: Math.max(0, num(input.evcs_ac_power_w, 0)), + evcsChargingSwitchOn: low(input.evcs_charging_switch || 'off') === 'on', + evcsAutoStartSwitchOn: low(input.evcs_auto_start_switch || 'off') === 'on', + + controllerEnabled: low(input.controller_enabled || 'off') === 'on', + notificationsEnabled: low(input.notifications_enabled || 'off') === 'on', + hardStopEnabledLegacy: low(input.hard_stop_enabled || 'off') === 'on', + chargePermissionNow: low(input.charge_permission_now || 'off') === 'on', + controlLatch: low(input.control_latch || 'off') === 'on', + departurePriorityEnabled: low(input.departure_priority_enabled || 'off') === 'on', + forecastModeEnabled: low(input.forecast_mode_enabled || 'off') === 'on', + + evcsPriorityManualOverride: low(input.evcs_priority_manual_override || 'off') === 'on', + evcsPriorityManualMode: cleanText(input.evcs_priority_manual_mode || 'normal', 'normal').toLowerCase(), + evcsPriorityManualReason: cleanText(input.evcs_priority_manual_reason), + + vebusOverload: low(input.vebus_overload || 'off') === 'on', + vebusL1Overload: low(input.vebus_l1_overload || 'off') === 'on', + vebusLowBattery: low(input.vebus_low_battery || 'off') === 'on', + vebusL1LowBattery: low(input.vebus_l1_low_battery || 'off') === 'on', + + vebusAcOutPowerW: Math.max(0, num(input.vebus_ac_out_l1_power_w, 0)), + vebusAcOutCurrentA: Math.max(0, num(input.vebus_ac_out_l1_current_a, 0)), + vebusAcOutVoltageV: num(input.vebus_ac_out_l1_voltage_v, CFG.nominalAcVoltageV), + + vebusDcVoltageV: num(input.vebus_dc_voltage_v, NaN), + vebusDcCurrentA: num(input.vebus_dc_current_a, NaN), + vebusDcPowerW: num(input.vebus_dc_power_w, NaN), + + vebusState: num(input.vebus_state, NaN), + vebusMainState: num(input.vebus_main_state, NaN), +}; + +parsed.evcsModeIsAuto = low(parsed.evcsModeRaw).includes('auto'); + +// ===================================================== +// PARAMETERS +// ===================================================== +const params = { + userStrategyRaw: input.victron_evcs_strategy || 'auto_solar', + + targetCurrentAutoA: clamp(num(input.target_current_auto_a, 6), 0, 32), + pvStartMinW: Math.max(0, num(input.pv_start_min_w, 1800)), + pvHoldMinW: Math.max(0, num(input.pv_hold_min_w, 1200)), + houseAcHeadroomW: Math.max(0, num(input.house_ac_headroom_w, 1200)), + + batteryDischargeSoftA: num(input.battery_discharge_soft_a, -5), + batteryDischargeHardA: num(input.battery_discharge_hard_a, -15), + batteryReserveUpA: Math.max(0, num(input.battery_reserve_up_a, 8)), + batteryNeutralBandA: Math.max(0, num(input.battery_neutral_band_a, 3)), + + socStartMin: clamp(num(input.soc_start_min, 60), 0, 100), + socStopMin: clamp(num(input.soc_stop_min, 35), 0, 100), + + currentMinA: clamp(num(input.current_min_a, 6), 6, 32), + currentMaxAutoA: clamp( + num(input.current_max_auto_a, 16), + clamp(num(input.current_min_a, 6), 6, 32), + 32 + ), + gridCurrentMaxA: clamp(num(input.grid_current_max_a, 20), 6, 32), + currentStepUpA: clamp(num(input.current_step_up_a, 1), 1, 5), + currentStepDownA: clamp(num(input.current_step_down_a, 2), 1, 8), + + currentChangeCooldownSeconds: Math.max(5, num(input.current_change_cooldown_seconds, 60)), + minRunMinutes: Math.max(0, num(input.min_run_minutes, 3)), + minStopMinutes: Math.max(0, num(input.min_stop_minutes, 10)), + + requiredEnergyKwh: Math.max(0, num(input.required_energy_kwh, 0)), + socComfortTarget: clamp(num(input.soc_comfort_target, 85), 0, 100), + endOfDaySocGuard: clamp(num(input.end_of_day_soc_guard, 85), 0, 100), + baseLoadRemainingKwh: Math.max(0, num(input.base_load_remaining_kwh, 0)), + flexibleSafetyMarginKwh: Math.max(0, num(input.flexible_safety_margin_kwh, 0)), + endOfDaySocTarget: clamp(num(input.end_of_day_soc_target, 85), 0, 100), + + acLoadsMaxSafeW: Math.max(0, num(input.ac_loads_max_safe_w, 8500)), + batteryFullSoc: clamp(num(input.battery_full_soc, 90), 0, 100), + batteryFullForecastMinKwh: Math.max(0, num(input.battery_full_forecast_min_kwh, 8)), + forecastCommitSocFloor: clamp(num(input.forecast_commit_soc_floor, 35), 0, 100), + forecastCommitMinKwh: Math.max(0, num(input.forecast_commit_min_kwh, 25)), + + cloudDebtPrevKwh: Math.max(0, num(input.cloud_debt_kwh, 0)), + cloudDebtMaxKwh: Math.max(0.05, num(input.cloud_debt_max_kwh, 1.2)), + cloudToleranceMinutes: Math.max(1, num(input.cloud_tolerance_minutes, 20)), + + departureTimeMin: parseTimeToMinutes(input.departure_time, null), + dayStartTimeMin: parseTimeToMinutes(input.day_start_time, 7 * 60 + 30), + dayStopTimeMin: parseTimeToMinutes(input.day_stop_time, 20 * 60 + 30), + + hcNightStartMin: parseTimeToMinutes(input.hc_night_start_time, 23 * 60), + hcNightStopMin: parseTimeToMinutes(input.hc_night_stop_time, 6 * 60), + morningStartMin: parseTimeToMinutes( + input.morning_start_time, + parseTimeToMinutes(input.day_start_time, 7 * 60 + 30) + ), + morningStopMin: parseTimeToMinutes(input.morning_stop_time, 11 * 60), + hcMiddayStartMin: parseTimeToMinutes(input.hc_midday_start_time, 12 * 60 + 21), + hcMiddayStopMin: parseTimeToMinutes(input.hc_midday_stop_time, 14 * 60 + 21), + eveningStartMin: parseTimeToMinutes(input.evening_start_time, 17 * 60), + eveningStopMin: parseTimeToMinutes( + input.evening_stop_time, + parseTimeToMinutes(input.day_stop_time, 20 * 60 + 30) + ), + + allowGridHcNight: low(input.allow_grid_hc_night || 'off') === 'on', + allowGridHcMidday: low(input.allow_grid_hc_midday || 'off') === 'on', + + probeEnabled: low(input.probe_enabled || 'off') === 'on', + probeMinutes: Math.max(1, num(input.probe_minutes, 3)), + probeRetryMinutes: Math.max(5, num(input.probe_retry_minutes, 45)), + probeCurrentA: clamp( + num(input.probe_current_a, clamp(num(input.current_min_a, 10), 6, 32)), + clamp(num(input.current_min_a, 10), 6, 32), + clamp(num(input.current_max_auto_a, 16), clamp(num(input.current_min_a, 10), 6, 32), 32) + ), + + gridBatterySoftA: num(input.grid_battery_soft_a, -3), + gridBatteryHardA: num(input.grid_battery_hard_a, -6), + + morningProbePvAvgMinW: Math.max(0, num(input.morning_probe_pv_avg_min_w, 1500)), + morningProbePvTrendMinW: num(input.morning_probe_pv_trend_min_w, 0), + middayProbePvAvgMinW: Math.max(0, num(input.midday_probe_pv_avg_min_w, 1200)), + + morningSocMin: clamp(num(input.morning_soc_min, 35), 0, 100), + middaySocMin: clamp(num(input.midday_soc_min, 75), 0, 100), + eveningSocStartMin: clamp(num(input.evening_soc_start_min, 85), 0, 100), + eveningSocStopMin: clamp(num(input.evening_soc_stop_min, 80), 0, 100), + + morningBatteryMaxDischargeA: num(input.morning_battery_max_discharge_a, -20), + middayBatteryMaxDischargeA: num(input.midday_battery_max_discharge_a, -40), + eveningBatteryMaxDischargeA: num(input.evening_battery_max_discharge_a, -30), + + // ===================================================== + // EVCS NIGHT ENGINE — NEW PARAMETERS + // ===================================================== + evcsNightBatteryHardFloorPct: clamp( + num(input.evcs_night_battery_hard_floor_pct, 22), + 0, + 100 + ), + + evcsNightStrongForecastKwh: Math.max( + 0, + num(input.evcs_night_strong_forecast_kwh, 18) + ), + + evcsNightMediumForecastKwh: Math.max( + 0, + num(input.evcs_night_medium_forecast_kwh, 10) + ), + + evcsNightTargetStrongForecastPct: clamp( + num(input.evcs_night_target_strong_forecast_pct, 35), + 0, + 100 + ), + + evcsNightTargetMediumForecastPct: clamp( + num(input.evcs_night_target_medium_forecast_pct, 55), + 0, + 100 + ), + + evcsNightTargetWeakForecastPct: clamp( + num(input.evcs_night_target_weak_forecast_pct, 85), + 0, + 100 + ), + + evcsNightBatteryRecoveryPowerKw: Math.max( + 0.1, + num(input.evcs_night_battery_recovery_power_kw, 2.5) + ), + + evcsNightDepartureSafetyMin: Math.max( + 0, + num(input.evcs_night_departure_safety_min, 30) + ), +}; + +// ===================================================== +// HISTORY / PERSISTED HELPERS +// ===================================================== +const nightSessionStartSocRaw = num( + input.evcs_night_session_start_soc ?? input.night_session_start_soc, + NaN +); + +const nightSessionStartAtRaw = cleanText( + input.evcs_night_session_start_at ?? input.night_session_start_at +); + +// ===================================================== +// VEHICLE DEMAND FILTER HISTORY RAW INPUTS +// ===================================================== +const evcsNoDemandSinceAtRaw = cleanText(input.evcs_no_demand_since_at); +const evcsNoDemandFilterEvalAtRaw = cleanText(input.evcs_no_demand_filter_eval_at); + +const history = { + lastBlockReason: cleanText(input.last_block_reason, 'none') || 'none', + blockedUntilAt: cleanText(input.blocked_until), + probeStartedAt: cleanText(input.probe_started_at), + lastStartAt: cleanText(input.last_start_at), + lastStopAt: cleanText(input.last_stop_at), + lastCurrentChangeAt: cleanText(input.last_current_change_at), + lastSignature: cleanText(input.last_signature), + cloudDebtStartedAt: cleanText(input.cloud_debt_started_at), + evcsPriorityManualUntilAt: cleanText(input.evcs_priority_manual_until), + + blockedUntilMs: parseParisStampToMs(cleanText(input.blocked_until)), + probeStartedMs: parseParisStampToMs(cleanText(input.probe_started_at)), + lastStartMs: parseParisStampToMs(cleanText(input.last_start_at)), + lastStopMs: parseParisStampToMs(cleanText(input.last_stop_at)), + lastCurrentChangeMs: parseParisStampToMs(cleanText(input.last_current_change_at)), + cloudDebtStartedMsPrev: parseParisStampToMs(cleanText(input.cloud_debt_started_at)), + evcsPriorityManualUntilMs: parseParisStampToMs(cleanText(input.evcs_priority_manual_until)), + + // ===================================================== + // EVCS NIGHT SESSION MEMORY + // ===================================================== + nightSessionStartSocPct: Number.isFinite(nightSessionStartSocRaw) + ? clamp(nightSessionStartSocRaw, 0, 100) + : null, + + nightSessionStartAt: nightSessionStartAtRaw || '', + nightSessionStartMs: parseParisStampToMs(nightSessionStartAtRaw), + + // ===================================================== + // VEHICLE DEMAND FILTER PERSISTENCE + // ===================================================== + evcsDeliveryRatioEma: num(input.evcs_delivery_ratio_ema, NaN), + evcsDeliveryNoiseEma: num(input.evcs_delivery_noise_ema, NaN), + evcsNoDemandScoreEma: num(input.evcs_no_demand_score_ema, NaN), + evcsNoDemandSemanticActive: low(input.evcs_no_demand_semantic_active || 'off') === 'on', + + evcsNoDemandSinceAt: evcsNoDemandSinceAtRaw || '', + evcsNoDemandSinceMs: parseParisStampToMs(evcsNoDemandSinceAtRaw), + + evcsNoDemandFilterEvalAt: evcsNoDemandFilterEvalAtRaw || '', + evcsNoDemandFilterEvalMs: parseParisStampToMs(evcsNoDemandFilterEvalAtRaw), +}; + +// ===================================================== +// BATTERY TUNING +// ===================================================== +const batteryTune = { + global: { + houseSpikeGraceMinutes: Math.max(0, num(input.house_spike_grace_minutes, 10)), + solarPotentialGraceMinutes: Math.max(0, num(input.solar_potential_grace_minutes, 12)), + stepUpCooldownSeconds: Math.max(5, num(input.step_up_cooldown_seconds, params.currentChangeCooldownSeconds)), + stepDownCooldownSeconds: Math.max(5, num(input.step_down_cooldown_seconds, params.currentChangeCooldownSeconds)), + forceModeAutoRevertMinutes: Math.max(0, num(input.force_mode_auto_revert_minutes, 120)), + overloadStepDownA: Math.max(1, num(input.overload_step_down_a, 2)), + overloadHoldSeconds: Math.max(5, num(input.overload_hold_seconds, 30)), + overloadStopAfterSeconds: Math.max(10, num(input.overload_stop_after_seconds, 120)), + }, + + bands: { + gt90: { + dischargeSoftA: num(input.batt_discharge_soft_gt90_a, -50), + dischargeHardA: num(input.batt_discharge_hard_gt90_a, -100), + chargeReserveA: Math.max(0, num(input.batt_charge_reserve_gt90_a, 5)), + softToleranceMin: Math.max(0, num(input.batt_soft_tol_gt90_min, 12)), + hardToleranceSec: Math.max(0, num(input.batt_hard_tol_gt90_sec, 20)), + recoveryMin: Math.max(0, num(input.batt_recovery_gt90_min, 6)), + }, + + '80_90': { + dischargeSoftA: num(input.batt_discharge_soft_80_90_a, -40), + dischargeHardA: num(input.batt_discharge_hard_80_90_a, -80), + chargeReserveA: Math.max(0, num(input.batt_charge_reserve_80_90_a, 10)), + softToleranceMin: Math.max(0, num(input.batt_soft_tol_80_90_min, 10)), + hardToleranceSec: Math.max(0, num(input.batt_hard_tol_80_90_sec, 20)), + recoveryMin: Math.max(0, num(input.batt_recovery_80_90_min, 6)), + }, + + '60_80': { + dischargeSoftA: num(input.batt_discharge_soft_60_80_a, -20), + dischargeHardA: num(input.batt_discharge_hard_60_80_a, -50), + chargeReserveA: Math.max(0, num(input.batt_charge_reserve_60_80_a, 40)), + softToleranceMin: Math.max(0, num(input.batt_soft_tol_60_80_min, 6)), + hardToleranceSec: Math.max(0, num(input.batt_hard_tol_60_80_sec, 15)), + recoveryMin: Math.max(0, num(input.batt_recovery_60_80_min, 8)), + }, + + '40_60': { + dischargeSoftA: num(input.batt_discharge_soft_40_60_a, -12), + dischargeHardA: num(input.batt_discharge_hard_40_60_a, -30), + chargeReserveA: Math.max(0, num(input.batt_charge_reserve_40_60_a, 70)), + softToleranceMin: Math.max(0, num(input.batt_soft_tol_40_60_min, 4)), + hardToleranceSec: Math.max(0, num(input.batt_hard_tol_40_60_sec, 10)), + recoveryMin: Math.max(0, num(input.batt_recovery_40_60_min, 10)), + }, + + '25_40': { + dischargeSoftA: num(input.batt_discharge_soft_25_40_a, -5), + dischargeHardA: num(input.batt_discharge_hard_25_40_a, -15), + chargeReserveA: Math.max(0, num(input.batt_charge_reserve_25_40_a, 110)), + softToleranceMin: Math.max(0, num(input.batt_soft_tol_25_40_min, 2)), + hardToleranceSec: Math.max(0, num(input.batt_hard_tol_25_40_sec, 5)), + recoveryMin: Math.max(0, num(input.batt_recovery_25_40_min, 12)), + }, + + lt25: { + dischargeSoftA: num(input.batt_discharge_soft_lt25_a, -3), + dischargeHardA: num(input.batt_discharge_hard_lt25_a, -8), + chargeReserveA: Math.max(0, num(input.batt_charge_reserve_lt25_a, 150)), + softToleranceMin: Math.max(0, num(input.batt_soft_tol_lt25_min, 1)), + hardToleranceSec: Math.max(0, num(input.batt_hard_tol_lt25_sec, 2)), + recoveryMin: Math.max(0, num(input.batt_recovery_lt25_min, 15)), + }, + }, +}; + +// ===================================================== +// OPTIONAL DEBUG / SUMMARY +// ===================================================== +const evcsNightConfig = { + batteryHardFloorPct: params.evcsNightBatteryHardFloorPct, + strongForecastKwh: params.evcsNightStrongForecastKwh, + mediumForecastKwh: params.evcsNightMediumForecastKwh, + targetStrongForecastPct: params.evcsNightTargetStrongForecastPct, + targetMediumForecastPct: params.evcsNightTargetMediumForecastPct, + targetWeakForecastPct: params.evcsNightTargetWeakForecastPct, + batteryRecoveryPowerKw: params.evcsNightBatteryRecoveryPowerKw, + departureSafetyMin: params.evcsNightDepartureSafetyMin, + sessionStartSocPct: history.nightSessionStartSocPct, + sessionStartAt: history.nightSessionStartAt || null, +}; + +const vehicleDemandHistoryDebug = { + evcsDeliveryRatioEma: finiteOrNull(history.evcsDeliveryRatioEma), + evcsDeliveryNoiseEma: finiteOrNull(history.evcsDeliveryNoiseEma), + evcsNoDemandScoreEma: finiteOrNull(history.evcsNoDemandScoreEma), + evcsNoDemandSemanticActive: history.evcsNoDemandSemanticActive, + evcsNoDemandSinceAt: history.evcsNoDemandSinceAt || null, + evcsNoDemandSinceMs: finiteOrNull(history.evcsNoDemandSinceMs), + evcsNoDemandFilterEvalAt: history.evcsNoDemandFilterEvalAt || null, + evcsNoDemandFilterEvalMs: finiteOrNull(history.evcsNoDemandFilterEvalMs), +}; + +// ===================================================== +// OUTPUT +// ===================================================== +return [{ + json: { + raw, + input, + nowMs, + now, + cfg: CFG, + parsed, + params, + history, + batteryTune, + evcsNightConfig, + vehicleDemandHistoryDebug, + } +}]; diff --git a/src/nodes/WINDOWS_SOURCEV2.js b/src/nodes/WINDOWS_SOURCEV2.js new file mode 100644 index 0000000..dba4bb6 --- /dev/null +++ b/src/nodes/WINDOWS_SOURCEV2.js @@ -0,0 +1,685 @@ +function minutesSince(ts, nowMs) { + if (ts === null || nowMs === null) return null; + return Math.max(0, (nowMs - ts) / 60000); +} + +function secondsSince(ts, nowMs) { + if (ts === null || nowMs === null) return null; + return Math.max(0, (nowMs - ts) / 1000); +} + +function isWithinWindow(nowMin, startMin, stopMin) { + if (!Number.isFinite(startMin) || !Number.isFinite(stopMin)) return false; + if (startMin === stopMin) return false; + if (startMin < stopMin) return nowMin >= startMin && nowMin < stopMin; + return nowMin >= startMin || nowMin < stopMin; +} + +function clamp(v, min, max) { + return Math.min(Math.max(v, min), max); +} + +const prev = $input.first().json; +const { + input, + nowMs, + now, + cfg, + parsed, + params, + history, + batteryTune +} = prev; + +// ===================================================== +// STRATEGY +// ===================================================== +const userStrategy = (params.userStrategyRaw ?? 'auto_solar').toString().trim().toLowerCase(); + +const isStopMode = userStrategy === 'stop'; +const forceGridRequested = userStrategy === 'force_grid_now'; +const forceChargeRequested = userStrategy === 'force_charge_now'; +const isForceMode = forceGridRequested || forceChargeRequested; + +// ===================================================== +// LIFECYCLE / TIMING +// ===================================================== +const minutesSinceStart = minutesSince(history.lastStartMs, nowMs); +const minutesSinceStop = minutesSince(history.lastStopMs, nowMs); +const secondsSinceCurrentChange = secondsSince(history.lastCurrentChangeMs, nowMs); +const minutesSinceProbeStart = minutesSince(history.probeStartedMs, nowMs); + +const minRunElapsed = + minutesSinceStart === null ? true : minutesSinceStart >= params.minRunMinutes; + +const minStopElapsed = + minutesSinceStop === null ? true : minutesSinceStop >= params.minStopMinutes; + +const currentCooldownBaseSec = parsed.evcsChargingSwitchOn + ? batteryTune.global.stepDownCooldownSeconds + : batteryTune.global.stepUpCooldownSeconds; + +const currentCooldownElapsed = + secondsSinceCurrentChange === null + ? true + : secondsSinceCurrentChange >= Math.max( + params.currentChangeCooldownSeconds, + currentCooldownBaseSec + ); + +// ===================================================== +// DEPARTURE PRIORITY +// ===================================================== +const minutesUntilDeparture = + params.departureTimeMin === null + ? null + : (() => { + const rawDiff = params.departureTimeMin - now.minNow; + return rawDiff >= 0 ? rawDiff : (1440 - now.minNow + params.departureTimeMin); + })(); + +const departurePriorityActive = + parsed.departurePriorityEnabled && + Number.isFinite(minutesUntilDeparture) && + minutesUntilDeparture >= 0 && + minutesUntilDeparture <= cfg.departurePriorityWindowMinutes && + parsed.evcsVehicleConnected; + +// ===================================================== +// MANUAL CHARGE SEMANTICS +// IMPORTANT: +// force_charge_now = bypass fenêtres pour la charge, +// MAIS PAS demande implicite de réseau. +// ===================================================== +const manualChargeBypassWindows = forceChargeRequested; +const manualChargeLocalRequested = forceChargeRequested && !forceGridRequested; + +// Compatibilité ascendante : on garde le champ dans context, +// mais on le neutralise volontairement. +const forceChargeDepartureGridOverride = false; + +// ===================================================== +// SYSTEM DERIVED VALUES +// ===================================================== +const acVoltageRef = + Number.isFinite(parsed.vebusAcOutVoltageV) && parsed.vebusAcOutVoltageV > 180 + ? parsed.vebusAcOutVoltageV + : cfg.nominalAcVoltageV; + +const batteryVoltageRef = + Number.isFinite(parsed.vebusDcVoltageV) && parsed.vebusDcVoltageV > 0 + ? parsed.vebusDcVoltageV + : 51.2; + +const houseNonEvcsW = Math.max(0, parsed.houseAcPowerW - parsed.evcsAcPowerW); +const vebusNonEvcsW = Math.max(0, parsed.vebusAcOutPowerW - parsed.evcsAcPowerW); + +const totalVebusSoftCeilingW = Math.max(6500, params.acLoadsMaxSafeW || 8500); +const totalVebusHardCeilingW = totalVebusSoftCeilingW + 400; + +// P5 : plafond EVCS configurable quand le réseau alimente réellement AC-IN +// (input_number.evcs_plafond_courant_edf_on). Hors réseau : plafond câblé prudent. +const evcsDedicatedCapBaseW = Math.max(0, cfg.evcsDedicatedMaxW - cfg.systemReserveW); +const safeEvcsByDedicatedCapW = parsed.acInAvailable + ? Math.max(0, params.gridCurrentMaxA * acVoltageRef) + : evcsDedicatedCapBaseW; +const safeEvcsByVebusW = Math.max(0, totalVebusSoftCeilingW - vebusNonEvcsW - cfg.systemReserveW); +const safeEvcsCombinedW = Math.max(0, Math.min(safeEvcsByDedicatedCapW, safeEvcsByVebusW)); + +const systemCapCurrentA = Math.max(0, Math.floor(safeEvcsCombinedW / acVoltageRef)); +const physicalCapA = clamp( + systemCapCurrentA, + 0, + parsed.acInAvailable + ? Math.max(params.currentMaxAutoA, params.gridCurrentMaxA) + : params.currentMaxAutoA +); +const canRunAtMinCurrent = physicalCapA >= params.currentMinA; + +const endDayGuardSoc = Math.max(params.endOfDaySocGuard, params.endOfDaySocTarget); + +const energyNeededForEndDayGuardKwh = Math.max( + 0, + ((endDayGuardSoc - parsed.soc) / 100) * cfg.batteryUsableCapacityKwh +); + +const forecastBufferKwh = + parsed.todayRemainingKwh - + params.baseLoadRemainingKwh - + params.flexibleSafetyMarginKwh - + params.requiredEnergyKwh - + energyNeededForEndDayGuardKwh; + +const projectedEndSocAfterCharge = clamp( + parsed.soc + ( + ( + parsed.todayRemainingKwh - + params.baseLoadRemainingKwh - + params.flexibleSafetyMarginKwh - + params.requiredEnergyKwh + ) / cfg.batteryUsableCapacityKwh + ) * 100, + 0, + 100 +); + +const currentReferenceA = clamp( + Number.isFinite(parsed.evcsCommandCurrentA) + ? parsed.evcsCommandCurrentA + : Number.isFinite(parsed.evcsSetCurrentA) + ? parsed.evcsSetCurrentA + : params.targetCurrentAutoA, + 0, + 32 +); + +const anyVebusOverload = parsed.vebusOverload || parsed.vebusL1Overload; +const anyVebusLowBattery = parsed.vebusLowBattery || parsed.vebusL1LowBattery; + +const vebusAcOutSoftReached = parsed.vebusAcOutPowerW >= totalVebusSoftCeilingW; +const vebusAcOutHardReached = parsed.vebusAcOutPowerW >= totalVebusHardCeilingW; + +const vebusDcVoltageSoftLow = + Number.isFinite(parsed.vebusDcVoltageV) && + parsed.vebusDcVoltageV > 0 && + parsed.vebusDcVoltageV <= cfg.vebusDcVoltageSoftFloorV; + +const vebusDcVoltageHardLow = + Number.isFinite(parsed.vebusDcVoltageV) && + parsed.vebusDcVoltageV > 0 && + parsed.vebusDcVoltageV <= cfg.vebusDcVoltageHardFloorV; + +const houseLoadHighVsHeadroom = houseNonEvcsW > params.houseAcHeadroomW; + +// ===================================================== +// WINDOWS / PROFILE +// ===================================================== +const inNightHcWindow = isWithinWindow(now.minNow, params.hcNightStartMin, params.hcNightStopMin); +const inMorningWindow = isWithinWindow(now.minNow, params.morningStartMin, params.morningStopMin); +const inMiddayWindow = isWithinWindow(now.minNow, params.hcMiddayStartMin, params.hcMiddayStopMin); +const inEveningWindow = isWithinWindow(now.minNow, params.eveningStartMin, params.eveningStopMin); + +const profile = + inNightHcWindow ? 'night_hc' : + inMorningWindow ? 'morning_solar' : + inMiddayWindow ? 'midday_hc_or_solar' : + inEveningWindow ? 'evening_high_soc' : + 'outside_window'; + +// ===================================================== +// EVCS NIGHT PREVIEW ENGINE +// ===================================================== +const nightSessionActive = + profile === 'night_hc' && + parsed.evcsVehicleConnected; + +const nightSessionStartSocPct = + nightSessionActive + ? ( + Number.isFinite(history.nightSessionStartSocPct) + ? history.nightSessionStartSocPct + : parsed.soc + ) + : null; + +const nightSessionStartAt = + nightSessionActive + ? (history.nightSessionStartAt || '') + : ''; + +const nightSessionStartMs = + nightSessionActive + ? (history.nightSessionStartMs ?? null) + : null; + +const nightSessionNeedsCapture = + nightSessionActive && + ( + !Number.isFinite(history.nightSessionStartSocPct) || + !nightSessionStartAt || + nightSessionStartMs === null + ); + +const nightSessionNeedsClear = + !nightSessionActive && + ( + Number.isFinite(history.nightSessionStartSocPct) || + !!history.nightSessionStartAt + ); + +const batteryHardFloorPct = + params.evcsNightBatteryHardFloorPct; + +// P4 : la nuit avant minuit, today_remaining ~ 0 => utiliser la prévision du lendemain. +const nightForecastHour = now.hour; +const nightForecastKwh = + (nightForecastHour >= 20 || nightForecastHour < 8) + ? Math.max(parsed.todayRemainingKwh, parsed.tomorrowKwh || 0) + : parsed.todayRemainingKwh; + +const batteryMorningTargetPct = + nightForecastKwh >= params.evcsNightStrongForecastKwh + ? params.evcsNightTargetStrongForecastPct + : nightForecastKwh >= params.evcsNightMediumForecastKwh + ? params.evcsNightTargetMediumForecastPct + : params.evcsNightTargetWeakForecastPct; + +const maxBatteryDischargePoints = + nightSessionActive && Number.isFinite(nightSessionStartSocPct) + ? Math.max(0, nightSessionStartSocPct - batteryMorningTargetPct) + : 0; + +const gridTakeoverTriggerPct = + Math.max(batteryHardFloorPct, batteryMorningTargetPct); + +const batteryBelowHardFloor = + parsed.soc <= batteryHardFloorPct; + +const batteryBelowMorningTarget = + parsed.soc < batteryMorningTargetPct; + +const batteryAtOrBelowGridTakeoverTrigger = + parsed.soc <= gridTakeoverTriggerPct; + +const batteryKwhMissingToTarget = + nightSessionActive + ? Math.max( + 0, + ((batteryMorningTargetPct - parsed.soc) / 100) * cfg.batteryUsableCapacityKwh + ) + : 0; + +const batteryRecoveryMinutesNeeded = + nightSessionActive && params.evcsNightBatteryRecoveryPowerKw > 0 + ? (batteryKwhMissingToTarget / params.evcsNightBatteryRecoveryPowerKw) * 60 + : 0; + +const batteryRecoveryUrgent = + nightSessionActive && + Number.isFinite(minutesUntilDeparture) && + minutesUntilDeparture >= 0 && + ( + batteryRecoveryMinutesNeeded + params.evcsNightDepartureSafetyMin + >= minutesUntilDeparture + ); + +// P2 ANTI-CRENEAUX : continuité du takeover réseau nocturne. +// Une fois le réseau HC engagé pendant une session nuit, on y reste +// jusqu'à la fin de la session (fenêtre/débranchement) ou batterie quasi pleine. +// Évite le cycle batterie->jeep->réseau->batterie (pertes + charge hachée). +const NIGHT_TAKEOVER_RELEASE_SOC_PCT = 90; +const nightTakeoverPrev = + (input.night_takeover_prev ?? 'off').toString().trim().toLowerCase() === 'on'; + +// P8 (07/08/2026) : "charge terminée" COLLANT via l'état surplus du cycle +// précédent (helper input_text.victron_evcs_surplus_state relu par le snapshot). +// Chaîne : VDF stoppe la charge (state vehicle_full_or_paused) → ce flag s'allume +// → DECISION émet night_session_done_idle → le flag s'auto-entretient jusqu'au +// débranchement (state vehicle_not_connected) ou à la fin de la fenêtre nuit. +const prevSurplusState = (input.prev_surplus_state ?? '').toString().trim().toLowerCase(); +const vehicleChargeCompleted = + nightSessionActive && + ( + prevSurplusState === 'vehicle_full_or_paused' || + prevSurplusState === 'night_session_done_idle' + ); + +// P8 : correction du fix 04/08 (logique inversée : "|| vehicleChargeCompleted" +// GARDAIT le latch après la charge au lieu de le libérer). +const nightTakeoverLatched = + nightSessionActive && + nightTakeoverPrev && + parsed.soc < NIGHT_TAKEOVER_RELEASE_SOC_PCT && + !vehicleChargeCompleted; + +const nightGridHcPreferred = + nightSessionActive && + params.allowGridHcNight && + !vehicleChargeCompleted; + +const batteryAssistAllowed = + nightSessionActive && + !nightGridHcPreferred && + parsed.soc > batteryHardFloorPct && + parsed.soc > gridTakeoverTriggerPct && + !nightTakeoverLatched && + !batteryRecoveryUrgent; + +const nightGridTakeoverNeeded = + nightSessionActive && + ( + nightGridHcPreferred || + batteryBelowHardFloor || + batteryAtOrBelowGridTakeoverTrigger || + batteryRecoveryUrgent || + nightTakeoverLatched + ); + +const nightPreferredSourceMode = + !nightSessionActive + ? 'blocked' + : vehicleChargeCompleted + ? 'blocked' + : batteryAssistAllowed + ? 'battery_assist_night' + : ( + batteryBelowHardFloor || + batteryBelowMorningTarget || + batteryRecoveryUrgent + ) + ? 'grid_forced' + : 'grid_hc_allowed'; + +const batteryRecoveryAllowedNow = + nightSessionActive && + nightGridTakeoverNeeded && + ( + batteryBelowMorningTarget || + batteryRecoveryUrgent + ); + +const batteryTargetReached = + nightSessionActive && + parsed.soc >= batteryMorningTargetPct; + +// ===================================================== +// PROBES / SOLAR POTENTIAL +// ===================================================== +// IMPORTANT: +// On ne met plus effectivePvStartMinW à 0 en force_charge_now, +// sinon la nuit 0W >= 0W ferait croire à un faux signal solaire. +const effectivePvStartMinW = params.pvStartMinW; + +const morningProbeEligible = + profile === 'morning_solar' && + params.probeEnabled && + parsed.pvPowerAvg10mW >= params.morningProbePvAvgMinW && + parsed.pvTrendW >= params.morningProbePvTrendMinW && + parsed.soc >= params.morningSocMin && + parsed.batteryCurrentA >= params.morningBatteryMaxDischargeA; + +const middayProbeEligible = + profile === 'midday_hc_or_solar' && + params.probeEnabled && + parsed.pvPowerAvg10mW >= params.middayProbePvAvgMinW && + parsed.soc >= params.middaySocMin && + parsed.batteryCurrentA >= params.middayBatteryMaxDischargeA; + +const afternoonProbeEligible = + profile === 'outside_window' && + params.probeEnabled && + parsed.soc >= params.afternoonProbeSocMin && + parsed.batteryCurrentA >= params.afternoonBatteryMaxDischargeA && + parsed.pvPowerAvg10mW >= params.afternoonProbePvAvgMinW; + +const cloudDebtOkForPotential = params.cloudDebtPrevKwh <= params.cloudDebtMaxKwh; +const batteryOkForPotential = parsed.batteryCurrentA >= -Math.max(1, params.batteryNeutralBandA); + +// P3 PROVOCATION PV (installation sans injection) : +// Batteries quasi pleines => le MPPT bride la production à la conso maison. +// Les seuils PV observés, le forecast buffer et la cloud debt ne veulent +// alors plus rien dire (la dette ne peut d'ailleurs plus se rembourser, +// batterie pleine = courant nul). On autorise solar_potential sur la seule +// santé batterie : SOC très haut + batterie non déchargée. +// Les gardes aval (BATTERY_CLOUD_LOCKS / DECISION) restent actives. +const PV_PROVOCATION_SOC_MIN = 90; +const pvProvocationEligible = + parsed.soc >= PV_PROVOCATION_SOC_MIN && + batteryOkForPotential; + +const isHighBatteryForPotential = + pvProvocationEligible || + ( + parsed.soc >= 85 && + forecastBufferKwh > 0 && + cloudDebtOkForPotential && + batteryOkForPotential + ); + +const potentialAllowed = userStrategy !== 'auto_solar'; + +// IMPORTANT: +// On impose un vrai PV > 0 pour le test sur pvPowerW. +// Ça évite qu'en force_charge_now la nuit, 0W soit pris pour un signal solaire. +const strongSolarSignal = + ( + parsed.pvPowerW > 0 && + parsed.pvPowerW >= effectivePvStartMinW + ) || + parsed.pvPowerAvg10mW >= params.pvHoldMinW || + (departurePriorityActive && parsed.pvPowerAvg10mW >= params.pvHoldMinW); + +// ===================================================== +// SOURCE MODE +// SÉMANTIQUE CORRIGÉE: +// - force_charge_now bypass les fenêtres +// - mais ne devient jamais grid_forced à lui seul +// - outside_window = pas de réseau programmé +// - la charge locale peut rester autorisée +// ===================================================== +let sourceMode = 'blocked'; + +if (isStopMode) { + sourceMode = 'blocked'; +} + +else if (forceGridRequested) { + // Demande explicite de réseau. + sourceMode = 'grid_forced'; +} + +else if (forceChargeRequested) { + // IMPORTANT: + // force_charge_now = intention de charger maintenant, + // sans exiger le réseau. + + if (strongSolarSignal) { + sourceMode = 'solar_only'; + } + + else if (nightSessionActive && batteryAssistAllowed) { + sourceMode = 'battery_assist_night'; + } + + else if (isHighBatteryForPotential && potentialAllowed) { + sourceMode = 'solar_potential'; + } + + else if (profile === 'midday_hc_or_solar' && middayProbeEligible) { + sourceMode = 'hybrid_probe'; + } + + else if (profile === 'morning_solar' && morningProbeEligible) { + sourceMode = 'hybrid_probe'; + } + + else { + // Fallback manuel local : + // on n'impose pas le grid, on laisse l'étage aval décider + // selon les garde-fous physiques / batterie. + sourceMode = 'solar_potential'; + } +} + +else if (profile === 'night_hc') { + if (nightSessionActive) { + sourceMode = nightPreferredSourceMode; + } else { + sourceMode = 'blocked'; + } +} + +else if (profile === 'midday_hc_or_solar' || profile === 'morning_solar') { + if (strongSolarSignal) { + sourceMode = 'solar_only'; + } + + else if (isHighBatteryForPotential && potentialAllowed) { + sourceMode = 'solar_potential'; + } + + else if (profile === 'midday_hc_or_solar' && middayProbeEligible) { + sourceMode = 'hybrid_probe'; + } + + else if (profile === 'morning_solar' && morningProbeEligible) { + sourceMode = 'hybrid_probe'; + } + + else if (profile === 'midday_hc_or_solar' && params.allowGridHcMidday && parsed.gridAvailable) { + sourceMode = 'grid_hc_allowed'; + } + + else { + sourceMode = 'blocked'; + } +} + +else if (profile === 'evening_high_soc') { + if (strongSolarSignal) { + sourceMode = 'solar_only'; + } + + else if (isHighBatteryForPotential && potentialAllowed) { + sourceMode = 'solar_potential'; + } + + else { + sourceMode = 'blocked'; + } +} + +else if (profile === 'outside_window') { + if (strongSolarSignal) { + sourceMode = 'solar_only'; + } + + else if (isHighBatteryForPotential && potentialAllowed) { + sourceMode = 'solar_potential'; + } + + else if (afternoonProbeEligible) { + sourceMode = 'hybrid_probe'; + } + + else { + // IMPORTANT: + // hors fenêtre = pas de réseau programmé. + // Donc on ne bascule plus vers grid_forced juste parce que le réseau existe. + sourceMode = 'blocked'; + } +} + +const operatingMode = profile; + +// ===================================================== +// OUTPUT +// ===================================================== +return [{ + json: { + ...prev, + + strategy: { + userStrategy, + isStopMode, + isForceMode, + forceGridRequested, + forceChargeRequested, + }, + + lifecycle: { + minutesSinceStart, + minutesSinceStop, + secondsSinceCurrentChange, + minutesSinceProbeStart, + minRunElapsed, + minStopElapsed, + currentCooldownElapsed, + minutesUntilDeparture, + }, + + windows: { + inNightHcWindow, + inMorningWindow, + inMiddayWindow, + inEveningWindow, + }, + + derived: { + acVoltageRef, + batteryVoltageRef, + houseNonEvcsW, + vebusNonEvcsW, + totalVebusSoftCeilingW, + totalVebusHardCeilingW, + safeEvcsByDedicatedCapW, + safeEvcsByVebusW, + safeEvcsCombinedW, + systemCapCurrentA, + physicalCapA, + canRunAtMinCurrent, + endDayGuardSoc, + energyNeededForEndDayGuardKwh, + forecastBufferKwh, + projectedEndSocAfterCharge, + currentReferenceA, + anyVebusOverload, + anyVebusLowBattery, + vebusAcOutSoftReached, + vebusAcOutHardReached, + vebusDcVoltageSoftLow, + vebusDcVoltageHardLow, + houseLoadHighVsHeadroom, + }, + + context: { + departurePriorityActive, + forceChargeDepartureGridOverride, + manualChargeBypassWindows, + manualChargeLocalRequested, + morningProbeEligible, + middayProbeEligible, + afternoonProbeEligible, + pvProvocationEligible, + isHighBatteryForPotential, + strongSolarSignal, + effectivePvStartMinW, + profile, + operatingMode, + sourceMode, + }, + + // R1 : source de vérité UNIQUE du moteur nuit. + // BATTERY_CLOUD_LOCKS consomme cet objet, il ne recalcule plus rien. + nightEvcsPreview: { + nightSessionActive, + nightSessionNeedsCapture, + nightSessionNeedsClear, + nightSessionStartSocPct, + nightSessionStartAt, + nightSessionStartMs, + + batteryHardFloorPct, + batteryMorningTargetPct, + maxBatteryDischargePoints, + gridTakeoverTriggerPct, + + batteryBelowHardFloor, + batteryBelowMorningTarget, + batteryAtOrBelowGridTakeoverTrigger, + + batteryKwhMissingToTarget, + batteryRecoveryMinutesNeeded, + batteryRecoveryUrgent, + batteryRecoveryAllowedNow, + batteryTargetReached, + + batteryAssistAllowed, + nightGridTakeoverNeeded, + nightTakeoverLatched, + nightGridHcPreferred, + vehicleChargeCompleted, + nightPreferredSourceMode, + } + } +}]; diff --git a/tools/lib-node-map.mjs b/tools/lib-node-map.mjs new file mode 100644 index 0000000..9e9ae52 --- /dev/null +++ b/tools/lib-node-map.mjs @@ -0,0 +1,32 @@ +// R2 — Cartographie src/nodes/ ↔ workflow JSON (source de vérité : src/nodes/). +// Utilisé par tools/n8n-extract.mjs (JSON → src) et tools/n8n-build.mjs (src → JSON). + +export const WORKFLOW_FILE = 'workflows/evcs-surplus-controller-v2.6.json'; +export const SRC_DIR = 'src/nodes'; + +export const NODE_MAP = [ + { file: 'INPUT_PARSE.js', node: 'INPUT_PARSE', kind: 'jsCode' }, + { file: 'WINDOWS_SOURCEV2.js', node: 'WINDOWS_SOURCEV2', kind: 'jsCode' }, + { file: 'BATTERY_CLOUD_LOCKS.js', node: 'BATTERY_CLOUD_LOCKS', kind: 'jsCode' }, + { file: 'DECISION_ENGINEV2.js', node: 'DECISION_ENGINEV2', kind: 'jsCode' }, + { file: 'HA_SERVICE_CALLS_OUTPUT.js', node: 'HA_SERVICE_CALLS_OUTPUT', kind: 'jsCode' }, + { file: 'BUILD_ACTIONS.js', node: 'Build EVCS Surplus Controller V2.0 Actions', kind: 'jsCode' }, + { file: 'HA_SNAPSHOT_TEMPLATE.json.tpl', node: 'HA Snapshot EVCS Surplus Controller V2.2', kind: 'template' }, +]; + +export function getCode(workflowNode, kind) { + if (kind === 'jsCode') return workflowNode.parameters.jsCode; + if (kind === 'template') { + return workflowNode.parameters.bodyParameters.parameters.find(p => p.name === 'template').value; + } + throw new Error(`kind inconnu: ${kind}`); +} + +export function setCode(workflowNode, kind, code) { + if (kind === 'jsCode') { workflowNode.parameters.jsCode = code; return; } + if (kind === 'template') { + workflowNode.parameters.bodyParameters.parameters.find(p => p.name === 'template').value = code; + return; + } + throw new Error(`kind inconnu: ${kind}`); +} diff --git a/tools/n8n-build.mjs b/tools/n8n-build.mjs new file mode 100644 index 0000000..cac3c25 --- /dev/null +++ b/tools/n8n-build.mjs @@ -0,0 +1,38 @@ +#!/usr/bin/env node +// R2 — BUILD : src/nodes/*.js → workflow JSON (src/ = SOURCE DE VÉRITÉ, src gagne). +// Usage : node tools/n8n-build.mjs [--check] +// --check : vérifie que le JSON est déjà synchronisé avec src/ (exit 1 sinon), +// sans rien écrire. + +import fs from 'fs'; +import path from 'path'; +import { WORKFLOW_FILE, SRC_DIR, NODE_MAP, getCode, setCode } from './lib-node-map.mjs'; + +const CHECK = process.argv.includes('--check'); +const wf = JSON.parse(fs.readFileSync(WORKFLOW_FILE, 'utf8')); + +let changes = 0; +for (const { file, node, kind } of NODE_MAP) { + const srcPath = path.join(SRC_DIR, file); + if (!fs.existsSync(srcPath)) throw new Error(`Fichier source manquant: ${srcPath}`); + const srcCode = fs.readFileSync(srcPath, 'utf8'); + const n = wf.nodes.find(x => x.name === node); + if (!n) throw new Error(`Node introuvable dans le JSON: ${node}`); + if (getCode(n, kind) !== srcCode) { + changes++; + console.log(`${CHECK ? 'DÉSYNCHRONISÉ' : 'inject'}: ${file} → "${node}"`); + if (!CHECK) setCode(n, kind, srcCode); + } +} + +if (CHECK) { + console.log(changes ? `\n${changes} node(s) désynchronisé(s) : lancer node tools/n8n-build.mjs` : 'JSON synchronisé avec src/nodes/'); + process.exit(changes ? 1 : 0); +} + +if (changes) { + fs.writeFileSync(WORKFLOW_FILE, JSON.stringify(wf, null, 2) + '\n'); + console.log(`\nBuild OK: ${changes} node(s) injecté(s) dans ${WORKFLOW_FILE}`); +} else { + console.log('Build: aucun changement (déjà synchronisé)'); +} diff --git a/tools/n8n-extract.mjs b/tools/n8n-extract.mjs new file mode 100644 index 0000000..4a03827 --- /dev/null +++ b/tools/n8n-extract.mjs @@ -0,0 +1,21 @@ +#!/usr/bin/env node +// R2 — EXTRACT : workflow JSON → src/nodes/*.js (+ template snapshot). +// À utiliser UNIQUEMENT pour (re)synchroniser src/ après une modification faite +// directement dans l'UI n8n (à éviter). En temps normal : éditer src/nodes/, +// puis tools/n8n-push.mjs reconstruit le JSON automatiquement. + +import fs from 'fs'; +import path from 'path'; +import { WORKFLOW_FILE, SRC_DIR, NODE_MAP, getCode } from './lib-node-map.mjs'; + +const wf = JSON.parse(fs.readFileSync(WORKFLOW_FILE, 'utf8')); +fs.mkdirSync(SRC_DIR, { recursive: true }); + +for (const { file, node, kind } of NODE_MAP) { + const n = wf.nodes.find(x => x.name === node); + if (!n) throw new Error(`Node introuvable: ${node}`); + const code = getCode(n, kind); + fs.writeFileSync(path.join(SRC_DIR, file), code); + console.log(`${file} ← "${node}" (${code.length} chars)`); +} +console.log(`\nExtraction OK dans ${SRC_DIR}/`); diff --git a/tools/n8n-push.mjs b/tools/n8n-push.mjs index 01cd3d5..cd8b20c 100644 --- a/tools/n8n-push.mjs +++ b/tools/n8n-push.mjs @@ -14,6 +14,21 @@ if (!INPUT) { console.error('Usage: node tools/n8n-push.mjs [workflowId]'); process.exit(1); } + +// R2 : src/nodes/ = SOURCE DE VÉRITÉ pour le workflow EVCS. +// Le build injecte src/nodes/*.js dans le JSON AVANT toute vérification : +// toute édition faite directement dans le JSON (ou l'UI n8n non ré-extraite) +// est écrasée par src/. Workflow EVCS uniquement. +if (path.basename(INPUT) === 'evcs-surplus-controller-v2.6.json' && fs.existsSync('src/nodes')) { + console.log('Build src/nodes/ → JSON (source de vérité : src/)...'); + const { spawnSync } = await import('child_process'); + const b = spawnSync('node', ['tools/n8n-build.mjs'], { stdio: 'inherit' }); + if (b.status !== 0) { + console.error('ABANDON: échec du build src → JSON.'); + process.exit(1); + } +} + const local = JSON.parse(fs.readFileSync(INPUT, 'utf8')); const WORKFLOW_ID = process.argv[3] || local.id || 'ATswvGfG4KJUls0K';