.",
+ "format": "unknown",
+ "index": 0
+ }
+ ]
+ }
+ },
+ "id": "prt_e3e046da4001TVt7gJkf6SFxHI",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e0444e5001XPLjILH8LbVPX3"
+ },
+ {
+ "reason": "tool-calls",
+ "type": "step-finish",
+ "tokens": {
+ "total": 18086,
+ "input": 9280,
+ "output": 111,
+ "reasoning": 183,
+ "cache": {
+ "write": 0,
+ "read": 8512
+ }
+ },
+ "cost": 0.00992846,
+ "id": "prt_e3e04762a001bn2D1F2UXZUd3C",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e0444e5001XPLjILH8LbVPX3"
+ }
+ ]
+ },
+ {
+ "info": {
+ "parentID": "msg_e3e044477001ADKhSz2Jx6n3An",
+ "role": "assistant",
+ "mode": "build",
+ "agent": "build",
+ "path": {
+ "cwd": "/mnt/projects/EVCS/EVCS",
+ "root": "/mnt/projects/EVCS/EVCS"
+ },
+ "cost": 0.00620391,
+ "tokens": {
+ "total": 19479,
+ "input": 1497,
+ "output": 117,
+ "reasoning": 73,
+ "cache": {
+ "write": 0,
+ "read": 17792
+ }
+ },
+ "modelID": "moonshotai/kimi-k2.6",
+ "providerID": "openrouter",
+ "time": {
+ "created": 1779156940337,
+ "completed": 1779156952063
+ },
+ "finish": "tool-calls",
+ "id": "msg_e3e047631001Zdl522XHSImFE7",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F"
+ },
+ "parts": [
+ {
+ "type": "step-start",
+ "id": "prt_e3e0486220010GJJdt70rBnXoL",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e047631001Zdl522XHSImFE7"
+ },
+ {
+ "type": "reasoning",
+ "text": "Le login a réussi ! Mais je ne vois pas le token JWT dans la réponse... étrange. Normalement, N8N retourne un cookie 'n8n-auth' ou un token dans le body. La réponse n8n en mode email/password renvoie souvent un header `Set-Cookie` avec le JWT. Voyons les headers de la réponse.",
+ "time": {
+ "start": 1779156944420,
+ "end": 1779156952001
+ },
+ "metadata": {
+ "openrouter": {
+ "reasoning_details": [
+ {
+ "type": "reasoning.text",
+ "text": "Le login a réussi ! Mais je ne vois pas le token JWT dans la réponse... étrange. Normalement, N8N retourne un cookie 'n8n-auth' ou un token dans le body. La réponse n8n en mode email/password renvoie souvent un header `Set-Cookie` avec le JWT. Voyons les headers de la réponse.",
+ "format": "unknown",
+ "index": 0
+ }
+ ]
+ }
+ },
+ "id": "prt_e3e048624001MsOvUV6Hoi2JvJ",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e047631001Zdl522XHSImFE7"
+ },
+ {
+ "type": "tool",
+ "tool": "bash",
+ "callID": "functions.bash:99",
+ "state": {
+ "status": "completed",
+ "input": {
+ "command": "curl -s -i -X POST http://10.10.10.17:5678/rest/login -H \"Content-Type: application/json\" -d '{\"emailOrLdapLoginId\":\"tallagrand.thierry@gmail.com\",\"password\":\"Airwan=25Z\"}' | grep -iE 'set-cookie|authorization|token|n8n-auth'",
+ "description": "Extract auth token/cookie from login"
+ },
+ "output": "Set-Cookie: n8n-auth=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjhiOWU5NTUyLWNkNzQtNDg2My04MmEzLTE3NGIzYjNiZWZhMiIsImhhc2giOiI0ejdua0psNVpWIiwidXNlZE1mYSI6ZmFsc2UsImlhdCI6MTc3OTE1Njk1MiwiZXhwIjoxNzc5NzYxNzUyfQ.oEut-tf5Nok6vg2Hx0n2e0zKyL-ndn3w654rCnJAtMM; Max-Age=604800; Path=/; Expires=Tue, 26 May 2026 02:15:52 GMT; HttpOnly; SameSite=Lax\r\n",
+ "metadata": {
+ "output": "Set-Cookie: n8n-auth=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjhiOWU5NTUyLWNkNzQtNDg2My04MmEzLTE3NGIzYjNiZWZhMiIsImhhc2giOiI0ejdua0psNVpWIiwidXNlZE1mYSI6ZmFsc2UsImlhdCI6MTc3OTE1Njk1MiwiZXhwIjoxNzc5NzYxNzUyfQ.oEut-tf5Nok6vg2Hx0n2e0zKyL-ndn3w654rCnJAtMM; Max-Age=604800; Path=/; Expires=Tue, 26 May 2026 02:15:52 GMT; HttpOnly; SameSite=Lax\r\n",
+ "exit": 0,
+ "description": "Extract auth token/cookie from login",
+ "truncated": false
+ },
+ "title": "Extract auth token/cookie from login",
+ "time": {
+ "start": 1779156952056,
+ "end": 1779156952059
+ }
+ },
+ "metadata": {
+ "openrouter": {
+ "reasoning_details": [
+ {
+ "type": "reasoning.text",
+ "text": "Le login a réussi ! Mais je ne vois pas le token JWT dans la réponse... étrange. Normalement, N8N retourne un cookie 'n8n-auth' ou un token dans le body. La réponse n8n en mode email/password renvoie souvent un header `Set-Cookie` avec le JWT. Voyons les headers de la réponse.",
+ "format": "unknown",
+ "index": 0
+ }
+ ]
+ }
+ },
+ "id": "prt_e3e04902e001J11SaN4Clbfb5a",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e047631001Zdl522XHSImFE7"
+ },
+ {
+ "reason": "tool-calls",
+ "type": "step-finish",
+ "tokens": {
+ "total": 19479,
+ "input": 1497,
+ "output": 117,
+ "reasoning": 73,
+ "cache": {
+ "write": 0,
+ "read": 17792
+ }
+ },
+ "cost": 0.00620391,
+ "id": "prt_e3e04a3fd001lPwrWF2FZv2m6B",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e047631001Zdl522XHSImFE7"
+ }
+ ]
+ },
+ {
+ "info": {
+ "parentID": "msg_e3e044477001ADKhSz2Jx6n3An",
+ "role": "assistant",
+ "mode": "build",
+ "agent": "build",
+ "path": {
+ "cwd": "/mnt/projects/EVCS/EVCS",
+ "root": "/mnt/projects/EVCS/EVCS"
+ },
+ "cost": 0.00754644,
+ "tokens": {
+ "total": 20388,
+ "input": 432,
+ "output": 369,
+ "reasoning": 323,
+ "cache": {
+ "write": 0,
+ "read": 19264
+ }
+ },
+ "modelID": "moonshotai/kimi-k2.6",
+ "providerID": "openrouter",
+ "time": {
+ "created": 1779156952067,
+ "completed": 1779156983141
+ },
+ "finish": "tool-calls",
+ "id": "msg_e3e04a403001qgdFHKPYw8V58L",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F"
+ },
+ "parts": [
+ {
+ "type": "step-start",
+ "id": "prt_e3e04b4d1001zNuvJJ0JMTgF2g",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e04a403001qgdFHKPYw8V58L"
+ },
+ {
+ "type": "reasoning",
+ "text": "Parfait ! Le cookie `n8n-auth` contient le token JWT. Il faut l'extraire et l'utiliser dans les requêtes API REST. Auparavant, MCP a échoué car il ne savait pas comment s'authentifier, mais maintenant on peut utiliser curl avec le cookie directement.\n\nPlan :\n1. Extraire le token JWT de `Set-Cookie`.\n2. Mettre à jour le workflow EVCS V2.5 via `PUT /rest/workflows/ATswvGfG4KJUls0K`.\n3. Mettre à jour le workflow Grid Hysteresis via `PUT /rest/workflows/7iIlu7LLTW7TZA8o`.\n4. Réactiver les deux workflows.\n\nEssayons l'update du premier workflow (EVCS). Il faut envoyer le JSON complet avec curl et le cookie.\nLe cookie est `n8n-auth=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjhiOWU5NTUyLWNkNzQtNDg2My04MmEzLTE3NGIzYjNiZWZhMiIsImhhc2giOiI0ejdua0psNVpWIiwidXNlZE1mYSI6ZmFsc2UsImlhdCI6MTc3OTE1Njk1MiwiZXhwIjoxNzc5NzYxNzUyfQ.oEut-tf5Nok6vg2Hx0n2e0zKyL-ndn3w654rCnJAtMM`.\n\nCréons une variable `COOKIE` et lançons l'update.\n\nNote: le JSON est très gros (>300KB). Utiliser curl avec `--data-binary @/tmp/evcs_v25.json` serait préférable pour éviter les problèmes de taille.\n\nMais avant de pusher tout le workflow, peut-être qu'il faut vérifier qu'on peut lire le workflow actuel via l'API avec le cookie.",
+ "time": {
+ "start": 1779156956370,
+ "end": 1779156983134
+ },
+ "metadata": {
+ "openrouter": {
+ "reasoning_details": [
+ {
+ "type": "reasoning.text",
+ "text": "Parfait ! Le cookie `n8n-auth` contient le token JWT. Il faut l'extraire et l'utiliser dans les requêtes API REST. Auparavant, MCP a échoué car il ne savait pas comment s'authentifier, mais maintenant on peut utiliser curl avec le cookie directement.\n\nPlan :\n1. Extraire le token JWT de `Set-Cookie`.\n2. Mettre à jour le workflow EVCS V2.5 via `PUT /rest/workflows/ATswvGfG4KJUls0K`.\n3. Mettre à jour le workflow Grid Hysteresis via `PUT /rest/workflows/7iIlu7LLTW7TZA8o`.\n4. Réactiver les deux workflows.\n\nEssayons l'update du premier workflow (EVCS). Il faut envoyer le JSON complet avec curl et le cookie.\nLe cookie est `n8n-auth=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjhiOWU5NTUyLWNkNzQtNDg2My04MmEzLTE3NGIzYjNiZWZhMiIsImhhc2giOiI0ejdua0psNVpWIiwidXNlZE1mYSI6ZmFsc2UsImlhdCI6MTc3OTE1Njk1MiwiZXhwIjoxNzc5NzYxNzUyfQ.oEut-tf5Nok6vg2Hx0n2e0zKyL-ndn3w654rCnJAtMM`.\n\nCréons une variable `COOKIE` et lançons l'update.\n\nNote: le JSON est très gros (>300KB). Utiliser curl avec `--data-binary @/tmp/evcs_v25.json` serait préférable pour éviter les problèmes de taille.\n\nMais avant de pusher tout le workflow, peut-être qu'il faut vérifier qu'on peut lire le workflow actuel via l'API avec le cookie.",
+ "format": "unknown",
+ "index": 0
+ }
+ ]
+ }
+ },
+ "id": "prt_e3e04b4d2001FKt9EX8YMsgjTq",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e04a403001qgdFHKPYw8V58L"
+ },
+ {
+ "type": "tool",
+ "tool": "bash",
+ "callID": "functions.bash:100",
+ "state": {
+ "status": "completed",
+ "input": {
+ "command": "COOKIE=\"n8n-auth=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjhiOWU5NTUyLWNkNzQtNDg2My04MmEzLTE3NGIzYjNiZWZhMiIsImhhc2giOiI0ejdua0psNVpWIiwidXNlZE1mYSI6ZmFsc2UsImlhdCI6MTc3OTE1Njk1MiwiZXhwIjoxNzc5NzYxNzUyfQ.oEut-tf5Nok6vg2Hx0n2e0zKyL-ndn3w654rCnJAtMM\"\ncurl -s -o /dev/null -w \"%{http_code}\" -b \"$COOKIE\" http://10.10.10.17:5678/rest/workflows/ATswvGfG4KJUls0K",
+ "description": "Test read workflow via REST API"
+ },
+ "output": "200",
+ "metadata": {
+ "output": "200",
+ "exit": 0,
+ "description": "Test read workflow via REST API",
+ "truncated": false
+ },
+ "title": "Test read workflow via REST API",
+ "time": {
+ "start": 1779156983090,
+ "end": 1779156983097
+ }
+ },
+ "metadata": {
+ "openrouter": {
+ "reasoning_details": [
+ {
+ "type": "reasoning.text",
+ "text": "Parfait ! Le cookie `n8n-auth` contient le token JWT. Il faut l'extraire et l'utiliser dans les requêtes API REST. Auparavant, MCP a échoué car il ne savait pas comment s'authentifier, mais maintenant on peut utiliser curl avec le cookie directement.\n\nPlan :\n1. Extraire le token JWT de `Set-Cookie`.\n2. Mettre à jour le workflow EVCS V2.5 via `PUT /rest/workflows/ATswvGfG4KJUls0K`.\n3. Mettre à jour le workflow Grid Hysteresis via `PUT /rest/workflows/7iIlu7LLTW7TZA8o`.\n4. Réactiver les deux workflows.\n\nEssayons l'update du premier workflow (EVCS). Il faut envoyer le JSON complet avec curl et le cookie.\nLe cookie est `n8n-auth=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjhiOWU5NTUyLWNkNzQtNDg2My04MmEzLTE3NGIzYjNiZWZhMiIsImhhc2giOiI0ejdua0psNVpWIiwidXNlZE1mYSI6ZmFsc2UsImlhdCI6MTc3OTE1Njk1MiwiZXhwIjoxNzc5NzYxNzUyfQ.oEut-tf5Nok6vg2Hx0n2e0zKyL-ndn3w654rCnJAtMM`.\n\nCréons une variable `COOKIE` et lançons l'update.\n\nNote: le JSON est très gros (>300KB). Utiliser curl avec `--data-binary @/tmp/evcs_v25.json` serait préférable pour éviter les problèmes de taille.\n\nMais avant de pusher tout le workflow, peut-être qu'il faut vérifier qu'on peut lire le workflow actuel via l'API avec le cookie.",
+ "format": "unknown",
+ "index": 0
+ }
+ ]
+ }
+ },
+ "id": "prt_e3e04ff68001Vi61R1myajTmlQ",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e04a403001qgdFHKPYw8V58L"
+ },
+ {
+ "reason": "tool-calls",
+ "type": "step-finish",
+ "tokens": {
+ "total": 20388,
+ "input": 432,
+ "output": 369,
+ "reasoning": 323,
+ "cache": {
+ "write": 0,
+ "read": 19264
+ }
+ },
+ "cost": 0.00754644,
+ "id": "prt_e3e051d61001DzEFXsNIJgjagO",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e04a403001qgdFHKPYw8V58L"
+ }
+ ]
+ },
+ {
+ "info": {
+ "parentID": "msg_e3e044477001ADKhSz2Jx6n3An",
+ "role": "assistant",
+ "mode": "build",
+ "agent": "build",
+ "path": {
+ "cwd": "/mnt/projects/EVCS/EVCS",
+ "root": "/mnt/projects/EVCS/EVCS"
+ },
+ "cost": 0.00864294,
+ "tokens": {
+ "total": 21018,
+ "input": 2935,
+ "output": 149,
+ "reasoning": 462,
+ "cache": {
+ "write": 0,
+ "read": 17472
+ }
+ },
+ "modelID": "moonshotai/kimi-k2.6",
+ "providerID": "openrouter",
+ "time": {
+ "created": 1779156983146,
+ "completed": 1779157005640
+ },
+ "finish": "tool-calls",
+ "id": "msg_e3e051d6a001mfyqMju5uxwAb7",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F"
+ },
+ "parts": [
+ {
+ "type": "step-start",
+ "id": "prt_e3e0529a3001HpjcFOyYCAtoyB",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e051d6a001mfyqMju5uxwAb7"
+ },
+ {
+ "type": "reasoning",
+ "text": "Parfait ! On a le token, la lecture fonctionne. Maintenant, on peut pousser les updates.\n\nMais attention : pour mettre à jour un workflow via REST, il faut généralement envoyer tout le JSON du workflow. Quand on récupère un workflow via GET /rest/workflows/:id, certaines propriétés comme `owner`, `sharedWith`, `versionId`, etc. sont présentes. Le PUT doit inclure l'id du workflow.\n\nLe plus sûr est de :\n1. GET le workflow actuel.\n2. Fusionner les nodes modifiées.\n3. PUT l'objet complet.\n\nHeureusement, j'ai déjà le JSON complet original dans `/tmp/evcs_v25.json` et `/tmp/grid_hyst.json`, qui contiennent déjà les bonnes propriétés (car c'est un export via API ou la copie que j'ai lue). Le seul problème potentiel est le champ `versionId` qui change à chaque update, ou `updatedAt`. Le mieux est de GET le workflow actuel, remplacer les nodes, et POST/PUT.\n\nAttendez : dans N8N, l'endpoint est souvent :\n- GET /rest/workflows/:id\n- PATCH /rest/workflows/:id (pour une mise à jour partielle)\n- PUT /rest/workflows/:id\n\nEssayons PUT avec le JSON complet depuis `/tmp/evcs_v25.json`.\n\nMais il faut s'assurer que le JSON contient bien l'ID du workflow. Le fichier original contenait `ATswvGfG4KJUls0K` comme ID. Laissez-moi vérifier que `id` est présent dans le JSON de `/tmp/evcs_v25.json`. C'est probablement le cas car c'est un fichier d'export.\n\nJe vais lancer le PUT pour EVCS puis pour Grid Hysteresis. Le JSON est gros ; j'utiliserai `-d @/tmp/evcs_v25.json`. La bonne approche est de lire le JSON via un fichier.\n\nMais il faut être sûr du Content-Type utilisé par N8N. C'est généralement `application/json`.\n\nVérifions d'abord que le champ id est bien dans le JSON /tmp/evcs_v25.json.",
+ "time": {
+ "start": 1779156986277,
+ "end": 1779157005635
+ },
+ "metadata": {
+ "openrouter": {
+ "reasoning_details": [
+ {
+ "type": "reasoning.text",
+ "text": "Parfait ! On a le token, la lecture fonctionne. Maintenant, on peut pousser les updates.\n\nMais attention : pour mettre à jour un workflow via REST, il faut généralement envoyer tout le JSON du workflow. Quand on récupère un workflow via GET /rest/workflows/:id, certaines propriétés comme `owner`, `sharedWith`, `versionId`, etc. sont présentes. Le PUT doit inclure l'id du workflow.\n\nLe plus sûr est de :\n1. GET le workflow actuel.\n2. Fusionner les nodes modifiées.\n3. PUT l'objet complet.\n\nHeureusement, j'ai déjà le JSON complet original dans `/tmp/evcs_v25.json` et `/tmp/grid_hyst.json`, qui contiennent déjà les bonnes propriétés (car c'est un export via API ou la copie que j'ai lue). Le seul problème potentiel est le champ `versionId` qui change à chaque update, ou `updatedAt`. Le mieux est de GET le workflow actuel, remplacer les nodes, et POST/PUT.\n\nAttendez : dans N8N, l'endpoint est souvent :\n- GET /rest/workflows/:id\n- PATCH /rest/workflows/:id (pour une mise à jour partielle)\n- PUT /rest/workflows/:id\n\nEssayons PUT avec le JSON complet depuis `/tmp/evcs_v25.json`.\n\nMais il faut s'assurer que le JSON contient bien l'ID du workflow. Le fichier original contenait `ATswvGfG4KJUls0K` comme ID. Laissez-moi vérifier que `id` est présent dans le JSON de `/tmp/evcs_v25.json`. C'est probablement le cas car c'est un fichier d'export.\n\nJe vais lancer le PUT pour EVCS puis pour Grid Hysteresis. Le JSON est gros ; j'utiliserai `-d @/tmp/evcs_v25.json`. La bonne approche est de lire le JSON via un fichier.\n\nMais il faut être sûr du Content-Type utilisé par N8N. C'est généralement `application/json`.\n\nVérifions d'abord que le champ id est bien dans le JSON /tmp/evcs_v25.json.",
+ "format": "unknown",
+ "index": 0
+ }
+ ]
+ }
+ },
+ "id": "prt_e3e0529a5001GaZjLTaLxAVPm9",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e051d6a001mfyqMju5uxwAb7"
+ },
+ {
+ "type": "tool",
+ "tool": "bash",
+ "callID": "functions.bash:101",
+ "state": {
+ "status": "completed",
+ "input": {
+ "command": "node -e \"const j=JSON.parse(require('fs').readFileSync('/tmp/evcs_v25.json')); console.log(j.id)\"",
+ "description": "Verify workflow ID in EVCS JSON"
+ },
+ "output": "ATswvGfG4KJUls0K\n",
+ "metadata": {
+ "output": "ATswvGfG4KJUls0K\n",
+ "exit": 0,
+ "description": "Verify workflow ID in EVCS JSON",
+ "truncated": false
+ },
+ "title": "Verify workflow ID in EVCS JSON",
+ "time": {
+ "start": 1779157004436,
+ "end": 1779157004440
+ }
+ },
+ "metadata": {
+ "openrouter": {
+ "reasoning_details": [
+ {
+ "type": "reasoning.text",
+ "text": "Parfait ! On a le token, la lecture fonctionne. Maintenant, on peut pousser les updates.\n\nMais attention : pour mettre à jour un workflow via REST, il faut généralement envoyer tout le JSON du workflow. Quand on récupère un workflow via GET /rest/workflows/:id, certaines propriétés comme `owner`, `sharedWith`, `versionId`, etc. sont présentes. Le PUT doit inclure l'id du workflow.\n\nLe plus sûr est de :\n1. GET le workflow actuel.\n2. Fusionner les nodes modifiées.\n3. PUT l'objet complet.\n\nHeureusement, j'ai déjà le JSON complet original dans `/tmp/evcs_v25.json` et `/tmp/grid_hyst.json`, qui contiennent déjà les bonnes propriétés (car c'est un export via API ou la copie que j'ai lue). Le seul problème potentiel est le champ `versionId` qui change à chaque update, ou `updatedAt`. Le mieux est de GET le workflow actuel, remplacer les nodes, et POST/PUT.\n\nAttendez : dans N8N, l'endpoint est souvent :\n- GET /rest/workflows/:id\n- PATCH /rest/workflows/:id (pour une mise à jour partielle)\n- PUT /rest/workflows/:id\n\nEssayons PUT avec le JSON complet depuis `/tmp/evcs_v25.json`.\n\nMais il faut s'assurer que le JSON contient bien l'ID du workflow. Le fichier original contenait `ATswvGfG4KJUls0K` comme ID. Laissez-moi vérifier que `id` est présent dans le JSON de `/tmp/evcs_v25.json`. C'est probablement le cas car c'est un fichier d'export.\n\nJe vais lancer le PUT pour EVCS puis pour Grid Hysteresis. Le JSON est gros ; j'utiliserai `-d @/tmp/evcs_v25.json`. La bonne approche est de lire le JSON via un fichier.\n\nMais il faut être sûr du Content-Type utilisé par N8N. C'est généralement `application/json`.\n\nVérifions d'abord que le champ id est bien dans le JSON /tmp/evcs_v25.json.",
+ "format": "unknown",
+ "index": 0
+ }
+ ]
+ }
+ },
+ "id": "prt_e3e056cce001d5n8qo0n113xty",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e051d6a001mfyqMju5uxwAb7"
+ },
+ {
+ "type": "tool",
+ "tool": "bash",
+ "callID": "functions.bash:102",
+ "state": {
+ "status": "completed",
+ "input": {
+ "command": "node -e \"const j=JSON.parse(require('fs').readFileSync('/tmp/grid_hyst.json')); console.log(j.id)\"",
+ "description": "Verify workflow ID in Grid JSON"
+ },
+ "output": "7iIlu7LLTW7TZA8o\n",
+ "metadata": {
+ "output": "7iIlu7LLTW7TZA8o\n",
+ "exit": 0,
+ "description": "Verify workflow ID in Grid JSON",
+ "truncated": false
+ },
+ "title": "Verify workflow ID in Grid JSON",
+ "time": {
+ "start": 1779157005614,
+ "end": 1779157005618
+ }
+ },
+ "id": "prt_e3e0570e5001zOSVBIfugkW9bu",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e051d6a001mfyqMju5uxwAb7"
+ },
+ {
+ "reason": "tool-calls",
+ "type": "step-finish",
+ "tokens": {
+ "total": 21018,
+ "input": 2935,
+ "output": 149,
+ "reasoning": 462,
+ "cache": {
+ "write": 0,
+ "read": 17472
+ }
+ },
+ "cost": 0.00864294,
+ "id": "prt_e3e0575450018E0WfkdUp2Ftb3",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e051d6a001mfyqMju5uxwAb7"
+ }
+ ]
+ },
+ {
+ "info": {
+ "parentID": "msg_e3e044477001ADKhSz2Jx6n3An",
+ "role": "assistant",
+ "mode": "build",
+ "agent": "build",
+ "path": {
+ "cwd": "/mnt/projects/EVCS/EVCS",
+ "root": "/mnt/projects/EVCS/EVCS"
+ },
+ "cost": 0.00761844,
+ "tokens": {
+ "total": 21552,
+ "input": 1427,
+ "output": 277,
+ "reasoning": 200,
+ "cache": {
+ "write": 0,
+ "read": 19648
+ }
+ },
+ "modelID": "moonshotai/kimi-k2.6",
+ "providerID": "openrouter",
+ "time": {
+ "created": 1779157005644,
+ "completed": 1779157026680
+ },
+ "finish": "tool-calls",
+ "id": "msg_e3e05754c001jEp8jD7aV1Rjn2",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F"
+ },
+ "parts": [
+ {
+ "type": "step-start",
+ "id": "prt_e3e059063001cwrgCFVK0z6MQL",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e05754c001jEp8jD7aV1Rjn2"
+ },
+ {
+ "type": "reasoning",
+ "text": "Parfait. Les IDs sont corrects. Maintenant lançons le PUT pour mettre à jour les workflows.\n\nJe vais d'abord vérifier que PUT /rest/workflows/:id avec Content-Type: application/json fonctionne en envoyant le JSON complet depuis le fichier.\n\nPour curl avec un fichier JSON :\n```\ncurl -s -X PUT http://10.10.10.17:5678/rest/workflows/ATswvGfG4KJUls0K \\\n -H \"Content-Type: application/json\" \\\n -b \"$COOKIE\" \\\n -d @/tmp/evcs_v25.json\n```\n\nEssayons pour EVCS. Si ça fonctionne, on fait Grid Hysteresis ensuite.\n\nPendant ce temps, préparons aussi la réactivation :\n- POST /rest/workflows/:id/activate\nou PATCH /rest/workflows/:id avec active: true.\n\nN8N utilise normalement :\n- POST /rest/workflows/:id/activate\n\nTestons.",
+ "time": {
+ "start": 1779157012580,
+ "end": 1779157026674
+ },
+ "metadata": {
+ "openrouter": {
+ "reasoning_details": [
+ {
+ "type": "reasoning.text",
+ "text": "Parfait. Les IDs sont corrects. Maintenant lançons le PUT pour mettre à jour les workflows.\n\nJe vais d'abord vérifier que PUT /rest/workflows/:id avec Content-Type: application/json fonctionne en envoyant le JSON complet depuis le fichier.\n\nPour curl avec un fichier JSON :\n```\ncurl -s -X PUT http://10.10.10.17:5678/rest/workflows/ATswvGfG4KJUls0K \\\n -H \"Content-Type: application/json\" \\\n -b \"$COOKIE\" \\\n -d @/tmp/evcs_v25.json\n```\n\nEssayons pour EVCS. Si ça fonctionne, on fait Grid Hysteresis ensuite.\n\nPendant ce temps, préparons aussi la réactivation :\n- POST /rest/workflows/:id/activate\nou PATCH /rest/workflows/:id avec active: true.\n\nN8N utilise normalement :\n- POST /rest/workflows/:id/activate\n\nTestons.",
+ "format": "unknown",
+ "index": 0
+ }
+ ]
+ }
+ },
+ "id": "prt_e3e059064001LfFfYm9sxdSgI0",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e05754c001jEp8jD7aV1Rjn2"
+ },
+ {
+ "type": "tool",
+ "tool": "bash",
+ "callID": "functions.bash:103",
+ "state": {
+ "status": "completed",
+ "input": {
+ "command": "COOKIE=\"n8n-auth=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjhiOWU5NTUyLWNkNzQtNDg2My04MmEzLTE3NGIzYjNiZWZhMiIsImhhc2giOiI0ejdua0psNVpWIiwidXNlZE1mYSI6ZmFsc2UsImlhdCI6MTc3OTE1Njk1MiwiZXhwIjoxNzc5NzYxNzUyfQ.oEut-tf5Nok6vg2Hx0n2e0zKyL-ndn3w654rCnJAtMM\"\ncurl -s -w \"\\nHTTP_CODE:%{http_code}\\n\" -X PUT http://10.10.10.17:5678/rest/workflows/ATswvGfG4KJUls0K -H \"Content-Type: application/json\" -b \"$COOKIE\" -d @/tmp/evcs_v25.json | tail -n 5",
+ "description": "Push EVCS patched workflow via REST"
+ },
+ "output": "Cannot PUT /rest/workflows/ATswvGfG4KJUls0K
\n\n\n\nHTTP_CODE:404\n",
+ "metadata": {
+ "output": "Cannot PUT /rest/workflows/ATswvGfG4KJUls0K
\n\n\n\nHTTP_CODE:404\n",
+ "exit": 0,
+ "description": "Push EVCS patched workflow via REST",
+ "truncated": false
+ },
+ "title": "Push EVCS patched workflow via REST",
+ "time": {
+ "start": 1779157026608,
+ "end": 1779157026612
+ }
+ },
+ "metadata": {
+ "openrouter": {
+ "reasoning_details": [
+ {
+ "type": "reasoning.text",
+ "text": "Parfait. Les IDs sont corrects. Maintenant lançons le PUT pour mettre à jour les workflows.\n\nJe vais d'abord vérifier que PUT /rest/workflows/:id avec Content-Type: application/json fonctionne en envoyant le JSON complet depuis le fichier.\n\nPour curl avec un fichier JSON :\n```\ncurl -s -X PUT http://10.10.10.17:5678/rest/workflows/ATswvGfG4KJUls0K \\\n -H \"Content-Type: application/json\" \\\n -b \"$COOKIE\" \\\n -d @/tmp/evcs_v25.json\n```\n\nEssayons pour EVCS. Si ça fonctionne, on fait Grid Hysteresis ensuite.\n\nPendant ce temps, préparons aussi la réactivation :\n- POST /rest/workflows/:id/activate\nou PATCH /rest/workflows/:id avec active: true.\n\nN8N utilise normalement :\n- POST /rest/workflows/:id/activate\n\nTestons.",
+ "format": "unknown",
+ "index": 0
+ }
+ ]
+ }
+ },
+ "id": "prt_e3e05ac24001X7qzdEWqJa6o0L",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e05754c001jEp8jD7aV1Rjn2"
+ },
+ {
+ "reason": "tool-calls",
+ "type": "step-finish",
+ "tokens": {
+ "total": 21552,
+ "input": 1427,
+ "output": 277,
+ "reasoning": 200,
+ "cache": {
+ "write": 0,
+ "read": 19648
+ }
+ },
+ "cost": 0.00761844,
+ "id": "prt_e3e05c775001X5XOHQK7TqHej2",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e05754c001jEp8jD7aV1Rjn2"
+ }
+ ]
+ },
+ {
+ "info": {
+ "parentID": "msg_e3e044477001ADKhSz2Jx6n3An",
+ "role": "assistant",
+ "mode": "build",
+ "agent": "build",
+ "path": {
+ "cwd": "/mnt/projects/EVCS/EVCS",
+ "root": "/mnt/projects/EVCS/EVCS"
+ },
+ "cost": 0.00692044,
+ "tokens": {
+ "total": 21964,
+ "input": 548,
+ "output": 272,
+ "reasoning": 88,
+ "cache": {
+ "write": 0,
+ "read": 21056
+ }
+ },
+ "modelID": "moonshotai/kimi-k2.6",
+ "providerID": "openrouter",
+ "time": {
+ "created": 1779157026684,
+ "completed": 1779157042278
+ },
+ "finish": "tool-calls",
+ "id": "msg_e3e05c77c001uNQybifM56jbD5",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F"
+ },
+ "parts": [
+ {
+ "type": "step-start",
+ "id": "prt_e3e05cde6001EQYbq1k1zZxYu9",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e05c77c001uNQybifM56jbD5"
+ },
+ {
+ "type": "reasoning",
+ "text": "Le PUT direct sur /rest/workflows/:id ne fonctionne pas. L'endpoint N8N pour mettre à jour un workflow est probablement PATCH ou avec un autre chemin.\n\nDans N8N récent, l'API est souvent :\n- GET /rest/workflows/:id\n- PATCH /rest/workflows/:id\n\nOu bien, il faut passer par :\n- POST /rest/workflows/save\n\nEssayons PATCH.",
+ "time": {
+ "start": 1779157028328,
+ "end": 1779157042189
+ },
+ "metadata": {
+ "openrouter": {
+ "reasoning_details": [
+ {
+ "type": "reasoning.text",
+ "text": "Le PUT direct sur /rest/workflows/:id ne fonctionne pas. L'endpoint N8N pour mettre à jour un workflow est probablement PATCH ou avec un autre chemin.\n\nDans N8N récent, l'API est souvent :\n- GET /rest/workflows/:id\n- PATCH /rest/workflows/:id\n\nOu bien, il faut passer par :\n- POST /rest/workflows/save\n\nEssayons PATCH.",
+ "format": "unknown",
+ "index": 0
+ }
+ ]
+ }
+ },
+ "id": "prt_e3e05cde8001akUZdbTBC9CzUc",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e05c77c001uNQybifM56jbD5"
+ },
+ {
+ "type": "tool",
+ "tool": "bash",
+ "callID": "functions.bash:104",
+ "state": {
+ "status": "completed",
+ "input": {
+ "command": "COOKIE=\"n8n-auth=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjhiOWU5NTUyLWNkNzQtNDg2My04MmEzLTE3NGIzYjNiZWZhMiIsImhhc2giOiI0ejdua0psNVpWIiwidXNlZE1mYSI6ZmFsc2UsImlhdCI6MTc3OTE1Njk1MiwiZXhwIjoxNzc5NzYxNzUyfQ.oEut-tf5Nok6vg2Hx0n2e0zKyL-ndn3w654rCnJAtMM\"\ncurl -s -w \"\\nHTTP_CODE:%{http_code}\\n\" -X PATCH http://10.10.10.17:5678/rest/workflows/ATswvGfG4KJUls0K -H \"Content-Type: application/json\" -b \"$COOKIE\" -d @/tmp/evcs_v25.json | tail -n 5",
+ "description": "Try PATCH to update EVCS workflow"
+ },
+ "output": "...output truncated...\n\nFull output saved to: /home/tmzf5455/.local/share/opencode/tool-output/tool_e3e06045f001khEVjd3qAG2gnJ\n\nHTTP_CODE:200\n",
+ "metadata": {
+ "output": "...\n\nission = parsed.chargePermissionNow;\\n desiredLatch = parsed.controlLatch;\\n desiredCurrentA = clamp(\\n derived.currentReferenceA || params.currentMinA,\\n params.currentMinA,\\n params.currentMaxAutoA\\n );\\n\\n state = 'controller_disabled';\\n currentDecision = 'hold';\\n dynamicOk = false;\\n mustStop = false;\\n reason.push('Contrôleur désactivé');\\n}\\n\\nelse if (strategy.isStopMode) {\\n setOff({\\n stateName: 'stopped_user',\\n decisionName: 'stop_stop_mode',\\n reasonText: 'Arrêt manuel via Stratégie STOP',\\n dynamic: false,\\n mustStopFlag: false,\\n blockReason: 'manual_stop',\\n blockedUntilStamp: now.stamp,\\n });\\n}\\n\\nelse if (parsed.evcsModeIsAuto) {\\n setOff({\\n stateName: 'mode_auto_conflict',\\n decisionName: 'stop_auto_mode_conflict',\\n reasonText: 'Borne en mode Auto interne',\\n dynamic: false,\\n mustStopFlag: true,\\n blockReason: 'mode_auto_conflict',\\n blockedUntilStamp: now.stamp,\\n });\\n}\\n\\nelse if (!parsed.evcsDeviceConnected || !parsed.evcsVehicleConnected) {\\n setOff({\\n stateName: 'vehicle_not_connected',\\n decisionName: 'stop_not_connected',\\n reasonText: 'Véhicule ou borne non connecté',\\n dynamic: false,\\n mustStopFlag: false,\\n blockReason: 'vehicle_not_connected',\\n blockedUntilStamp: now.stamp,\\n });\\n}\\n\\nelse if (safeguards.mustStopHard) {\\n setOff({\\n stateName:\\n derived.anyVebusLowBattery ? 'vebus_low_battery_hard_stop' :\\n derived.vebusDcVoltageHardLow ? 'vebus_dc_voltage_hard_stop' :\\n derived.vebusAcOutHardReached ? 'vebus_ac_hard_stop' :\\n safeguards.batteryCurrentHardExceeded ? 'battery_current_hard_stop' :\\n 'hard_stop',\\n decisionName: 'stop_hard',\\n reasonText: 'Hard stop de protection',\\n dynamic: false,\\n mustStopFlag: true,\\n blockReason:\\n safeguards.batteryCurrentHardExceeded ? 'battery_current_hard_stop' :\\n derived.vebusDcVoltageHardLow ? 'vebus_dc_voltage_stop' :\\n derived.anyVebusLowBattery ? 'vebus_low_battery_stop' :\\n derived.vebusAcOutHardReached ? 'vebus_ac_hard_stop' :\\n 'battery_protection_stop',\\n blockedUntilStamp: formatParisStampFromMs(nowMs + 15 * 60000),\\n });\\n}\\n\\n// =====================================================\\n// ACTIVE SESSION BRANCH\\n// =====================================================\\nelse if (cloud.sessionObservedActive) {\\n if (cloud.enteringCloudDebt) {\\n setCloudDebtStartedAt = true;\\n }\\n\\n if (conditions.shouldStopOverloadAtMin) {\\n setOff({\\n stateName: 'vebus_overload_stop_min_current',\\n decisionName: 'stop',\\n reasonText: 'Arrêt: surcharge convertisseur persistante au courant mini',\\n dynamic: false,\\n mustStopFlag: true,\\n blockReason: 'vebus_overload_persistent',\\n blockedUntilStamp: formatParisStampFromMs(\\n nowMs + batteryTune.global.overloadStopAfterSeconds * 1000\\n ),\\n });\\n }\\n\\n else if (safeguards.gridBatteryProtectionStop) {\\n setOff({\\n stateName: 'stop_grid_battery_guard',\\n decisionName: 'stop',\\n reasonText: `Arrêt charge réseau: batterie trop sollicitée (${round1(parsed.batteryCurrentA)}A)`,\\n dynamic: false,\\n mustStopFlag: true,\\n blockReason: 'grid_battery_guard',\\n blockedUntilStamp: formatParisStampFromMs(nowMs + 20 * 60000),\\n });\\n }\\n\\n else if (conditions.probeFailedNow) {\\n setOff({\\n stateName: 'probe_failed_lock',\\n decisionName: 'stop',\\n reasonText: 'Mode sonde échoué',\\n dynamic: false,\\n mustStopFlag: true,\\n blockReason: 'probe_failed',\\n blockedUntilStamp: formatParisStampFromMs(nowMs + params.probeRetryMinutes * 60000),\\n });\\n }\\n\\n else if (derived.physicalCapA < params.currentMinA) {\\n if (lifecycle.minRunElapsed) {\\n setOff({\\n stateName: 'stop_power_cap',\\n decisionName: 'stop_power_cap',\\n reasonText: 'Capacité système insuffisante',\\n dynamic: false,\\n mustStopFlag: true,\\n blockReason: 'system_capacity_stop',\\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\\n });\\n } else {\\n setOn({\\n stateName: 'hold_min_run_power_cap',\\n decisionName: 'hold',\\n currentA: params.currentMinA,\\n reasonText: \\\"Maintien jusqu'au temps mini ON\\\",\\n dynamic: false,\\n mustStopFlag: false,\\n });\\n }\\n }\\n\\n else if (conditions.probeWindowActive && !conditions.probeSuccessNow) {\\n if (!conditions.probeHoldGuardOk) {\\n setOff({\\n stateName: 'probe_stop_guard',\\n decisionName: 'stop',\\n reasonText: 'Sonde interrompue: garde-fous non respectés',\\n dynamic: false,\\n mustStopFlag: true,\\n blockReason: 'probe_failed',\\n blockedUntilStamp: formatParisStampFromMs(nowMs + params.probeRetryMinutes * 60000),\\n });\\n } else {\\n setOn({\\n stateName: 'probe_hold',\\n decisionName: 'hold',\\n currentA: Math.min(params.probeCurrentA, derived.physicalCapA),\\n reasonText: `Mode sonde actif à ${round1(Math.min(params.probeCurrentA, derived.physicalCapA))}A`,\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n }\\n\\n else {\\n const activeSourceMode =\\n conditions.probeSuccessNow\\n ? 'solar_only'\\n : context.sourceMode;\\n\\n const holdConditionsOk =\\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced')\\n ? conditions.gridHoldOk\\n : (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential')\\n ? conditions.solarHoldOk\\n : activeSourceMode === 'hybrid_probe'\\n ? conditions.probeHoldGuardOk\\n : activeSourceMode === 'battery_assist_night'\\n ? conditions.batteryAssistNightHoldOk\\n : false;\\n\\n const manualLocalHoldOverride =\\n manualLocalChargeAllowed &&\\n (\\n activeSourceMode === 'solar_only' ||\\n activeSourceMode === 'solar_potential' ||\\n activeSourceMode === 'blocked'\\n );\\n\\n const effectiveHoldConditionsOk =\\n holdConditionsOk || manualLocalHoldOverride;\\n\\n // =================================================\\n // VEHICLE FULL / PAUSED - NUMERIC FILTER DECISION\\n // IMPORTANT:\\n // - uniquement en session active\\n // - uniquement si la source est réellement livrable\\n // - sans utiliser le PV comme critère\\n // =================================================\\n if (\\n vehicleDemandFilter.evcsVehicleNoDemandSemantic &&\\n vehicleDemandFilter.sourceDeliverableForNoDemand\\n ) {\\n setOff({\\n stateName: 'vehicle_full_or_paused',\\n decisionName: 'stop_vehicle_full_or_paused',\\n reasonText: 'Véhicule branché mais ne demande plus réellement de puissance',\\n dynamic: false,\\n mustStopFlag: false,\\n blockReason: 'none',\\n blockedUntilStamp: now.stamp,\\n });\\n\\n reason.push(`DeliveryRatioEma=${vehicleDemandFilter.deliveryRatioEma}`);\\n reason.push(`NoiseEma=${vehicleDemandFilter.deliveryNoiseEma}`);\\n reason.push(`NoDemandScore=${vehicleDemandFilter.noDemandScoreEma}`);\\n reason.push(`ScoreOn=${vehicleDemandFilter.noDemandScoreOn}`);\\n reason.push(`ScoreOff=${vehicleDemandFilter.noDemandScoreOff}`);\\n\\n if (vehicleDemandFilter.evcsNoDemandSinceMinutes !== null) {\\n reason.push(`NoDemand depuis ${round1(vehicleDemandFilter.evcsNoDemandSinceMinutes)} min`);\\n }\\n }\\n\\n else if (manualLocalHoldOverride && activeSourceMode === 'blocked') {\\n setOn({\\n stateName: 'charging_hold_manual_local',\\n decisionName: 'hold',\\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\\n reasonText: 'Maintien manuel local hors fenêtre sans réseau',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n\\n else if (activeSourceMode === 'blocked') {\\n if (lifecycle.minRunElapsed) {\\n setOff({\\n stateName: 'stop_source_lost',\\n decisionName: 'stop',\\n reasonText: 'Source autorisée perdue',\\n dynamic: false,\\n mustStopFlag: true,\\n blockReason: context.profile === 'outside_window' ? 'outside_window' : 'source_unavailable',\\n blockedUntilStamp:\\n context.profile === 'outside_window' && Number.isFinite(locks.nextAllowedWindowStartMs)\\n ? formatParisStampFromMs(locks.nextAllowedWindowStartMs)\\n : formatParisStampFromMs(nowMs + params.minStopMinutes * 60000),\\n });\\n } else {\\n setOn({\\n stateName: 'hold_min_run_source_lost',\\n decisionName: 'hold',\\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\\n reasonText: \\\"Source perdue mais maintien jusqu'au mini ON\\\",\\n dynamic: false,\\n mustStopFlag: false,\\n });\\n }\\n }\\n\\n else if (\\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&\\n !parsed.gridAvailable\\n ) {\\n if (lifecycle.minRunElapsed) {\\n setOff({\\n stateName:\\n context.profile === 'night_hc'\\n ? 'night_wait_grid_takeover_runtime'\\n : 'wait_grid_source_runtime',\\n decisionName: 'hold',\\n reasonText:\\n context.profile === 'night_hc'\\n ? 'Relais réseau demandé pendant la session nuit EVCS'\\n : 'Source réseau demandée mais pas encore disponible',\\n dynamic: false,\\n mustStopFlag: false,\\n blockReason:\\n context.profile === 'night_hc' ? 'grid_hc_unavailable' : 'grid_unavailable',\\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\\n });\\n } else {\\n setOn({\\n stateName: 'hold_min_run_wait_grid_takeover',\\n decisionName: 'hold',\\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\\n reasonText: \\\"Maintien jusqu'au mini ON en attente du réseau\\\",\\n dynamic: false,\\n mustStopFlag: false,\\n });\\n }\\n }\\n\\n else if (!effectiveHoldConditionsOk) {\\n if (lifecycle.minRunElapsed) {\\n setOff({\\n stateName: 'stop_conditions_lost',\\n decisionName: 'stop',\\n reasonText: 'Conditions de maintien perdues',\\n dynamic: false,\\n mustStopFlag: true,\\n blockReason: 'hold_conditions_lost',\\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\\n });\\n } else {\\n setOn({\\n stateName: 'hold_min_run',\\n decisionName: 'hold',\\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\\n reasonText: \\\"Maintien jusqu'au temps mini ON\\\",\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n }\\n\\n else if ((derived.currentReferenceA > derived.physicalCapA) && lifecycle.currentCooldownElapsed) {\\n setOn({\\n stateName: 'charging_step_down_system_cap',\\n decisionName: 'step_down',\\n currentA: Math.max(\\n params.currentMinA,\\n Math.min(\\n derived.currentReferenceA - Math.max(params.currentStepDownA, 2),\\n derived.physicalCapA\\n )\\n ),\\n reasonText: 'Réduction pour respecter la capacité système',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n\\n else if (conditions.shouldStepDownOverload) {\\n setOn({\\n stateName: 'charging_step_down_overload',\\n decisionName: 'step_down_fast',\\n currentA: Math.max(\\n params.currentMinA,\\n Math.min(\\n derived.currentReferenceA - Math.max(batteryTune.global.overloadStepDownA, params.currentStepDownA),\\n derived.physicalCapA\\n )\\n ),\\n reasonText: 'Baisse rapide: surcharge convertisseur',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n\\n else if (conditions.shouldStepDownVebusPower) {\\n setOn({\\n stateName: 'charging_step_down_vebus_power',\\n decisionName: 'step_down_fast',\\n currentA: Math.max(\\n params.currentMinA,\\n Math.min(\\n derived.currentReferenceA - Math.max(params.currentStepDownA + 1, 3),\\n derived.physicalCapA\\n )\\n ),\\n reasonText: 'Baisse rapide: puissance VE.Bus élevée',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n\\n else if (conditions.shouldStepDownVebusVoltage) {\\n setOn({\\n stateName: 'charging_step_down_dc_voltage',\\n decisionName: 'step_down_fast',\\n currentA: Math.max(\\n params.currentMinA,\\n Math.min(\\n derived.currentReferenceA - Math.max(params.currentStepDownA + 1, 3),\\n derived.physicalCapA\\n )\\n ),\\n reasonText: 'Baisse rapide: tension DC basse',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n\\n else if (\\n activeSourceMode === 'battery_assist_night' &&\\n conditions.batteryAssistNightShouldStepDown\\n ) {\\n setOn({\\n stateName: 'charging_step_down_battery_assist_night',\\n decisionName: 'step_down',\\n currentA: Math.max(\\n params.currentMinA,\\n Math.min(\\n derived.currentReferenceA - params.currentStepDownA,\\n derived.physicalCapA\\n )\\n ),\\n reasonText:\\n nightEvcs?.batteryRecoveryUrgent\\n ? 'Réduction: approche départ, préparation takeover réseau'\\n : 'Réduction: assistance batterie nuit devient trop coûteuse',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n\\n else if (\\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&\\n conditions.gridShouldStepDownBattery\\n ) {\\n setOn({\\n stateName:\\n activeSourceMode === 'grid_forced'\\n ? 'charging_step_down_grid_forced_guard'\\n : 'charging_step_down_grid_guard',\\n decisionName: 'step_down',\\n currentA: Math.max(\\n params.currentMinA,\\n Math.min(\\n derived.currentReferenceA - params.currentStepDownA,\\n derived.physicalCapA\\n )\\n ),\\n reasonText:\\n activeSourceMode === 'grid_forced'\\n ? 'Réduction réseau forcé: la batterie commence à participer'\\n : 'Réduction réseau: la batterie commence à participer',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n\\n else if (\\n (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&\\n conditions.solarShouldStepDownCloudDebt\\n ) {\\n setOn({\\n stateName: 'charging_step_down_cloud_debt',\\n decisionName: 'step_down',\\n currentA: Math.max(\\n params.currentMinA,\\n Math.min(\\n derived.currentReferenceA - Math.max(params.currentStepDownA, 2),\\n derived.physicalCapA\\n )\\n ),\\n reasonText: 'Réduction: dette nuage trop élevée',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n\\n else if (\\n (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&\\n conditions.solarShouldStepDownBattery\\n ) {\\n setOn({\\n stateName: 'charging_step_down_battery',\\n decisionName: 'step_down',\\n currentA: Math.max(\\n params.currentMinA,\\n Math.min(\\n derived.currentReferenceA - params.currentStepDownA,\\n derived.physicalCapA\\n )\\n ),\\n reasonText: 'Réduction: batterie trop sollicitée',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n\\n else if (\\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&\\n conditions.gridShouldStepUp\\n ) {\\n setOn({\\n stateName:\\n activeSourceMode === 'grid_forced'\\n ? (\\n nightEvcs?.batteryRecoveryUrgent\\n ? 'night_departure_deadline_grid_forced'\\n : 'charging_step_up_grid_forced'\\n )\\n : 'charging_step_up_grid_hc',\\n decisionName: 'step_up',\\n currentA: clamp(\\n derived.currentReferenceA + params.currentStepUpA,\\n params.currentMinA,\\n derived.physicalCapA\\n ),\\n reasonText:\\n activeSourceMode === 'grid_forced'\\n ? (\\n nightEvcs?.batteryRecoveryUrgent\\n ? 'Hausse prudente: priorité départ, réseau forcé'\\n : 'Hausse prudente du courant en charge réseau forcée'\\n )\\n : 'Hausse prudente du courant en HC réseau',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n\\n else if (\\n activeSourceMode === 'battery_assist_night' &&\\n conditions.batteryAssistNightShouldStepUp\\n ) {\\n setOn({\\n stateName: 'charging_step_up_battery_assist_night',\\n decisionName: 'step_up',\\n currentA: clamp(\\n derived.currentReferenceA + params.currentStepUpA,\\n params.currentMinA,\\n derived.physicalCapA\\n ),\\n reasonText: 'Hausse prudente du courant en assistance batterie nuit',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n\\n else if (\\n (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&\\n conditions.solarShouldStepUp\\n ) {\\n setOn({\\n stateName:\\n conditions.probeSuccessNow\\n ? 'probe_success_step_up'\\n : (modes.batteryPriorityMode\\n ? 'charging_step_up_battery'\\n : 'charging_step_up_forecast'),\\n decisionName: 'step_up',\\n currentA: clamp(\\n derived.currentReferenceA + params.currentStepUpA,\\n params.currentMinA,\\n derived.physicalCapA\\n ),\\n reasonText: 'Hausse prudente du courant',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n\\n else {\\n setOn({\\n stateName:\\n manualLocalHoldOverride ? 'charging_hold_manual_local' :\\n conditions.probeSuccessNow ? 'probe_success_hold' :\\n activeSourceMode === 'battery_assist_night' ? 'charging_hold_battery_assist_night' :\\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') ? 'charging_hold_grid' :\\n cloud.cloudDebtActive ? 'charging_hold_cloud_tolerance' :\\n 'charging_hold',\\n decisionName: 'hold',\\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\\n reasonText:\\n manualLocalHoldOverride\\n ? 'Maintien manuel local du courant'\\n : activeSourceMode === 'battery_assist_night'\\n ? 'Maintien du courant en assistance batterie nuit'\\n : 'Maintien du courant',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n }\\n}\\n\\n// =====================================================\\n// START BRANCH\\n// =====================================================\\nelse {\\n const startConditionsOk =\\n (context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced')\\n ? conditions.gridStartOk\\n : (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential')\\n ? conditions.solarStartOk\\n : context.sourceMode === 'hybrid_probe'\\n ? conditions.probeStartOk\\n : context.sourceMode === 'battery_assist_night'\\n ? conditions.batteryAssistNightStartOk\\n : false;\\n\\n const manualLocalStartOverride =\\n manualLocalChargeAllowed &&\\n (\\n context.sourceMode === 'solar_only' ||\\n context.sourceMode === 'solar_potential' ||\\n context.sourceMode === 'blocked'\\n );\\n\\n if (!derived.canRunAtMinCurrent) {\\n setOff({\\n stateName: 'blocked_by_capacity_min_current',\\n decisionName: 'hold',\\n reasonText: 'Capacité insuffisante pour courant mini',\\n dynamic: false,\\n mustStopFlag: false,\\n blockReason: 'system_capacity_stop',\\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\\n });\\n }\\n\\n else if (manualLocalStartOverride) {\\n setOn({\\n stateName: 'start_manual_local_min_current',\\n decisionName: 'start',\\n currentA: params.currentMinA,\\n reasonText:\\n context.profile === 'outside_window'\\n ? 'Démarrage manuel local hors fenêtre sans réseau'\\n : 'Démarrage manuel local sans réseau',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n\\n nextBlockReason = 'none';\\n nextBlockedUntilStamp = now.stamp;\\n setProbeStartedAt = false;\\n }\\n\\n else if (context.sourceMode === 'blocked') {\\n setOff({\\n stateName:\\n context.profile === 'outside_window'\\n ? 'blocked_outside_window'\\n : (context.profile === 'night_hc'\\n ? 'night_no_grid_hc_lock'\\n : 'idle_wait_source'),\\n decisionName: 'hold',\\n reasonText:\\n context.profile === 'outside_window'\\n ? 'Hors fenêtre préférée sans source suffisante'\\n : context.profile === 'night_hc'\\n ? 'Nuit sans autorisation HC réseau'\\n : 'Source non autorisée ou insuffisante',\\n dynamic: false,\\n mustStopFlag: false,\\n blockReason:\\n context.profile === 'outside_window'\\n ? 'outside_window'\\n : context.profile === 'night_hc'\\n ? 'grid_hc_unavailable'\\n : 'source_unavailable',\\n blockedUntilStamp:\\n Number.isFinite(locks.nextAllowedWindowStartMs)\\n ? formatParisStampFromMs(locks.nextAllowedWindowStartMs)\\n : formatParisStampFromMs(nowMs + 15 * 60000),\\n });\\n }\\n\\n else if (\\n (context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced') &&\\n !parsed.gridAvailable\\n ) {\\n setOff({\\n stateName:\\n context.profile === 'night_hc'\\n ? 'night_wait_grid_takeover'\\n : 'wait_grid_source',\\n decisionName: 'hold',\\n reasonText:\\n context.profile === 'night_hc'\\n ? (\\n nightEvcs?.batteryRecoveryUrgent\\n ? 'Réseau demandé: urgence recharge batterie avant départ'\\n : 'Prise de relais réseau demandée pour session nuit EVCS'\\n )\\n : 'Source réseau demandée mais pas encore disponible',\\n dynamic: false,\\n mustStopFlag: false,\\n blockReason:\\n context.profile === 'night_hc'\\n ? 'grid_hc_unavailable'\\n : 'grid_unavailable',\\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\\n });\\n }\\n\\n else if (locks.startBlockedByReason) {\\n setOff({\\n stateName: 'wait_reason_lock',\\n decisionName: 'hold',\\n reasonText: `Blocage anti-cliquetis: ${history.lastBlockReason}`,\\n dynamic: false,\\n mustStopFlag: false,\\n blockReason: history.lastBlockReason,\\n blockedUntilStamp: history.blockedUntilAt || now.stamp,\\n });\\n }\\n\\n else if (!startConditionsOk) {\\n setOff({\\n stateName: 'idle_wait_conditions',\\n decisionName: 'hold',\\n reasonText:\\n context.sourceMode === 'battery_assist_night'\\n ? 'Conditions de démarrage nuit batterie non réunies'\\n : 'Conditions de démarrage non réunies',\\n dynamic: false,\\n mustStopFlag: false,\\n blockReason: 'none',\\n blockedUntilStamp: now.stamp,\\n });\\n }\\n\\n else if (!lifecycle.minStopElapsed) {\\n setOff({\\n stateName: 'wait_min_stop',\\n decisionName: 'hold',\\n reasonText: 'Temps mini OFF non écoulé',\\n dynamic: false,\\n mustStopFlag: false,\\n blockReason: 'none',\\n blockedUntilStamp: now.stamp,\\n });\\n }\\n\\n else {\\n const startCurrentA =\\n context.sourceMode === 'hybrid_probe'\\n ? clamp(params.probeCurrentA, params.currentMinA, derived.physicalCapA)\\n : params.currentMinA;\\n\\n setOn({\\n stateName:\\n context.sourceMode === 'grid_forced' ? (\\n nightEvcs?.batteryRecoveryUrgent\\n ? 'night_departure_deadline_grid_forced'\\n : 'start_grid_forced_min_current'\\n ) :\\n context.sourceMode === 'grid_hc_allowed' ? 'start_grid_hc_min_current' :\\n context.sourceMode === 'battery_assist_night' ? 'start_battery_assist_night_min_current' :\\n context.sourceMode === 'hybrid_probe' ? 'probe_start_6a' :\\n context.sourceMode === 'solar_potential' ? 'start_forecast_solar_potential' :\\n modes.batteryFullHarvestMode ? 'start_min_current_battery_full_harvest' :\\n modes.forecastCommitMode ? 'start_min_current_forecast_commit' :\\n 'start_min_current',\\n decisionName: 'start',\\n currentA: startCurrentA,\\n reasonText:\\n context.sourceMode === 'grid_forced'\\n ? (\\n nightEvcs?.batteryRecoveryUrgent\\n ? 'Démarrage réseau forcé: priorité départ / recharge batterie'\\n : 'Démarrage en charge réseau forcée'\\n )\\n : context.sourceMode === 'grid_hc_allowed'\\n ? 'Démarrage en fenêtre HC réseau'\\n : context.sourceMode === 'battery_assist_night'\\n ? 'Démarrage nuit en assistance batterie contrôlée'\\n : context.sourceMode === 'hybrid_probe'\\n ? 'Démarrage en mode sonde'\\n : context.sourceMode === 'solar_potential'\\n ? 'Démarrage par potentiel solaire confirmé'\\n : modes.batteryFullHarvestMode\\n ? 'Démarrage harvest solaire'\\n : modes.forecastCommitMode\\n ? 'Démarrage forecast commit'\\n : 'Démarrage prudent à courant mini',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n\\n nextBlockReason = 'none';\\n nextBlockedUntilStamp = now.stamp;\\n setProbeStartedAt = context.sourceMode === 'hybrid_probe';\\n }\\n}\\n\\n// =====================================================\\n// CONTEXT REASONS\\n// =====================================================\\nif (nightEvcs?.nightSessionActive) {\\n reason.push(\\n `Nuit EVCS: target=${round1(nightEvcs.batteryMorningTargetPct)}% | trigger réseau=${round1(nightEvcs.gridTakeoverTriggerPct)}%`\\n );\\n\\n if (Number.isFinite(nightEvcs.nightSessionStartSocPct)) {\\n reason.push(\\n `SOC départ session nuit=${round1(nightEvcs.nightSessionStartSocPct)}% | décharge autorisée=${round1(nightEvcs.maxBatteryDischargePoints)} pts`\\n );\\n }\\n\\n if (nightEvcs.batteryRecoveryUrgent) {\\n reason.push(\\\"Urgence départ: il faut anticiper la reprise réseau\\\");\\n }\\n}\\n\\nif (manualLocalChargeAllowed) {\\n reason.push(`ManualLocalCharge=on`);\\n reason.push(`ManualLocalProfile=${context.profile}`);\\n reason.push(`ManualLocalSource=${context.sourceMode}`);\\n}\\n\\nreason.push(`VehicleDemandFilter delivery=${vehicleDemandFilter.deliveryRatioEma}`);\\nreason.push(`VehicleDemandFilter noise=${vehicleDemandFilter.deliveryNoiseEma}`);\\nreason.push(`VehicleDemandFilter score=${vehicleDemandFilter.noDemandScoreEma}`);\\nreason.push(`VehicleDemandFilter noDemand=${vehicleDemandFilter.evcsVehicleNoDemandSemantic ? 'on' : 'off'}`);\\n\\n// =====================================================\\n// OUTPUT\\n// =====================================================\\nreturn [{\\n json: {\\n ...prev,\\n vehicleDemandFilter,\\n decision: {\\n desiredCharging,\\n desiredPermission,\\n desiredLatch,\\n desiredCurrentA,\\n\\n state,\\n currentDecision,\\n dynamicOk,\\n mustStop,\\n reason,\\n\\n nextBlockReason,\\n nextBlockedUntilStamp,\\n\\n setCloudDebtStartedAt,\\n setProbeStartedAt,\\n\\n setNightSessionStart,\\n clearNightSessionStart,\\n\\n notifyWanted,\\n notifyTitle,\\n notifyMessage,\\n }\\n }\\n}];\\n\"},\"type\":\"n8n-nodes-base.code\",\"typeVersion\":2,\"position\":[496,32],\"id\":\"1a8271fd-112a-45c6-8bc6-664d1215f72f\",\"name\":\"DECISION_ENGINEV2\"}],\"connections\":{\"Schedule Trigger\":{\"main\":[[{\"node\":\"HA Snapshot EVCS Surplus Controller V2.2\",\"type\":\"main\",\"index\":0}]]},\"Build EVCS Surplus Controller V2.0 Actions\":{\"main\":[[{\"node\":\"Execute HA Service EVCS Surplus\",\"type\":\"main\",\"index\":0}]]},\"BATTERY_CLOUD_LOCKS\":{\"main\":[[{\"node\":\"DECISION_ENGINEV2\",\"type\":\"main\",\"index\":0}]]},\"INPUT_PARSE\":{\"main\":[[{\"node\":\"WINDOWS_SOURCEV2\",\"type\":\"main\",\"index\":0}]]},\"HA_SERVICE_CALLS_OUTPUT\":{\"main\":[[{\"node\":\"Build EVCS Surplus Controller V2.0 Actions\",\"type\":\"main\",\"index\":0}]]},\"HA Snapshot EVCS Surplus Controller V2.2\":{\"main\":[[{\"node\":\"INPUT_PARSE\",\"type\":\"main\",\"index\":0}]]},\"WINDOWS_SOURCEV2\":{\"main\":[[{\"node\":\"BATTERY_CLOUD_LOCKS\",\"type\":\"main\",\"index\":0}]]},\"DECISION_ENGINEV2\":{\"main\":[[{\"node\":\"HA_SERVICE_CALLS_OUTPUT\",\"type\":\"main\",\"index\":0}]]}},\"settings\":{\"executionOrder\":\"v1\",\"binaryMode\":\"separate\",\"availableInMCP\":false,\"timeSavedMode\":\"fixed\",\"callerPolicy\":\"workflowsFromSameOwner\"},\"staticData\":{\"node:Schedule Trigger\":{\"recurrenceRules\":[]}},\"meta\":null,\"pinData\":{},\"versionId\":\"0637fe44-61a3-450b-8ded-74302727a829\",\"activeVersionId\":null,\"versionCounter\":188,\"triggerCount\":1,\"tags\":[],\"activeVersion\":null,\"scopes\":[\"workflow:create\",\"workflow:delete\",\"workflow:execute\",\"workflow:execute-chat\",\"workflow:list\",\"workflow:move\",\"workflow:publish\",\"workflow:read\",\"workflow:share\",\"workflow:unpublish\",\"workflow:unshare\",\"workflow:update\",\"workflow:updateRedactionSetting\"],\"checksum\":\"30efb7d06b0baf018e5542f4ea01327ca41358cd84a3ee907132666681712a7e\"}}\nHTTP_CODE:200\n",
+ "exit": 0,
+ "description": "Try PATCH to update EVCS workflow",
+ "truncated": true,
+ "outputPath": "/home/tmzf5455/.local/share/opencode/tool-output/tool_e3e06045f001khEVjd3qAG2gnJ"
+ },
+ "title": "Try PATCH to update EVCS workflow",
+ "time": {
+ "start": 1779157042109,
+ "end": 1779157042274
+ }
+ },
+ "metadata": {
+ "openrouter": {
+ "reasoning_details": [
+ {
+ "type": "reasoning.text",
+ "text": "Le PUT direct sur /rest/workflows/:id ne fonctionne pas. L'endpoint N8N pour mettre à jour un workflow est probablement PATCH ou avec un autre chemin.\n\nDans N8N récent, l'API est souvent :\n- GET /rest/workflows/:id\n- PATCH /rest/workflows/:id\n\nOu bien, il faut passer par :\n- POST /rest/workflows/save\n\nEssayons PATCH.",
+ "format": "unknown",
+ "index": 0
+ }
+ ]
+ }
+ },
+ "id": "prt_e3e05d7d3001354PnNhDfsvEgR",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e05c77c001uNQybifM56jbD5"
+ },
+ {
+ "reason": "tool-calls",
+ "type": "step-finish",
+ "tokens": {
+ "total": 21964,
+ "input": 548,
+ "output": 272,
+ "reasoning": 88,
+ "cache": {
+ "write": 0,
+ "read": 21056
+ }
+ },
+ "cost": 0.00692044,
+ "id": "prt_e3e060464001LraV0ikQwykAMG",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e05c77c001uNQybifM56jbD5"
+ }
+ ]
+ },
+ {
+ "info": {
+ "parentID": "msg_e3e044477001ADKhSz2Jx6n3An",
+ "role": "assistant",
+ "mode": "build",
+ "agent": "build",
+ "path": {
+ "cwd": "/mnt/projects/EVCS/EVCS",
+ "root": "/mnt/projects/EVCS/EVCS"
+ },
+ "cost": 0.0069842,
+ "tokens": {
+ "total": 22388,
+ "input": 460,
+ "output": 269,
+ "reasoning": 91,
+ "cache": {
+ "write": 0,
+ "read": 21568
+ }
+ },
+ "modelID": "moonshotai/kimi-k2.6",
+ "providerID": "openrouter",
+ "time": {
+ "created": 1779157042283,
+ "completed": 1779157076561
+ },
+ "finish": "tool-calls",
+ "id": "msg_e3e06046b001FSH3EClOFX3jyD",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F"
+ },
+ "parts": [
+ {
+ "type": "step-start",
+ "id": "prt_e3e0642ce0014gFsQHmYij70Rk",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e06046b001FSH3EClOFX3jyD"
+ },
+ {
+ "type": "reasoning",
+ "text": "Excellent ! Le PATCH a retourné HTTP 200. Parfait. Maintenant, faisons la même chose pour le workflow Grid Hysteresis. Puis on les réactivera.\n\nVérifions rapidement le contenu du output pour m'assurer que c'est bien le workflow mis à jour et pas une erreur dissimulée. Mais 200 = succès probable.\n\nEnsuite, PATCH Grid Hysteresis.",
+ "time": {
+ "start": 1779157058256,
+ "end": 1779157076554
+ },
+ "metadata": {
+ "openrouter": {
+ "reasoning_details": [
+ {
+ "type": "reasoning.text",
+ "text": "Excellent ! Le PATCH a retourné HTTP 200. Parfait. Maintenant, faisons la même chose pour le workflow Grid Hysteresis. Puis on les réactivera.\n\nVérifions rapidement le contenu du output pour m'assurer que c'est bien le workflow mis à jour et pas une erreur dissimulée. Mais 200 = succès probable.\n\nEnsuite, PATCH Grid Hysteresis.",
+ "format": "unknown",
+ "index": 0
+ }
+ ]
+ }
+ },
+ "id": "prt_e3e0642d0001xpODAxpC4uvkeg",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e06046b001FSH3EClOFX3jyD"
+ },
+ {
+ "type": "tool",
+ "tool": "bash",
+ "callID": "functions.bash:105",
+ "state": {
+ "status": "completed",
+ "input": {
+ "command": "COOKIE=\"n8n-auth=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjhiOWU5NTUyLWNkNzQtNDg2My04MmEzLTE3NGIzYjNiZWZhMiIsImhhc2giOiI0ejdua0psNVpVIiwidXNlZE1mYSI6ZmFsc2UsImlhdCI6MTc3OTE1Njk1MiwiZXhwIjoxNzc5NzYxNzUyfQ.oEut-tf5Nok6vg2Hx0n2e0zKyL-ndn3w654rCnJAtMM\"\ncurl -s -w \"\\nHTTP_CODE:%{http_code}\\n\" -X PATCH http://10.10.10.17:5678/rest/workflows/7iIlu7LLTW7TZA8o -H \"Content-Type: application/json\" -b \"$COOKIE\" -d @/tmp/grid_hyst.json | tail -n 5",
+ "description": "Push Grid Hysteresis patched workflow"
+ },
+ "output": "{\"status\":\"error\",\"message\":\"Unauthorized\"}\nHTTP_CODE:401\n",
+ "metadata": {
+ "output": "{\"status\":\"error\",\"message\":\"Unauthorized\"}\nHTTP_CODE:401\n",
+ "exit": 0,
+ "description": "Push Grid Hysteresis patched workflow",
+ "truncated": false
+ },
+ "title": "Push Grid Hysteresis patched workflow",
+ "time": {
+ "start": 1779157076494,
+ "end": 1779157076497
+ }
+ },
+ "metadata": {
+ "openrouter": {
+ "reasoning_details": [
+ {
+ "type": "reasoning.text",
+ "text": "Excellent ! Le PATCH a retourné HTTP 200. Parfait. Maintenant, faisons la même chose pour le workflow Grid Hysteresis. Puis on les réactivera.\n\nVérifions rapidement le contenu du output pour m'assurer que c'est bien le workflow mis à jour et pas une erreur dissimulée. Mais 200 = succès probable.\n\nEnsuite, PATCH Grid Hysteresis.",
+ "format": "unknown",
+ "index": 0
+ }
+ ]
+ }
+ },
+ "id": "prt_e3e0656cf001sKTnfTitxtBMmn",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e06046b001FSH3EClOFX3jyD"
+ },
+ {
+ "reason": "tool-calls",
+ "type": "step-finish",
+ "tokens": {
+ "total": 22388,
+ "input": 460,
+ "output": 269,
+ "reasoning": 91,
+ "cache": {
+ "write": 0,
+ "read": 21568
+ }
+ },
+ "cost": 0.0069842,
+ "id": "prt_e3e068a4d001FmasRVwgLEI90V",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e06046b001FSH3EClOFX3jyD"
+ }
+ ]
+ },
+ {
+ "info": {
+ "parentID": "msg_e3e044477001ADKhSz2Jx6n3An",
+ "role": "assistant",
+ "mode": "build",
+ "agent": "build",
+ "path": {
+ "cwd": "/mnt/projects/EVCS/EVCS",
+ "root": "/mnt/projects/EVCS/EVCS"
+ },
+ "cost": 0.00758182,
+ "tokens": {
+ "total": 22702,
+ "input": 2068,
+ "output": 126,
+ "reasoning": 156,
+ "cache": {
+ "write": 0,
+ "read": 20352
+ }
+ },
+ "modelID": "moonshotai/kimi-k2.6",
+ "providerID": "openrouter",
+ "time": {
+ "created": 1779157076567,
+ "completed": 1779157089757
+ },
+ "finish": "tool-calls",
+ "id": "msg_e3e068a57001mKDANToLD3rpDy",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F"
+ },
+ "parts": [
+ {
+ "type": "step-start",
+ "id": "prt_e3e06970d001TKc79Zh0O11Nmk",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e068a57001mKDANToLD3rpDy"
+ },
+ {
+ "type": "reasoning",
+ "text": "Le cookie a probablement expiré entre temps, ou il y a un problème avec le cookie pour le deuxième appel. Auparavant le login a fonctionné. Peut-être que le cookie est trop long ou mal passé. Je vais reloguer pour obtenir un cookie frais, puis refaire le PATCH Grid Hysteresis.\n\nNote : le cookie précédent contenait `usedMfa=false` et `exp` vers le 26 mai. Mais le deuxième appel a retourné 401. Peut-être une histoire de `SameSite=Lax` ou de CSRF token ? Ou le cookie change entre les requêtes ?\n\nReloggons et stockons le cookie dans un fichier `-c` pour le réutiliser.",
+ "time": {
+ "start": 1779157079823,
+ "end": 1779157089668
+ },
+ "metadata": {
+ "openrouter": {
+ "reasoning_details": [
+ {
+ "type": "reasoning.text",
+ "text": "Le cookie a probablement expiré entre temps, ou il y a un problème avec le cookie pour le deuxième appel. Auparavant le login a fonctionné. Peut-être que le cookie est trop long ou mal passé. Je vais reloguer pour obtenir un cookie frais, puis refaire le PATCH Grid Hysteresis.\n\nNote : le cookie précédent contenait `usedMfa=false` et `exp` vers le 26 mai. Mais le deuxième appel a retourné 401. Peut-être une histoire de `SameSite=Lax` ou de CSRF token ? Ou le cookie change entre les requêtes ?\n\nReloggons et stockons le cookie dans un fichier `-c` pour le réutiliser.",
+ "format": "unknown",
+ "index": 0
+ }
+ ]
+ }
+ },
+ "id": "prt_e3e06970f001Rfv6phBwXrqWFx",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e068a57001mKDANToLD3rpDy"
+ },
+ {
+ "type": "tool",
+ "tool": "bash",
+ "callID": "functions.bash:106",
+ "state": {
+ "status": "completed",
+ "input": {
+ "command": "curl -s -i -X POST http://10.10.10.17:5678/rest/login -H \"Content-Type: application/json\" -d '{\"emailOrLdapLoginId\":\"tallagrand.thierry@gmail.com\",\"password\":\"Airwan=25Z\"}' -c /tmp/n8n_cookies.txt | grep -iE 'set-cookie|n8n-auth'",
+ "description": "Re-login and save cookies to file"
+ },
+ "output": "Set-Cookie: n8n-auth=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjhiOWU5NTUyLWNkNzQtNDg2My04MmEzLTE3NGIzYjNiZWZhMiIsImhhc2giOiI0ejdua0psNVpWIiwidXNlZE1mYSI6ZmFsc2UsImlhdCI6MTc3OTE1NzA4OSwiZXhwIjoxNzc5NzYxODg5fQ.lyffPwCScEgOlwf-cuAl5crpctEsq3KHpDtkdBT_NRU; Max-Age=604800; Path=/; Expires=Tue, 26 May 2026 02:18:09 GMT; HttpOnly; SameSite=Lax\r\n",
+ "metadata": {
+ "output": "Set-Cookie: n8n-auth=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjhiOWU5NTUyLWNkNzQtNDg2My04MmEzLTE3NGIzYjNiZWZhMiIsImhhc2giOiI0ejdua0psNVpWIiwidXNlZE1mYSI6ZmFsc2UsImlhdCI6MTc3OTE1NzA4OSwiZXhwIjoxNzc5NzYxODg5fQ.lyffPwCScEgOlwf-cuAl5crpctEsq3KHpDtkdBT_NRU; Max-Age=604800; Path=/; Expires=Tue, 26 May 2026 02:18:09 GMT; HttpOnly; SameSite=Lax\r\n",
+ "exit": 0,
+ "description": "Re-login and save cookies to file",
+ "truncated": false
+ },
+ "title": "Re-login and save cookies to file",
+ "time": {
+ "start": 1779157089749,
+ "end": 1779157089753
+ }
+ },
+ "metadata": {
+ "openrouter": {
+ "reasoning_details": [
+ {
+ "type": "reasoning.text",
+ "text": "Le cookie a probablement expiré entre temps, ou il y a un problème avec le cookie pour le deuxième appel. Auparavant le login a fonctionné. Peut-être que le cookie est trop long ou mal passé. Je vais reloguer pour obtenir un cookie frais, puis refaire le PATCH Grid Hysteresis.\n\nNote : le cookie précédent contenait `usedMfa=false` et `exp` vers le 26 mai. Mais le deuxième appel a retourné 401. Peut-être une histoire de `SameSite=Lax` ou de CSRF token ? Ou le cookie change entre les requêtes ?\n\nReloggons et stockons le cookie dans un fichier `-c` pour le réutiliser.",
+ "format": "unknown",
+ "index": 0
+ }
+ ]
+ }
+ },
+ "id": "prt_e3e06afe10010X3dEnzgeJtetX",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e068a57001mKDANToLD3rpDy"
+ },
+ {
+ "reason": "tool-calls",
+ "type": "step-finish",
+ "tokens": {
+ "total": 22702,
+ "input": 2068,
+ "output": 126,
+ "reasoning": 156,
+ "cache": {
+ "write": 0,
+ "read": 20352
+ }
+ },
+ "cost": 0.00758182,
+ "id": "prt_e3e06bddb001Wj2ER6jz3o7GVm",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e068a57001mKDANToLD3rpDy"
+ }
+ ]
+ },
+ {
+ "info": {
+ "parentID": "msg_e3e044477001ADKhSz2Jx6n3An",
+ "role": "assistant",
+ "mode": "build",
+ "agent": "build",
+ "path": {
+ "cwd": "/mnt/projects/EVCS/EVCS",
+ "root": "/mnt/projects/EVCS/EVCS"
+ },
+ "cost": 0.00670747,
+ "tokens": {
+ "total": 23071,
+ "input": 898,
+ "output": 117,
+ "reasoning": 40,
+ "cache": {
+ "write": 0,
+ "read": 22016
+ }
+ },
+ "modelID": "moonshotai/kimi-k2.6",
+ "providerID": "openrouter",
+ "time": {
+ "created": 1779157089762,
+ "completed": 1779157099768
+ },
+ "finish": "tool-calls",
+ "id": "msg_e3e06bde2001TiaPSDoTNX5DER",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F"
+ },
+ "parts": [
+ {
+ "type": "step-start",
+ "id": "prt_e3e06cfa8001T0Haj6PHfKeSDn",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e06bde2001TiaPSDoTNX5DER"
+ },
+ {
+ "type": "reasoning",
+ "text": "Maintenant je vais utiliser le fichier cookie /tmp/n8n_cookies.txt pour faire le PATCH Grid Hysteresis. Cette fois, je spécifie `-b /tmp/n8n_cookies.txt`.",
+ "time": {
+ "start": 1779157094314,
+ "end": 1779157099718
+ },
+ "metadata": {
+ "openrouter": {
+ "reasoning_details": [
+ {
+ "type": "reasoning.text",
+ "text": "Maintenant je vais utiliser le fichier cookie /tmp/n8n_cookies.txt pour faire le PATCH Grid Hysteresis. Cette fois, je spécifie `-b /tmp/n8n_cookies.txt`.",
+ "format": "unknown",
+ "index": 0
+ }
+ ]
+ }
+ },
+ "id": "prt_e3e06cfaa001lUCIxC1BgqxOpq",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e06bde2001TiaPSDoTNX5DER"
+ },
+ {
+ "type": "tool",
+ "tool": "bash",
+ "callID": "functions.bash:107",
+ "state": {
+ "status": "completed",
+ "input": {
+ "command": "curl -s -w \"\\nHTTP_CODE:%{http_code}\\n\" -X PATCH http://10.10.10.17:5678/rest/workflows/7iIlu7LLTW7TZA8o -H \"Content-Type: application/json\" -b /tmp/n8n_cookies.txt -d @/tmp/grid_hyst.json | tail -n 5",
+ "description": "PATCH Grid Hysteresis with cookie file"
+ },
+ "output": "{\"data\":{\"updatedAt\":\"2026-05-19T02:18:19.712Z\",\"createdAt\":\"2026-03-17T01:43:29.018Z\",\"id\":\"7iIlu7LLTW7TZA8o\",\"name\":\"Victron - Battery / Grid Hysteresis Fix V1\",\"description\":null,\"active\":false,\"isArchived\":false,\"nodes\":[{\"parameters\":{\"rule\":{\"interval\":[{\"field\":\"minutes\",\"minutesInterval\":1}]}},\"type\":\"n8n-nodes-base.scheduleTrigger\",\"typeVersion\":1.3,\"position\":[0,0],\"id\":\"0b9ad256-b51b-4921-b81b-5ba8a2113412\",\"name\":\"Schedule Trigger\"},{\"parameters\":{\"method\":\"POST\",\"url\":\"=http://10.10.10.17:8123/api/template\\n\",\"authentication\":\"genericCredentialType\",\"genericAuthType\":\"httpBearerAuth\",\"sendHeaders\":true,\"headerParameters\":{\"parameters\":[{\"name\":\"Content-Type\",\"value\":\"application/json\"},{\"name\":\"Accept\",\"value\":\"text/plain\"}]},\"sendBody\":true,\"bodyParameters\":{\"parameters\":[{\"name\":\"template\",\"value\":\"{{ {\\n \\\"soc_percent\\\": states(\\\"sensor.victron_battery_soc\\\"),\\n \\\"grid_state\\\": states(\\\"switch.edf_general_ok_switch\\\"),\\n \\\"pv_power_w\\\": states(\\\"sensor.victron_pv_power_system\\\"),\\n\\n \\\"house_ac_power_w\\\": states(\\\"sensor.victron_consumption_power_l1\\\"),\\n \\\"grid_power_w\\\": states(\\\"sensor.victron_grid_power_l1\\\"),\\n\\n \\\"evcs_ac_power_w\\\": states(\\\"sensor.victron_evcs_ac_power\\\"),\\n \\\"evcs_charging\\\": states(\\\"switch.victron_evcs_charging\\\"),\\n \\\"evcs_status_raw\\\": states(\\\"sensor.victron_evcs_status_raw\\\"),\\n\\n \\\"hysteresis_enabled\\\": states(\\\"input_boolean.victron_grid_hysteresis_enabled\\\"),\\n \\\"notifications_enabled\\\": states(\\\"input_boolean.victron_grid_hysteresis_notifications\\\"),\\n \\\"grid_latch\\\": states(\\\"input_boolean.victron_grid_hysteresis_latch\\\"),\\n\\n \\\"grid_soc_on\\\": states(\\\"input_number.victron_grid_soc_on\\\"),\\n \\\"grid_soc_off\\\": states(\\\"input_number.victron_grid_soc_off\\\"),\\n \\\"grid_min_on_minutes\\\": states(\\\"input_number.victron_grid_min_on_minutes\\\"),\\n \\\"grid_cooldown_minutes\\\": states(\\\"input_number.victron_grid_cooldown_minutes\\\"),\\n \\\"ac_loads_max_safe_w\\\": states(\\\"input_number.victron_ac_loads_max_safe_w\\\"),\\n\\n \\\"last_on_at\\\": states(\\\"input_datetime.victron_grid_last_on_at\\\"),\\n \\\"last_off_at\\\": states(\\\"input_datetime.victron_grid_last_off_at\\\"),\\n \\\"last_signature\\\": states(\\\"input_text.victron_grid_control_last_signature\\\"),\\n\\n \\\"evcs_priority_active\\\": states(\\\"input_boolean.victron_evcs_priority_active\\\"),\\n \\\"night_grid_takeover_needed\\\": states(\\\"input_boolean.victron_evcs_night_grid_takeover_needed\\\"),\\n \\\"evcs_source_mode\\\": states(\\\"input_text.victron_evcs_source_mode\\\"),\\n \\\"user_strategy\\\": states(\\\"input_select.victron_evcs_user_strategy\\\"),\\n \\\"evcs_surplus_state\\\": states(\\\"input_text.victron_evcs_surplus_state\\\")\\n} | tojson }}\\n\"}]},\"options\":{}},\"id\":\"577c8308-a790-44f8-a9dc-098811bd5e0b\",\"name\":\"HA Snapshot Grid Hysteresis\",\"type\":\"n8n-nodes-base.httpRequest\",\"typeVersion\":4.2,\"position\":[224,0],\"credentials\":{\"httpBearerAuth\":{\"id\":\"uv3sknYYigxBkaVN\",\"name\":\"Bearer Auth account 2\"}}},{\"parameters\":{\"jsCode\":\"function num(v, fallback = NaN) {\\n const n = Number.parseFloat(v);\\n return Number.isFinite(n) ? n : fallback;\\n}\\n\\nfunction low(v) {\\n return (v ?? '').toString().trim().toLowerCase();\\n}\\n\\nfunction parseMaybeJson(v) {\\n if (typeof v !== 'string') return v;\\n try {\\n return JSON.parse(v);\\n } catch {\\n return v;\\n }\\n}\\n\\nfunction extractSnapshot(input) {\\n if (typeof input === 'string') return parseMaybeJson(input);\\n if (input?.data && typeof input.data === 'string') return parseMaybeJson(input.data);\\n if (input?.body && typeof input.body === 'string') return parseMaybeJson(input.body);\\n if (input?.data && typeof input.data === 'object') return input.data;\\n if (input?.body && typeof input.body === 'object') return input.body;\\n return input;\\n}\\n\\nfunction round1(v) {\\n return Math.round(v * 10) / 10;\\n}\\n\\nfunction clamp(v, min, max) {\\n return Math.min(Math.max(v, min), max);\\n}\\n\\nfunction pickDefined(...values) {\\n for (const v of values) {\\n if (v === undefined || v === null) continue;\\n if (typeof v === 'string' && v.trim() === '') continue;\\n return v;\\n }\\n return undefined;\\n}\\n\\nfunction isOnLike(v) {\\n const s = low(v);\\n return s === 'on' || s === 'true' || s === '1' || s === 'yes';\\n}\\n\\nfunction getParisNowParts() {\\n const fmt = new Intl.DateTimeFormat('fr-FR', {\\n timeZone: 'Europe/Paris',\\n year: 'numeric',\\n month: '2-digit',\\n day: '2-digit',\\n hour: '2-digit',\\n minute: '2-digit',\\n second: '2-digit',\\n hour12: false,\\n });\\n\\n const parts = Object.fromEntries(\\n fmt.formatToParts(new Date())\\n .filter(p => p.type !== 'literal')\\n .map(p => [p.type, p.value])\\n );\\n\\n const date = `${parts.year}-${parts.month}-${parts.day}`;\\n const stamp = `${date} ${parts.hour}:${parts.minute}:${parts.second}`;\\n\\n return {\\n year: Number(parts.year),\\n month: Number(parts.month),\\n day: Number(parts.day),\\n hour: Number(parts.hour),\\n minute: Number(parts.minute),\\n second: Number(parts.second),\\n date,\\n stamp,\\n };\\n}\\n\\nfunction parseLocalStampToMs(s) {\\n const text = (s ?? '').toString().trim();\\n const m = text.match(/^(\\\\d{4})-(\\\\d{2})-(\\\\d{2}) (\\\\d{2}):(\\\\d{2}):(\\\\d{2})$/);\\n if (!m) return null;\\n\\n const y = Number(m[1]);\\n const mo = Number(m[2]);\\n const d = Number(m[3]);\\n const h = Number(m[4]);\\n const mi = Number(m[5]);\\n const se = Number(m[6]);\\n\\n if (![y, mo, d, h, mi, se].every(Number.isFinite)) return null;\\n\\n return new Date(y, mo - 1, d, h, mi, se).getTime();\\n}\\n\\nconst cfg = {\\n mobileNotifyDomain: 'notify',\\n mobileNotifyService: 'mobile_app_iphone_16_de_tt',\\n};\\n\\nconst raw = $input.first().json;\\nconst input = extractSnapshot(raw);\\nconst now = getParisNowParts();\\nconst nowMs = parseLocalStampToMs(now.stamp);\\n\\n// ======================================================\\n// INPUTS PRINCIPAUX\\n// ======================================================\\nconst soc = clamp(num(input.soc_percent, 0), 0, 100);\\nconst gridIsOn = low(input.grid_state || 'off') === 'on';\\nconst pvPowerW = Math.max(0, num(input.pv_power_w, 0));\\n\\nconst houseAcPowerW = Math.max(0, num(input.house_ac_power_w, 0));\\nconst gridPowerW = num(input.grid_power_w, 0);\\n\\nconst evcsAcPowerW = Math.max(0, num(input.evcs_ac_power_w, 0));\\nconst evcsCharging = low(input.evcs_charging || 'off') === 'on';\\nconst evcsStatusRaw = num(input.evcs_status_raw, NaN);\\n\\nconst hysteresisEnabled = low(input.hysteresis_enabled || 'off') === 'on';\\nconst notificationsEnabled = low(input.notifications_enabled || 'off') === 'on';\\nconst latchIsOn = low(input.grid_latch || 'off') === 'on';\\n\\nconst gridSocOn = clamp(num(input.grid_soc_on, 22), 0, 100);\\nconst gridSocOff = clamp(num(input.grid_soc_off, 35), 0, 100);\\nconst gridMinOnMinutes = Math.max(0, num(input.grid_min_on_minutes, 45));\\nconst gridCooldownMinutes = Math.max(0, num(input.grid_cooldown_minutes, 15));\\nconst acLoadsMaxSafeW = Math.max(0, num(input.ac_loads_max_safe_w, 7800));\\n\\nconst lastOnAt = (input.last_on_at ?? '').toString().trim();\\nconst lastOffAt = (input.last_off_at ?? '').toString().trim();\\nconst lastSignature = (input.last_signature ?? '').toString().trim();\\n\\nconst lastOnMs = parseLocalStampToMs(lastOnAt);\\nconst lastOffMs = parseLocalStampToMs(lastOffAt);\\n\\nconst minutesSinceLastOn =\\n lastOnMs !== null && nowMs !== null ? Math.max(0, (nowMs - lastOnMs) / 60000) : null;\\n\\nconst minutesSinceLastOff =\\n lastOffMs !== null && nowMs !== null ? Math.max(0, (nowMs - lastOffMs) / 60000) : null;\\n\\nconst minOnElapsed =\\n minutesSinceLastOn === null ? true : minutesSinceLastOn >= gridMinOnMinutes;\\n\\nconst cooldownElapsed =\\n minutesSinceLastOff === null ? true : minutesSinceLastOff >= gridCooldownMinutes;\\n\\nconst socBand =\\n soc <= gridSocOn ? 'low' :\\n soc < gridSocOff ? 'mid' :\\n 'high';\\n\\nconst evcsSeemsActive = evcsCharging || evcsAcPowerW > 300;\\nconst acOverSafeLimit = houseAcPowerW >= acLoadsMaxSafeW;\\n\\n// ======================================================\\n// EVCS OVERRIDE INPUTS\\n// ======================================================\\nconst evcsPriorityActive = isOnLike(\\n pickDefined(\\n input.evcs_priority_active,\\n input.victron_evcs_priority_active,\\n input['input_boolean.victron_evcs_priority_active']\\n )\\n);\\n\\nconst evcsNightGridTakeoverNeeded = isOnLike(\\n pickDefined(\\n input.night_grid_takeover_needed,\\n input.victron_evcs_night_grid_takeover_needed,\\n input['input_boolean.victron_evcs_night_grid_takeover_needed']\\n )\\n);\\n\\nconst evcsSourceModeRaw = (\\n pickDefined(\\n input.evcs_source_mode,\\n input.victron_evcs_source_mode,\\n input['input_text.victron_evcs_source_mode']\\n ) ?? ''\\n).toString().trim();\\n\\nconst evcsSourceMode = low(evcsSourceModeRaw);\\n\\nconst userStrategyRaw = (\\n pickDefined(\\n input.user_strategy,\\n input.victron_evcs_user_strategy,\\n input['input_select.victron_evcs_user_strategy']\\n ) ?? ''\\n).toString().trim();\\n\\nconst userStrategy = low(userStrategyRaw);\\n\\n// ======================================================\\n// EVCS SEMANTIC STATE\\n// Ici on ne détecte PAS la voiture pleine par seuils.\\n// On consomme seulement l'état sémantique déjà produit\\n// par le workflow EVCS.\\n// ======================================================\\nconst evcsSurplusStateRaw = (\\n pickDefined(\\n input.evcs_surplus_state,\\n input.victron_evcs_surplus_state,\\n input['input_text.victron_evcs_surplus_state']\\n ) ?? ''\\n).toString().trim();\\n\\nconst evcsSurplusState = low(evcsSurplusStateRaw);\\n\\nconst evcsVehicleNoDemandSemantic =\\n evcsSurplusState === 'vehicle_full_or_paused' ||\\n evcsSurplusState === 'vehicle_not_requesting_power' ||\\n evcsSurplusState === 'vehicle_full' ||\\n evcsSurplusState === 'charge_complete' ||\\n evcsSurplusState.includes('full_or_paused') ||\\n evcsSurplusState.includes('not_requesting_power') ||\\n evcsSurplusState.includes('charge_complete') ||\\n evcsSurplusState.includes('no_demand');\\n\\n// ======================================================\\n// OVERRIDE EVCS VERS GRID\\n// CORRECTION:\\n// - evcsPriorityActive seul ne doit PLUS suffire à allumer EDF\\n// - seuls les cas explicitement réseau peuvent forcer le grid\\n// - ainsi \\\"Réseau HC midi = OFF\\\" redevient réellement respecté\\n// ======================================================\\nconst evcsExplicitGridRequest =\\n userStrategy === 'force_grid_now' ||\\n userStrategy === 'force_charge_now' ||\\n evcsNightGridTakeoverNeeded ||\\n evcsSourceMode === 'réseau forcé' ||\\n evcsSourceMode === 'réseau hc autorisé' ||\\n evcsSourceMode === 'grid_forced' ||\\n evcsSourceMode === 'grid_hc_allowed';\\n\\nconst evcsGridOverride =\\n !evcsVehicleNoDemandSemantic &&\\n evcsExplicitGridRequest;\\n\\n// ======================================================\\n// DÉCISION\\n// ======================================================\\nlet state = 'observe_only';\\nlet reason = [];\\nlet desiredGrid = gridIsOn ? 'on' : 'off';\\nlet desiredLatch = latchIsOn ? 'on' : 'off';\\n\\n// ======================================================\\n// FORCE_GRID + VEHICLE FULL IMMEDIATE CUT\\n// FIX V2.5: Si force_grid_now actif et voiture pleine,\\n// couper EDF immédiatement sans attendre minOnRuntime.\\n// ======================================================\\nif (\\n userStrategy === 'force_grid_now' &&\\n evcsVehicleNoDemandSemantic &&\\n latchIsOn\\n) {\\n desiredGrid = 'off';\\n desiredLatch = 'off';\\n state = 'turn_off_force_grid_vehicle_full';\\n reason.push('Force grid + voiture pleine : coupure immediate du reseau');\\n}\\n\\n\\n// ======================================================\\n// CORE HYSTERESIS LOGIC\\n// ======================================================\\nif (evcsGridOverride) {\\n desiredGrid = 'on';\\n desiredLatch = 'on';\\n\\n if (!gridIsOn) {\\n state = 'turn_on_evcs_override';\\n reason.push('Override EVCS actif : activation EDF forcée');\\n } else if (!latchIsOn) {\\n state = 'adopt_existing_grid_on_evcs_override';\\n reason.push('Override EVCS actif : EDF déjà ON, adoption par latch');\\n } else {\\n state = 'hold_on_evcs_override';\\n reason.push('Override EVCS actif : maintien EDF ON');\\n }\\n\\n if (userStrategy === 'force_grid_now') {\\n reason.push('Cause override : force_grid_now');\\n } else if (userStrategy === 'force_charge_now') {\\n reason.push('Cause override : force_charge_now');\\n } else if (evcsNightGridTakeoverNeeded) {\\n reason.push('Cause override : night_grid_takeover_needed');\\n } else {\\n reason.push(`Cause override : source_mode=${evcsSourceModeRaw || 'n/a'}`);\\n }\\n\\n} else if (!hysteresisEnabled) {\\n state = 'hysteresis_disabled';\\n desiredGrid = gridIsOn ? 'on' : 'off';\\n desiredLatch = latchIsOn ? 'on' : 'off';\\n reason.push('Automatisme hystérésis réseau désactivé');\\n\\n} else if (soc <= gridSocOn) {\\n desiredGrid = 'on';\\n desiredLatch = 'on';\\n\\n if (!cooldownElapsed && !gridIsOn) {\\n state = 'turn_on_low_soc_cooldown_bypassed';\\n reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : activation EDF malgré cooldown`);\\n } else if (!gridIsOn) {\\n state = 'turn_on_low_soc';\\n reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : activation EDF`);\\n } else if (!latchIsOn) {\\n state = 'adopt_existing_grid_on_low_soc';\\n reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : EDF déjà ON, prise de contrôle par latch`);\\n } else {\\n state = 'hold_on_low_soc';\\n reason.push(`SOC encore bas ${round1(soc)}% : maintien EDF ON`);\\n }\\n\\n} else if (gridIsOn && !latchIsOn) {\\n if (soc < gridSocOff) {\\n desiredGrid = 'on';\\n desiredLatch = 'on';\\n state = 'adopt_existing_grid_on';\\n reason.push(`EDF déjà ON avec latch OFF et SOC ${round1(soc)}% < seuil OFF ${gridSocOff}% : adoption de l'état ON`);\\n } else {\\n desiredGrid = 'off';\\n desiredLatch = 'off';\\n state = 'turn_off_stray_grid_on';\\n reason.push(`EDF ON avec latch OFF et SOC ${round1(soc)}% >= seuil OFF ${gridSocOff}% : coupure de reprise de contrôle`);\\n }\\n\\n} else if (latchIsOn) {\\n if (!minOnElapsed) {\\n desiredGrid = 'on';\\n desiredLatch = 'on';\\n state = 'hold_on_min_runtime';\\n reason.push(`EDF maintenu ON : durée mini ${gridMinOnMinutes} min non atteinte (${round1(minutesSinceLastOn || 0)} min)`);\\n } else if (soc < gridSocOff) {\\n desiredGrid = 'on';\\n desiredLatch = 'on';\\n state = 'hold_on_until_soc_off';\\n reason.push(`EDF maintenu ON : SOC ${round1(soc)}% < seuil OFF ${gridSocOff}%`);\\n } else {\\n desiredGrid = 'off';\\n desiredLatch = 'off';\\n state = 'turn_off_recovered';\\n reason.push(`SOC ${round1(soc)}% >= seuil OFF ${gridSocOff}% : coupure EDF`);\\n }\\n\\n} else {\\n desiredGrid = 'off';\\n desiredLatch = 'off';\\n state = 'observe_only';\\n reason.push('Aucune action : hors zone d’hystérésis');\\n}\\n\\n// ======================================================\\n// COMPARE DESIRED VS CURRENT\\n// ======================================================\\nconst gridAction =\\n desiredGrid === (gridIsOn ? 'on' : 'off')\\n ? 'none'\\n : desiredGrid === 'on'\\n ? 'turn_on'\\n : 'turn_off';\\n\\nconst latchAction =\\n desiredLatch === (latchIsOn ? 'on' : 'off')\\n ? 'none'\\n : desiredLatch === 'on'\\n ? 'turn_on'\\n : 'turn_off';\\n\\n// ======================================================\\n// TIMESTAMPS\\n// ======================================================\\nconst setLastOnAt =\\n gridAction === 'turn_on' ||\\n state === 'adopt_existing_grid_on' ||\\n state === 'adopt_existing_grid_on_low_soc' ||\\n state === 'adopt_existing_grid_on_evcs_override';\\n\\nconst setLastOffAt =\\n gridAction === 'turn_off';\\n\\n// ======================================================\\n// CONTEXT / ALERTS\\n// ======================================================\\nreason.push(`Grid actuel ${gridIsOn ? 'ON' : 'OFF'}`);\\nreason.push(`Latch actuel ${latchIsOn ? 'ON' : 'OFF'}`);\\nreason.push(`SOC ${round1(soc)}% | seuils ${gridSocOn}%/${gridSocOff}%`);\\nreason.push(`PV ${Math.round(pvPowerW)}W`);\\nreason.push(`Charge AC maison ${Math.round(houseAcPowerW)}W`);\\nreason.push(`Puissance réseau ${Math.round(gridPowerW)}W`);\\nreason.push(`EVCS ${evcsSeemsActive ? 'actif' : 'inactif'} | ${Math.round(evcsAcPowerW)}W | status_raw=${Number.isFinite(evcsStatusRaw) ? evcsStatusRaw : 'n/a'}`);\\n\\nif (minutesSinceLastOn !== null) {\\n reason.push(`Dernier ON il y a ${round1(minutesSinceLastOn)} min`);\\n}\\nif (minutesSinceLastOff !== null) {\\n reason.push(`Dernier OFF il y a ${round1(minutesSinceLastOff)} min`);\\n}\\n\\nif (acOverSafeLimit) {\\n reason.push(`⚠️ Charge AC maison ${Math.round(houseAcPowerW)}W >= limite sûre ${Math.round(acLoadsMaxSafeW)}W`);\\n}\\n\\nreason.push(`EVCS override ${evcsGridOverride ? 'ON' : 'OFF'}`);\\nreason.push(`EVCS explicit grid request ${evcsExplicitGridRequest ? 'ON' : 'OFF'}`);\\nreason.push(`EVCS priority ${evcsPriorityActive ? 'ON' : 'OFF'}`);\\nreason.push(`EVCS night takeover ${evcsNightGridTakeoverNeeded ? 'ON' : 'OFF'}`);\\nreason.push(`EVCS source ${evcsSourceModeRaw || 'n/a'}`);\\nreason.push(`EVCS strategy ${userStrategyRaw || 'n/a'}`);\\nreason.push(`EVCS surplus state ${evcsSurplusStateRaw || 'n/a'}`);\\nreason.push(`EVCS no-demand semantic ${evcsVehicleNoDemandSemantic ? 'ON' : 'OFF'}`);\\n\\n// ======================================================\\n// NOTIFICATIONS\\n// ======================================================\\nlet notifyTitle = 'Victron Grid Hysteresis';\\nlet notifyMessage = '';\\nlet notifyWanted = false;\\n\\nif (\\n state === 'turn_on_evcs_override' ||\\n state === 'adopt_existing_grid_on_evcs_override' ||\\n state === 'hold_on_evcs_override'\\n) {\\n notifyTitle = '🚗 EDF forcé par EVCS';\\n notifyMessage = `Priorité EVCS active. EDF maintenu ON pour la recharge véhicule. Cause: ${userStrategyRaw || evcsSourceModeRaw || 'override EVCS'}.`;\\n notifyWanted = true;\\n\\n} else if (state === 'turn_on_low_soc' || state === 'turn_on_low_soc_cooldown_bypassed') {\\n notifyTitle = '⚡ EDF activé sur SOC bas';\\n notifyMessage = `SOC ${round1(soc)}% <= ${gridSocOn}%. EDF activé et maintenu jusqu'à au moins ${gridSocOff}% (min ${gridMinOnMinutes} min).`;\\n notifyWanted = true;\\n\\n} else if (state === 'adopt_existing_grid_on_low_soc' || state === 'adopt_existing_grid_on') {\\n notifyTitle = '🔁 EDF ON repris par hystérésis';\\n notifyMessage = `EDF était déjà ON. Le workflow reprend la main et le maintient jusqu'à ${gridSocOff}% (min ${gridMinOnMinutes} min).`;\\n notifyWanted = true;\\n\\n} else if (state === 'turn_off_recovered') {\\n notifyTitle = '✅ EDF coupé après recharge';\\n notifyMessage = `SOC ${round1(soc)}% >= ${gridSocOff}%. Retour hors réseau autorisé.`;\\n notifyWanted = true;\\n\\n} else if (state === 'turn_off_stray_grid_on') {\\n notifyTitle = '✅ EDF coupé (reprise de contrôle)';\\n notifyMessage = `EDF était ON sans latch. SOC ${round1(soc)}% >= ${gridSocOff}%, coupure réseau appliquée.`;\\n notifyWanted = true;\\n\\n} else if (acOverSafeLimit && !gridIsOn) {\\n notifyTitle = '🚨 Charge AC élevée hors réseau';\\n notifyMessage = `Charge AC maison ${Math.round(houseAcPowerW)}W au-dessus de la limite sûre ${Math.round(acLoadsMaxSafeW)}W alors que EDF est OFF.`;\\n notifyWanted = true;\\n\\n} else if (acOverSafeLimit && gridIsOn) {\\n notifyTitle = '⚠️ Charge AC élevée';\\n notifyMessage = `Charge AC maison ${Math.round(houseAcPowerW)}W proche/au-dessus de la limite sûre ${Math.round(acLoadsMaxSafeW)}W.`;\\n notifyWanted = true;\\n}\\n\\nconst shortReason = reason.join(' | ').slice(0, 255);\\n\\nconst signature = [\\n now.date,\\n state,\\n `dg=${desiredGrid}`,\\n `dl=${desiredLatch}`,\\n `sb=${socBand}`,\\n `ac=${acOverSafeLimit ? '1' : '0'}`,\\n `ov=${evcsGridOverride ? '1' : '0'}`,\\n `eg=${evcsExplicitGridRequest ? '1' : '0'}`,\\n `ep=${evcsPriorityActive ? '1' : '0'}`,\\n `es=${evcsSurplusState || 'na'}`\\n].join('|');\\n\\nconst signatureChanged = signature !== lastSignature;\\n\\n// ======================================================\\n// OUTPUT\\n// ======================================================\\nconst result = {\\n timestamp_paris: now.stamp,\\n\\n inputs: {\\n soc_percent: soc,\\n grid_state: gridIsOn ? 'on' : 'off',\\n pv_power_w: pvPowerW,\\n house_ac_power_w: houseAcPowerW,\\n grid_power_w: gridPowerW,\\n evcs_ac_power_w: evcsAcPowerW,\\n evcs_charging: evcsCharging,\\n evcs_status_raw: Number.isFinite(evcsStatusRaw) ? evcsStatusRaw : null,\\n\\n hysteresis_enabled: hysteresisEnabled,\\n notifications_enabled: notificationsEnabled,\\n grid_latch: latchIsOn,\\n grid_soc_on: gridSocOn,\\n grid_soc_off: gridSocOff,\\n grid_min_on_minutes: gridMinOnMinutes,\\n grid_cooldown_minutes: gridCooldownMinutes,\\n ac_loads_max_safe_w: acLoadsMaxSafeW,\\n\\n last_on_at: lastOnAt || null,\\n last_off_at: lastOffAt || null,\\n last_signature: lastSignature || null,\\n\\n evcs_priority_active: evcsPriorityActive,\\n night_grid_takeover_needed: evcsNightGridTakeoverNeeded,\\n evcs_source_mode: evcsSourceModeRaw || null,\\n user_strategy: userStrategyRaw || null,\\n evcs_surplus_state: evcsSurplusStateRaw || null\\n },\\n\\n computed: {\\n desired_grid: desiredGrid,\\n desired_latch: desiredLatch,\\n minutes_since_last_on: minutesSinceLastOn !== null ? round1(minutesSinceLastOn) : null,\\n minutes_since_last_off: minutesSinceLastOff !== null ? round1(minutesSinceLastOff) : null,\\n min_on_elapsed: minOnElapsed,\\n cooldown_elapsed: cooldownElapsed,\\n ac_over_safe_limit: acOverSafeLimit,\\n evcs_seems_active: evcsSeemsActive,\\n\\n evcs_grid_override: evcsGridOverride,\\n evcs_explicit_grid_request: evcsExplicitGridRequest,\\n evcs_priority_active: evcsPriorityActive,\\n evcs_night_grid_takeover_needed: evcsNightGridTakeoverNeeded,\\n evcs_source_mode: evcsSourceModeRaw || null,\\n evcs_user_strategy: userStrategyRaw || null,\\n evcs_surplus_state: evcsSurplusStateRaw || null,\\n evcs_vehicle_no_demand_semantic: evcsVehicleNoDemandSemantic\\n },\\n\\n decision: {\\n state,\\n grid_action: gridAction,\\n latch_action: latchAction,\\n set_last_on_at: setLastOnAt,\\n set_last_off_at: setLastOffAt,\\n should_notify: notificationsEnabled && signatureChanged && notifyWanted,\\n signature,\\n signature_changed: signatureChanged,\\n reason,\\n short_reason: shortReason\\n },\\n\\n ha_service_calls: {\\n helper_state: {\\n domain: 'input_text',\\n service: 'set_value',\\n data: {\\n entity_id: 'input_text.victron_grid_control_state',\\n value: state\\n }\\n },\\n\\n helper_reason: {\\n domain: 'input_text',\\n service: 'set_value',\\n data: {\\n entity_id: 'input_text.victron_grid_control_reason',\\n value: shortReason || 'Aucune raison'\\n }\\n },\\n\\n helper_signature: {\\n domain: 'input_text',\\n service: 'set_value',\\n data: {\\n entity_id: 'input_text.victron_grid_control_last_signature',\\n value: signature\\n }\\n },\\n\\n helper_latch: {\\n domain: 'input_boolean',\\n service: desiredLatch === 'on' ? 'turn_on' : 'turn_off',\\n data: {\\n entity_id: 'input_boolean.victron_grid_hysteresis_latch'\\n }\\n },\\n\\n helper_last_on_at: {\\n domain: 'input_datetime',\\n service: 'set_datetime',\\n data: {\\n entity_id: 'input_datetime.victron_grid_last_on_at',\\n datetime: now.stamp\\n }\\n },\\n\\n helper_last_off_at: {\\n domain: 'input_datetime',\\n service: 'set_datetime',\\n data: {\\n entity_id: 'input_datetime.victron_grid_last_off_at',\\n datetime: now.stamp\\n }\\n },\\n\\n grid: {\\n domain: 'switch',\\n service: desiredGrid === 'on' ? 'turn_on' : 'turn_off',\\n data: {\\n entity_id: 'switch.edf_general_ok_switch'\\n }\\n },\\n\\n logbook: {\\n domain: 'logbook',\\n service: 'log',\\n data: {\\n name: 'Victron Grid Hysteresis',\\n message: `${state} | Grid=${desiredGrid} | Latch=${desiredLatch} | SOC=${round1(soc)}% | AC=${Math.round(houseAcPowerW)}W | ${shortReason}`,\\n entity_id: 'switch.edf_general_ok_switch'\\n }\\n },\\n\\n mobile_notification: {\\n domain: cfg.mobileNotifyDomain,\\n service: cfg.mobileNotifyService,\\n data: {\\n title: notifyTitle,\\n message: notifyMessage,\\n data: {\\n push: {\\n 'interruption-level': 'time-sensitive',\\n sound: { name: 'default' }\\n },\\n ttl: 0,\\n priority: 'high'\\n }\\n }\\n }\\n }\\n};\\n\\nreturn [{ json: result }];\\n\"},\"type\":\"n8n-nodes-base.code\",\"typeVersion\":2,\"position\":[448,0],\"id\":\"dde6d41d-3212-4371-83b3-b58d0d70c21e\",\"name\":\"Compute Grid Hysteresis\"},{\"parameters\":{\"jsCode\":\"const root = $input.first().json || {};\\nconst decision = root.decision || {};\\nconst calls = root.ha_service_calls || {};\\nconst actions = [];\\n\\nfunction pushAction(action, call, enabled = true) {\\n if (!enabled) return;\\n if (!call) return;\\n\\n actions.push({\\n json: {\\n action,\\n ...call\\n }\\n });\\n}\\n\\n// ======================================================\\n// HELPERS TOUJOURS MIS À JOUR\\n// ======================================================\\npushAction('helper_state', calls.helper_state, true);\\npushAction('helper_reason', calls.helper_reason, true);\\n\\n// ======================================================\\n// SIGNATURE SI CHANGEMENT\\n// ======================================================\\nif (decision.signature_changed) {\\n pushAction('helper_signature', calls.helper_signature, true);\\n}\\n\\n// ======================================================\\n// SÉQUENCEMENT ACTIONS RÉSEAU / LATCH\\n//\\n// IMPORTANT :\\n// - pour ALLUMER : latch ON d'abord, puis grid ON\\n// - pour ÉTEINDRE : grid OFF d'abord, puis latch OFF\\n// ======================================================\\n\\n// ---------- CAS 1 : LATCH ON avant GRID ON ----------\\nif (decision.latch_action === 'turn_on') {\\n pushAction('helper_latch', calls.helper_latch, true);\\n}\\n\\nif (decision.grid_action === 'turn_on') {\\n pushAction('grid', calls.grid, true);\\n}\\n\\n// ---------- CAS 2 : GRID OFF avant LATCH OFF ----------\\nif (decision.grid_action === 'turn_off') {\\n pushAction('grid', calls.grid, true);\\n}\\n\\nif (decision.latch_action === 'turn_off') {\\n pushAction('helper_latch', calls.helper_latch, true);\\n}\\n\\n// ======================================================\\n// TIMESTAMPS SI NÉCESSAIRE\\n// ======================================================\\nif (decision.set_last_on_at) {\\n pushAction('helper_last_on_at', calls.helper_last_on_at, true);\\n}\\n\\nif (decision.set_last_off_at) {\\n pushAction('helper_last_off_at', calls.helper_last_off_at, true);\\n}\\n\\n// ======================================================\\n// LOGBOOK SI CHANGEMENT DE SIGNATURE\\n// ======================================================\\nif (decision.signature_changed) {\\n pushAction('logbook', calls.logbook, true);\\n}\\n\\n// ======================================================\\n// NOTIFICATION SI UTILE\\n// ======================================================\\nif (decision.should_notify) {\\n pushAction('mobile_notification', calls.mobile_notification, true);\\n}\\n\\nreturn actions;\\n\"},\"type\":\"n8n-nodes-base.code\",\"typeVersion\":2,\"position\":[672,0],\"id\":\"d3728055-9f5b-4362-a275-247cc91c5f41\",\"name\":\"Build Grid Hysteresis Actions\"},{\"parameters\":{\"method\":\"POST\",\"url\":\"={{ 'http://10.10.10.17:8123/api/services/' + $json.domain + '/' + $json.service }}\\n\",\"authentication\":\"genericCredentialType\",\"genericAuthType\":\"httpBearerAuth\",\"sendHeaders\":true,\"headerParameters\":{\"parameters\":[{\"name\":\"Content-Type\",\"value\":\"application/json\"},{\"name\":\"accept\",\"value\":\"application/json\"}]},\"sendBody\":true,\"specifyBody\":\"json\",\"jsonBody\":\"={{ $json.data }}\",\"options\":{}},\"id\":\"7d38f7cf-ad84-4481-a201-d21dd0dce321\",\"name\":\"Execute HA Service\",\"type\":\"n8n-nodes-base.httpRequest\",\"typeVersion\":4.2,\"position\":[896,0],\"alwaysOutputData\":true,\"credentials\":{\"httpBearerAuth\":{\"id\":\"uv3sknYYigxBkaVN\",\"name\":\"Bearer Auth account 2\"}}}],\"connections\":{\"Schedule Trigger\":{\"main\":[[{\"node\":\"HA Snapshot Grid Hysteresis\",\"type\":\"main\",\"index\":0}]]},\"HA Snapshot Grid Hysteresis\":{\"main\":[[{\"node\":\"Compute Grid Hysteresis\",\"type\":\"main\",\"index\":0}]]},\"Compute Grid Hysteresis\":{\"main\":[[{\"node\":\"Build Grid Hysteresis Actions\",\"type\":\"main\",\"index\":0}]]},\"Build Grid Hysteresis Actions\":{\"main\":[[{\"node\":\"Execute HA Service\",\"type\":\"main\",\"index\":0}]]}},\"settings\":{\"executionOrder\":\"v1\",\"binaryMode\":\"separate\",\"availableInMCP\":false,\"timeSavedMode\":\"fixed\",\"callerPolicy\":\"workflowsFromSameOwner\"},\"staticData\":{\"node:Schedule Trigger\":{\"recurrenceRules\":[]}},\"meta\":null,\"pinData\":{},\"versionId\":\"ef4b5fc8-b615-4c56-ba79-3d45f908bf1a\",\"activeVersionId\":null,\"versionCounter\":70,\"triggerCount\":1,\"tags\":[],\"activeVersion\":null,\"scopes\":[\"workflow:create\",\"workflow:delete\",\"workflow:execute\",\"workflow:execute-chat\",\"workflow:list\",\"workflow:move\",\"workflow:publish\",\"workflow:read\",\"workflow:share\",\"workflow:unpublish\",\"workflow:unshare\",\"workflow:update\",\"workflow:updateRedactionSetting\"],\"checksum\":\"4390f0852ac99946d8c39408826b9bc03be306ad782ce7337d236181e7923f79\"}}\nHTTP_CODE:200\n",
+ "metadata": {
+ "output": "{\"data\":{\"updatedAt\":\"2026-05-19T02:18:19.712Z\",\"createdAt\":\"2026-03-17T01:43:29.018Z\",\"id\":\"7iIlu7LLTW7TZA8o\",\"name\":\"Victron - Battery / Grid Hysteresis Fix V1\",\"description\":null,\"active\":false,\"isArchived\":false,\"nodes\":[{\"parameters\":{\"rule\":{\"interval\":[{\"field\":\"minutes\",\"minutesInterval\":1}]}},\"type\":\"n8n-nodes-base.scheduleTrigger\",\"typeVersion\":1.3,\"position\":[0,0],\"id\":\"0b9ad256-b51b-4921-b81b-5ba8a2113412\",\"name\":\"Schedule Trigger\"},{\"parameters\":{\"method\":\"POST\",\"url\":\"=http://10.10.10.17:8123/api/template\\n\",\"authentication\":\"genericCredentialType\",\"genericAuthType\":\"httpBearerAuth\",\"sendHeaders\":true,\"headerParameters\":{\"parameters\":[{\"name\":\"Content-Type\",\"value\":\"application/json\"},{\"name\":\"Accept\",\"value\":\"text/plain\"}]},\"sendBody\":true,\"bodyParameters\":{\"parameters\":[{\"name\":\"template\",\"value\":\"{{ {\\n \\\"soc_percent\\\": states(\\\"sensor.victron_battery_soc\\\"),\\n \\\"grid_state\\\": states(\\\"switch.edf_general_ok_switch\\\"),\\n \\\"pv_power_w\\\": states(\\\"sensor.victron_pv_power_system\\\"),\\n\\n \\\"house_ac_power_w\\\": states(\\\"sensor.victron_consumption_power_l1\\\"),\\n \\\"grid_power_w\\\": states(\\\"sensor.victron_grid_power_l1\\\"),\\n\\n \\\"evcs_ac_power_w\\\": states(\\\"sensor.victron_evcs_ac_power\\\"),\\n \\\"evcs_charging\\\": states(\\\"switch.victron_evcs_charging\\\"),\\n \\\"evcs_status_raw\\\": states(\\\"sensor.victron_evcs_status_raw\\\"),\\n\\n \\\"hysteresis_enabled\\\": states(\\\"input_boolean.victron_grid_hysteresis_enabled\\\"),\\n \\\"notifications_enabled\\\": states(\\\"input_boolean.victron_grid_hysteresis_notifications\\\"),\\n \\\"grid_latch\\\": states(\\\"input_boolean.victron_grid_hysteresis_latch\\\"),\\n\\n \\\"grid_soc_on\\\": states(\\\"input_number.victron_grid_soc_on\\\"),\\n \\\"grid_soc_off\\\": states(\\\"input_number.victron_grid_soc_off\\\"),\\n \\\"grid_min_on_minutes\\\": states(\\\"input_number.victron_grid_min_on_minutes\\\"),\\n \\\"grid_cooldown_minutes\\\": states(\\\"input_number.victron_grid_cooldown_minutes\\\"),\\n \\\"ac_loads_max_safe_w\\\": states(\\\"input_number.victron_ac_loads_max_safe_w\\\"),\\n\\n \\\"last_on_at\\\": states(\\\"input_datetime.victron_grid_last_on_at\\\"),\\n \\\"last_off_at\\\": states(\\\"input_datetime.victron_grid_last_off_at\\\"),\\n \\\"last_signature\\\": states(\\\"input_text.victron_grid_control_last_signature\\\"),\\n\\n \\\"evcs_priority_active\\\": states(\\\"input_boolean.victron_evcs_priority_active\\\"),\\n \\\"night_grid_takeover_needed\\\": states(\\\"input_boolean.victron_evcs_night_grid_takeover_needed\\\"),\\n \\\"evcs_source_mode\\\": states(\\\"input_text.victron_evcs_source_mode\\\"),\\n \\\"user_strategy\\\": states(\\\"input_select.victron_evcs_user_strategy\\\"),\\n \\\"evcs_surplus_state\\\": states(\\\"input_text.victron_evcs_surplus_state\\\")\\n} | tojson }}\\n\"}]},\"options\":{}},\"id\":\"577c8308-a790-44f8-a9dc-098811bd5e0b\",\"name\":\"HA Snapshot Grid Hysteresis\",\"type\":\"n8n-nodes-base.httpRequest\",\"typeVersion\":4.2,\"position\":[224,0],\"credentials\":{\"httpBearerAuth\":{\"id\":\"uv3sknYYigxBkaVN\",\"name\":\"Bearer Auth account 2\"}}},{\"parameters\":{\"jsCode\":\"function num(v, fallback = NaN) {\\n const n = Number.parseFloat(v);\\n return Number.isFinite(n) ? n : fallback;\\n}\\n\\nfunction low(v) {\\n return (v ?? '').toString().trim().toLowerCase();\\n}\\n\\nfunction parseMaybeJson(v) {\\n if (typeof v !== 'string') return v;\\n try {\\n return JSON.parse(v);\\n } catch {\\n return v;\\n }\\n}\\n\\nfunction extractSnapshot(input) {\\n if (typeof input === 'string') return parseMaybeJson(input);\\n if (input?.data && typeof input.data === 'string') return parseMaybeJson(input.data);\\n if (input?.body && typeof input.body === 'string') return parseMaybeJson(input.body);\\n if (input?.data && typeof input.data === 'object') return input.data;\\n if (input?.body && typeof input.body === 'object') return input.body;\\n return input;\\n}\\n\\nfunction round1(v) {\\n return Math.round(v * 10) / 10;\\n}\\n\\nfunction clamp(v, min, max) {\\n return Math.min(Math.max(v, min), max);\\n}\\n\\nfunction pickDefined(...values) {\\n for (const v of values) {\\n if (v === undefined || v === null) continue;\\n if (typeof v === 'string' && v.trim() === '') continue;\\n return v;\\n }\\n return undefined;\\n}\\n\\nfunction isOnLike(v) {\\n const s = low(v);\\n return s === 'on' || s === 'true' || s === '1' || s === 'yes';\\n}\\n\\nfunction getParisNowParts() {\\n const fmt = new Intl.DateTimeFormat('fr-FR', {\\n timeZone: 'Europe/Paris',\\n year: 'numeric',\\n month: '2-digit',\\n day: '2-digit',\\n hour: '2-digit',\\n minute: '2-digit',\\n second: '2-digit',\\n hour12: false,\\n });\\n\\n const parts = Object.fromEntries(\\n fmt.formatToParts(new Date())\\n .filter(p => p.type !== 'literal')\\n .map(p => [p.type, p.value])\\n );\\n\\n const date = `${parts.year}-${parts.month}-${parts.day}`;\\n const stamp = `${date} ${parts.hour}:${parts.minute}:${parts.second}`;\\n\\n return {\\n year: Number(parts.year),\\n month: Number(parts.month),\\n day: Number(parts.day),\\n hour: Number(parts.hour),\\n minute: Number(parts.minute),\\n second: Number(parts.second),\\n date,\\n stamp,\\n };\\n}\\n\\nfunction parseLocalStampToMs(s) {\\n const text = (s ?? '').toString().trim();\\n const m = text.match(/^(\\\\d{4})-(\\\\d{2})-(\\\\d{2}) (\\\\d{2}):(\\\\d{2}):(\\\\d{2})$/);\\n if (!m) return null;\\n\\n const y = Number(m[1]);\\n const mo = Number(m[2]);\\n const d = Number(m[3]);\\n const h = Number(m[4]);\\n const mi = Number(m[5]);\\n const se = Number(m[6]);\\n\\n if (![y, mo, d, h, mi, se].every(Number.isFinite)) return null;\\n\\n return new Date(y, mo - 1, d, h, mi, se).getTime();\\n}\\n\\nconst cfg = {\\n mobileNotifyDomain: 'notify',\\n mobileNotifyService: 'mobile_app_iphone_16_de_tt',\\n};\\n\\nconst raw = $input.first().json;\\nconst input = extractSnapshot(raw);\\nconst now = getParisNowParts();\\nconst nowMs = parseLocalStampToMs(now.stamp);\\n\\n// ======================================================\\n// INPUTS PRINCIPAUX\\n// ======================================================\\nconst soc = clamp(num(input.soc_percent, 0), 0, 100);\\nconst gridIsOn = low(input.grid_state || 'off') === 'on';\\nconst pvPowerW = Math.max(0, num(input.pv_power_w, 0));\\n\\nconst houseAcPowerW = Math.max(0, num(input.house_ac_power_w, 0));\\nconst gridPowerW = num(input.grid_power_w, 0);\\n\\nconst evcsAcPowerW = Math.max(0, num(input.evcs_ac_power_w, 0));\\nconst evcsCharging = low(input.evcs_charging || 'off') === 'on';\\nconst evcsStatusRaw = num(input.evcs_status_raw, NaN);\\n\\nconst hysteresisEnabled = low(input.hysteresis_enabled || 'off') === 'on';\\nconst notificationsEnabled = low(input.notifications_enabled || 'off') === 'on';\\nconst latchIsOn = low(input.grid_latch || 'off') === 'on';\\n\\nconst gridSocOn = clamp(num(input.grid_soc_on, 22), 0, 100);\\nconst gridSocOff = clamp(num(input.grid_soc_off, 35), 0, 100);\\nconst gridMinOnMinutes = Math.max(0, num(input.grid_min_on_minutes, 45));\\nconst gridCooldownMinutes = Math.max(0, num(input.grid_cooldown_minutes, 15));\\nconst acLoadsMaxSafeW = Math.max(0, num(input.ac_loads_max_safe_w, 7800));\\n\\nconst lastOnAt = (input.last_on_at ?? '').toString().trim();\\nconst lastOffAt = (input.last_off_at ?? '').toString().trim();\\nconst lastSignature = (input.last_signature ?? '').toString().trim();\\n\\nconst lastOnMs = parseLocalStampToMs(lastOnAt);\\nconst lastOffMs = parseLocalStampToMs(lastOffAt);\\n\\nconst minutesSinceLastOn =\\n lastOnMs !== null && nowMs !== null ? Math.max(0, (nowMs - lastOnMs) / 60000) : null;\\n\\nconst minutesSinceLastOff =\\n lastOffMs !== null && nowMs !== null ? Math.max(0, (nowMs - lastOffMs) / 60000) : null;\\n\\nconst minOnElapsed =\\n minutesSinceLastOn === null ? true : minutesSinceLastOn >= gridMinOnMinutes;\\n\\nconst cooldownElapsed =\\n minutesSinceLastOff === null ? true : minutesSinceLastOff >= gridCooldownMinutes;\\n\\nconst socBand =\\n soc <= gridSocOn ? 'low' :\\n soc < gridSocOff ? 'mid' :\\n 'high';\\n\\nconst evcsSeemsActive = evcsCharging || evcsAcPowerW > 300;\\nconst acOverSafeLimit = houseAcPowerW >= acLoadsMaxSafeW;\\n\\n// ======================================================\\n// EVCS OVERRIDE INPUTS\\n// ======================================================\\nconst evcsPriorityActive = isOnLike(\\n pickDefined(\\n input.evcs_priority_active,\\n input.victron_evcs_priority_active,\\n input['input_boolean.victron_evcs_priority_active']\\n )\\n);\\n\\nconst evcsNightGridTakeoverNeeded = isOnLike(\\n pickDefined(\\n input.night_grid_takeover_needed,\\n input.victron_evcs_night_grid_takeover_needed,\\n input['input_boolean.victron_evcs_night_grid_takeover_needed']\\n )\\n);\\n\\nconst evcsSourceModeRaw = (\\n pickDefined(\\n input.evcs_source_mode,\\n input.victron_evcs_source_mode,\\n input['input_text.victron_evcs_source_mode']\\n ) ?? ''\\n).toString().trim();\\n\\nconst evcsSourceMode = low(evcsSourceModeRaw);\\n\\nconst userStrategyRaw = (\\n pickDefined(\\n input.user_strategy,\\n input.victron_evcs_user_strategy,\\n input['input_select.victron_evcs_user_strategy']\\n ) ?? ''\\n).toString().trim();\\n\\nconst userStrategy = low(userStrategyRaw);\\n\\n// ======================================================\\n// EVCS SEMANTIC STATE\\n// Ici on ne détecte PAS la voiture pleine par seuils.\\n// On consomme seulement l'état sémantique déjà produit\\n// par le workflow EVCS.\\n// ======================================================\\nconst evcsSurplusStateRaw = (\\n pickDefined(\\n input.evcs_surplus_state,\\n input.victron_evcs_surplus_state,\\n input['input_text.victron_evcs_surplus_state']\\n ) ?? ''\\n).toString().trim();\\n\\nconst evcsSurplusState = low(evcsSurplusStateRaw);\\n\\nconst evcsVehicleNoDemandSemantic =\\n evcsSurplusState === 'vehicle_full_or_paused' ||\\n evcsSurplusState === 'vehicle_not_requesting_power' ||\\n evcsSurplusState === 'vehicle_full' ||\\n evcsSurplusState === 'charge_complete' ||\\n evcsSurplusState.includes('full_or_paused') ||\\n evcsSurplusState.includes('not_requesting_power') ||\\n evcsSurplusState.includes('charge_complete') ||\\n evcsSurplusState.includes('no_demand');\\n\\n// ======================================================\\n// OVERRIDE EVCS VERS GRID\\n// CORRECTION:\\n// - evcsPriorityActive seul ne doit PLUS suffire à allumer EDF\\n// - seuls les cas explicitement réseau peuvent forcer le grid\\n// - ainsi \\\"Réseau HC midi = OFF\\\" redevient réellement respecté\\n// ======================================================\\nconst evcsExplicitGridRequest =\\n userStrategy === 'force_grid_now' ||\\n userStrategy === 'force_charge_now' ||\\n evcsNightGridTakeoverNeeded ||\\n evcsSourceMode === 'réseau forcé' ||\\n evcsSourceMode === 'réseau hc autorisé' ||\\n evcsSourceMode === 'grid_forced' ||\\n evcsSourceMode === 'grid_hc_allowed';\\n\\nconst evcsGridOverride =\\n !evcsVehicleNoDemandSemantic &&\\n evcsExplicitGridRequest;\\n\\n// ======================================================\\n// DÉCISION\\n// ======================================================\\nlet state = 'observe_only';\\nlet reason = [];\\nlet desiredGrid = gridIsOn ? 'on' : 'off';\\nlet desiredLatch = latchIsOn ? 'on' : 'off';\\n\\n// ======================================================\\n// FORCE_GRID + VEHICLE FULL IMMEDIATE CUT\\n// FIX V2.5: Si force_grid_now actif et voiture pleine,\\n// couper EDF immédiatement sans attendre minOnRuntime.\\n// ======================================================\\nif (\\n userStrategy === 'force_grid_now' &&\\n evcsVehicleNoDemandSemantic &&\\n latchIsOn\\n) {\\n desiredGrid = 'off';\\n desiredLatch = 'off';\\n state = 'turn_off_force_grid_vehicle_full';\\n reason.push('Force grid + voiture pleine : coupure immediate du reseau');\\n}\\n\\n\\n// ======================================================\\n// CORE HYSTERESIS LOGIC\\n// ======================================================\\nif (evcsGridOverride) {\\n desiredGrid = 'on';\\n desiredLatch = 'on';\\n\\n if (!gridIsOn) {\\n state = 'turn_on_evcs_override';\\n reason.push('Override EVCS actif : activation EDF forcée');\\n } else if (!latchIsOn) {\\n state = 'adopt_existing_grid_on_evcs_override';\\n reason.push('Override EVCS actif : EDF déjà ON, adoption par latch');\\n } else {\\n state = 'hold_on_evcs_override';\\n reason.push('Override EVCS actif : maintien EDF ON');\\n }\\n\\n if (userStrategy === 'force_grid_now') {\\n reason.push('Cause override : force_grid_now');\\n } else if (userStrategy === 'force_charge_now') {\\n reason.push('Cause override : force_charge_now');\\n } else if (evcsNightGridTakeoverNeeded) {\\n reason.push('Cause override : night_grid_takeover_needed');\\n } else {\\n reason.push(`Cause override : source_mode=${evcsSourceModeRaw || 'n/a'}`);\\n }\\n\\n} else if (!hysteresisEnabled) {\\n state = 'hysteresis_disabled';\\n desiredGrid = gridIsOn ? 'on' : 'off';\\n desiredLatch = latchIsOn ? 'on' : 'off';\\n reason.push('Automatisme hystérésis réseau désactivé');\\n\\n} else if (soc <= gridSocOn) {\\n desiredGrid = 'on';\\n desiredLatch = 'on';\\n\\n if (!cooldownElapsed && !gridIsOn) {\\n state = 'turn_on_low_soc_cooldown_bypassed';\\n reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : activation EDF malgré cooldown`);\\n } else if (!gridIsOn) {\\n state = 'turn_on_low_soc';\\n reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : activation EDF`);\\n } else if (!latchIsOn) {\\n state = 'adopt_existing_grid_on_low_soc';\\n reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : EDF déjà ON, prise de contrôle par latch`);\\n } else {\\n state = 'hold_on_low_soc';\\n reason.push(`SOC encore bas ${round1(soc)}% : maintien EDF ON`);\\n }\\n\\n} else if (gridIsOn && !latchIsOn) {\\n if (soc < gridSocOff) {\\n desiredGrid = 'on';\\n desiredLatch = 'on';\\n state = 'adopt_existing_grid_on';\\n reason.push(`EDF déjà ON avec latch OFF et SOC ${round1(soc)}% < seuil OFF ${gridSocOff}% : adoption de l'état ON`);\\n } else {\\n desiredGrid = 'off';\\n desiredLatch = 'off';\\n state = 'turn_off_stray_grid_on';\\n reason.push(`EDF ON avec latch OFF et SOC ${round1(soc)}% >= seuil OFF ${gridSocOff}% : coupure de reprise de contrôle`);\\n }\\n\\n} else if (latchIsOn) {\\n if (!minOnElapsed) {\\n desiredGrid = 'on';\\n desiredLatch = 'on';\\n state = 'hold_on_min_runtime';\\n reason.push(`EDF maintenu ON : durée mini ${gridMinOnMinutes} min non atteinte (${round1(minutesSinceLastOn || 0)} min)`);\\n } else if (soc < gridSocOff) {\\n desiredGrid = 'on';\\n desiredLatch = 'on';\\n state = 'hold_on_until_soc_off';\\n reason.push(`EDF maintenu ON : SOC ${round1(soc)}% < seuil OFF ${gridSocOff}%`);\\n } else {\\n desiredGrid = 'off';\\n desiredLatch = 'off';\\n state = 'turn_off_recovered';\\n reason.push(`SOC ${round1(soc)}% >= seuil OFF ${gridSocOff}% : coupure EDF`);\\n }\\n\\n} else {\\n desiredGrid = 'off';\\n desiredLatch = 'off';\\n state = 'observe_only';\\n reason.push('Aucune action : hors zone d’hystérésis');\\n}\\n\\n// ======================================================\\n// COMPARE DESIRED VS CURRENT\\n// ======================================================\\nconst gridAction =\\n desiredGrid === (gridIsOn ? 'on' : 'off')\\n ? 'none'\\n : desiredGrid === 'on'\\n ? 'turn_on'\\n : 'turn_off';\\n\\nconst latchAction =\\n desiredLatch === (latchIsOn ? 'on' : 'off')\\n ? 'none'\\n : desiredLatch === 'on'\\n ? 'turn_on'\\n : 'turn_off';\\n\\n// ======================================================\\n// TIMESTAMPS\\n// ======================================================\\nconst setLastOnAt =\\n gridAction === 'turn_on' ||\\n state === 'adopt_existing_grid_on' ||\\n state === 'adopt_existing_grid_on_low_soc' ||\\n state === 'adopt_existing_grid_on_evcs_override';\\n\\nconst setLastOffAt =\\n gridAction === 'turn_off';\\n\\n// ======================================================\\n// CONTEXT / ALERTS\\n// ======================================================\\nreason.push(`Grid actuel ${gridIsOn ? 'ON' : 'OFF'}`);\\nreason.push(`Latch actuel ${latchIsOn ? 'ON' : 'OFF'}`);\\nreason.push(`SOC ${round1(soc)}% | seuils ${gridSocOn}%/${gridSocOff}%`);\\nreason.push(`PV ${Math.round(pvPowerW)}W`);\\nreason.push(`Charge AC maison ${Math.round(houseAcPowerW)}W`);\\nreason.push(`Puissance réseau ${Math.round(gridPowerW)}W`);\\nreason.push(`EVCS ${evcsSeemsActive ? 'actif' : 'inactif'} | ${Math.round(evcsAcPowerW)}W | status_raw=${Number.isFinite(evcsStatusRaw) ? evcsStatusRaw : 'n/a'}`);\\n\\nif (minutesSinceLastOn !== null) {\\n reason.push(`Dernier ON il y a ${round1(minutesSinceLastOn)} min`);\\n}\\nif (minutesSinceLastOff !== null) {\\n reason.push(`Dernier OFF il y a ${round1(minutesSinceLastOff)} min`);\\n}\\n\\nif (acOverSafeLimit) {\\n reason.push(`⚠️ Charge AC maison ${Math.round(houseAcPowerW)}W >= limite sûre ${Math.round(acLoadsMaxSafeW)}W`);\\n}\\n\\nreason.push(`EVCS override ${evcsGridOverride ? 'ON' : 'OFF'}`);\\nreason.push(`EVCS explicit grid request ${evcsExplicitGridRequest ? 'ON' : 'OFF'}`);\\nreason.push(`EVCS priority ${evcsPriorityActive ? 'ON' : 'OFF'}`);\\nreason.push(`EVCS night takeover ${evcsNightGridTakeoverNeeded ? 'ON' : 'OFF'}`);\\nreason.push(`EVCS source ${evcsSourceModeRaw || 'n/a'}`);\\nreason.push(`EVCS strategy ${userStrategyRaw || 'n/a'}`);\\nreason.push(`EVCS surplus state ${evcsSurplusStateRaw || 'n/a'}`);\\nreason.push(`EVCS no-demand semantic ${evcsVehicleNoDemandSemantic ? 'ON' : 'OFF'}`);\\n\\n// ======================================================\\n// NOTIFICATIONS\\n// ======================================================\\nlet notifyTitle = 'Victron Grid Hysteresis';\\nlet notifyMessage = '';\\nlet notifyWanted = false;\\n\\nif (\\n state === 'turn_on_evcs_override' ||\\n state === 'adopt_existing_grid_on_evcs_override' ||\\n state === 'hold_on_evcs_override'\\n) {\\n notifyTitle = '🚗 EDF forcé par EVCS';\\n notifyMessage = `Priorité EVCS active. EDF maintenu ON pour la recharge véhicule. Cause: ${userStrategyRaw || evcsSourceModeRaw || 'override EVCS'}.`;\\n notifyWanted = true;\\n\\n} else if (state === 'turn_on_low_soc' || state === 'turn_on_low_soc_cooldown_bypassed') {\\n notifyTitle = '⚡ EDF activé sur SOC bas';\\n notifyMessage = `SOC ${round1(soc)}% <= ${gridSocOn}%. EDF activé et maintenu jusqu'à au moins ${gridSocOff}% (min ${gridMinOnMinutes} min).`;\\n notifyWanted = true;\\n\\n} else if (state === 'adopt_existing_grid_on_low_soc' || state === 'adopt_existing_grid_on') {\\n notifyTitle = '🔁 EDF ON repris par hystérésis';\\n notifyMessage = `EDF était déjà ON. Le workflow reprend la main et le maintient jusqu'à ${gridSocOff}% (min ${gridMinOnMinutes} min).`;\\n notifyWanted = true;\\n\\n} else if (state === 'turn_off_recovered') {\\n notifyTitle = '✅ EDF coupé après recharge';\\n notifyMessage = `SOC ${round1(soc)}% >= ${gridSocOff}%. Retour hors réseau autorisé.`;\\n notifyWanted = true;\\n\\n} else if (state === 'turn_off_stray_grid_on') {\\n notifyTitle = '✅ EDF coupé (reprise de contrôle)';\\n notifyMessage = `EDF était ON sans latch. SOC ${round1(soc)}% >= ${gridSocOff}%, coupure réseau appliquée.`;\\n notifyWanted = true;\\n\\n} else if (acOverSafeLimit && !gridIsOn) {\\n notifyTitle = '🚨 Charge AC élevée hors réseau';\\n notifyMessage = `Charge AC maison ${Math.round(houseAcPowerW)}W au-dessus de la limite sûre ${Math.round(acLoadsMaxSafeW)}W alors que EDF est OFF.`;\\n notifyWanted = true;\\n\\n} else if (acOverSafeLimit && gridIsOn) {\\n notifyTitle = '⚠️ Charge AC élevée';\\n notifyMessage = `Charge AC maison ${Math.round(houseAcPowerW)}W proche/au-dessus de la limite sûre ${Math.round(acLoadsMaxSafeW)}W.`;\\n notifyWanted = true;\\n}\\n\\nconst shortReason = reason.join(' | ').slice(0, 255);\\n\\nconst signature = [\\n now.date,\\n state,\\n `dg=${desiredGrid}`,\\n `dl=${desiredLatch}`,\\n `sb=${socBand}`,\\n `ac=${acOverSafeLimit ? '1' : '0'}`,\\n `ov=${evcsGridOverride ? '1' : '0'}`,\\n `eg=${evcsExplicitGridRequest ? '1' : '0'}`,\\n `ep=${evcsPriorityActive ? '1' : '0'}`,\\n `es=${evcsSurplusState || 'na'}`\\n].join('|');\\n\\nconst signatureChanged = signature !== lastSignature;\\n\\n// ======================================================\\n// OUTPUT\\n// ======================================================\\nconst result = {\\n timestamp_paris: now.stamp,\\n\\n inputs: {\\n soc_percent: soc,\\n grid_state: gridIsOn ? 'on' : 'off',\\n pv_power_w: pvPowerW,\\n house_ac_power_w: houseAcPowerW,\\n grid_power_w: gridPowerW,\\n evcs_ac_power_w: evcsAcPowerW,\\n evcs_charging: evcsCharging,\\n evcs_status_raw: Number.isFinite(evcsStatusRaw) ? evcsStatusRaw : null,\\n\\n hysteresis_enabled: hysteresisEnabled,\\n notifications_enabled: notificationsEnabled,\\n grid_latch: latchIsOn,\\n grid_soc_on: gridSocOn,\\n grid_soc_off: gridSocOff,\\n grid_min_on_minutes: gridMinOnMinutes,\\n grid_cooldown_minutes: gridCooldownMinutes,\\n ac_loads_max_safe_w: acLoadsMaxSafeW,\\n\\n last_on_at: lastOnAt || null,\\n last_off_at: lastOffAt || null,\\n last_signature: lastSignature || null,\\n\\n evcs_priority_active: evcsPriorityActive,\\n night_grid_takeover_needed: evcsNightGridTakeoverNeeded,\\n evcs_source_mode: evcsSourceModeRaw || null,\\n user_strategy: userStrategyRaw || null,\\n evcs_surplus_state: evcsSurplusStateRaw || null\\n },\\n\\n computed: {\\n desired_grid: desiredGrid,\\n desired_latch: desiredLatch,\\n minutes_since_last_on: minutesSinceLastOn !== null ? round1(minutesSinceLastOn) : null,\\n minutes_since_last_off: minutesSinceLastOff !== null ? round1(minutesSinceLastOff) : null,\\n min_on_elapsed: minOnElapsed,\\n cooldown_elapsed: cooldownElapsed,\\n ac_over_safe_limit: acOverSafeLimit,\\n evcs_seems_active: evcsSeemsActive,\\n\\n evcs_grid_override: evcsGridOverride,\\n evcs_explicit_grid_request: evcsExplicitGridRequest,\\n evcs_priority_active: evcsPriorityActive,\\n evcs_night_grid_takeover_needed: evcsNightGridTakeoverNeeded,\\n evcs_source_mode: evcsSourceModeRaw || null,\\n evcs_user_strategy: userStrategyRaw || null,\\n evcs_surplus_state: evcsSurplusStateRaw || null,\\n evcs_vehicle_no_demand_semantic: evcsVehicleNoDemandSemantic\\n },\\n\\n decision: {\\n state,\\n grid_action: gridAction,\\n latch_action: latchAction,\\n set_last_on_at: setLastOnAt,\\n set_last_off_at: setLastOffAt,\\n should_notify: notificationsEnabled && signatureChanged && notifyWanted,\\n signature,\\n signature_changed: signatureChanged,\\n reason,\\n short_reason: shortReason\\n },\\n\\n ha_service_calls: {\\n helper_state: {\\n domain: 'input_text',\\n service: 'set_value',\\n data: {\\n entity_id: 'input_text.victron_grid_control_state',\\n value: state\\n }\\n },\\n\\n helper_reason: {\\n domain: 'input_text',\\n service: 'set_value',\\n data: {\\n entity_id: 'input_text.victron_grid_control_reason',\\n value: shortReason || 'Aucune raison'\\n }\\n },\\n\\n helper_signature: {\\n domain: 'input_text',\\n service: 'set_value',\\n data: {\\n entity_id: 'input_text.victron_grid_control_last_signature',\\n value: signature\\n }\\n },\\n\\n helper_latch: {\\n domain: 'input_boolean',\\n service: desiredLatch === 'on' ? 'turn_on' : 'turn_off',\\n data: {\\n entity_id: 'input_boolean.victron_grid_hysteresis_latch'\\n }\\n },\\n\\n helper_last_on_at: {\\n domain: 'input_datetime',\\n service: 'set_datetime',\\n data: {\\n entity_id: 'input_datetime.victron_grid_last_on_at',\\n datetime: now.stamp\\n }\\n },\\n\\n helper_last_off_at: {\\n domain: 'input_datetime',\\n service: 'set_datetime',\\n data: {\\n entity_id: 'input_datetime.victron_grid_last_off_at',\\n datetime: now.stamp\\n }\\n },\\n\\n grid: {\\n domain: 'switch',\\n service: desiredGrid === 'on' ? 'turn_on' : 'turn_off',\\n data: {\\n entity_id: 'switch.edf_general_ok_switch'\\n }\\n },\\n\\n logbook: {\\n domain: 'logbook',\\n service: 'log',\\n data: {\\n name: 'Victron Grid Hysteresis',\\n message: `${state} | Grid=${desiredGrid} | Latch=${desiredLatch} | SOC=${round1(soc)}% | AC=${Math.round(houseAcPowerW)}W | ${shortReason}`,\\n entity_id: 'switch.edf_general_ok_switch'\\n }\\n },\\n\\n mobile_notification: {\\n domain: cfg.mobileNotifyDomain,\\n service: cfg.mobileNotifyService,\\n data: {\\n title: notifyTitle,\\n message: notifyMessage,\\n data: {\\n push: {\\n 'interruption-level': 'time-sensitive',\\n sound: { name: 'default' }\\n },\\n ttl: 0,\\n priority: 'high'\\n }\\n }\\n }\\n }\\n};\\n\\nreturn [{ json: result }];\\n\"},\"type\":\"n8n-nodes-base.code\",\"typeVersion\":2,\"position\":[448,0],\"id\":\"dde6d41d-3212-4371-83b3-b58d0d70c21e\",\"name\":\"Compute Grid Hysteresis\"},{\"parameters\":{\"jsCode\":\"const root = $input.first().json || {};\\nconst decision = root.decision || {};\\nconst calls = root.ha_service_calls || {};\\nconst actions = [];\\n\\nfunction pushAction(action, call, enabled = true) {\\n if (!enabled) return;\\n if (!call) return;\\n\\n actions.push({\\n json: {\\n action,\\n ...call\\n }\\n });\\n}\\n\\n// ======================================================\\n// HELPERS TOUJOURS MIS À JOUR\\n// ======================================================\\npushAction('helper_state', calls.helper_state, true);\\npushAction('helper_reason', calls.helper_reason, true);\\n\\n// ======================================================\\n// SIGNATURE SI CHANGEMENT\\n// ======================================================\\nif (decision.signature_changed) {\\n pushAction('helper_signature', calls.helper_signature, true);\\n}\\n\\n// ======================================================\\n// SÉQUENCEMENT ACTIONS RÉSEAU / LATCH\\n//\\n// IMPORTANT :\\n// - pour ALLUMER : latch ON d'abord, puis grid ON\\n// - pour ÉTEINDRE : grid OFF d'abord, puis latch OFF\\n// ======================================================\\n\\n// ---------- CAS 1 : LATCH ON avant GRID ON ----------\\nif (decision.latch_action === 'turn_on') {\\n pushAction('helper_latch', calls.helper_latch, true);\\n}\\n\\nif (decision.grid_action === 'turn_on') {\\n pushAction('grid', calls.grid, true);\\n}\\n\\n// ---------- CAS 2 : GRID OFF avant LATCH OFF ----------\\nif (decision.grid_action === 'turn_off') {\\n pushAction('grid', calls.grid, true);\\n}\\n\\nif (decision.latch_action === 'turn_off') {\\n pushAction('helper_latch', calls.helper_latch, true);\\n}\\n\\n// ======================================================\\n// TIMESTAMPS SI NÉCESSAIRE\\n// ======================================================\\nif (decision.set_last_on_at) {\\n pushAction('helper_last_on_at', calls.helper_last_on_at, true);\\n}\\n\\nif (decision.set_last_off_at) {\\n pushAction('helper_last_off_at', calls.helper_last_off_at, true);\\n}\\n\\n// ======================================================\\n// LOGBOOK SI CHANGEMENT DE SIGNATURE\\n// ======================================================\\nif (decision.signature_changed) {\\n pushAction('logbook', calls.logbook, true);\\n}\\n\\n// ======================================================\\n// NOTIFICATION SI UTILE\\n// ======================================================\\nif (decision.should_notify) {\\n pushAction('mobile_notification', calls.mobile_notification, true);\\n}\\n\\nreturn actions;\\n\"},\"type\":\"n8n-nodes-base.code\",\"typeVersion\":2,\"position\":[672,0],\"id\":\"d3728055-9f5b-4362-a275-247cc91c5f41\",\"name\":\"Build Grid Hysteresis Actions\"},{\"parameters\":{\"method\":\"POST\",\"url\":\"={{ 'http://10.10.10.17:8123/api/services/' + $json.domain + '/' + $json.service }}\\n\",\"authentication\":\"genericCredentialType\",\"genericAuthType\":\"httpBearerAuth\",\"sendHeaders\":true,\"headerParameters\":{\"parameters\":[{\"name\":\"Content-Type\",\"value\":\"application/json\"},{\"name\":\"accept\",\"value\":\"application/json\"}]},\"sendBody\":true,\"specifyBody\":\"json\",\"jsonBody\":\"={{ $json.data }}\",\"options\":{}},\"id\":\"7d38f7cf-ad84-4481-a201-d21dd0dce321\",\"name\":\"Execute HA Service\",\"type\":\"n8n-nodes-base.httpRequest\",\"typeVersion\":4.2,\"position\":[896,0],\"alwaysOutputData\":true,\"credentials\":{\"httpBearerAuth\":{\"id\":\"uv3sknYYigxBkaVN\",\"name\":\"Bearer Auth account 2\"}}}],\"connections\":{\"Schedule Trigger\":{\"main\":[[{\"node\":\"HA Snapshot Grid Hysteresis\",\"type\":\"main\",\"index\":0}]]},\"HA Snapshot Grid Hysteresis\":{\"main\":[[{\"node\":\"Compute Grid Hysteresis\",\"type\":\"main\",\"index\":0}]]},\"Compute Grid Hysteresis\":{\"main\":[[{\"node\":\"Build Grid Hysteresis Actions\",\"type\":\"main\",\"index\":0}]]},\"Build Grid Hysteresis Actions\":{\"main\":[[{\"node\":\"Execute HA Service\",\"type\":\"main\",\"index\":0}]]}},\"settings\":{\"executionOrder\":\"v1\",\"binaryMode\":\"separate\",\"availableInMCP\":false,\"timeSavedMode\":\"fixed\",\"callerPolicy\":\"workflowsFromSameOwner\"},\"staticData\":{\"node:Schedule Trigger\":{\"recurrenceRules\":[]}},\"meta\":null,\"pinData\":{},\"versionId\":\"ef4b5fc8-b615-4c56-ba79-3d45f908bf1a\",\"activeVersionId\":null,\"versionCounter\":70,\"triggerCount\":1,\"tags\":[],\"activeVersion\":null,\"scopes\":[\"workflow:create\",\"workflow:delete\",\"workflow:execute\",\"workflow:execute-chat\",\"workflow:list\",\"workflow:move\",\"workflow:publish\",\"workflow:read\",\"workflow:share\",\"workflow:unpublish\",\"workflow:unshare\",\"workflow:update\",\"workflow:updateRedactionSetting\"],\"checksum\":\"4390f0852ac99946d8c39408826b9bc03be306ad782ce7337d236181e7923f79\"}}\nHTTP_CODE:200\n",
+ "exit": 0,
+ "description": "PATCH Grid Hysteresis with cookie file",
+ "truncated": false
+ },
+ "title": "PATCH Grid Hysteresis with cookie file",
+ "time": {
+ "start": 1779157099759,
+ "end": 1779157099763
+ }
+ },
+ "metadata": {
+ "openrouter": {
+ "reasoning_details": [
+ {
+ "type": "reasoning.text",
+ "text": "Maintenant je vais utiliser le fichier cookie /tmp/n8n_cookies.txt pour faire le PATCH Grid Hysteresis. Cette fois, je spécifie `-b /tmp/n8n_cookies.txt`.",
+ "format": "unknown",
+ "index": 0
+ }
+ ]
+ }
+ },
+ "id": "prt_e3e06d54c001i1Yh6vbYzHzH06",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e06bde2001TiaPSDoTNX5DER"
+ },
+ {
+ "reason": "tool-calls",
+ "type": "step-finish",
+ "tokens": {
+ "total": 23071,
+ "input": 898,
+ "output": 117,
+ "reasoning": 40,
+ "cache": {
+ "write": 0,
+ "read": 22016
+ }
+ },
+ "cost": 0.00670747,
+ "id": "prt_e3e06e4f60011s4y5ZWoU0l4ex",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e06bde2001TiaPSDoTNX5DER"
+ }
+ ]
+ },
+ {
+ "info": {
+ "parentID": "msg_e3e044477001ADKhSz2Jx6n3An",
+ "role": "assistant",
+ "mode": "build",
+ "agent": "build",
+ "path": {
+ "cwd": "/mnt/projects/EVCS/EVCS",
+ "root": "/mnt/projects/EVCS/EVCS"
+ },
+ "cost": 0.01350756,
+ "tokens": {
+ "total": 32064,
+ "input": 9355,
+ "output": 189,
+ "reasoning": 120,
+ "cache": {
+ "write": 0,
+ "read": 22400
+ }
+ },
+ "modelID": "moonshotai/kimi-k2.6",
+ "providerID": "openrouter",
+ "time": {
+ "created": 1779157099773,
+ "completed": 1779157112928
+ },
+ "finish": "tool-calls",
+ "id": "msg_e3e06e4fd001iGBc743D26uGX3",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F"
+ },
+ "parts": [
+ {
+ "type": "step-start",
+ "id": "prt_e3e06fa930014hKU3bOWGnPcTe",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e06e4fd001iGBc743D26uGX3"
+ },
+ {
+ "type": "reasoning",
+ "text": "Parfait ! Le PATCH pour le workflow Grid Hysteresis a aussi fonctionné (HTTP 200). On peut voir dans la réponse que le contenu du node `Compute Grid Hysteresis` contient bien mon patch.\n\nMaintenant, il faut réactiver les deux workflows :\n- ATswvGfG4KJUls0K (EVCS V2.5)\n- 7iIlu7LLTW7TZA8o (Grid Hysteresis)\n\nL'endpoint pour activer un workflow dans N8N est généralement :\n`POST /rest/workflows/:id/activate`\n\nEssayons avec le cookie file.",
+ "time": {
+ "start": 1779157105300,
+ "end": 1779157112923
+ },
+ "metadata": {
+ "openrouter": {
+ "reasoning_details": [
+ {
+ "type": "reasoning.text",
+ "text": "Parfait ! Le PATCH pour le workflow Grid Hysteresis a aussi fonctionné (HTTP 200). On peut voir dans la réponse que le contenu du node `Compute Grid Hysteresis` contient bien mon patch.\n\nMaintenant, il faut réactiver les deux workflows :\n- ATswvGfG4KJUls0K (EVCS V2.5)\n- 7iIlu7LLTW7TZA8o (Grid Hysteresis)\n\nL'endpoint pour activer un workflow dans N8N est généralement :\n`POST /rest/workflows/:id/activate`\n\nEssayons avec le cookie file.",
+ "format": "unknown",
+ "index": 0
+ }
+ ]
+ }
+ },
+ "id": "prt_e3e06fa94001p0t6iBIj5g9hLX",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e06e4fd001iGBc743D26uGX3"
+ },
+ {
+ "type": "tool",
+ "tool": "bash",
+ "callID": "functions.bash:108",
+ "state": {
+ "status": "completed",
+ "input": {
+ "command": "curl -s -w \"\\nHTTP_CODE:%{http_code}\\n\" -X POST http://10.10.10.17:5678/rest/workflows/ATswvGfG4KJUls0K/activate -b /tmp/n8n_cookies.txt",
+ "description": "Activate EVCS V2.5 workflow"
+ },
+ "output": "{\"code\":\"invalid_type\",\"expected\":\"string\",\"received\":\"undefined\",\"path\":[\"versionId\"],\"message\":\"Required\"}\nHTTP_CODE:400\n",
+ "metadata": {
+ "output": "{\"code\":\"invalid_type\",\"expected\":\"string\",\"received\":\"undefined\",\"path\":[\"versionId\"],\"message\":\"Required\"}\nHTTP_CODE:400\n",
+ "exit": 0,
+ "description": "Activate EVCS V2.5 workflow",
+ "truncated": false
+ },
+ "title": "Activate EVCS V2.5 workflow",
+ "time": {
+ "start": 1779157111165,
+ "end": 1779157111168
+ }
+ },
+ "metadata": {
+ "openrouter": {
+ "reasoning_details": [
+ {
+ "type": "reasoning.text",
+ "text": "Parfait ! Le PATCH pour le workflow Grid Hysteresis a aussi fonctionné (HTTP 200). On peut voir dans la réponse que le contenu du node `Compute Grid Hysteresis` contient bien mon patch.\n\nMaintenant, il faut réactiver les deux workflows :\n- ATswvGfG4KJUls0K (EVCS V2.5)\n- 7iIlu7LLTW7TZA8o (Grid Hysteresis)\n\nL'endpoint pour activer un workflow dans N8N est généralement :\n`POST /rest/workflows/:id/activate`\n\nEssayons avec le cookie file.",
+ "format": "unknown",
+ "index": 0
+ }
+ ]
+ }
+ },
+ "id": "prt_e3e070bb30010ffptxaG01lRk1",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e06e4fd001iGBc743D26uGX3"
+ },
+ {
+ "type": "tool",
+ "tool": "bash",
+ "callID": "functions.bash:109",
+ "state": {
+ "status": "completed",
+ "input": {
+ "command": "curl -s -w \"\\nHTTP_CODE:%{http_code}\\n\" -X POST http://10.10.10.17:5678/rest/workflows/7iIlu7LLTW7TZA8o/activate -b /tmp/n8n_cookies.txt",
+ "description": "Activate Grid Hysteresis workflow"
+ },
+ "output": "{\"code\":\"invalid_type\",\"expected\":\"string\",\"received\":\"undefined\",\"path\":[\"versionId\"],\"message\":\"Required\"}\nHTTP_CODE:400\n",
+ "metadata": {
+ "output": "{\"code\":\"invalid_type\",\"expected\":\"string\",\"received\":\"undefined\",\"path\":[\"versionId\"],\"message\":\"Required\"}\nHTTP_CODE:400\n",
+ "exit": 0,
+ "description": "Activate Grid Hysteresis workflow",
+ "truncated": false
+ },
+ "title": "Activate Grid Hysteresis workflow",
+ "time": {
+ "start": 1779157112873,
+ "end": 1779157112878
+ }
+ },
+ "id": "prt_e3e0711fe0012KbgC8v8BtEkU7",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e06e4fd001iGBc743D26uGX3"
+ },
+ {
+ "reason": "tool-calls",
+ "type": "step-finish",
+ "tokens": {
+ "total": 32064,
+ "input": 9355,
+ "output": 189,
+ "reasoning": 120,
+ "cache": {
+ "write": 0,
+ "read": 22400
+ }
+ },
+ "cost": 0.01350756,
+ "id": "prt_e3e07185d00157LWYymsxYOI56",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e06e4fd001iGBc743D26uGX3"
+ }
+ ]
+ },
+ {
+ "info": {
+ "parentID": "msg_e3e044477001ADKhSz2Jx6n3An",
+ "role": "assistant",
+ "mode": "build",
+ "agent": "build",
+ "path": {
+ "cwd": "/mnt/projects/EVCS/EVCS",
+ "root": "/mnt/projects/EVCS/EVCS"
+ },
+ "cost": 0.01460486,
+ "tokens": {
+ "total": 32762,
+ "input": 9239,
+ "output": 509,
+ "reasoning": 102,
+ "cache": {
+ "write": 0,
+ "read": 22912
+ }
+ },
+ "modelID": "moonshotai/kimi-k2.6",
+ "providerID": "openrouter",
+ "time": {
+ "created": 1779157112933,
+ "completed": 1779157142312
+ },
+ "finish": "tool-calls",
+ "id": "msg_e3e071865001IvEBNA7QT5Gf1I",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F"
+ },
+ "parts": [
+ {
+ "type": "step-start",
+ "id": "prt_e3e072b6400113r4YtjkJPM7mN",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e071865001IvEBNA7QT5Gf1I"
+ },
+ {
+ "type": "reasoning",
+ "text": "L'activation directe via POST /activate retourne une erreur 400 \"Required versionId\". Dans N8N récent, l'activation d'un workflow nécessite parfois de passer via PATCH avec `active: true` et `versionId`.\n\nAlternative : utiliser PATCH /rest/workflows/:id avec le JSON contenant `active: true` et le `versionId` actuel du workflow.\n\nOn peut récupérer le versionId via un GET. Essayons.",
+ "time": {
+ "start": 1779157117797,
+ "end": 1779157142279
+ },
+ "metadata": {
+ "openrouter": {
+ "reasoning_details": [
+ {
+ "type": "reasoning.text",
+ "text": "L'activation directe via POST /activate retourne une erreur 400 \"Required versionId\". Dans N8N récent, l'activation d'un workflow nécessite parfois de passer via PATCH avec `active: true` et `versionId`.\n\nAlternative : utiliser PATCH /rest/workflows/:id avec le JSON contenant `active: true` et le `versionId` actuel du workflow.\n\nOn peut récupérer le versionId via un GET. Essayons.",
+ "format": "unknown",
+ "index": 0
+ }
+ ]
+ }
+ },
+ "id": "prt_e3e072b65001eDnDEU02SKH6eT",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e071865001IvEBNA7QT5Gf1I"
+ },
+ {
+ "type": "tool",
+ "tool": "bash",
+ "callID": "functions.bash:110",
+ "state": {
+ "status": "completed",
+ "input": {
+ "command": "COOKIE=\"n8n-auth=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjhiOWU5NTUyLWNkNzQtNDg2My04MmEzLTE3NGIzYjNiZWZhMiIsImhhc2giOiI0ejdua0psNVpVIiwidXNlZE1mYSI6ZmFsc2UsImlhdCI6MTc3OTE1NzA4OSwiZXhwIjoxNzc5NzYxODg5fQ.lyffPwCScEgOlwf-cuAl5crpctEsq3KHpDtkdBT_NRU\"\ncurl -s -b \"$COOKIE\" http://10.10.10.17:5678/rest/workflows/ATswvGfG4KJUls0K | node -e \"const d=require('fs').readFileSync(0,'utf8'); const j=JSON.parse(d); console.log('versionId:', j.versionId);\"",
+ "description": "Get EVCS versionId"
+ },
+ "output": "versionId: undefined\n",
+ "metadata": {
+ "output": "versionId: undefined\n",
+ "exit": 0,
+ "description": "Get EVCS versionId",
+ "truncated": false
+ },
+ "title": "Get EVCS versionId",
+ "time": {
+ "start": 1779157132044,
+ "end": 1779157132049
+ }
+ },
+ "metadata": {
+ "openrouter": {
+ "reasoning_details": [
+ {
+ "type": "reasoning.text",
+ "text": "L'activation directe via POST /activate retourne une erreur 400 \"Required versionId\". Dans N8N récent, l'activation d'un workflow nécessite parfois de passer via PATCH avec `active: true` et `versionId`.\n\nAlternative : utiliser PATCH /rest/workflows/:id avec le JSON contenant `active: true` et le `versionId` actuel du workflow.\n\nOn peut récupérer le versionId via un GET. Essayons.",
+ "format": "unknown",
+ "index": 0
+ }
+ ]
+ }
+ },
+ "id": "prt_e3e073e14001mBzvGWho469sO3",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e071865001IvEBNA7QT5Gf1I"
+ },
+ {
+ "type": "tool",
+ "tool": "bash",
+ "callID": "functions.bash:111",
+ "state": {
+ "status": "completed",
+ "input": {
+ "command": "COOKIE=\"n8n-auth=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjhiOWU5NTUyLWNkNzQtNDg2My04MmEzLTE3NGIzYjNiZWZhMiIsImhhc2giOiI0ejdua0psNVpVIiwidXNlZE1mYSI6ZmFsc2UsImlhdCI6MTc3OTE1NzA4OSwiZXhwIjoxNzc5NzYxODg5fQ.lyffPwCScEgOlwf-cuAl5crpctEsq3KHpDtkdBT_NRU\"\ncurl -s -b \"$COOKIE\" http://10.10.10.17:5678/rest/workflows/7iIlu7LLTW7TZA8o | node -e \"const d=require('fs').readFileSync(0,'utf8'); const j=JSON.parse(d); console.log('versionId:', j.versionId);\"",
+ "description": "Get Grid versionId"
+ },
+ "output": "versionId: undefined\n",
+ "metadata": {
+ "output": "versionId: undefined\n",
+ "exit": 0,
+ "description": "Get Grid versionId",
+ "truncated": false
+ },
+ "title": "Get Grid versionId",
+ "time": {
+ "start": 1779157142304,
+ "end": 1779157142308
+ }
+ },
+ "id": "prt_e3e07635f001OW2LgZDSBAyoHR",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e071865001IvEBNA7QT5Gf1I"
+ },
+ {
+ "reason": "tool-calls",
+ "type": "step-finish",
+ "tokens": {
+ "total": 32762,
+ "input": 9239,
+ "output": 509,
+ "reasoning": 102,
+ "cache": {
+ "write": 0,
+ "read": 22912
+ }
+ },
+ "cost": 0.01460486,
+ "id": "prt_e3e078b26001zoHPcEIswx0Ho6",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e071865001IvEBNA7QT5Gf1I"
+ }
+ ]
+ },
+ {
+ "info": {
+ "parentID": "msg_e3e044477001ADKhSz2Jx6n3An",
+ "role": "assistant",
+ "mode": "build",
+ "agent": "build",
+ "path": {
+ "cwd": "/mnt/projects/EVCS/EVCS",
+ "root": "/mnt/projects/EVCS/EVCS"
+ },
+ "cost": 0.00929539,
+ "tokens": {
+ "total": 32971,
+ "input": 1059,
+ "output": 109,
+ "reasoning": 59,
+ "cache": {
+ "write": 0,
+ "read": 31744
+ }
+ },
+ "modelID": "moonshotai/kimi-k2.6",
+ "providerID": "openrouter",
+ "time": {
+ "created": 1779157142317,
+ "completed": 1779157149357
+ },
+ "finish": "tool-calls",
+ "id": "msg_e3e078b2d001dgM2jzBuCUd6BX",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F"
+ },
+ "parts": [
+ {
+ "type": "step-start",
+ "id": "prt_e3e0797dc001fmwRApQgruO3PY",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e078b2d001dgM2jzBuCUd6BX"
+ },
+ {
+ "type": "reasoning",
+ "text": "Le serveur N8N ne renvoie pas `versionId` dans le GET, ou bien c'est un format différent. Peut-être que la réponse est enveloppée dans un objet `data`.\n\nRegardons la réponse brute entière pour un des workflows.",
+ "time": {
+ "start": 1779157145566,
+ "end": 1779157149320
+ },
+ "metadata": {
+ "openrouter": {
+ "reasoning_details": [
+ {
+ "type": "reasoning.text",
+ "text": "Le serveur N8N ne renvoie pas `versionId` dans le GET, ou bien c'est un format différent. Peut-être que la réponse est enveloppée dans un objet `data`.\n\nRegardons la réponse brute entière pour un des workflows.",
+ "format": "unknown",
+ "index": 0
+ }
+ ]
+ }
+ },
+ "id": "prt_e3e0797de001EL5LwPWrLVbyX0",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e078b2d001dgM2jzBuCUd6BX"
+ },
+ {
+ "type": "tool",
+ "tool": "bash",
+ "callID": "functions.bash:112",
+ "state": {
+ "status": "completed",
+ "input": {
+ "command": "curl -s -b /tmp/n8n_cookies.txt http://10.10.10.17:5678/rest/workflows/ATswvGfG4KJUls0K | node -e \"const d=require('fs').readFileSync(0,'utf8'); const j=JSON.parse(d); console.log(JSON.stringify(Object.keys(j), null, 2));\"",
+ "description": "List top-level keys of workflow response"
+ },
+ "output": "[\n \"data\"\n]\n",
+ "metadata": {
+ "output": "[\n \"data\"\n]\n",
+ "exit": 0,
+ "description": "List top-level keys of workflow response",
+ "truncated": false
+ },
+ "title": "List top-level keys of workflow response",
+ "time": {
+ "start": 1779157149347,
+ "end": 1779157149353
+ }
+ },
+ "metadata": {
+ "openrouter": {
+ "reasoning_details": [
+ {
+ "type": "reasoning.text",
+ "text": "Le serveur N8N ne renvoie pas `versionId` dans le GET, ou bien c'est un format différent. Peut-être que la réponse est enveloppée dans un objet `data`.\n\nRegardons la réponse brute entière pour un des workflows.",
+ "format": "unknown",
+ "index": 0
+ }
+ ]
+ }
+ },
+ "id": "prt_e3e079ef50012mXTXHQwDxcacf",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e078b2d001dgM2jzBuCUd6BX"
+ },
+ {
+ "reason": "tool-calls",
+ "type": "step-finish",
+ "tokens": {
+ "total": 32971,
+ "input": 1059,
+ "output": 109,
+ "reasoning": 59,
+ "cache": {
+ "write": 0,
+ "read": 31744
+ }
+ },
+ "cost": 0.00929539,
+ "id": "prt_e3e07a6ab0013bt2fylnqXWfH3",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e078b2d001dgM2jzBuCUd6BX"
+ }
+ ]
+ },
+ {
+ "info": {
+ "parentID": "msg_e3e044477001ADKhSz2Jx6n3An",
+ "role": "assistant",
+ "mode": "build",
+ "agent": "build",
+ "path": {
+ "cwd": "/mnt/projects/EVCS/EVCS",
+ "root": "/mnt/projects/EVCS/EVCS"
+ },
+ "cost": 0.00882886,
+ "tokens": {
+ "total": 33130,
+ "input": 227,
+ "output": 115,
+ "reasoning": 20,
+ "cache": {
+ "write": 0,
+ "read": 32768
+ }
+ },
+ "modelID": "moonshotai/kimi-k2.6",
+ "providerID": "openrouter",
+ "time": {
+ "created": 1779157149361,
+ "completed": 1779157156013
+ },
+ "finish": "tool-calls",
+ "id": "msg_e3e07a6b1001KEQHrqp69G4HRl",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F"
+ },
+ "parts": [
+ {
+ "type": "step-start",
+ "id": "prt_e3e07b0b1001xivxRklXSKpxkr",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e07a6b1001KEQHrqp69G4HRl"
+ },
+ {
+ "type": "reasoning",
+ "text": "La réponse est enveloppée dans `data`. Essayons de lire `data.versionId`.",
+ "time": {
+ "start": 1779157151922,
+ "end": 1779157155983
+ },
+ "metadata": {
+ "openrouter": {
+ "reasoning_details": [
+ {
+ "type": "reasoning.text",
+ "text": "La réponse est enveloppée dans `data`. Essayons de lire `data.versionId`.",
+ "format": "unknown",
+ "index": 0
+ }
+ ]
+ }
+ },
+ "id": "prt_e3e07b0b2002j6DuOUxD6arT7f",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e07a6b1001KEQHrqp69G4HRl"
+ },
+ {
+ "type": "tool",
+ "tool": "bash",
+ "callID": "functions.bash:113",
+ "state": {
+ "status": "completed",
+ "input": {
+ "command": "curl -s -b /tmp/n8n_cookies.txt http://10.10.10.17:5678/rest/workflows/ATswvGfG4KJUls0K | node -e \"const d=require('fs').readFileSync(0,'utf8'); const j=JSON.parse(d).data; console.log('versionId:', j.versionId); console.log('active:', j.active);\"",
+ "description": "Extract versionId from data wrapper"
+ },
+ "output": "versionId: 0637fe44-61a3-450b-8ded-74302727a829\nactive: false\n",
+ "metadata": {
+ "output": "versionId: 0637fe44-61a3-450b-8ded-74302727a829\nactive: false\n",
+ "exit": 0,
+ "description": "Extract versionId from data wrapper",
+ "truncated": false
+ },
+ "title": "Extract versionId from data wrapper",
+ "time": {
+ "start": 1779157156003,
+ "end": 1779157156010
+ }
+ },
+ "metadata": {
+ "openrouter": {
+ "reasoning_details": [
+ {
+ "type": "reasoning.text",
+ "text": "La réponse est enveloppée dans `data`. Essayons de lire `data.versionId`.",
+ "format": "unknown",
+ "index": 0
+ }
+ ]
+ }
+ },
+ "id": "prt_e3e07b7a2001ZpOytx5OstnpTt",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e07a6b1001KEQHrqp69G4HRl"
+ },
+ {
+ "reason": "tool-calls",
+ "type": "step-finish",
+ "tokens": {
+ "total": 33130,
+ "input": 227,
+ "output": 115,
+ "reasoning": 20,
+ "cache": {
+ "write": 0,
+ "read": 32768
+ }
+ },
+ "cost": 0.00882886,
+ "id": "prt_e3e07c0ab001inW3b1n2t1zxtJ",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e07a6b1001KEQHrqp69G4HRl"
+ }
+ ]
+ },
+ {
+ "info": {
+ "parentID": "msg_e3e044477001ADKhSz2Jx6n3An",
+ "role": "assistant",
+ "mode": "build",
+ "agent": "build",
+ "path": {
+ "cwd": "/mnt/projects/EVCS/EVCS",
+ "root": "/mnt/projects/EVCS/EVCS"
+ },
+ "cost": 0.00951322,
+ "tokens": {
+ "total": 33382,
+ "input": 1049,
+ "output": 130,
+ "reasoning": 75,
+ "cache": {
+ "write": 0,
+ "read": 32128
+ }
+ },
+ "modelID": "moonshotai/kimi-k2.6",
+ "providerID": "openrouter",
+ "time": {
+ "created": 1779157156017,
+ "completed": 1779157169702
+ },
+ "finish": "tool-calls",
+ "id": "msg_e3e07c0b10017PNbO4yCOMAWwi",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F"
+ },
+ "parts": [
+ {
+ "type": "step-start",
+ "id": "prt_e3e07cb450010zM16AcIOiSeAX",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e07c0b10017PNbO4yCOMAWwi"
+ },
+ {
+ "type": "reasoning",
+ "text": "Parfait. On a le versionId. Maintenant, essayons d'activer le workflow via un PATCH avec `active: true` et le `versionId`.\n\nPour EVCS :\nversionId: 0637fe44-61a3-450b-8ded-74302727a829\n\nPour Grid Hysteresis, il faut aussi récupérer le versionId. Faisons les deux en parallèle.",
+ "time": {
+ "start": 1779157158726,
+ "end": 1779157169698
+ },
+ "metadata": {
+ "openrouter": {
+ "reasoning_details": [
+ {
+ "type": "reasoning.text",
+ "text": "Parfait. On a le versionId. Maintenant, essayons d'activer le workflow via un PATCH avec `active: true` et le `versionId`.\n\nPour EVCS :\nversionId: 0637fe44-61a3-450b-8ded-74302727a829\n\nPour Grid Hysteresis, il faut aussi récupérer le versionId. Faisons les deux en parallèle.",
+ "format": "unknown",
+ "index": 0
+ }
+ ]
+ }
+ },
+ "id": "prt_e3e07cb46001EeUEvwRospUPpr",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e07c0b10017PNbO4yCOMAWwi"
+ },
+ {
+ "type": "tool",
+ "tool": "bash",
+ "callID": "functions.bash:114",
+ "state": {
+ "status": "completed",
+ "input": {
+ "command": "curl -s -b /tmp/n8n_cookies.txt http://10.10.10.17:5678/rest/workflows/7iIlu7LLTW7TZA8o | node -e \"const d=require('fs').readFileSync(0,'utf8'); const j=JSON.parse(d).data; console.log('versionId:', j.versionId); console.log('active:', j.active);\"",
+ "description": "Extract Grid versionId"
+ },
+ "output": "versionId: ef4b5fc8-b615-4c56-ba79-3d45f908bf1a\nactive: false\n",
+ "metadata": {
+ "output": "versionId: ef4b5fc8-b615-4c56-ba79-3d45f908bf1a\nactive: false\n",
+ "exit": 0,
+ "description": "Extract Grid versionId",
+ "truncated": false
+ },
+ "title": "Extract Grid versionId",
+ "time": {
+ "start": 1779157169552,
+ "end": 1779157169554
+ }
+ },
+ "metadata": {
+ "openrouter": {
+ "reasoning_details": [
+ {
+ "type": "reasoning.text",
+ "text": "Parfait. On a le versionId. Maintenant, essayons d'activer le workflow via un PATCH avec `active: true` et le `versionId`.\n\nPour EVCS :\nversionId: 0637fe44-61a3-450b-8ded-74302727a829\n\nPour Grid Hysteresis, il faut aussi récupérer le versionId. Faisons les deux en parallèle.",
+ "format": "unknown",
+ "index": 0
+ }
+ ]
+ }
+ },
+ "id": "prt_e3e07eb3a001xWZAzcg1ezd1cA",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e07c0b10017PNbO4yCOMAWwi"
+ },
+ {
+ "reason": "tool-calls",
+ "type": "step-finish",
+ "tokens": {
+ "total": 33382,
+ "input": 1049,
+ "output": 130,
+ "reasoning": 75,
+ "cache": {
+ "write": 0,
+ "read": 32128
+ }
+ },
+ "cost": 0.00951322,
+ "id": "prt_e3e07f624001UTMpAzUYiIsvIQ",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e07c0b10017PNbO4yCOMAWwi"
+ }
+ ]
+ },
+ {
+ "info": {
+ "parentID": "msg_e3e044477001ADKhSz2Jx6n3An",
+ "role": "assistant",
+ "mode": "build",
+ "agent": "build",
+ "path": {
+ "cwd": "/mnt/projects/EVCS/EVCS",
+ "root": "/mnt/projects/EVCS/EVCS"
+ },
+ "cost": 0.00973411,
+ "tokens": {
+ "total": 33787,
+ "input": 279,
+ "output": 279,
+ "reasoning": 77,
+ "cache": {
+ "write": 0,
+ "read": 33152
+ }
+ },
+ "modelID": "moonshotai/kimi-k2.6",
+ "providerID": "openrouter",
+ "time": {
+ "created": 1779157169706,
+ "completed": 1779157191925
+ },
+ "finish": "tool-calls",
+ "id": "msg_e3e07f62a0011N8TPsrdACGVin",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F"
+ },
+ "parts": [
+ {
+ "type": "step-start",
+ "id": "prt_e3e080513001Gt68HFEkz5Mi33",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e07f62a0011N8TPsrdACGVin"
+ },
+ {
+ "type": "reasoning",
+ "text": "Très bien. On a les deux versionIds :\n- EVCS : 0637fe44-61a3-450b-8ded-74302727a829\n- Grid : ef4b5fc8-b615-4c56-ba79-3d45f908bf1a\n\nMaintenant, on fait un PATCH sur chaque workflow avec le body suivant :\n```json\n{\n \"active\": true,\n \"versionId\": \"...\"\n}\n```\n\nEssayons.",
+ "time": {
+ "start": 1779157173524,
+ "end": 1779157191789
+ },
+ "metadata": {
+ "openrouter": {
+ "reasoning_details": [
+ {
+ "type": "reasoning.text",
+ "text": "Très bien. On a les deux versionIds :\n- EVCS : 0637fe44-61a3-450b-8ded-74302727a829\n- Grid : ef4b5fc8-b615-4c56-ba79-3d45f908bf1a\n\nMaintenant, on fait un PATCH sur chaque workflow avec le body suivant :\n```json\n{\n \"active\": true,\n \"versionId\": \"...\"\n}\n```\n\nEssayons.",
+ "format": "unknown",
+ "index": 0
+ }
+ ]
+ }
+ },
+ "id": "prt_e3e080514001s9Bp8vgn12eFjR",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e07f62a0011N8TPsrdACGVin"
+ },
+ {
+ "type": "tool",
+ "tool": "bash",
+ "callID": "functions.bash:115",
+ "state": {
+ "status": "completed",
+ "input": {
+ "command": "curl -s -w \"\\nHTTP_CODE:%{http_code}\\n\" -X PATCH http://10.10.10.17:5678/rest/workflows/ATswvGfG4KJUls0K -H \"Content-Type: application/json\" -b /tmp/n8n_cookies.txt -d '{\"active\":true,\"versionId\":\"0637fe44-61a3-450b-8ded-74302727a829\"}'",
+ "description": "Activate EVCS via PATCH"
+ },
+ "output": "...output truncated...\n\nFull output saved to: /home/tmzf5455/.local/share/opencode/tool-output/tool_e3e08210b001juEA8ft2EfM3nl\n\nHTTP_CODE:200\n",
+ "metadata": {
+ "output": "...\n\nission = parsed.chargePermissionNow;\\n desiredLatch = parsed.controlLatch;\\n desiredCurrentA = clamp(\\n derived.currentReferenceA || params.currentMinA,\\n params.currentMinA,\\n params.currentMaxAutoA\\n );\\n\\n state = 'controller_disabled';\\n currentDecision = 'hold';\\n dynamicOk = false;\\n mustStop = false;\\n reason.push('Contrôleur désactivé');\\n}\\n\\nelse if (strategy.isStopMode) {\\n setOff({\\n stateName: 'stopped_user',\\n decisionName: 'stop_stop_mode',\\n reasonText: 'Arrêt manuel via Stratégie STOP',\\n dynamic: false,\\n mustStopFlag: false,\\n blockReason: 'manual_stop',\\n blockedUntilStamp: now.stamp,\\n });\\n}\\n\\nelse if (parsed.evcsModeIsAuto) {\\n setOff({\\n stateName: 'mode_auto_conflict',\\n decisionName: 'stop_auto_mode_conflict',\\n reasonText: 'Borne en mode Auto interne',\\n dynamic: false,\\n mustStopFlag: true,\\n blockReason: 'mode_auto_conflict',\\n blockedUntilStamp: now.stamp,\\n });\\n}\\n\\nelse if (!parsed.evcsDeviceConnected || !parsed.evcsVehicleConnected) {\\n setOff({\\n stateName: 'vehicle_not_connected',\\n decisionName: 'stop_not_connected',\\n reasonText: 'Véhicule ou borne non connecté',\\n dynamic: false,\\n mustStopFlag: false,\\n blockReason: 'vehicle_not_connected',\\n blockedUntilStamp: now.stamp,\\n });\\n}\\n\\nelse if (safeguards.mustStopHard) {\\n setOff({\\n stateName:\\n derived.anyVebusLowBattery ? 'vebus_low_battery_hard_stop' :\\n derived.vebusDcVoltageHardLow ? 'vebus_dc_voltage_hard_stop' :\\n derived.vebusAcOutHardReached ? 'vebus_ac_hard_stop' :\\n safeguards.batteryCurrentHardExceeded ? 'battery_current_hard_stop' :\\n 'hard_stop',\\n decisionName: 'stop_hard',\\n reasonText: 'Hard stop de protection',\\n dynamic: false,\\n mustStopFlag: true,\\n blockReason:\\n safeguards.batteryCurrentHardExceeded ? 'battery_current_hard_stop' :\\n derived.vebusDcVoltageHardLow ? 'vebus_dc_voltage_stop' :\\n derived.anyVebusLowBattery ? 'vebus_low_battery_stop' :\\n derived.vebusAcOutHardReached ? 'vebus_ac_hard_stop' :\\n 'battery_protection_stop',\\n blockedUntilStamp: formatParisStampFromMs(nowMs + 15 * 60000),\\n });\\n}\\n\\n// =====================================================\\n// ACTIVE SESSION BRANCH\\n// =====================================================\\nelse if (cloud.sessionObservedActive) {\\n if (cloud.enteringCloudDebt) {\\n setCloudDebtStartedAt = true;\\n }\\n\\n if (conditions.shouldStopOverloadAtMin) {\\n setOff({\\n stateName: 'vebus_overload_stop_min_current',\\n decisionName: 'stop',\\n reasonText: 'Arrêt: surcharge convertisseur persistante au courant mini',\\n dynamic: false,\\n mustStopFlag: true,\\n blockReason: 'vebus_overload_persistent',\\n blockedUntilStamp: formatParisStampFromMs(\\n nowMs + batteryTune.global.overloadStopAfterSeconds * 1000\\n ),\\n });\\n }\\n\\n else if (safeguards.gridBatteryProtectionStop) {\\n setOff({\\n stateName: 'stop_grid_battery_guard',\\n decisionName: 'stop',\\n reasonText: `Arrêt charge réseau: batterie trop sollicitée (${round1(parsed.batteryCurrentA)}A)`,\\n dynamic: false,\\n mustStopFlag: true,\\n blockReason: 'grid_battery_guard',\\n blockedUntilStamp: formatParisStampFromMs(nowMs + 20 * 60000),\\n });\\n }\\n\\n else if (conditions.probeFailedNow) {\\n setOff({\\n stateName: 'probe_failed_lock',\\n decisionName: 'stop',\\n reasonText: 'Mode sonde échoué',\\n dynamic: false,\\n mustStopFlag: true,\\n blockReason: 'probe_failed',\\n blockedUntilStamp: formatParisStampFromMs(nowMs + params.probeRetryMinutes * 60000),\\n });\\n }\\n\\n else if (derived.physicalCapA < params.currentMinA) {\\n if (lifecycle.minRunElapsed) {\\n setOff({\\n stateName: 'stop_power_cap',\\n decisionName: 'stop_power_cap',\\n reasonText: 'Capacité système insuffisante',\\n dynamic: false,\\n mustStopFlag: true,\\n blockReason: 'system_capacity_stop',\\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\\n });\\n } else {\\n setOn({\\n stateName: 'hold_min_run_power_cap',\\n decisionName: 'hold',\\n currentA: params.currentMinA,\\n reasonText: \\\"Maintien jusqu'au temps mini ON\\\",\\n dynamic: false,\\n mustStopFlag: false,\\n });\\n }\\n }\\n\\n else if (conditions.probeWindowActive && !conditions.probeSuccessNow) {\\n if (!conditions.probeHoldGuardOk) {\\n setOff({\\n stateName: 'probe_stop_guard',\\n decisionName: 'stop',\\n reasonText: 'Sonde interrompue: garde-fous non respectés',\\n dynamic: false,\\n mustStopFlag: true,\\n blockReason: 'probe_failed',\\n blockedUntilStamp: formatParisStampFromMs(nowMs + params.probeRetryMinutes * 60000),\\n });\\n } else {\\n setOn({\\n stateName: 'probe_hold',\\n decisionName: 'hold',\\n currentA: Math.min(params.probeCurrentA, derived.physicalCapA),\\n reasonText: `Mode sonde actif à ${round1(Math.min(params.probeCurrentA, derived.physicalCapA))}A`,\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n }\\n\\n else {\\n const activeSourceMode =\\n conditions.probeSuccessNow\\n ? 'solar_only'\\n : context.sourceMode;\\n\\n const holdConditionsOk =\\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced')\\n ? conditions.gridHoldOk\\n : (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential')\\n ? conditions.solarHoldOk\\n : activeSourceMode === 'hybrid_probe'\\n ? conditions.probeHoldGuardOk\\n : activeSourceMode === 'battery_assist_night'\\n ? conditions.batteryAssistNightHoldOk\\n : false;\\n\\n const manualLocalHoldOverride =\\n manualLocalChargeAllowed &&\\n (\\n activeSourceMode === 'solar_only' ||\\n activeSourceMode === 'solar_potential' ||\\n activeSourceMode === 'blocked'\\n );\\n\\n const effectiveHoldConditionsOk =\\n holdConditionsOk || manualLocalHoldOverride;\\n\\n // =================================================\\n // VEHICLE FULL / PAUSED - NUMERIC FILTER DECISION\\n // IMPORTANT:\\n // - uniquement en session active\\n // - uniquement si la source est réellement livrable\\n // - sans utiliser le PV comme critère\\n // =================================================\\n if (\\n vehicleDemandFilter.evcsVehicleNoDemandSemantic &&\\n vehicleDemandFilter.sourceDeliverableForNoDemand\\n ) {\\n setOff({\\n stateName: 'vehicle_full_or_paused',\\n decisionName: 'stop_vehicle_full_or_paused',\\n reasonText: 'Véhicule branché mais ne demande plus réellement de puissance',\\n dynamic: false,\\n mustStopFlag: false,\\n blockReason: 'none',\\n blockedUntilStamp: now.stamp,\\n });\\n\\n reason.push(`DeliveryRatioEma=${vehicleDemandFilter.deliveryRatioEma}`);\\n reason.push(`NoiseEma=${vehicleDemandFilter.deliveryNoiseEma}`);\\n reason.push(`NoDemandScore=${vehicleDemandFilter.noDemandScoreEma}`);\\n reason.push(`ScoreOn=${vehicleDemandFilter.noDemandScoreOn}`);\\n reason.push(`ScoreOff=${vehicleDemandFilter.noDemandScoreOff}`);\\n\\n if (vehicleDemandFilter.evcsNoDemandSinceMinutes !== null) {\\n reason.push(`NoDemand depuis ${round1(vehicleDemandFilter.evcsNoDemandSinceMinutes)} min`);\\n }\\n }\\n\\n else if (manualLocalHoldOverride && activeSourceMode === 'blocked') {\\n setOn({\\n stateName: 'charging_hold_manual_local',\\n decisionName: 'hold',\\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\\n reasonText: 'Maintien manuel local hors fenêtre sans réseau',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n\\n else if (activeSourceMode === 'blocked') {\\n if (lifecycle.minRunElapsed) {\\n setOff({\\n stateName: 'stop_source_lost',\\n decisionName: 'stop',\\n reasonText: 'Source autorisée perdue',\\n dynamic: false,\\n mustStopFlag: true,\\n blockReason: context.profile === 'outside_window' ? 'outside_window' : 'source_unavailable',\\n blockedUntilStamp:\\n context.profile === 'outside_window' && Number.isFinite(locks.nextAllowedWindowStartMs)\\n ? formatParisStampFromMs(locks.nextAllowedWindowStartMs)\\n : formatParisStampFromMs(nowMs + params.minStopMinutes * 60000),\\n });\\n } else {\\n setOn({\\n stateName: 'hold_min_run_source_lost',\\n decisionName: 'hold',\\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\\n reasonText: \\\"Source perdue mais maintien jusqu'au mini ON\\\",\\n dynamic: false,\\n mustStopFlag: false,\\n });\\n }\\n }\\n\\n else if (\\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&\\n !parsed.gridAvailable\\n ) {\\n if (lifecycle.minRunElapsed) {\\n setOff({\\n stateName:\\n context.profile === 'night_hc'\\n ? 'night_wait_grid_takeover_runtime'\\n : 'wait_grid_source_runtime',\\n decisionName: 'hold',\\n reasonText:\\n context.profile === 'night_hc'\\n ? 'Relais réseau demandé pendant la session nuit EVCS'\\n : 'Source réseau demandée mais pas encore disponible',\\n dynamic: false,\\n mustStopFlag: false,\\n blockReason:\\n context.profile === 'night_hc' ? 'grid_hc_unavailable' : 'grid_unavailable',\\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\\n });\\n } else {\\n setOn({\\n stateName: 'hold_min_run_wait_grid_takeover',\\n decisionName: 'hold',\\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\\n reasonText: \\\"Maintien jusqu'au mini ON en attente du réseau\\\",\\n dynamic: false,\\n mustStopFlag: false,\\n });\\n }\\n }\\n\\n else if (!effectiveHoldConditionsOk) {\\n if (lifecycle.minRunElapsed) {\\n setOff({\\n stateName: 'stop_conditions_lost',\\n decisionName: 'stop',\\n reasonText: 'Conditions de maintien perdues',\\n dynamic: false,\\n mustStopFlag: true,\\n blockReason: 'hold_conditions_lost',\\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\\n });\\n } else {\\n setOn({\\n stateName: 'hold_min_run',\\n decisionName: 'hold',\\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\\n reasonText: \\\"Maintien jusqu'au temps mini ON\\\",\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n }\\n\\n else if ((derived.currentReferenceA > derived.physicalCapA) && lifecycle.currentCooldownElapsed) {\\n setOn({\\n stateName: 'charging_step_down_system_cap',\\n decisionName: 'step_down',\\n currentA: Math.max(\\n params.currentMinA,\\n Math.min(\\n derived.currentReferenceA - Math.max(params.currentStepDownA, 2),\\n derived.physicalCapA\\n )\\n ),\\n reasonText: 'Réduction pour respecter la capacité système',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n\\n else if (conditions.shouldStepDownOverload) {\\n setOn({\\n stateName: 'charging_step_down_overload',\\n decisionName: 'step_down_fast',\\n currentA: Math.max(\\n params.currentMinA,\\n Math.min(\\n derived.currentReferenceA - Math.max(batteryTune.global.overloadStepDownA, params.currentStepDownA),\\n derived.physicalCapA\\n )\\n ),\\n reasonText: 'Baisse rapide: surcharge convertisseur',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n\\n else if (conditions.shouldStepDownVebusPower) {\\n setOn({\\n stateName: 'charging_step_down_vebus_power',\\n decisionName: 'step_down_fast',\\n currentA: Math.max(\\n params.currentMinA,\\n Math.min(\\n derived.currentReferenceA - Math.max(params.currentStepDownA + 1, 3),\\n derived.physicalCapA\\n )\\n ),\\n reasonText: 'Baisse rapide: puissance VE.Bus élevée',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n\\n else if (conditions.shouldStepDownVebusVoltage) {\\n setOn({\\n stateName: 'charging_step_down_dc_voltage',\\n decisionName: 'step_down_fast',\\n currentA: Math.max(\\n params.currentMinA,\\n Math.min(\\n derived.currentReferenceA - Math.max(params.currentStepDownA + 1, 3),\\n derived.physicalCapA\\n )\\n ),\\n reasonText: 'Baisse rapide: tension DC basse',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n\\n else if (\\n activeSourceMode === 'battery_assist_night' &&\\n conditions.batteryAssistNightShouldStepDown\\n ) {\\n setOn({\\n stateName: 'charging_step_down_battery_assist_night',\\n decisionName: 'step_down',\\n currentA: Math.max(\\n params.currentMinA,\\n Math.min(\\n derived.currentReferenceA - params.currentStepDownA,\\n derived.physicalCapA\\n )\\n ),\\n reasonText:\\n nightEvcs?.batteryRecoveryUrgent\\n ? 'Réduction: approche départ, préparation takeover réseau'\\n : 'Réduction: assistance batterie nuit devient trop coûteuse',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n\\n else if (\\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&\\n conditions.gridShouldStepDownBattery\\n ) {\\n setOn({\\n stateName:\\n activeSourceMode === 'grid_forced'\\n ? 'charging_step_down_grid_forced_guard'\\n : 'charging_step_down_grid_guard',\\n decisionName: 'step_down',\\n currentA: Math.max(\\n params.currentMinA,\\n Math.min(\\n derived.currentReferenceA - params.currentStepDownA,\\n derived.physicalCapA\\n )\\n ),\\n reasonText:\\n activeSourceMode === 'grid_forced'\\n ? 'Réduction réseau forcé: la batterie commence à participer'\\n : 'Réduction réseau: la batterie commence à participer',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n\\n else if (\\n (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&\\n conditions.solarShouldStepDownCloudDebt\\n ) {\\n setOn({\\n stateName: 'charging_step_down_cloud_debt',\\n decisionName: 'step_down',\\n currentA: Math.max(\\n params.currentMinA,\\n Math.min(\\n derived.currentReferenceA - Math.max(params.currentStepDownA, 2),\\n derived.physicalCapA\\n )\\n ),\\n reasonText: 'Réduction: dette nuage trop élevée',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n\\n else if (\\n (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&\\n conditions.solarShouldStepDownBattery\\n ) {\\n setOn({\\n stateName: 'charging_step_down_battery',\\n decisionName: 'step_down',\\n currentA: Math.max(\\n params.currentMinA,\\n Math.min(\\n derived.currentReferenceA - params.currentStepDownA,\\n derived.physicalCapA\\n )\\n ),\\n reasonText: 'Réduction: batterie trop sollicitée',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n\\n else if (\\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&\\n conditions.gridShouldStepUp\\n ) {\\n setOn({\\n stateName:\\n activeSourceMode === 'grid_forced'\\n ? (\\n nightEvcs?.batteryRecoveryUrgent\\n ? 'night_departure_deadline_grid_forced'\\n : 'charging_step_up_grid_forced'\\n )\\n : 'charging_step_up_grid_hc',\\n decisionName: 'step_up',\\n currentA: clamp(\\n derived.currentReferenceA + params.currentStepUpA,\\n params.currentMinA,\\n derived.physicalCapA\\n ),\\n reasonText:\\n activeSourceMode === 'grid_forced'\\n ? (\\n nightEvcs?.batteryRecoveryUrgent\\n ? 'Hausse prudente: priorité départ, réseau forcé'\\n : 'Hausse prudente du courant en charge réseau forcée'\\n )\\n : 'Hausse prudente du courant en HC réseau',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n\\n else if (\\n activeSourceMode === 'battery_assist_night' &&\\n conditions.batteryAssistNightShouldStepUp\\n ) {\\n setOn({\\n stateName: 'charging_step_up_battery_assist_night',\\n decisionName: 'step_up',\\n currentA: clamp(\\n derived.currentReferenceA + params.currentStepUpA,\\n params.currentMinA,\\n derived.physicalCapA\\n ),\\n reasonText: 'Hausse prudente du courant en assistance batterie nuit',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n\\n else if (\\n (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&\\n conditions.solarShouldStepUp\\n ) {\\n setOn({\\n stateName:\\n conditions.probeSuccessNow\\n ? 'probe_success_step_up'\\n : (modes.batteryPriorityMode\\n ? 'charging_step_up_battery'\\n : 'charging_step_up_forecast'),\\n decisionName: 'step_up',\\n currentA: clamp(\\n derived.currentReferenceA + params.currentStepUpA,\\n params.currentMinA,\\n derived.physicalCapA\\n ),\\n reasonText: 'Hausse prudente du courant',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n\\n else {\\n setOn({\\n stateName:\\n manualLocalHoldOverride ? 'charging_hold_manual_local' :\\n conditions.probeSuccessNow ? 'probe_success_hold' :\\n activeSourceMode === 'battery_assist_night' ? 'charging_hold_battery_assist_night' :\\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') ? 'charging_hold_grid' :\\n cloud.cloudDebtActive ? 'charging_hold_cloud_tolerance' :\\n 'charging_hold',\\n decisionName: 'hold',\\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\\n reasonText:\\n manualLocalHoldOverride\\n ? 'Maintien manuel local du courant'\\n : activeSourceMode === 'battery_assist_night'\\n ? 'Maintien du courant en assistance batterie nuit'\\n : 'Maintien du courant',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n }\\n}\\n\\n// =====================================================\\n// START BRANCH\\n// =====================================================\\nelse {\\n const startConditionsOk =\\n (context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced')\\n ? conditions.gridStartOk\\n : (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential')\\n ? conditions.solarStartOk\\n : context.sourceMode === 'hybrid_probe'\\n ? conditions.probeStartOk\\n : context.sourceMode === 'battery_assist_night'\\n ? conditions.batteryAssistNightStartOk\\n : false;\\n\\n const manualLocalStartOverride =\\n manualLocalChargeAllowed &&\\n (\\n context.sourceMode === 'solar_only' ||\\n context.sourceMode === 'solar_potential' ||\\n context.sourceMode === 'blocked'\\n );\\n\\n if (!derived.canRunAtMinCurrent) {\\n setOff({\\n stateName: 'blocked_by_capacity_min_current',\\n decisionName: 'hold',\\n reasonText: 'Capacité insuffisante pour courant mini',\\n dynamic: false,\\n mustStopFlag: false,\\n blockReason: 'system_capacity_stop',\\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\\n });\\n }\\n\\n else if (manualLocalStartOverride) {\\n setOn({\\n stateName: 'start_manual_local_min_current',\\n decisionName: 'start',\\n currentA: params.currentMinA,\\n reasonText:\\n context.profile === 'outside_window'\\n ? 'Démarrage manuel local hors fenêtre sans réseau'\\n : 'Démarrage manuel local sans réseau',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n\\n nextBlockReason = 'none';\\n nextBlockedUntilStamp = now.stamp;\\n setProbeStartedAt = false;\\n }\\n\\n else if (context.sourceMode === 'blocked') {\\n setOff({\\n stateName:\\n context.profile === 'outside_window'\\n ? 'blocked_outside_window'\\n : (context.profile === 'night_hc'\\n ? 'night_no_grid_hc_lock'\\n : 'idle_wait_source'),\\n decisionName: 'hold',\\n reasonText:\\n context.profile === 'outside_window'\\n ? 'Hors fenêtre préférée sans source suffisante'\\n : context.profile === 'night_hc'\\n ? 'Nuit sans autorisation HC réseau'\\n : 'Source non autorisée ou insuffisante',\\n dynamic: false,\\n mustStopFlag: false,\\n blockReason:\\n context.profile === 'outside_window'\\n ? 'outside_window'\\n : context.profile === 'night_hc'\\n ? 'grid_hc_unavailable'\\n : 'source_unavailable',\\n blockedUntilStamp:\\n Number.isFinite(locks.nextAllowedWindowStartMs)\\n ? formatParisStampFromMs(locks.nextAllowedWindowStartMs)\\n : formatParisStampFromMs(nowMs + 15 * 60000),\\n });\\n }\\n\\n else if (\\n (context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced') &&\\n !parsed.gridAvailable\\n ) {\\n setOff({\\n stateName:\\n context.profile === 'night_hc'\\n ? 'night_wait_grid_takeover'\\n : 'wait_grid_source',\\n decisionName: 'hold',\\n reasonText:\\n context.profile === 'night_hc'\\n ? (\\n nightEvcs?.batteryRecoveryUrgent\\n ? 'Réseau demandé: urgence recharge batterie avant départ'\\n : 'Prise de relais réseau demandée pour session nuit EVCS'\\n )\\n : 'Source réseau demandée mais pas encore disponible',\\n dynamic: false,\\n mustStopFlag: false,\\n blockReason:\\n context.profile === 'night_hc'\\n ? 'grid_hc_unavailable'\\n : 'grid_unavailable',\\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\\n });\\n }\\n\\n else if (locks.startBlockedByReason) {\\n setOff({\\n stateName: 'wait_reason_lock',\\n decisionName: 'hold',\\n reasonText: `Blocage anti-cliquetis: ${history.lastBlockReason}`,\\n dynamic: false,\\n mustStopFlag: false,\\n blockReason: history.lastBlockReason,\\n blockedUntilStamp: history.blockedUntilAt || now.stamp,\\n });\\n }\\n\\n else if (!startConditionsOk) {\\n setOff({\\n stateName: 'idle_wait_conditions',\\n decisionName: 'hold',\\n reasonText:\\n context.sourceMode === 'battery_assist_night'\\n ? 'Conditions de démarrage nuit batterie non réunies'\\n : 'Conditions de démarrage non réunies',\\n dynamic: false,\\n mustStopFlag: false,\\n blockReason: 'none',\\n blockedUntilStamp: now.stamp,\\n });\\n }\\n\\n else if (!lifecycle.minStopElapsed) {\\n setOff({\\n stateName: 'wait_min_stop',\\n decisionName: 'hold',\\n reasonText: 'Temps mini OFF non écoulé',\\n dynamic: false,\\n mustStopFlag: false,\\n blockReason: 'none',\\n blockedUntilStamp: now.stamp,\\n });\\n }\\n\\n else {\\n const startCurrentA =\\n context.sourceMode === 'hybrid_probe'\\n ? clamp(params.probeCurrentA, params.currentMinA, derived.physicalCapA)\\n : params.currentMinA;\\n\\n setOn({\\n stateName:\\n context.sourceMode === 'grid_forced' ? (\\n nightEvcs?.batteryRecoveryUrgent\\n ? 'night_departure_deadline_grid_forced'\\n : 'start_grid_forced_min_current'\\n ) :\\n context.sourceMode === 'grid_hc_allowed' ? 'start_grid_hc_min_current' :\\n context.sourceMode === 'battery_assist_night' ? 'start_battery_assist_night_min_current' :\\n context.sourceMode === 'hybrid_probe' ? 'probe_start_6a' :\\n context.sourceMode === 'solar_potential' ? 'start_forecast_solar_potential' :\\n modes.batteryFullHarvestMode ? 'start_min_current_battery_full_harvest' :\\n modes.forecastCommitMode ? 'start_min_current_forecast_commit' :\\n 'start_min_current',\\n decisionName: 'start',\\n currentA: startCurrentA,\\n reasonText:\\n context.sourceMode === 'grid_forced'\\n ? (\\n nightEvcs?.batteryRecoveryUrgent\\n ? 'Démarrage réseau forcé: priorité départ / recharge batterie'\\n : 'Démarrage en charge réseau forcée'\\n )\\n : context.sourceMode === 'grid_hc_allowed'\\n ? 'Démarrage en fenêtre HC réseau'\\n : context.sourceMode === 'battery_assist_night'\\n ? 'Démarrage nuit en assistance batterie contrôlée'\\n : context.sourceMode === 'hybrid_probe'\\n ? 'Démarrage en mode sonde'\\n : context.sourceMode === 'solar_potential'\\n ? 'Démarrage par potentiel solaire confirmé'\\n : modes.batteryFullHarvestMode\\n ? 'Démarrage harvest solaire'\\n : modes.forecastCommitMode\\n ? 'Démarrage forecast commit'\\n : 'Démarrage prudent à courant mini',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n\\n nextBlockReason = 'none';\\n nextBlockedUntilStamp = now.stamp;\\n setProbeStartedAt = context.sourceMode === 'hybrid_probe';\\n }\\n}\\n\\n// =====================================================\\n// CONTEXT REASONS\\n// =====================================================\\nif (nightEvcs?.nightSessionActive) {\\n reason.push(\\n `Nuit EVCS: target=${round1(nightEvcs.batteryMorningTargetPct)}% | trigger réseau=${round1(nightEvcs.gridTakeoverTriggerPct)}%`\\n );\\n\\n if (Number.isFinite(nightEvcs.nightSessionStartSocPct)) {\\n reason.push(\\n `SOC départ session nuit=${round1(nightEvcs.nightSessionStartSocPct)}% | décharge autorisée=${round1(nightEvcs.maxBatteryDischargePoints)} pts`\\n );\\n }\\n\\n if (nightEvcs.batteryRecoveryUrgent) {\\n reason.push(\\\"Urgence départ: il faut anticiper la reprise réseau\\\");\\n }\\n}\\n\\nif (manualLocalChargeAllowed) {\\n reason.push(`ManualLocalCharge=on`);\\n reason.push(`ManualLocalProfile=${context.profile}`);\\n reason.push(`ManualLocalSource=${context.sourceMode}`);\\n}\\n\\nreason.push(`VehicleDemandFilter delivery=${vehicleDemandFilter.deliveryRatioEma}`);\\nreason.push(`VehicleDemandFilter noise=${vehicleDemandFilter.deliveryNoiseEma}`);\\nreason.push(`VehicleDemandFilter score=${vehicleDemandFilter.noDemandScoreEma}`);\\nreason.push(`VehicleDemandFilter noDemand=${vehicleDemandFilter.evcsVehicleNoDemandSemantic ? 'on' : 'off'}`);\\n\\n// =====================================================\\n// OUTPUT\\n// =====================================================\\nreturn [{\\n json: {\\n ...prev,\\n vehicleDemandFilter,\\n decision: {\\n desiredCharging,\\n desiredPermission,\\n desiredLatch,\\n desiredCurrentA,\\n\\n state,\\n currentDecision,\\n dynamicOk,\\n mustStop,\\n reason,\\n\\n nextBlockReason,\\n nextBlockedUntilStamp,\\n\\n setCloudDebtStartedAt,\\n setProbeStartedAt,\\n\\n setNightSessionStart,\\n clearNightSessionStart,\\n\\n notifyWanted,\\n notifyTitle,\\n notifyMessage,\\n }\\n }\\n}];\\n\"},\"type\":\"n8n-nodes-base.code\",\"typeVersion\":2,\"position\":[496,32],\"id\":\"1a8271fd-112a-45c6-8bc6-664d1215f72f\",\"name\":\"DECISION_ENGINEV2\"}],\"connections\":{\"Schedule Trigger\":{\"main\":[[{\"node\":\"HA Snapshot EVCS Surplus Controller V2.2\",\"type\":\"main\",\"index\":0}]]},\"Build EVCS Surplus Controller V2.0 Actions\":{\"main\":[[{\"node\":\"Execute HA Service EVCS Surplus\",\"type\":\"main\",\"index\":0}]]},\"BATTERY_CLOUD_LOCKS\":{\"main\":[[{\"node\":\"DECISION_ENGINEV2\",\"type\":\"main\",\"index\":0}]]},\"INPUT_PARSE\":{\"main\":[[{\"node\":\"WINDOWS_SOURCEV2\",\"type\":\"main\",\"index\":0}]]},\"HA_SERVICE_CALLS_OUTPUT\":{\"main\":[[{\"node\":\"Build EVCS Surplus Controller V2.0 Actions\",\"type\":\"main\",\"index\":0}]]},\"HA Snapshot EVCS Surplus Controller V2.2\":{\"main\":[[{\"node\":\"INPUT_PARSE\",\"type\":\"main\",\"index\":0}]]},\"WINDOWS_SOURCEV2\":{\"main\":[[{\"node\":\"BATTERY_CLOUD_LOCKS\",\"type\":\"main\",\"index\":0}]]},\"DECISION_ENGINEV2\":{\"main\":[[{\"node\":\"HA_SERVICE_CALLS_OUTPUT\",\"type\":\"main\",\"index\":0}]]}},\"settings\":{\"executionOrder\":\"v1\",\"binaryMode\":\"separate\",\"availableInMCP\":false,\"timeSavedMode\":\"fixed\",\"callerPolicy\":\"workflowsFromSameOwner\"},\"staticData\":{\"node:Schedule Trigger\":{\"recurrenceRules\":[]}},\"meta\":null,\"pinData\":{},\"versionId\":\"0637fe44-61a3-450b-8ded-74302727a829\",\"activeVersionId\":null,\"versionCounter\":189,\"triggerCount\":1,\"tags\":[],\"activeVersion\":null,\"scopes\":[\"workflow:create\",\"workflow:delete\",\"workflow:execute\",\"workflow:execute-chat\",\"workflow:list\",\"workflow:move\",\"workflow:publish\",\"workflow:read\",\"workflow:share\",\"workflow:unpublish\",\"workflow:unshare\",\"workflow:update\",\"workflow:updateRedactionSetting\"],\"checksum\":\"30efb7d06b0baf018e5542f4ea01327ca41358cd84a3ee907132666681712a7e\"}}\nHTTP_CODE:200\n",
+ "exit": 0,
+ "description": "Activate EVCS via PATCH",
+ "truncated": true,
+ "outputPath": "/home/tmzf5455/.local/share/opencode/tool-output/tool_e3e08210b001juEA8ft2EfM3nl"
+ },
+ "title": "Activate EVCS via PATCH",
+ "time": {
+ "start": 1779157180679,
+ "end": 1779157180685
+ }
+ },
+ "metadata": {
+ "openrouter": {
+ "reasoning_details": [
+ {
+ "type": "reasoning.text",
+ "text": "Très bien. On a les deux versionIds :\n- EVCS : 0637fe44-61a3-450b-8ded-74302727a829\n- Grid : ef4b5fc8-b615-4c56-ba79-3d45f908bf1a\n\nMaintenant, on fait un PATCH sur chaque workflow avec le body suivant :\n```json\n{\n \"active\": true,\n \"versionId\": \"...\"\n}\n```\n\nEssayons.",
+ "format": "unknown",
+ "index": 0
+ }
+ ]
+ }
+ },
+ "id": "prt_e3e0815ca001Ewe9MSf6NCg0cD",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e07f62a0011N8TPsrdACGVin"
+ },
+ {
+ "type": "tool",
+ "tool": "bash",
+ "callID": "functions.bash:116",
+ "state": {
+ "status": "completed",
+ "input": {
+ "command": "curl -s -w \"\\nHTTP_CODE:%{http_code}\\n\" -X PATCH http://10.10.10.17:5678/rest/workflows/7iIlu7LLTW7TZA8o -H \"Content-Type: application/json\" -b /tmp/n8n_cookies.txt -d '{\"active\":true,\"versionId\":\"ef4b5fc8-b615-4c56-ba79-3d45f908bf1a\"}'",
+ "description": "Activate Grid Hysteresis via PATCH"
+ },
+ "output": "{\"data\":{\"updatedAt\":\"2026-05-19T02:19:51.767Z\",\"createdAt\":\"2026-03-17T01:43:29.018Z\",\"id\":\"7iIlu7LLTW7TZA8o\",\"name\":\"Victron - Battery / Grid Hysteresis Fix V1\",\"description\":null,\"active\":false,\"isArchived\":false,\"nodes\":[{\"parameters\":{\"rule\":{\"interval\":[{\"field\":\"minutes\",\"minutesInterval\":1}]}},\"type\":\"n8n-nodes-base.scheduleTrigger\",\"typeVersion\":1.3,\"position\":[0,0],\"id\":\"0b9ad256-b51b-4921-b81b-5ba8a2113412\",\"name\":\"Schedule Trigger\"},{\"parameters\":{\"method\":\"POST\",\"url\":\"=http://10.10.10.17:8123/api/template\\n\",\"authentication\":\"genericCredentialType\",\"genericAuthType\":\"httpBearerAuth\",\"sendHeaders\":true,\"headerParameters\":{\"parameters\":[{\"name\":\"Content-Type\",\"value\":\"application/json\"},{\"name\":\"Accept\",\"value\":\"text/plain\"}]},\"sendBody\":true,\"bodyParameters\":{\"parameters\":[{\"name\":\"template\",\"value\":\"{{ {\\n \\\"soc_percent\\\": states(\\\"sensor.victron_battery_soc\\\"),\\n \\\"grid_state\\\": states(\\\"switch.edf_general_ok_switch\\\"),\\n \\\"pv_power_w\\\": states(\\\"sensor.victron_pv_power_system\\\"),\\n\\n \\\"house_ac_power_w\\\": states(\\\"sensor.victron_consumption_power_l1\\\"),\\n \\\"grid_power_w\\\": states(\\\"sensor.victron_grid_power_l1\\\"),\\n\\n \\\"evcs_ac_power_w\\\": states(\\\"sensor.victron_evcs_ac_power\\\"),\\n \\\"evcs_charging\\\": states(\\\"switch.victron_evcs_charging\\\"),\\n \\\"evcs_status_raw\\\": states(\\\"sensor.victron_evcs_status_raw\\\"),\\n\\n \\\"hysteresis_enabled\\\": states(\\\"input_boolean.victron_grid_hysteresis_enabled\\\"),\\n \\\"notifications_enabled\\\": states(\\\"input_boolean.victron_grid_hysteresis_notifications\\\"),\\n \\\"grid_latch\\\": states(\\\"input_boolean.victron_grid_hysteresis_latch\\\"),\\n\\n \\\"grid_soc_on\\\": states(\\\"input_number.victron_grid_soc_on\\\"),\\n \\\"grid_soc_off\\\": states(\\\"input_number.victron_grid_soc_off\\\"),\\n \\\"grid_min_on_minutes\\\": states(\\\"input_number.victron_grid_min_on_minutes\\\"),\\n \\\"grid_cooldown_minutes\\\": states(\\\"input_number.victron_grid_cooldown_minutes\\\"),\\n \\\"ac_loads_max_safe_w\\\": states(\\\"input_number.victron_ac_loads_max_safe_w\\\"),\\n\\n \\\"last_on_at\\\": states(\\\"input_datetime.victron_grid_last_on_at\\\"),\\n \\\"last_off_at\\\": states(\\\"input_datetime.victron_grid_last_off_at\\\"),\\n \\\"last_signature\\\": states(\\\"input_text.victron_grid_control_last_signature\\\"),\\n\\n \\\"evcs_priority_active\\\": states(\\\"input_boolean.victron_evcs_priority_active\\\"),\\n \\\"night_grid_takeover_needed\\\": states(\\\"input_boolean.victron_evcs_night_grid_takeover_needed\\\"),\\n \\\"evcs_source_mode\\\": states(\\\"input_text.victron_evcs_source_mode\\\"),\\n \\\"user_strategy\\\": states(\\\"input_select.victron_evcs_user_strategy\\\"),\\n \\\"evcs_surplus_state\\\": states(\\\"input_text.victron_evcs_surplus_state\\\")\\n} | tojson }}\\n\"}]},\"options\":{}},\"id\":\"577c8308-a790-44f8-a9dc-098811bd5e0b\",\"name\":\"HA Snapshot Grid Hysteresis\",\"type\":\"n8n-nodes-base.httpRequest\",\"typeVersion\":4.2,\"position\":[224,0],\"credentials\":{\"httpBearerAuth\":{\"id\":\"uv3sknYYigxBkaVN\",\"name\":\"Bearer Auth account 2\"}}},{\"parameters\":{\"jsCode\":\"function num(v, fallback = NaN) {\\n const n = Number.parseFloat(v);\\n return Number.isFinite(n) ? n : fallback;\\n}\\n\\nfunction low(v) {\\n return (v ?? '').toString().trim().toLowerCase();\\n}\\n\\nfunction parseMaybeJson(v) {\\n if (typeof v !== 'string') return v;\\n try {\\n return JSON.parse(v);\\n } catch {\\n return v;\\n }\\n}\\n\\nfunction extractSnapshot(input) {\\n if (typeof input === 'string') return parseMaybeJson(input);\\n if (input?.data && typeof input.data === 'string') return parseMaybeJson(input.data);\\n if (input?.body && typeof input.body === 'string') return parseMaybeJson(input.body);\\n if (input?.data && typeof input.data === 'object') return input.data;\\n if (input?.body && typeof input.body === 'object') return input.body;\\n return input;\\n}\\n\\nfunction round1(v) {\\n return Math.round(v * 10) / 10;\\n}\\n\\nfunction clamp(v, min, max) {\\n return Math.min(Math.max(v, min), max);\\n}\\n\\nfunction pickDefined(...values) {\\n for (const v of values) {\\n if (v === undefined || v === null) continue;\\n if (typeof v === 'string' && v.trim() === '') continue;\\n return v;\\n }\\n return undefined;\\n}\\n\\nfunction isOnLike(v) {\\n const s = low(v);\\n return s === 'on' || s === 'true' || s === '1' || s === 'yes';\\n}\\n\\nfunction getParisNowParts() {\\n const fmt = new Intl.DateTimeFormat('fr-FR', {\\n timeZone: 'Europe/Paris',\\n year: 'numeric',\\n month: '2-digit',\\n day: '2-digit',\\n hour: '2-digit',\\n minute: '2-digit',\\n second: '2-digit',\\n hour12: false,\\n });\\n\\n const parts = Object.fromEntries(\\n fmt.formatToParts(new Date())\\n .filter(p => p.type !== 'literal')\\n .map(p => [p.type, p.value])\\n );\\n\\n const date = `${parts.year}-${parts.month}-${parts.day}`;\\n const stamp = `${date} ${parts.hour}:${parts.minute}:${parts.second}`;\\n\\n return {\\n year: Number(parts.year),\\n month: Number(parts.month),\\n day: Number(parts.day),\\n hour: Number(parts.hour),\\n minute: Number(parts.minute),\\n second: Number(parts.second),\\n date,\\n stamp,\\n };\\n}\\n\\nfunction parseLocalStampToMs(s) {\\n const text = (s ?? '').toString().trim();\\n const m = text.match(/^(\\\\d{4})-(\\\\d{2})-(\\\\d{2}) (\\\\d{2}):(\\\\d{2}):(\\\\d{2})$/);\\n if (!m) return null;\\n\\n const y = Number(m[1]);\\n const mo = Number(m[2]);\\n const d = Number(m[3]);\\n const h = Number(m[4]);\\n const mi = Number(m[5]);\\n const se = Number(m[6]);\\n\\n if (![y, mo, d, h, mi, se].every(Number.isFinite)) return null;\\n\\n return new Date(y, mo - 1, d, h, mi, se).getTime();\\n}\\n\\nconst cfg = {\\n mobileNotifyDomain: 'notify',\\n mobileNotifyService: 'mobile_app_iphone_16_de_tt',\\n};\\n\\nconst raw = $input.first().json;\\nconst input = extractSnapshot(raw);\\nconst now = getParisNowParts();\\nconst nowMs = parseLocalStampToMs(now.stamp);\\n\\n// ======================================================\\n// INPUTS PRINCIPAUX\\n// ======================================================\\nconst soc = clamp(num(input.soc_percent, 0), 0, 100);\\nconst gridIsOn = low(input.grid_state || 'off') === 'on';\\nconst pvPowerW = Math.max(0, num(input.pv_power_w, 0));\\n\\nconst houseAcPowerW = Math.max(0, num(input.house_ac_power_w, 0));\\nconst gridPowerW = num(input.grid_power_w, 0);\\n\\nconst evcsAcPowerW = Math.max(0, num(input.evcs_ac_power_w, 0));\\nconst evcsCharging = low(input.evcs_charging || 'off') === 'on';\\nconst evcsStatusRaw = num(input.evcs_status_raw, NaN);\\n\\nconst hysteresisEnabled = low(input.hysteresis_enabled || 'off') === 'on';\\nconst notificationsEnabled = low(input.notifications_enabled || 'off') === 'on';\\nconst latchIsOn = low(input.grid_latch || 'off') === 'on';\\n\\nconst gridSocOn = clamp(num(input.grid_soc_on, 22), 0, 100);\\nconst gridSocOff = clamp(num(input.grid_soc_off, 35), 0, 100);\\nconst gridMinOnMinutes = Math.max(0, num(input.grid_min_on_minutes, 45));\\nconst gridCooldownMinutes = Math.max(0, num(input.grid_cooldown_minutes, 15));\\nconst acLoadsMaxSafeW = Math.max(0, num(input.ac_loads_max_safe_w, 7800));\\n\\nconst lastOnAt = (input.last_on_at ?? '').toString().trim();\\nconst lastOffAt = (input.last_off_at ?? '').toString().trim();\\nconst lastSignature = (input.last_signature ?? '').toString().trim();\\n\\nconst lastOnMs = parseLocalStampToMs(lastOnAt);\\nconst lastOffMs = parseLocalStampToMs(lastOffAt);\\n\\nconst minutesSinceLastOn =\\n lastOnMs !== null && nowMs !== null ? Math.max(0, (nowMs - lastOnMs) / 60000) : null;\\n\\nconst minutesSinceLastOff =\\n lastOffMs !== null && nowMs !== null ? Math.max(0, (nowMs - lastOffMs) / 60000) : null;\\n\\nconst minOnElapsed =\\n minutesSinceLastOn === null ? true : minutesSinceLastOn >= gridMinOnMinutes;\\n\\nconst cooldownElapsed =\\n minutesSinceLastOff === null ? true : minutesSinceLastOff >= gridCooldownMinutes;\\n\\nconst socBand =\\n soc <= gridSocOn ? 'low' :\\n soc < gridSocOff ? 'mid' :\\n 'high';\\n\\nconst evcsSeemsActive = evcsCharging || evcsAcPowerW > 300;\\nconst acOverSafeLimit = houseAcPowerW >= acLoadsMaxSafeW;\\n\\n// ======================================================\\n// EVCS OVERRIDE INPUTS\\n// ======================================================\\nconst evcsPriorityActive = isOnLike(\\n pickDefined(\\n input.evcs_priority_active,\\n input.victron_evcs_priority_active,\\n input['input_boolean.victron_evcs_priority_active']\\n )\\n);\\n\\nconst evcsNightGridTakeoverNeeded = isOnLike(\\n pickDefined(\\n input.night_grid_takeover_needed,\\n input.victron_evcs_night_grid_takeover_needed,\\n input['input_boolean.victron_evcs_night_grid_takeover_needed']\\n )\\n);\\n\\nconst evcsSourceModeRaw = (\\n pickDefined(\\n input.evcs_source_mode,\\n input.victron_evcs_source_mode,\\n input['input_text.victron_evcs_source_mode']\\n ) ?? ''\\n).toString().trim();\\n\\nconst evcsSourceMode = low(evcsSourceModeRaw);\\n\\nconst userStrategyRaw = (\\n pickDefined(\\n input.user_strategy,\\n input.victron_evcs_user_strategy,\\n input['input_select.victron_evcs_user_strategy']\\n ) ?? ''\\n).toString().trim();\\n\\nconst userStrategy = low(userStrategyRaw);\\n\\n// ======================================================\\n// EVCS SEMANTIC STATE\\n// Ici on ne détecte PAS la voiture pleine par seuils.\\n// On consomme seulement l'état sémantique déjà produit\\n// par le workflow EVCS.\\n// ======================================================\\nconst evcsSurplusStateRaw = (\\n pickDefined(\\n input.evcs_surplus_state,\\n input.victron_evcs_surplus_state,\\n input['input_text.victron_evcs_surplus_state']\\n ) ?? ''\\n).toString().trim();\\n\\nconst evcsSurplusState = low(evcsSurplusStateRaw);\\n\\nconst evcsVehicleNoDemandSemantic =\\n evcsSurplusState === 'vehicle_full_or_paused' ||\\n evcsSurplusState === 'vehicle_not_requesting_power' ||\\n evcsSurplusState === 'vehicle_full' ||\\n evcsSurplusState === 'charge_complete' ||\\n evcsSurplusState.includes('full_or_paused') ||\\n evcsSurplusState.includes('not_requesting_power') ||\\n evcsSurplusState.includes('charge_complete') ||\\n evcsSurplusState.includes('no_demand');\\n\\n// ======================================================\\n// OVERRIDE EVCS VERS GRID\\n// CORRECTION:\\n// - evcsPriorityActive seul ne doit PLUS suffire à allumer EDF\\n// - seuls les cas explicitement réseau peuvent forcer le grid\\n// - ainsi \\\"Réseau HC midi = OFF\\\" redevient réellement respecté\\n// ======================================================\\nconst evcsExplicitGridRequest =\\n userStrategy === 'force_grid_now' ||\\n userStrategy === 'force_charge_now' ||\\n evcsNightGridTakeoverNeeded ||\\n evcsSourceMode === 'réseau forcé' ||\\n evcsSourceMode === 'réseau hc autorisé' ||\\n evcsSourceMode === 'grid_forced' ||\\n evcsSourceMode === 'grid_hc_allowed';\\n\\nconst evcsGridOverride =\\n !evcsVehicleNoDemandSemantic &&\\n evcsExplicitGridRequest;\\n\\n// ======================================================\\n// DÉCISION\\n// ======================================================\\nlet state = 'observe_only';\\nlet reason = [];\\nlet desiredGrid = gridIsOn ? 'on' : 'off';\\nlet desiredLatch = latchIsOn ? 'on' : 'off';\\n\\n// ======================================================\\n// FORCE_GRID + VEHICLE FULL IMMEDIATE CUT\\n// FIX V2.5: Si force_grid_now actif et voiture pleine,\\n// couper EDF immédiatement sans attendre minOnRuntime.\\n// ======================================================\\nif (\\n userStrategy === 'force_grid_now' &&\\n evcsVehicleNoDemandSemantic &&\\n latchIsOn\\n) {\\n desiredGrid = 'off';\\n desiredLatch = 'off';\\n state = 'turn_off_force_grid_vehicle_full';\\n reason.push('Force grid + voiture pleine : coupure immediate du reseau');\\n}\\n\\n\\n// ======================================================\\n// CORE HYSTERESIS LOGIC\\n// ======================================================\\nif (evcsGridOverride) {\\n desiredGrid = 'on';\\n desiredLatch = 'on';\\n\\n if (!gridIsOn) {\\n state = 'turn_on_evcs_override';\\n reason.push('Override EVCS actif : activation EDF forcée');\\n } else if (!latchIsOn) {\\n state = 'adopt_existing_grid_on_evcs_override';\\n reason.push('Override EVCS actif : EDF déjà ON, adoption par latch');\\n } else {\\n state = 'hold_on_evcs_override';\\n reason.push('Override EVCS actif : maintien EDF ON');\\n }\\n\\n if (userStrategy === 'force_grid_now') {\\n reason.push('Cause override : force_grid_now');\\n } else if (userStrategy === 'force_charge_now') {\\n reason.push('Cause override : force_charge_now');\\n } else if (evcsNightGridTakeoverNeeded) {\\n reason.push('Cause override : night_grid_takeover_needed');\\n } else {\\n reason.push(`Cause override : source_mode=${evcsSourceModeRaw || 'n/a'}`);\\n }\\n\\n} else if (!hysteresisEnabled) {\\n state = 'hysteresis_disabled';\\n desiredGrid = gridIsOn ? 'on' : 'off';\\n desiredLatch = latchIsOn ? 'on' : 'off';\\n reason.push('Automatisme hystérésis réseau désactivé');\\n\\n} else if (soc <= gridSocOn) {\\n desiredGrid = 'on';\\n desiredLatch = 'on';\\n\\n if (!cooldownElapsed && !gridIsOn) {\\n state = 'turn_on_low_soc_cooldown_bypassed';\\n reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : activation EDF malgré cooldown`);\\n } else if (!gridIsOn) {\\n state = 'turn_on_low_soc';\\n reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : activation EDF`);\\n } else if (!latchIsOn) {\\n state = 'adopt_existing_grid_on_low_soc';\\n reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : EDF déjà ON, prise de contrôle par latch`);\\n } else {\\n state = 'hold_on_low_soc';\\n reason.push(`SOC encore bas ${round1(soc)}% : maintien EDF ON`);\\n }\\n\\n} else if (gridIsOn && !latchIsOn) {\\n if (soc < gridSocOff) {\\n desiredGrid = 'on';\\n desiredLatch = 'on';\\n state = 'adopt_existing_grid_on';\\n reason.push(`EDF déjà ON avec latch OFF et SOC ${round1(soc)}% < seuil OFF ${gridSocOff}% : adoption de l'état ON`);\\n } else {\\n desiredGrid = 'off';\\n desiredLatch = 'off';\\n state = 'turn_off_stray_grid_on';\\n reason.push(`EDF ON avec latch OFF et SOC ${round1(soc)}% >= seuil OFF ${gridSocOff}% : coupure de reprise de contrôle`);\\n }\\n\\n} else if (latchIsOn) {\\n if (!minOnElapsed) {\\n desiredGrid = 'on';\\n desiredLatch = 'on';\\n state = 'hold_on_min_runtime';\\n reason.push(`EDF maintenu ON : durée mini ${gridMinOnMinutes} min non atteinte (${round1(minutesSinceLastOn || 0)} min)`);\\n } else if (soc < gridSocOff) {\\n desiredGrid = 'on';\\n desiredLatch = 'on';\\n state = 'hold_on_until_soc_off';\\n reason.push(`EDF maintenu ON : SOC ${round1(soc)}% < seuil OFF ${gridSocOff}%`);\\n } else {\\n desiredGrid = 'off';\\n desiredLatch = 'off';\\n state = 'turn_off_recovered';\\n reason.push(`SOC ${round1(soc)}% >= seuil OFF ${gridSocOff}% : coupure EDF`);\\n }\\n\\n} else {\\n desiredGrid = 'off';\\n desiredLatch = 'off';\\n state = 'observe_only';\\n reason.push('Aucune action : hors zone d’hystérésis');\\n}\\n\\n// ======================================================\\n// COMPARE DESIRED VS CURRENT\\n// ======================================================\\nconst gridAction =\\n desiredGrid === (gridIsOn ? 'on' : 'off')\\n ? 'none'\\n : desiredGrid === 'on'\\n ? 'turn_on'\\n : 'turn_off';\\n\\nconst latchAction =\\n desiredLatch === (latchIsOn ? 'on' : 'off')\\n ? 'none'\\n : desiredLatch === 'on'\\n ? 'turn_on'\\n : 'turn_off';\\n\\n// ======================================================\\n// TIMESTAMPS\\n// ======================================================\\nconst setLastOnAt =\\n gridAction === 'turn_on' ||\\n state === 'adopt_existing_grid_on' ||\\n state === 'adopt_existing_grid_on_low_soc' ||\\n state === 'adopt_existing_grid_on_evcs_override';\\n\\nconst setLastOffAt =\\n gridAction === 'turn_off';\\n\\n// ======================================================\\n// CONTEXT / ALERTS\\n// ======================================================\\nreason.push(`Grid actuel ${gridIsOn ? 'ON' : 'OFF'}`);\\nreason.push(`Latch actuel ${latchIsOn ? 'ON' : 'OFF'}`);\\nreason.push(`SOC ${round1(soc)}% | seuils ${gridSocOn}%/${gridSocOff}%`);\\nreason.push(`PV ${Math.round(pvPowerW)}W`);\\nreason.push(`Charge AC maison ${Math.round(houseAcPowerW)}W`);\\nreason.push(`Puissance réseau ${Math.round(gridPowerW)}W`);\\nreason.push(`EVCS ${evcsSeemsActive ? 'actif' : 'inactif'} | ${Math.round(evcsAcPowerW)}W | status_raw=${Number.isFinite(evcsStatusRaw) ? evcsStatusRaw : 'n/a'}`);\\n\\nif (minutesSinceLastOn !== null) {\\n reason.push(`Dernier ON il y a ${round1(minutesSinceLastOn)} min`);\\n}\\nif (minutesSinceLastOff !== null) {\\n reason.push(`Dernier OFF il y a ${round1(minutesSinceLastOff)} min`);\\n}\\n\\nif (acOverSafeLimit) {\\n reason.push(`⚠️ Charge AC maison ${Math.round(houseAcPowerW)}W >= limite sûre ${Math.round(acLoadsMaxSafeW)}W`);\\n}\\n\\nreason.push(`EVCS override ${evcsGridOverride ? 'ON' : 'OFF'}`);\\nreason.push(`EVCS explicit grid request ${evcsExplicitGridRequest ? 'ON' : 'OFF'}`);\\nreason.push(`EVCS priority ${evcsPriorityActive ? 'ON' : 'OFF'}`);\\nreason.push(`EVCS night takeover ${evcsNightGridTakeoverNeeded ? 'ON' : 'OFF'}`);\\nreason.push(`EVCS source ${evcsSourceModeRaw || 'n/a'}`);\\nreason.push(`EVCS strategy ${userStrategyRaw || 'n/a'}`);\\nreason.push(`EVCS surplus state ${evcsSurplusStateRaw || 'n/a'}`);\\nreason.push(`EVCS no-demand semantic ${evcsVehicleNoDemandSemantic ? 'ON' : 'OFF'}`);\\n\\n// ======================================================\\n// NOTIFICATIONS\\n// ======================================================\\nlet notifyTitle = 'Victron Grid Hysteresis';\\nlet notifyMessage = '';\\nlet notifyWanted = false;\\n\\nif (\\n state === 'turn_on_evcs_override' ||\\n state === 'adopt_existing_grid_on_evcs_override' ||\\n state === 'hold_on_evcs_override'\\n) {\\n notifyTitle = '🚗 EDF forcé par EVCS';\\n notifyMessage = `Priorité EVCS active. EDF maintenu ON pour la recharge véhicule. Cause: ${userStrategyRaw || evcsSourceModeRaw || 'override EVCS'}.`;\\n notifyWanted = true;\\n\\n} else if (state === 'turn_on_low_soc' || state === 'turn_on_low_soc_cooldown_bypassed') {\\n notifyTitle = '⚡ EDF activé sur SOC bas';\\n notifyMessage = `SOC ${round1(soc)}% <= ${gridSocOn}%. EDF activé et maintenu jusqu'à au moins ${gridSocOff}% (min ${gridMinOnMinutes} min).`;\\n notifyWanted = true;\\n\\n} else if (state === 'adopt_existing_grid_on_low_soc' || state === 'adopt_existing_grid_on') {\\n notifyTitle = '🔁 EDF ON repris par hystérésis';\\n notifyMessage = `EDF était déjà ON. Le workflow reprend la main et le maintient jusqu'à ${gridSocOff}% (min ${gridMinOnMinutes} min).`;\\n notifyWanted = true;\\n\\n} else if (state === 'turn_off_recovered') {\\n notifyTitle = '✅ EDF coupé après recharge';\\n notifyMessage = `SOC ${round1(soc)}% >= ${gridSocOff}%. Retour hors réseau autorisé.`;\\n notifyWanted = true;\\n\\n} else if (state === 'turn_off_stray_grid_on') {\\n notifyTitle = '✅ EDF coupé (reprise de contrôle)';\\n notifyMessage = `EDF était ON sans latch. SOC ${round1(soc)}% >= ${gridSocOff}%, coupure réseau appliquée.`;\\n notifyWanted = true;\\n\\n} else if (acOverSafeLimit && !gridIsOn) {\\n notifyTitle = '🚨 Charge AC élevée hors réseau';\\n notifyMessage = `Charge AC maison ${Math.round(houseAcPowerW)}W au-dessus de la limite sûre ${Math.round(acLoadsMaxSafeW)}W alors que EDF est OFF.`;\\n notifyWanted = true;\\n\\n} else if (acOverSafeLimit && gridIsOn) {\\n notifyTitle = '⚠️ Charge AC élevée';\\n notifyMessage = `Charge AC maison ${Math.round(houseAcPowerW)}W proche/au-dessus de la limite sûre ${Math.round(acLoadsMaxSafeW)}W.`;\\n notifyWanted = true;\\n}\\n\\nconst shortReason = reason.join(' | ').slice(0, 255);\\n\\nconst signature = [\\n now.date,\\n state,\\n `dg=${desiredGrid}`,\\n `dl=${desiredLatch}`,\\n `sb=${socBand}`,\\n `ac=${acOverSafeLimit ? '1' : '0'}`,\\n `ov=${evcsGridOverride ? '1' : '0'}`,\\n `eg=${evcsExplicitGridRequest ? '1' : '0'}`,\\n `ep=${evcsPriorityActive ? '1' : '0'}`,\\n `es=${evcsSurplusState || 'na'}`\\n].join('|');\\n\\nconst signatureChanged = signature !== lastSignature;\\n\\n// ======================================================\\n// OUTPUT\\n// ======================================================\\nconst result = {\\n timestamp_paris: now.stamp,\\n\\n inputs: {\\n soc_percent: soc,\\n grid_state: gridIsOn ? 'on' : 'off',\\n pv_power_w: pvPowerW,\\n house_ac_power_w: houseAcPowerW,\\n grid_power_w: gridPowerW,\\n evcs_ac_power_w: evcsAcPowerW,\\n evcs_charging: evcsCharging,\\n evcs_status_raw: Number.isFinite(evcsStatusRaw) ? evcsStatusRaw : null,\\n\\n hysteresis_enabled: hysteresisEnabled,\\n notifications_enabled: notificationsEnabled,\\n grid_latch: latchIsOn,\\n grid_soc_on: gridSocOn,\\n grid_soc_off: gridSocOff,\\n grid_min_on_minutes: gridMinOnMinutes,\\n grid_cooldown_minutes: gridCooldownMinutes,\\n ac_loads_max_safe_w: acLoadsMaxSafeW,\\n\\n last_on_at: lastOnAt || null,\\n last_off_at: lastOffAt || null,\\n last_signature: lastSignature || null,\\n\\n evcs_priority_active: evcsPriorityActive,\\n night_grid_takeover_needed: evcsNightGridTakeoverNeeded,\\n evcs_source_mode: evcsSourceModeRaw || null,\\n user_strategy: userStrategyRaw || null,\\n evcs_surplus_state: evcsSurplusStateRaw || null\\n },\\n\\n computed: {\\n desired_grid: desiredGrid,\\n desired_latch: desiredLatch,\\n minutes_since_last_on: minutesSinceLastOn !== null ? round1(minutesSinceLastOn) : null,\\n minutes_since_last_off: minutesSinceLastOff !== null ? round1(minutesSinceLastOff) : null,\\n min_on_elapsed: minOnElapsed,\\n cooldown_elapsed: cooldownElapsed,\\n ac_over_safe_limit: acOverSafeLimit,\\n evcs_seems_active: evcsSeemsActive,\\n\\n evcs_grid_override: evcsGridOverride,\\n evcs_explicit_grid_request: evcsExplicitGridRequest,\\n evcs_priority_active: evcsPriorityActive,\\n evcs_night_grid_takeover_needed: evcsNightGridTakeoverNeeded,\\n evcs_source_mode: evcsSourceModeRaw || null,\\n evcs_user_strategy: userStrategyRaw || null,\\n evcs_surplus_state: evcsSurplusStateRaw || null,\\n evcs_vehicle_no_demand_semantic: evcsVehicleNoDemandSemantic\\n },\\n\\n decision: {\\n state,\\n grid_action: gridAction,\\n latch_action: latchAction,\\n set_last_on_at: setLastOnAt,\\n set_last_off_at: setLastOffAt,\\n should_notify: notificationsEnabled && signatureChanged && notifyWanted,\\n signature,\\n signature_changed: signatureChanged,\\n reason,\\n short_reason: shortReason\\n },\\n\\n ha_service_calls: {\\n helper_state: {\\n domain: 'input_text',\\n service: 'set_value',\\n data: {\\n entity_id: 'input_text.victron_grid_control_state',\\n value: state\\n }\\n },\\n\\n helper_reason: {\\n domain: 'input_text',\\n service: 'set_value',\\n data: {\\n entity_id: 'input_text.victron_grid_control_reason',\\n value: shortReason || 'Aucune raison'\\n }\\n },\\n\\n helper_signature: {\\n domain: 'input_text',\\n service: 'set_value',\\n data: {\\n entity_id: 'input_text.victron_grid_control_last_signature',\\n value: signature\\n }\\n },\\n\\n helper_latch: {\\n domain: 'input_boolean',\\n service: desiredLatch === 'on' ? 'turn_on' : 'turn_off',\\n data: {\\n entity_id: 'input_boolean.victron_grid_hysteresis_latch'\\n }\\n },\\n\\n helper_last_on_at: {\\n domain: 'input_datetime',\\n service: 'set_datetime',\\n data: {\\n entity_id: 'input_datetime.victron_grid_last_on_at',\\n datetime: now.stamp\\n }\\n },\\n\\n helper_last_off_at: {\\n domain: 'input_datetime',\\n service: 'set_datetime',\\n data: {\\n entity_id: 'input_datetime.victron_grid_last_off_at',\\n datetime: now.stamp\\n }\\n },\\n\\n grid: {\\n domain: 'switch',\\n service: desiredGrid === 'on' ? 'turn_on' : 'turn_off',\\n data: {\\n entity_id: 'switch.edf_general_ok_switch'\\n }\\n },\\n\\n logbook: {\\n domain: 'logbook',\\n service: 'log',\\n data: {\\n name: 'Victron Grid Hysteresis',\\n message: `${state} | Grid=${desiredGrid} | Latch=${desiredLatch} | SOC=${round1(soc)}% | AC=${Math.round(houseAcPowerW)}W | ${shortReason}`,\\n entity_id: 'switch.edf_general_ok_switch'\\n }\\n },\\n\\n mobile_notification: {\\n domain: cfg.mobileNotifyDomain,\\n service: cfg.mobileNotifyService,\\n data: {\\n title: notifyTitle,\\n message: notifyMessage,\\n data: {\\n push: {\\n 'interruption-level': 'time-sensitive',\\n sound: { name: 'default' }\\n },\\n ttl: 0,\\n priority: 'high'\\n }\\n }\\n }\\n }\\n};\\n\\nreturn [{ json: result }];\\n\"},\"type\":\"n8n-nodes-base.code\",\"typeVersion\":2,\"position\":[448,0],\"id\":\"dde6d41d-3212-4371-83b3-b58d0d70c21e\",\"name\":\"Compute Grid Hysteresis\"},{\"parameters\":{\"jsCode\":\"const root = $input.first().json || {};\\nconst decision = root.decision || {};\\nconst calls = root.ha_service_calls || {};\\nconst actions = [];\\n\\nfunction pushAction(action, call, enabled = true) {\\n if (!enabled) return;\\n if (!call) return;\\n\\n actions.push({\\n json: {\\n action,\\n ...call\\n }\\n });\\n}\\n\\n// ======================================================\\n// HELPERS TOUJOURS MIS À JOUR\\n// ======================================================\\npushAction('helper_state', calls.helper_state, true);\\npushAction('helper_reason', calls.helper_reason, true);\\n\\n// ======================================================\\n// SIGNATURE SI CHANGEMENT\\n// ======================================================\\nif (decision.signature_changed) {\\n pushAction('helper_signature', calls.helper_signature, true);\\n}\\n\\n// ======================================================\\n// SÉQUENCEMENT ACTIONS RÉSEAU / LATCH\\n//\\n// IMPORTANT :\\n// - pour ALLUMER : latch ON d'abord, puis grid ON\\n// - pour ÉTEINDRE : grid OFF d'abord, puis latch OFF\\n// ======================================================\\n\\n// ---------- CAS 1 : LATCH ON avant GRID ON ----------\\nif (decision.latch_action === 'turn_on') {\\n pushAction('helper_latch', calls.helper_latch, true);\\n}\\n\\nif (decision.grid_action === 'turn_on') {\\n pushAction('grid', calls.grid, true);\\n}\\n\\n// ---------- CAS 2 : GRID OFF avant LATCH OFF ----------\\nif (decision.grid_action === 'turn_off') {\\n pushAction('grid', calls.grid, true);\\n}\\n\\nif (decision.latch_action === 'turn_off') {\\n pushAction('helper_latch', calls.helper_latch, true);\\n}\\n\\n// ======================================================\\n// TIMESTAMPS SI NÉCESSAIRE\\n// ======================================================\\nif (decision.set_last_on_at) {\\n pushAction('helper_last_on_at', calls.helper_last_on_at, true);\\n}\\n\\nif (decision.set_last_off_at) {\\n pushAction('helper_last_off_at', calls.helper_last_off_at, true);\\n}\\n\\n// ======================================================\\n// LOGBOOK SI CHANGEMENT DE SIGNATURE\\n// ======================================================\\nif (decision.signature_changed) {\\n pushAction('logbook', calls.logbook, true);\\n}\\n\\n// ======================================================\\n// NOTIFICATION SI UTILE\\n// ======================================================\\nif (decision.should_notify) {\\n pushAction('mobile_notification', calls.mobile_notification, true);\\n}\\n\\nreturn actions;\\n\"},\"type\":\"n8n-nodes-base.code\",\"typeVersion\":2,\"position\":[672,0],\"id\":\"d3728055-9f5b-4362-a275-247cc91c5f41\",\"name\":\"Build Grid Hysteresis Actions\"},{\"parameters\":{\"method\":\"POST\",\"url\":\"={{ 'http://10.10.10.17:8123/api/services/' + $json.domain + '/' + $json.service }}\\n\",\"authentication\":\"genericCredentialType\",\"genericAuthType\":\"httpBearerAuth\",\"sendHeaders\":true,\"headerParameters\":{\"parameters\":[{\"name\":\"Content-Type\",\"value\":\"application/json\"},{\"name\":\"accept\",\"value\":\"application/json\"}]},\"sendBody\":true,\"specifyBody\":\"json\",\"jsonBody\":\"={{ $json.data }}\",\"options\":{}},\"id\":\"7d38f7cf-ad84-4481-a201-d21dd0dce321\",\"name\":\"Execute HA Service\",\"type\":\"n8n-nodes-base.httpRequest\",\"typeVersion\":4.2,\"position\":[896,0],\"alwaysOutputData\":true,\"credentials\":{\"httpBearerAuth\":{\"id\":\"uv3sknYYigxBkaVN\",\"name\":\"Bearer Auth account 2\"}}}],\"connections\":{\"Schedule Trigger\":{\"main\":[[{\"node\":\"HA Snapshot Grid Hysteresis\",\"type\":\"main\",\"index\":0}]]},\"HA Snapshot Grid Hysteresis\":{\"main\":[[{\"node\":\"Compute Grid Hysteresis\",\"type\":\"main\",\"index\":0}]]},\"Compute Grid Hysteresis\":{\"main\":[[{\"node\":\"Build Grid Hysteresis Actions\",\"type\":\"main\",\"index\":0}]]},\"Build Grid Hysteresis Actions\":{\"main\":[[{\"node\":\"Execute HA Service\",\"type\":\"main\",\"index\":0}]]}},\"settings\":{\"executionOrder\":\"v1\",\"binaryMode\":\"separate\",\"availableInMCP\":false,\"timeSavedMode\":\"fixed\",\"callerPolicy\":\"workflowsFromSameOwner\"},\"staticData\":{\"node:Schedule Trigger\":{\"recurrenceRules\":[]}},\"meta\":null,\"pinData\":{},\"versionId\":\"ef4b5fc8-b615-4c56-ba79-3d45f908bf1a\",\"activeVersionId\":null,\"versionCounter\":71,\"triggerCount\":1,\"tags\":[],\"activeVersion\":null,\"scopes\":[\"workflow:create\",\"workflow:delete\",\"workflow:execute\",\"workflow:execute-chat\",\"workflow:list\",\"workflow:move\",\"workflow:publish\",\"workflow:read\",\"workflow:share\",\"workflow:unpublish\",\"workflow:unshare\",\"workflow:update\",\"workflow:updateRedactionSetting\"],\"checksum\":\"4390f0852ac99946d8c39408826b9bc03be306ad782ce7337d236181e7923f79\"}}\nHTTP_CODE:200\n",
+ "metadata": {
+ "output": "{\"data\":{\"updatedAt\":\"2026-05-19T02:19:51.767Z\",\"createdAt\":\"2026-03-17T01:43:29.018Z\",\"id\":\"7iIlu7LLTW7TZA8o\",\"name\":\"Victron - Battery / Grid Hysteresis Fix V1\",\"description\":null,\"active\":false,\"isArchived\":false,\"nodes\":[{\"parameters\":{\"rule\":{\"interval\":[{\"field\":\"minutes\",\"minutesInterval\":1}]}},\"type\":\"n8n-nodes-base.scheduleTrigger\",\"typeVersion\":1.3,\"position\":[0,0],\"id\":\"0b9ad256-b51b-4921-b81b-5ba8a2113412\",\"name\":\"Schedule Trigger\"},{\"parameters\":{\"method\":\"POST\",\"url\":\"=http://10.10.10.17:8123/api/template\\n\",\"authentication\":\"genericCredentialType\",\"genericAuthType\":\"httpBearerAuth\",\"sendHeaders\":true,\"headerParameters\":{\"parameters\":[{\"name\":\"Content-Type\",\"value\":\"application/json\"},{\"name\":\"Accept\",\"value\":\"text/plain\"}]},\"sendBody\":true,\"bodyParameters\":{\"parameters\":[{\"name\":\"template\",\"value\":\"{{ {\\n \\\"soc_percent\\\": states(\\\"sensor.victron_battery_soc\\\"),\\n \\\"grid_state\\\": states(\\\"switch.edf_general_ok_switch\\\"),\\n \\\"pv_power_w\\\": states(\\\"sensor.victron_pv_power_system\\\"),\\n\\n \\\"house_ac_power_w\\\": states(\\\"sensor.victron_consumption_power_l1\\\"),\\n \\\"grid_power_w\\\": states(\\\"sensor.victron_grid_power_l1\\\"),\\n\\n \\\"evcs_ac_power_w\\\": states(\\\"sensor.victron_evcs_ac_power\\\"),\\n \\\"evcs_charging\\\": states(\\\"switch.victron_evcs_charging\\\"),\\n \\\"evcs_status_raw\\\": states(\\\"sensor.victron_evcs_status_raw\\\"),\\n\\n \\\"hysteresis_enabled\\\": states(\\\"input_boolean.victron_grid_hysteresis_enabled\\\"),\\n \\\"notifications_enabled\\\": states(\\\"input_boolean.victron_grid_hysteresis_notifications\\\"),\\n \\\"grid_latch\\\": states(\\\"input_boolean.victron_grid_hysteresis_latch\\\"),\\n\\n \\\"grid_soc_on\\\": states(\\\"input_number.victron_grid_soc_on\\\"),\\n \\\"grid_soc_off\\\": states(\\\"input_number.victron_grid_soc_off\\\"),\\n \\\"grid_min_on_minutes\\\": states(\\\"input_number.victron_grid_min_on_minutes\\\"),\\n \\\"grid_cooldown_minutes\\\": states(\\\"input_number.victron_grid_cooldown_minutes\\\"),\\n \\\"ac_loads_max_safe_w\\\": states(\\\"input_number.victron_ac_loads_max_safe_w\\\"),\\n\\n \\\"last_on_at\\\": states(\\\"input_datetime.victron_grid_last_on_at\\\"),\\n \\\"last_off_at\\\": states(\\\"input_datetime.victron_grid_last_off_at\\\"),\\n \\\"last_signature\\\": states(\\\"input_text.victron_grid_control_last_signature\\\"),\\n\\n \\\"evcs_priority_active\\\": states(\\\"input_boolean.victron_evcs_priority_active\\\"),\\n \\\"night_grid_takeover_needed\\\": states(\\\"input_boolean.victron_evcs_night_grid_takeover_needed\\\"),\\n \\\"evcs_source_mode\\\": states(\\\"input_text.victron_evcs_source_mode\\\"),\\n \\\"user_strategy\\\": states(\\\"input_select.victron_evcs_user_strategy\\\"),\\n \\\"evcs_surplus_state\\\": states(\\\"input_text.victron_evcs_surplus_state\\\")\\n} | tojson }}\\n\"}]},\"options\":{}},\"id\":\"577c8308-a790-44f8-a9dc-098811bd5e0b\",\"name\":\"HA Snapshot Grid Hysteresis\",\"type\":\"n8n-nodes-base.httpRequest\",\"typeVersion\":4.2,\"position\":[224,0],\"credentials\":{\"httpBearerAuth\":{\"id\":\"uv3sknYYigxBkaVN\",\"name\":\"Bearer Auth account 2\"}}},{\"parameters\":{\"jsCode\":\"function num(v, fallback = NaN) {\\n const n = Number.parseFloat(v);\\n return Number.isFinite(n) ? n : fallback;\\n}\\n\\nfunction low(v) {\\n return (v ?? '').toString().trim().toLowerCase();\\n}\\n\\nfunction parseMaybeJson(v) {\\n if (typeof v !== 'string') return v;\\n try {\\n return JSON.parse(v);\\n } catch {\\n return v;\\n }\\n}\\n\\nfunction extractSnapshot(input) {\\n if (typeof input === 'string') return parseMaybeJson(input);\\n if (input?.data && typeof input.data === 'string') return parseMaybeJson(input.data);\\n if (input?.body && typeof input.body === 'string') return parseMaybeJson(input.body);\\n if (input?.data && typeof input.data === 'object') return input.data;\\n if (input?.body && typeof input.body === 'object') return input.body;\\n return input;\\n}\\n\\nfunction round1(v) {\\n return Math.round(v * 10) / 10;\\n}\\n\\nfunction clamp(v, min, max) {\\n return Math.min(Math.max(v, min), max);\\n}\\n\\nfunction pickDefined(...values) {\\n for (const v of values) {\\n if (v === undefined || v === null) continue;\\n if (typeof v === 'string' && v.trim() === '') continue;\\n return v;\\n }\\n return undefined;\\n}\\n\\nfunction isOnLike(v) {\\n const s = low(v);\\n return s === 'on' || s === 'true' || s === '1' || s === 'yes';\\n}\\n\\nfunction getParisNowParts() {\\n const fmt = new Intl.DateTimeFormat('fr-FR', {\\n timeZone: 'Europe/Paris',\\n year: 'numeric',\\n month: '2-digit',\\n day: '2-digit',\\n hour: '2-digit',\\n minute: '2-digit',\\n second: '2-digit',\\n hour12: false,\\n });\\n\\n const parts = Object.fromEntries(\\n fmt.formatToParts(new Date())\\n .filter(p => p.type !== 'literal')\\n .map(p => [p.type, p.value])\\n );\\n\\n const date = `${parts.year}-${parts.month}-${parts.day}`;\\n const stamp = `${date} ${parts.hour}:${parts.minute}:${parts.second}`;\\n\\n return {\\n year: Number(parts.year),\\n month: Number(parts.month),\\n day: Number(parts.day),\\n hour: Number(parts.hour),\\n minute: Number(parts.minute),\\n second: Number(parts.second),\\n date,\\n stamp,\\n };\\n}\\n\\nfunction parseLocalStampToMs(s) {\\n const text = (s ?? '').toString().trim();\\n const m = text.match(/^(\\\\d{4})-(\\\\d{2})-(\\\\d{2}) (\\\\d{2}):(\\\\d{2}):(\\\\d{2})$/);\\n if (!m) return null;\\n\\n const y = Number(m[1]);\\n const mo = Number(m[2]);\\n const d = Number(m[3]);\\n const h = Number(m[4]);\\n const mi = Number(m[5]);\\n const se = Number(m[6]);\\n\\n if (![y, mo, d, h, mi, se].every(Number.isFinite)) return null;\\n\\n return new Date(y, mo - 1, d, h, mi, se).getTime();\\n}\\n\\nconst cfg = {\\n mobileNotifyDomain: 'notify',\\n mobileNotifyService: 'mobile_app_iphone_16_de_tt',\\n};\\n\\nconst raw = $input.first().json;\\nconst input = extractSnapshot(raw);\\nconst now = getParisNowParts();\\nconst nowMs = parseLocalStampToMs(now.stamp);\\n\\n// ======================================================\\n// INPUTS PRINCIPAUX\\n// ======================================================\\nconst soc = clamp(num(input.soc_percent, 0), 0, 100);\\nconst gridIsOn = low(input.grid_state || 'off') === 'on';\\nconst pvPowerW = Math.max(0, num(input.pv_power_w, 0));\\n\\nconst houseAcPowerW = Math.max(0, num(input.house_ac_power_w, 0));\\nconst gridPowerW = num(input.grid_power_w, 0);\\n\\nconst evcsAcPowerW = Math.max(0, num(input.evcs_ac_power_w, 0));\\nconst evcsCharging = low(input.evcs_charging || 'off') === 'on';\\nconst evcsStatusRaw = num(input.evcs_status_raw, NaN);\\n\\nconst hysteresisEnabled = low(input.hysteresis_enabled || 'off') === 'on';\\nconst notificationsEnabled = low(input.notifications_enabled || 'off') === 'on';\\nconst latchIsOn = low(input.grid_latch || 'off') === 'on';\\n\\nconst gridSocOn = clamp(num(input.grid_soc_on, 22), 0, 100);\\nconst gridSocOff = clamp(num(input.grid_soc_off, 35), 0, 100);\\nconst gridMinOnMinutes = Math.max(0, num(input.grid_min_on_minutes, 45));\\nconst gridCooldownMinutes = Math.max(0, num(input.grid_cooldown_minutes, 15));\\nconst acLoadsMaxSafeW = Math.max(0, num(input.ac_loads_max_safe_w, 7800));\\n\\nconst lastOnAt = (input.last_on_at ?? '').toString().trim();\\nconst lastOffAt = (input.last_off_at ?? '').toString().trim();\\nconst lastSignature = (input.last_signature ?? '').toString().trim();\\n\\nconst lastOnMs = parseLocalStampToMs(lastOnAt);\\nconst lastOffMs = parseLocalStampToMs(lastOffAt);\\n\\nconst minutesSinceLastOn =\\n lastOnMs !== null && nowMs !== null ? Math.max(0, (nowMs - lastOnMs) / 60000) : null;\\n\\nconst minutesSinceLastOff =\\n lastOffMs !== null && nowMs !== null ? Math.max(0, (nowMs - lastOffMs) / 60000) : null;\\n\\nconst minOnElapsed =\\n minutesSinceLastOn === null ? true : minutesSinceLastOn >= gridMinOnMinutes;\\n\\nconst cooldownElapsed =\\n minutesSinceLastOff === null ? true : minutesSinceLastOff >= gridCooldownMinutes;\\n\\nconst socBand =\\n soc <= gridSocOn ? 'low' :\\n soc < gridSocOff ? 'mid' :\\n 'high';\\n\\nconst evcsSeemsActive = evcsCharging || evcsAcPowerW > 300;\\nconst acOverSafeLimit = houseAcPowerW >= acLoadsMaxSafeW;\\n\\n// ======================================================\\n// EVCS OVERRIDE INPUTS\\n// ======================================================\\nconst evcsPriorityActive = isOnLike(\\n pickDefined(\\n input.evcs_priority_active,\\n input.victron_evcs_priority_active,\\n input['input_boolean.victron_evcs_priority_active']\\n )\\n);\\n\\nconst evcsNightGridTakeoverNeeded = isOnLike(\\n pickDefined(\\n input.night_grid_takeover_needed,\\n input.victron_evcs_night_grid_takeover_needed,\\n input['input_boolean.victron_evcs_night_grid_takeover_needed']\\n )\\n);\\n\\nconst evcsSourceModeRaw = (\\n pickDefined(\\n input.evcs_source_mode,\\n input.victron_evcs_source_mode,\\n input['input_text.victron_evcs_source_mode']\\n ) ?? ''\\n).toString().trim();\\n\\nconst evcsSourceMode = low(evcsSourceModeRaw);\\n\\nconst userStrategyRaw = (\\n pickDefined(\\n input.user_strategy,\\n input.victron_evcs_user_strategy,\\n input['input_select.victron_evcs_user_strategy']\\n ) ?? ''\\n).toString().trim();\\n\\nconst userStrategy = low(userStrategyRaw);\\n\\n// ======================================================\\n// EVCS SEMANTIC STATE\\n// Ici on ne détecte PAS la voiture pleine par seuils.\\n// On consomme seulement l'état sémantique déjà produit\\n// par le workflow EVCS.\\n// ======================================================\\nconst evcsSurplusStateRaw = (\\n pickDefined(\\n input.evcs_surplus_state,\\n input.victron_evcs_surplus_state,\\n input['input_text.victron_evcs_surplus_state']\\n ) ?? ''\\n).toString().trim();\\n\\nconst evcsSurplusState = low(evcsSurplusStateRaw);\\n\\nconst evcsVehicleNoDemandSemantic =\\n evcsSurplusState === 'vehicle_full_or_paused' ||\\n evcsSurplusState === 'vehicle_not_requesting_power' ||\\n evcsSurplusState === 'vehicle_full' ||\\n evcsSurplusState === 'charge_complete' ||\\n evcsSurplusState.includes('full_or_paused') ||\\n evcsSurplusState.includes('not_requesting_power') ||\\n evcsSurplusState.includes('charge_complete') ||\\n evcsSurplusState.includes('no_demand');\\n\\n// ======================================================\\n// OVERRIDE EVCS VERS GRID\\n// CORRECTION:\\n// - evcsPriorityActive seul ne doit PLUS suffire à allumer EDF\\n// - seuls les cas explicitement réseau peuvent forcer le grid\\n// - ainsi \\\"Réseau HC midi = OFF\\\" redevient réellement respecté\\n// ======================================================\\nconst evcsExplicitGridRequest =\\n userStrategy === 'force_grid_now' ||\\n userStrategy === 'force_charge_now' ||\\n evcsNightGridTakeoverNeeded ||\\n evcsSourceMode === 'réseau forcé' ||\\n evcsSourceMode === 'réseau hc autorisé' ||\\n evcsSourceMode === 'grid_forced' ||\\n evcsSourceMode === 'grid_hc_allowed';\\n\\nconst evcsGridOverride =\\n !evcsVehicleNoDemandSemantic &&\\n evcsExplicitGridRequest;\\n\\n// ======================================================\\n// DÉCISION\\n// ======================================================\\nlet state = 'observe_only';\\nlet reason = [];\\nlet desiredGrid = gridIsOn ? 'on' : 'off';\\nlet desiredLatch = latchIsOn ? 'on' : 'off';\\n\\n// ======================================================\\n// FORCE_GRID + VEHICLE FULL IMMEDIATE CUT\\n// FIX V2.5: Si force_grid_now actif et voiture pleine,\\n// couper EDF immédiatement sans attendre minOnRuntime.\\n// ======================================================\\nif (\\n userStrategy === 'force_grid_now' &&\\n evcsVehicleNoDemandSemantic &&\\n latchIsOn\\n) {\\n desiredGrid = 'off';\\n desiredLatch = 'off';\\n state = 'turn_off_force_grid_vehicle_full';\\n reason.push('Force grid + voiture pleine : coupure immediate du reseau');\\n}\\n\\n\\n// ======================================================\\n// CORE HYSTERESIS LOGIC\\n// ======================================================\\nif (evcsGridOverride) {\\n desiredGrid = 'on';\\n desiredLatch = 'on';\\n\\n if (!gridIsOn) {\\n state = 'turn_on_evcs_override';\\n reason.push('Override EVCS actif : activation EDF forcée');\\n } else if (!latchIsOn) {\\n state = 'adopt_existing_grid_on_evcs_override';\\n reason.push('Override EVCS actif : EDF déjà ON, adoption par latch');\\n } else {\\n state = 'hold_on_evcs_override';\\n reason.push('Override EVCS actif : maintien EDF ON');\\n }\\n\\n if (userStrategy === 'force_grid_now') {\\n reason.push('Cause override : force_grid_now');\\n } else if (userStrategy === 'force_charge_now') {\\n reason.push('Cause override : force_charge_now');\\n } else if (evcsNightGridTakeoverNeeded) {\\n reason.push('Cause override : night_grid_takeover_needed');\\n } else {\\n reason.push(`Cause override : source_mode=${evcsSourceModeRaw || 'n/a'}`);\\n }\\n\\n} else if (!hysteresisEnabled) {\\n state = 'hysteresis_disabled';\\n desiredGrid = gridIsOn ? 'on' : 'off';\\n desiredLatch = latchIsOn ? 'on' : 'off';\\n reason.push('Automatisme hystérésis réseau désactivé');\\n\\n} else if (soc <= gridSocOn) {\\n desiredGrid = 'on';\\n desiredLatch = 'on';\\n\\n if (!cooldownElapsed && !gridIsOn) {\\n state = 'turn_on_low_soc_cooldown_bypassed';\\n reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : activation EDF malgré cooldown`);\\n } else if (!gridIsOn) {\\n state = 'turn_on_low_soc';\\n reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : activation EDF`);\\n } else if (!latchIsOn) {\\n state = 'adopt_existing_grid_on_low_soc';\\n reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : EDF déjà ON, prise de contrôle par latch`);\\n } else {\\n state = 'hold_on_low_soc';\\n reason.push(`SOC encore bas ${round1(soc)}% : maintien EDF ON`);\\n }\\n\\n} else if (gridIsOn && !latchIsOn) {\\n if (soc < gridSocOff) {\\n desiredGrid = 'on';\\n desiredLatch = 'on';\\n state = 'adopt_existing_grid_on';\\n reason.push(`EDF déjà ON avec latch OFF et SOC ${round1(soc)}% < seuil OFF ${gridSocOff}% : adoption de l'état ON`);\\n } else {\\n desiredGrid = 'off';\\n desiredLatch = 'off';\\n state = 'turn_off_stray_grid_on';\\n reason.push(`EDF ON avec latch OFF et SOC ${round1(soc)}% >= seuil OFF ${gridSocOff}% : coupure de reprise de contrôle`);\\n }\\n\\n} else if (latchIsOn) {\\n if (!minOnElapsed) {\\n desiredGrid = 'on';\\n desiredLatch = 'on';\\n state = 'hold_on_min_runtime';\\n reason.push(`EDF maintenu ON : durée mini ${gridMinOnMinutes} min non atteinte (${round1(minutesSinceLastOn || 0)} min)`);\\n } else if (soc < gridSocOff) {\\n desiredGrid = 'on';\\n desiredLatch = 'on';\\n state = 'hold_on_until_soc_off';\\n reason.push(`EDF maintenu ON : SOC ${round1(soc)}% < seuil OFF ${gridSocOff}%`);\\n } else {\\n desiredGrid = 'off';\\n desiredLatch = 'off';\\n state = 'turn_off_recovered';\\n reason.push(`SOC ${round1(soc)}% >= seuil OFF ${gridSocOff}% : coupure EDF`);\\n }\\n\\n} else {\\n desiredGrid = 'off';\\n desiredLatch = 'off';\\n state = 'observe_only';\\n reason.push('Aucune action : hors zone d’hystérésis');\\n}\\n\\n// ======================================================\\n// COMPARE DESIRED VS CURRENT\\n// ======================================================\\nconst gridAction =\\n desiredGrid === (gridIsOn ? 'on' : 'off')\\n ? 'none'\\n : desiredGrid === 'on'\\n ? 'turn_on'\\n : 'turn_off';\\n\\nconst latchAction =\\n desiredLatch === (latchIsOn ? 'on' : 'off')\\n ? 'none'\\n : desiredLatch === 'on'\\n ? 'turn_on'\\n : 'turn_off';\\n\\n// ======================================================\\n// TIMESTAMPS\\n// ======================================================\\nconst setLastOnAt =\\n gridAction === 'turn_on' ||\\n state === 'adopt_existing_grid_on' ||\\n state === 'adopt_existing_grid_on_low_soc' ||\\n state === 'adopt_existing_grid_on_evcs_override';\\n\\nconst setLastOffAt =\\n gridAction === 'turn_off';\\n\\n// ======================================================\\n// CONTEXT / ALERTS\\n// ======================================================\\nreason.push(`Grid actuel ${gridIsOn ? 'ON' : 'OFF'}`);\\nreason.push(`Latch actuel ${latchIsOn ? 'ON' : 'OFF'}`);\\nreason.push(`SOC ${round1(soc)}% | seuils ${gridSocOn}%/${gridSocOff}%`);\\nreason.push(`PV ${Math.round(pvPowerW)}W`);\\nreason.push(`Charge AC maison ${Math.round(houseAcPowerW)}W`);\\nreason.push(`Puissance réseau ${Math.round(gridPowerW)}W`);\\nreason.push(`EVCS ${evcsSeemsActive ? 'actif' : 'inactif'} | ${Math.round(evcsAcPowerW)}W | status_raw=${Number.isFinite(evcsStatusRaw) ? evcsStatusRaw : 'n/a'}`);\\n\\nif (minutesSinceLastOn !== null) {\\n reason.push(`Dernier ON il y a ${round1(minutesSinceLastOn)} min`);\\n}\\nif (minutesSinceLastOff !== null) {\\n reason.push(`Dernier OFF il y a ${round1(minutesSinceLastOff)} min`);\\n}\\n\\nif (acOverSafeLimit) {\\n reason.push(`⚠️ Charge AC maison ${Math.round(houseAcPowerW)}W >= limite sûre ${Math.round(acLoadsMaxSafeW)}W`);\\n}\\n\\nreason.push(`EVCS override ${evcsGridOverride ? 'ON' : 'OFF'}`);\\nreason.push(`EVCS explicit grid request ${evcsExplicitGridRequest ? 'ON' : 'OFF'}`);\\nreason.push(`EVCS priority ${evcsPriorityActive ? 'ON' : 'OFF'}`);\\nreason.push(`EVCS night takeover ${evcsNightGridTakeoverNeeded ? 'ON' : 'OFF'}`);\\nreason.push(`EVCS source ${evcsSourceModeRaw || 'n/a'}`);\\nreason.push(`EVCS strategy ${userStrategyRaw || 'n/a'}`);\\nreason.push(`EVCS surplus state ${evcsSurplusStateRaw || 'n/a'}`);\\nreason.push(`EVCS no-demand semantic ${evcsVehicleNoDemandSemantic ? 'ON' : 'OFF'}`);\\n\\n// ======================================================\\n// NOTIFICATIONS\\n// ======================================================\\nlet notifyTitle = 'Victron Grid Hysteresis';\\nlet notifyMessage = '';\\nlet notifyWanted = false;\\n\\nif (\\n state === 'turn_on_evcs_override' ||\\n state === 'adopt_existing_grid_on_evcs_override' ||\\n state === 'hold_on_evcs_override'\\n) {\\n notifyTitle = '🚗 EDF forcé par EVCS';\\n notifyMessage = `Priorité EVCS active. EDF maintenu ON pour la recharge véhicule. Cause: ${userStrategyRaw || evcsSourceModeRaw || 'override EVCS'}.`;\\n notifyWanted = true;\\n\\n} else if (state === 'turn_on_low_soc' || state === 'turn_on_low_soc_cooldown_bypassed') {\\n notifyTitle = '⚡ EDF activé sur SOC bas';\\n notifyMessage = `SOC ${round1(soc)}% <= ${gridSocOn}%. EDF activé et maintenu jusqu'à au moins ${gridSocOff}% (min ${gridMinOnMinutes} min).`;\\n notifyWanted = true;\\n\\n} else if (state === 'adopt_existing_grid_on_low_soc' || state === 'adopt_existing_grid_on') {\\n notifyTitle = '🔁 EDF ON repris par hystérésis';\\n notifyMessage = `EDF était déjà ON. Le workflow reprend la main et le maintient jusqu'à ${gridSocOff}% (min ${gridMinOnMinutes} min).`;\\n notifyWanted = true;\\n\\n} else if (state === 'turn_off_recovered') {\\n notifyTitle = '✅ EDF coupé après recharge';\\n notifyMessage = `SOC ${round1(soc)}% >= ${gridSocOff}%. Retour hors réseau autorisé.`;\\n notifyWanted = true;\\n\\n} else if (state === 'turn_off_stray_grid_on') {\\n notifyTitle = '✅ EDF coupé (reprise de contrôle)';\\n notifyMessage = `EDF était ON sans latch. SOC ${round1(soc)}% >= ${gridSocOff}%, coupure réseau appliquée.`;\\n notifyWanted = true;\\n\\n} else if (acOverSafeLimit && !gridIsOn) {\\n notifyTitle = '🚨 Charge AC élevée hors réseau';\\n notifyMessage = `Charge AC maison ${Math.round(houseAcPowerW)}W au-dessus de la limite sûre ${Math.round(acLoadsMaxSafeW)}W alors que EDF est OFF.`;\\n notifyWanted = true;\\n\\n} else if (acOverSafeLimit && gridIsOn) {\\n notifyTitle = '⚠️ Charge AC élevée';\\n notifyMessage = `Charge AC maison ${Math.round(houseAcPowerW)}W proche/au-dessus de la limite sûre ${Math.round(acLoadsMaxSafeW)}W.`;\\n notifyWanted = true;\\n}\\n\\nconst shortReason = reason.join(' | ').slice(0, 255);\\n\\nconst signature = [\\n now.date,\\n state,\\n `dg=${desiredGrid}`,\\n `dl=${desiredLatch}`,\\n `sb=${socBand}`,\\n `ac=${acOverSafeLimit ? '1' : '0'}`,\\n `ov=${evcsGridOverride ? '1' : '0'}`,\\n `eg=${evcsExplicitGridRequest ? '1' : '0'}`,\\n `ep=${evcsPriorityActive ? '1' : '0'}`,\\n `es=${evcsSurplusState || 'na'}`\\n].join('|');\\n\\nconst signatureChanged = signature !== lastSignature;\\n\\n// ======================================================\\n// OUTPUT\\n// ======================================================\\nconst result = {\\n timestamp_paris: now.stamp,\\n\\n inputs: {\\n soc_percent: soc,\\n grid_state: gridIsOn ? 'on' : 'off',\\n pv_power_w: pvPowerW,\\n house_ac_power_w: houseAcPowerW,\\n grid_power_w: gridPowerW,\\n evcs_ac_power_w: evcsAcPowerW,\\n evcs_charging: evcsCharging,\\n evcs_status_raw: Number.isFinite(evcsStatusRaw) ? evcsStatusRaw : null,\\n\\n hysteresis_enabled: hysteresisEnabled,\\n notifications_enabled: notificationsEnabled,\\n grid_latch: latchIsOn,\\n grid_soc_on: gridSocOn,\\n grid_soc_off: gridSocOff,\\n grid_min_on_minutes: gridMinOnMinutes,\\n grid_cooldown_minutes: gridCooldownMinutes,\\n ac_loads_max_safe_w: acLoadsMaxSafeW,\\n\\n last_on_at: lastOnAt || null,\\n last_off_at: lastOffAt || null,\\n last_signature: lastSignature || null,\\n\\n evcs_priority_active: evcsPriorityActive,\\n night_grid_takeover_needed: evcsNightGridTakeoverNeeded,\\n evcs_source_mode: evcsSourceModeRaw || null,\\n user_strategy: userStrategyRaw || null,\\n evcs_surplus_state: evcsSurplusStateRaw || null\\n },\\n\\n computed: {\\n desired_grid: desiredGrid,\\n desired_latch: desiredLatch,\\n minutes_since_last_on: minutesSinceLastOn !== null ? round1(minutesSinceLastOn) : null,\\n minutes_since_last_off: minutesSinceLastOff !== null ? round1(minutesSinceLastOff) : null,\\n min_on_elapsed: minOnElapsed,\\n cooldown_elapsed: cooldownElapsed,\\n ac_over_safe_limit: acOverSafeLimit,\\n evcs_seems_active: evcsSeemsActive,\\n\\n evcs_grid_override: evcsGridOverride,\\n evcs_explicit_grid_request: evcsExplicitGridRequest,\\n evcs_priority_active: evcsPriorityActive,\\n evcs_night_grid_takeover_needed: evcsNightGridTakeoverNeeded,\\n evcs_source_mode: evcsSourceModeRaw || null,\\n evcs_user_strategy: userStrategyRaw || null,\\n evcs_surplus_state: evcsSurplusStateRaw || null,\\n evcs_vehicle_no_demand_semantic: evcsVehicleNoDemandSemantic\\n },\\n\\n decision: {\\n state,\\n grid_action: gridAction,\\n latch_action: latchAction,\\n set_last_on_at: setLastOnAt,\\n set_last_off_at: setLastOffAt,\\n should_notify: notificationsEnabled && signatureChanged && notifyWanted,\\n signature,\\n signature_changed: signatureChanged,\\n reason,\\n short_reason: shortReason\\n },\\n\\n ha_service_calls: {\\n helper_state: {\\n domain: 'input_text',\\n service: 'set_value',\\n data: {\\n entity_id: 'input_text.victron_grid_control_state',\\n value: state\\n }\\n },\\n\\n helper_reason: {\\n domain: 'input_text',\\n service: 'set_value',\\n data: {\\n entity_id: 'input_text.victron_grid_control_reason',\\n value: shortReason || 'Aucune raison'\\n }\\n },\\n\\n helper_signature: {\\n domain: 'input_text',\\n service: 'set_value',\\n data: {\\n entity_id: 'input_text.victron_grid_control_last_signature',\\n value: signature\\n }\\n },\\n\\n helper_latch: {\\n domain: 'input_boolean',\\n service: desiredLatch === 'on' ? 'turn_on' : 'turn_off',\\n data: {\\n entity_id: 'input_boolean.victron_grid_hysteresis_latch'\\n }\\n },\\n\\n helper_last_on_at: {\\n domain: 'input_datetime',\\n service: 'set_datetime',\\n data: {\\n entity_id: 'input_datetime.victron_grid_last_on_at',\\n datetime: now.stamp\\n }\\n },\\n\\n helper_last_off_at: {\\n domain: 'input_datetime',\\n service: 'set_datetime',\\n data: {\\n entity_id: 'input_datetime.victron_grid_last_off_at',\\n datetime: now.stamp\\n }\\n },\\n\\n grid: {\\n domain: 'switch',\\n service: desiredGrid === 'on' ? 'turn_on' : 'turn_off',\\n data: {\\n entity_id: 'switch.edf_general_ok_switch'\\n }\\n },\\n\\n logbook: {\\n domain: 'logbook',\\n service: 'log',\\n data: {\\n name: 'Victron Grid Hysteresis',\\n message: `${state} | Grid=${desiredGrid} | Latch=${desiredLatch} | SOC=${round1(soc)}% | AC=${Math.round(houseAcPowerW)}W | ${shortReason}`,\\n entity_id: 'switch.edf_general_ok_switch'\\n }\\n },\\n\\n mobile_notification: {\\n domain: cfg.mobileNotifyDomain,\\n service: cfg.mobileNotifyService,\\n data: {\\n title: notifyTitle,\\n message: notifyMessage,\\n data: {\\n push: {\\n 'interruption-level': 'time-sensitive',\\n sound: { name: 'default' }\\n },\\n ttl: 0,\\n priority: 'high'\\n }\\n }\\n }\\n }\\n};\\n\\nreturn [{ json: result }];\\n\"},\"type\":\"n8n-nodes-base.code\",\"typeVersion\":2,\"position\":[448,0],\"id\":\"dde6d41d-3212-4371-83b3-b58d0d70c21e\",\"name\":\"Compute Grid Hysteresis\"},{\"parameters\":{\"jsCode\":\"const root = $input.first().json || {};\\nconst decision = root.decision || {};\\nconst calls = root.ha_service_calls || {};\\nconst actions = [];\\n\\nfunction pushAction(action, call, enabled = true) {\\n if (!enabled) return;\\n if (!call) return;\\n\\n actions.push({\\n json: {\\n action,\\n ...call\\n }\\n });\\n}\\n\\n// ======================================================\\n// HELPERS TOUJOURS MIS À JOUR\\n// ======================================================\\npushAction('helper_state', calls.helper_state, true);\\npushAction('helper_reason', calls.helper_reason, true);\\n\\n// ======================================================\\n// SIGNATURE SI CHANGEMENT\\n// ======================================================\\nif (decision.signature_changed) {\\n pushAction('helper_signature', calls.helper_signature, true);\\n}\\n\\n// ======================================================\\n// SÉQUENCEMENT ACTIONS RÉSEAU / LATCH\\n//\\n// IMPORTANT :\\n// - pour ALLUMER : latch ON d'abord, puis grid ON\\n// - pour ÉTEINDRE : grid OFF d'abord, puis latch OFF\\n// ======================================================\\n\\n// ---------- CAS 1 : LATCH ON avant GRID ON ----------\\nif (decision.latch_action === 'turn_on') {\\n pushAction('helper_latch', calls.helper_latch, true);\\n}\\n\\nif (decision.grid_action === 'turn_on') {\\n pushAction('grid', calls.grid, true);\\n}\\n\\n// ---------- CAS 2 : GRID OFF avant LATCH OFF ----------\\nif (decision.grid_action === 'turn_off') {\\n pushAction('grid', calls.grid, true);\\n}\\n\\nif (decision.latch_action === 'turn_off') {\\n pushAction('helper_latch', calls.helper_latch, true);\\n}\\n\\n// ======================================================\\n// TIMESTAMPS SI NÉCESSAIRE\\n// ======================================================\\nif (decision.set_last_on_at) {\\n pushAction('helper_last_on_at', calls.helper_last_on_at, true);\\n}\\n\\nif (decision.set_last_off_at) {\\n pushAction('helper_last_off_at', calls.helper_last_off_at, true);\\n}\\n\\n// ======================================================\\n// LOGBOOK SI CHANGEMENT DE SIGNATURE\\n// ======================================================\\nif (decision.signature_changed) {\\n pushAction('logbook', calls.logbook, true);\\n}\\n\\n// ======================================================\\n// NOTIFICATION SI UTILE\\n// ======================================================\\nif (decision.should_notify) {\\n pushAction('mobile_notification', calls.mobile_notification, true);\\n}\\n\\nreturn actions;\\n\"},\"type\":\"n8n-nodes-base.code\",\"typeVersion\":2,\"position\":[672,0],\"id\":\"d3728055-9f5b-4362-a275-247cc91c5f41\",\"name\":\"Build Grid Hysteresis Actions\"},{\"parameters\":{\"method\":\"POST\",\"url\":\"={{ 'http://10.10.10.17:8123/api/services/' + $json.domain + '/' + $json.service }}\\n\",\"authentication\":\"genericCredentialType\",\"genericAuthType\":\"httpBearerAuth\",\"sendHeaders\":true,\"headerParameters\":{\"parameters\":[{\"name\":\"Content-Type\",\"value\":\"application/json\"},{\"name\":\"accept\",\"value\":\"application/json\"}]},\"sendBody\":true,\"specifyBody\":\"json\",\"jsonBody\":\"={{ $json.data }}\",\"options\":{}},\"id\":\"7d38f7cf-ad84-4481-a201-d21dd0dce321\",\"name\":\"Execute HA Service\",\"type\":\"n8n-nodes-base.httpRequest\",\"typeVersion\":4.2,\"position\":[896,0],\"alwaysOutputData\":true,\"credentials\":{\"httpBearerAuth\":{\"id\":\"uv3sknYYigxBkaVN\",\"name\":\"Bearer Auth account 2\"}}}],\"connections\":{\"Schedule Trigger\":{\"main\":[[{\"node\":\"HA Snapshot Grid Hysteresis\",\"type\":\"main\",\"index\":0}]]},\"HA Snapshot Grid Hysteresis\":{\"main\":[[{\"node\":\"Compute Grid Hysteresis\",\"type\":\"main\",\"index\":0}]]},\"Compute Grid Hysteresis\":{\"main\":[[{\"node\":\"Build Grid Hysteresis Actions\",\"type\":\"main\",\"index\":0}]]},\"Build Grid Hysteresis Actions\":{\"main\":[[{\"node\":\"Execute HA Service\",\"type\":\"main\",\"index\":0}]]}},\"settings\":{\"executionOrder\":\"v1\",\"binaryMode\":\"separate\",\"availableInMCP\":false,\"timeSavedMode\":\"fixed\",\"callerPolicy\":\"workflowsFromSameOwner\"},\"staticData\":{\"node:Schedule Trigger\":{\"recurrenceRules\":[]}},\"meta\":null,\"pinData\":{},\"versionId\":\"ef4b5fc8-b615-4c56-ba79-3d45f908bf1a\",\"activeVersionId\":null,\"versionCounter\":71,\"triggerCount\":1,\"tags\":[],\"activeVersion\":null,\"scopes\":[\"workflow:create\",\"workflow:delete\",\"workflow:execute\",\"workflow:execute-chat\",\"workflow:list\",\"workflow:move\",\"workflow:publish\",\"workflow:read\",\"workflow:share\",\"workflow:unpublish\",\"workflow:unshare\",\"workflow:update\",\"workflow:updateRedactionSetting\"],\"checksum\":\"4390f0852ac99946d8c39408826b9bc03be306ad782ce7337d236181e7923f79\"}}\nHTTP_CODE:200\n",
+ "exit": 0,
+ "description": "Activate Grid Hysteresis via PATCH",
+ "truncated": false
+ },
+ "title": "Activate Grid Hysteresis via PATCH",
+ "time": {
+ "start": 1779157191916,
+ "end": 1779157191920
+ }
+ },
+ "id": "prt_e3e082164001WRLsoh1HN6dp7x",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e07f62a0011N8TPsrdACGVin"
+ },
+ {
+ "reason": "tool-calls",
+ "type": "step-finish",
+ "tokens": {
+ "total": 33787,
+ "input": 279,
+ "output": 279,
+ "reasoning": 77,
+ "cache": {
+ "write": 0,
+ "read": 33152
+ }
+ },
+ "cost": 0.00973411,
+ "id": "prt_e3e084cf30015v9QhLO71Jtp6d",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e07f62a0011N8TPsrdACGVin"
+ }
+ ]
+ },
+ {
+ "info": {
+ "parentID": "msg_e3e044477001ADKhSz2Jx6n3An",
+ "role": "assistant",
+ "mode": "build",
+ "agent": "build",
+ "path": {
+ "cwd": "/mnt/projects/EVCS/EVCS",
+ "root": "/mnt/projects/EVCS/EVCS"
+ },
+ "cost": 0.01590523,
+ "tokens": {
+ "total": 42787,
+ "input": 9123,
+ "output": 139,
+ "reasoning": 117,
+ "cache": {
+ "write": 0,
+ "read": 33408
+ }
+ },
+ "modelID": "moonshotai/kimi-k2.6",
+ "providerID": "openrouter",
+ "time": {
+ "created": 1779157191930,
+ "completed": 1779157208605
+ },
+ "finish": "tool-calls",
+ "id": "msg_e3e084cfa001UCvqR6cjbdtvNs",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F"
+ },
+ "parts": [
+ {
+ "type": "step-start",
+ "id": "prt_e3e085bb8001Z6p2lxesi7Wt9e",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e084cfa001UCvqR6cjbdtvNs"
+ },
+ {
+ "type": "reasoning",
+ "text": "Le second PATCH a retourné HTTP 200 mais la réponse montre que \"active\" est toujours false. Le premier a aussi retourné 200 mais je n'ai pas vu la réponse. Vérifions si le EVCS est activé. Il faut peut-être un `activeVersionId` ? Ou `active: true` seul suffit ?\n\nDans N8N, pour activer un workflow, il faut parfois aussi mettre à jour `activeVersionId` avec le même `versionId`. Essayons de PATCH avec également `activeVersionId`.",
+ "time": {
+ "start": 1779157195705,
+ "end": 1779157208570
+ },
+ "metadata": {
+ "openrouter": {
+ "reasoning_details": [
+ {
+ "type": "reasoning.text",
+ "text": "Le second PATCH a retourné HTTP 200 mais la réponse montre que \"active\" est toujours false. Le premier a aussi retourné 200 mais je n'ai pas vu la réponse. Vérifions si le EVCS est activé. Il faut peut-être un `activeVersionId` ? Ou `active: true` seul suffit ?\n\nDans N8N, pour activer un workflow, il faut parfois aussi mettre à jour `activeVersionId` avec le même `versionId`. Essayons de PATCH avec également `activeVersionId`.",
+ "format": "unknown",
+ "index": 0
+ }
+ ]
+ }
+ },
+ "id": "prt_e3e085bb9001NasT0q4V7RhHr8",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e084cfa001UCvqR6cjbdtvNs"
+ },
+ {
+ "type": "tool",
+ "tool": "bash",
+ "callID": "functions.bash:117",
+ "state": {
+ "status": "completed",
+ "input": {
+ "command": "curl -s -b /tmp/n8n_cookies.txt http://10.10.10.17:5678/rest/workflows/ATswvGfG4KJUls0K | node -e \"const d=require('fs').readFileSync(0,'utf8'); const j=JSON.parse(d).data; console.log('active:', j.active); console.log('versionId:', j.versionId); console.log('activeVersionId:', j.activeVersionId);\"",
+ "description": "Check EVCS active status after PATCH"
+ },
+ "output": "active: false\nversionId: 0637fe44-61a3-450b-8ded-74302727a829\nactiveVersionId: null\n",
+ "metadata": {
+ "output": "active: false\nversionId: 0637fe44-61a3-450b-8ded-74302727a829\nactiveVersionId: null\n",
+ "exit": 0,
+ "description": "Check EVCS active status after PATCH",
+ "truncated": false
+ },
+ "title": "Check EVCS active status after PATCH",
+ "time": {
+ "start": 1779157208598,
+ "end": 1779157208602
+ }
+ },
+ "metadata": {
+ "openrouter": {
+ "reasoning_details": [
+ {
+ "type": "reasoning.text",
+ "text": "Le second PATCH a retourné HTTP 200 mais la réponse montre que \"active\" est toujours false. Le premier a aussi retourné 200 mais je n'ai pas vu la réponse. Vérifions si le EVCS est activé. Il faut peut-être un `activeVersionId` ? Ou `active: true` seul suffit ?\n\nDans N8N, pour activer un workflow, il faut parfois aussi mettre à jour `activeVersionId` avec le même `versionId`. Essayons de PATCH avec également `activeVersionId`.",
+ "format": "unknown",
+ "index": 0
+ }
+ ]
+ }
+ },
+ "id": "prt_e3e0883ae001TgEWOtk9omV5OY",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e084cfa001UCvqR6cjbdtvNs"
+ },
+ {
+ "reason": "tool-calls",
+ "type": "step-finish",
+ "tokens": {
+ "total": 42787,
+ "input": 9123,
+ "output": 139,
+ "reasoning": 117,
+ "cache": {
+ "write": 0,
+ "read": 33408
+ }
+ },
+ "cost": 0.01590523,
+ "id": "prt_e3e088e1b001khILsNeWjb8Xz3",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e084cfa001UCvqR6cjbdtvNs"
+ }
+ ]
+ },
+ {
+ "info": {
+ "parentID": "msg_e3e044477001ADKhSz2Jx6n3An",
+ "role": "assistant",
+ "mode": "build",
+ "agent": "build",
+ "path": {
+ "cwd": "/mnt/projects/EVCS/EVCS",
+ "root": "/mnt/projects/EVCS/EVCS"
+ },
+ "cost": 0.01237582,
+ "tokens": {
+ "total": 43270,
+ "input": 344,
+ "output": 336,
+ "reasoning": 94,
+ "cache": {
+ "write": 0,
+ "read": 42496
+ }
+ },
+ "modelID": "moonshotai/kimi-k2.6",
+ "providerID": "openrouter",
+ "time": {
+ "created": 1779157208609,
+ "completed": 1779157224589
+ },
+ "finish": "tool-calls",
+ "id": "msg_e3e088e21001yzrmjDE4BXNSIO",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F"
+ },
+ "parts": [
+ {
+ "type": "step-start",
+ "id": "prt_e3e089cbd0014xLfrdnxP2MzRA",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e088e21001yzrmjDE4BXNSIO"
+ },
+ {
+ "type": "reasoning",
+ "text": "Le PATCH avec `active: true` a modifié le workflow mais ne l'a pas activé. Dans N8N, pour activer, il faut probablement aussi passer `activeVersionId` égal au `versionId` actuel.\n\nEssayons :\n```json\n{\n \"active\": true,\n \"versionId\": \"0637fe44-61a3-450b-8ded-74302727a829\",\n \"activeVersionId\": \"0637fe44-61a3-450b-8ded-74302727a829\"\n}\n```",
+ "time": {
+ "start": 1779157212351,
+ "end": 1779157224584
+ },
+ "metadata": {
+ "openrouter": {
+ "reasoning_details": [
+ {
+ "type": "reasoning.text",
+ "text": "Le PATCH avec `active: true` a modifié le workflow mais ne l'a pas activé. Dans N8N, pour activer, il faut probablement aussi passer `activeVersionId` égal au `versionId` actuel.\n\nEssayons :\n```json\n{\n \"active\": true,\n \"versionId\": \"0637fe44-61a3-450b-8ded-74302727a829\",\n \"activeVersionId\": \"0637fe44-61a3-450b-8ded-74302727a829\"\n}\n```",
+ "format": "unknown",
+ "index": 0
+ }
+ ]
+ }
+ },
+ "id": "prt_e3e089cbf001Cofj7283sPOP1r",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e088e21001yzrmjDE4BXNSIO"
+ },
+ {
+ "type": "tool",
+ "tool": "bash",
+ "callID": "functions.bash:118",
+ "state": {
+ "status": "completed",
+ "input": {
+ "command": "curl -s -w \"\\nHTTP_CODE:%{http_code}\\n\" -X PATCH http://10.10.10.17:5678/rest/workflows/ATswvGfG4KJUls0K -H \"Content-Type: application/json\" -b /tmp/n8n_cookies.txt -d '{\"active\":true,\"versionId\":\"0637fe44-61a3-450b-8ded-74302727a829\",\"activeVersionId\":\"0637fe44-61a3-450b-8ded-74302727a829\"}'",
+ "description": "Activate EVCS with activeVersionId"
+ },
+ "output": "...output truncated...\n\nFull output saved to: /home/tmzf5455/.local/share/opencode/tool-output/tool_e3e08b938001APWdgzNQhGdXzS\n\nHTTP_CODE:200\n",
+ "metadata": {
+ "output": "...\n\nission = parsed.chargePermissionNow;\\n desiredLatch = parsed.controlLatch;\\n desiredCurrentA = clamp(\\n derived.currentReferenceA || params.currentMinA,\\n params.currentMinA,\\n params.currentMaxAutoA\\n );\\n\\n state = 'controller_disabled';\\n currentDecision = 'hold';\\n dynamicOk = false;\\n mustStop = false;\\n reason.push('Contrôleur désactivé');\\n}\\n\\nelse if (strategy.isStopMode) {\\n setOff({\\n stateName: 'stopped_user',\\n decisionName: 'stop_stop_mode',\\n reasonText: 'Arrêt manuel via Stratégie STOP',\\n dynamic: false,\\n mustStopFlag: false,\\n blockReason: 'manual_stop',\\n blockedUntilStamp: now.stamp,\\n });\\n}\\n\\nelse if (parsed.evcsModeIsAuto) {\\n setOff({\\n stateName: 'mode_auto_conflict',\\n decisionName: 'stop_auto_mode_conflict',\\n reasonText: 'Borne en mode Auto interne',\\n dynamic: false,\\n mustStopFlag: true,\\n blockReason: 'mode_auto_conflict',\\n blockedUntilStamp: now.stamp,\\n });\\n}\\n\\nelse if (!parsed.evcsDeviceConnected || !parsed.evcsVehicleConnected) {\\n setOff({\\n stateName: 'vehicle_not_connected',\\n decisionName: 'stop_not_connected',\\n reasonText: 'Véhicule ou borne non connecté',\\n dynamic: false,\\n mustStopFlag: false,\\n blockReason: 'vehicle_not_connected',\\n blockedUntilStamp: now.stamp,\\n });\\n}\\n\\nelse if (safeguards.mustStopHard) {\\n setOff({\\n stateName:\\n derived.anyVebusLowBattery ? 'vebus_low_battery_hard_stop' :\\n derived.vebusDcVoltageHardLow ? 'vebus_dc_voltage_hard_stop' :\\n derived.vebusAcOutHardReached ? 'vebus_ac_hard_stop' :\\n safeguards.batteryCurrentHardExceeded ? 'battery_current_hard_stop' :\\n 'hard_stop',\\n decisionName: 'stop_hard',\\n reasonText: 'Hard stop de protection',\\n dynamic: false,\\n mustStopFlag: true,\\n blockReason:\\n safeguards.batteryCurrentHardExceeded ? 'battery_current_hard_stop' :\\n derived.vebusDcVoltageHardLow ? 'vebus_dc_voltage_stop' :\\n derived.anyVebusLowBattery ? 'vebus_low_battery_stop' :\\n derived.vebusAcOutHardReached ? 'vebus_ac_hard_stop' :\\n 'battery_protection_stop',\\n blockedUntilStamp: formatParisStampFromMs(nowMs + 15 * 60000),\\n });\\n}\\n\\n// =====================================================\\n// ACTIVE SESSION BRANCH\\n// =====================================================\\nelse if (cloud.sessionObservedActive) {\\n if (cloud.enteringCloudDebt) {\\n setCloudDebtStartedAt = true;\\n }\\n\\n if (conditions.shouldStopOverloadAtMin) {\\n setOff({\\n stateName: 'vebus_overload_stop_min_current',\\n decisionName: 'stop',\\n reasonText: 'Arrêt: surcharge convertisseur persistante au courant mini',\\n dynamic: false,\\n mustStopFlag: true,\\n blockReason: 'vebus_overload_persistent',\\n blockedUntilStamp: formatParisStampFromMs(\\n nowMs + batteryTune.global.overloadStopAfterSeconds * 1000\\n ),\\n });\\n }\\n\\n else if (safeguards.gridBatteryProtectionStop) {\\n setOff({\\n stateName: 'stop_grid_battery_guard',\\n decisionName: 'stop',\\n reasonText: `Arrêt charge réseau: batterie trop sollicitée (${round1(parsed.batteryCurrentA)}A)`,\\n dynamic: false,\\n mustStopFlag: true,\\n blockReason: 'grid_battery_guard',\\n blockedUntilStamp: formatParisStampFromMs(nowMs + 20 * 60000),\\n });\\n }\\n\\n else if (conditions.probeFailedNow) {\\n setOff({\\n stateName: 'probe_failed_lock',\\n decisionName: 'stop',\\n reasonText: 'Mode sonde échoué',\\n dynamic: false,\\n mustStopFlag: true,\\n blockReason: 'probe_failed',\\n blockedUntilStamp: formatParisStampFromMs(nowMs + params.probeRetryMinutes * 60000),\\n });\\n }\\n\\n else if (derived.physicalCapA < params.currentMinA) {\\n if (lifecycle.minRunElapsed) {\\n setOff({\\n stateName: 'stop_power_cap',\\n decisionName: 'stop_power_cap',\\n reasonText: 'Capacité système insuffisante',\\n dynamic: false,\\n mustStopFlag: true,\\n blockReason: 'system_capacity_stop',\\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\\n });\\n } else {\\n setOn({\\n stateName: 'hold_min_run_power_cap',\\n decisionName: 'hold',\\n currentA: params.currentMinA,\\n reasonText: \\\"Maintien jusqu'au temps mini ON\\\",\\n dynamic: false,\\n mustStopFlag: false,\\n });\\n }\\n }\\n\\n else if (conditions.probeWindowActive && !conditions.probeSuccessNow) {\\n if (!conditions.probeHoldGuardOk) {\\n setOff({\\n stateName: 'probe_stop_guard',\\n decisionName: 'stop',\\n reasonText: 'Sonde interrompue: garde-fous non respectés',\\n dynamic: false,\\n mustStopFlag: true,\\n blockReason: 'probe_failed',\\n blockedUntilStamp: formatParisStampFromMs(nowMs + params.probeRetryMinutes * 60000),\\n });\\n } else {\\n setOn({\\n stateName: 'probe_hold',\\n decisionName: 'hold',\\n currentA: Math.min(params.probeCurrentA, derived.physicalCapA),\\n reasonText: `Mode sonde actif à ${round1(Math.min(params.probeCurrentA, derived.physicalCapA))}A`,\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n }\\n\\n else {\\n const activeSourceMode =\\n conditions.probeSuccessNow\\n ? 'solar_only'\\n : context.sourceMode;\\n\\n const holdConditionsOk =\\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced')\\n ? conditions.gridHoldOk\\n : (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential')\\n ? conditions.solarHoldOk\\n : activeSourceMode === 'hybrid_probe'\\n ? conditions.probeHoldGuardOk\\n : activeSourceMode === 'battery_assist_night'\\n ? conditions.batteryAssistNightHoldOk\\n : false;\\n\\n const manualLocalHoldOverride =\\n manualLocalChargeAllowed &&\\n (\\n activeSourceMode === 'solar_only' ||\\n activeSourceMode === 'solar_potential' ||\\n activeSourceMode === 'blocked'\\n );\\n\\n const effectiveHoldConditionsOk =\\n holdConditionsOk || manualLocalHoldOverride;\\n\\n // =================================================\\n // VEHICLE FULL / PAUSED - NUMERIC FILTER DECISION\\n // IMPORTANT:\\n // - uniquement en session active\\n // - uniquement si la source est réellement livrable\\n // - sans utiliser le PV comme critère\\n // =================================================\\n if (\\n vehicleDemandFilter.evcsVehicleNoDemandSemantic &&\\n vehicleDemandFilter.sourceDeliverableForNoDemand\\n ) {\\n setOff({\\n stateName: 'vehicle_full_or_paused',\\n decisionName: 'stop_vehicle_full_or_paused',\\n reasonText: 'Véhicule branché mais ne demande plus réellement de puissance',\\n dynamic: false,\\n mustStopFlag: false,\\n blockReason: 'none',\\n blockedUntilStamp: now.stamp,\\n });\\n\\n reason.push(`DeliveryRatioEma=${vehicleDemandFilter.deliveryRatioEma}`);\\n reason.push(`NoiseEma=${vehicleDemandFilter.deliveryNoiseEma}`);\\n reason.push(`NoDemandScore=${vehicleDemandFilter.noDemandScoreEma}`);\\n reason.push(`ScoreOn=${vehicleDemandFilter.noDemandScoreOn}`);\\n reason.push(`ScoreOff=${vehicleDemandFilter.noDemandScoreOff}`);\\n\\n if (vehicleDemandFilter.evcsNoDemandSinceMinutes !== null) {\\n reason.push(`NoDemand depuis ${round1(vehicleDemandFilter.evcsNoDemandSinceMinutes)} min`);\\n }\\n }\\n\\n else if (manualLocalHoldOverride && activeSourceMode === 'blocked') {\\n setOn({\\n stateName: 'charging_hold_manual_local',\\n decisionName: 'hold',\\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\\n reasonText: 'Maintien manuel local hors fenêtre sans réseau',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n\\n else if (activeSourceMode === 'blocked') {\\n if (lifecycle.minRunElapsed) {\\n setOff({\\n stateName: 'stop_source_lost',\\n decisionName: 'stop',\\n reasonText: 'Source autorisée perdue',\\n dynamic: false,\\n mustStopFlag: true,\\n blockReason: context.profile === 'outside_window' ? 'outside_window' : 'source_unavailable',\\n blockedUntilStamp:\\n context.profile === 'outside_window' && Number.isFinite(locks.nextAllowedWindowStartMs)\\n ? formatParisStampFromMs(locks.nextAllowedWindowStartMs)\\n : formatParisStampFromMs(nowMs + params.minStopMinutes * 60000),\\n });\\n } else {\\n setOn({\\n stateName: 'hold_min_run_source_lost',\\n decisionName: 'hold',\\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\\n reasonText: \\\"Source perdue mais maintien jusqu'au mini ON\\\",\\n dynamic: false,\\n mustStopFlag: false,\\n });\\n }\\n }\\n\\n else if (\\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&\\n !parsed.gridAvailable\\n ) {\\n if (lifecycle.minRunElapsed) {\\n setOff({\\n stateName:\\n context.profile === 'night_hc'\\n ? 'night_wait_grid_takeover_runtime'\\n : 'wait_grid_source_runtime',\\n decisionName: 'hold',\\n reasonText:\\n context.profile === 'night_hc'\\n ? 'Relais réseau demandé pendant la session nuit EVCS'\\n : 'Source réseau demandée mais pas encore disponible',\\n dynamic: false,\\n mustStopFlag: false,\\n blockReason:\\n context.profile === 'night_hc' ? 'grid_hc_unavailable' : 'grid_unavailable',\\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\\n });\\n } else {\\n setOn({\\n stateName: 'hold_min_run_wait_grid_takeover',\\n decisionName: 'hold',\\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\\n reasonText: \\\"Maintien jusqu'au mini ON en attente du réseau\\\",\\n dynamic: false,\\n mustStopFlag: false,\\n });\\n }\\n }\\n\\n else if (!effectiveHoldConditionsOk) {\\n if (lifecycle.minRunElapsed) {\\n setOff({\\n stateName: 'stop_conditions_lost',\\n decisionName: 'stop',\\n reasonText: 'Conditions de maintien perdues',\\n dynamic: false,\\n mustStopFlag: true,\\n blockReason: 'hold_conditions_lost',\\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\\n });\\n } else {\\n setOn({\\n stateName: 'hold_min_run',\\n decisionName: 'hold',\\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\\n reasonText: \\\"Maintien jusqu'au temps mini ON\\\",\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n }\\n\\n else if ((derived.currentReferenceA > derived.physicalCapA) && lifecycle.currentCooldownElapsed) {\\n setOn({\\n stateName: 'charging_step_down_system_cap',\\n decisionName: 'step_down',\\n currentA: Math.max(\\n params.currentMinA,\\n Math.min(\\n derived.currentReferenceA - Math.max(params.currentStepDownA, 2),\\n derived.physicalCapA\\n )\\n ),\\n reasonText: 'Réduction pour respecter la capacité système',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n\\n else if (conditions.shouldStepDownOverload) {\\n setOn({\\n stateName: 'charging_step_down_overload',\\n decisionName: 'step_down_fast',\\n currentA: Math.max(\\n params.currentMinA,\\n Math.min(\\n derived.currentReferenceA - Math.max(batteryTune.global.overloadStepDownA, params.currentStepDownA),\\n derived.physicalCapA\\n )\\n ),\\n reasonText: 'Baisse rapide: surcharge convertisseur',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n\\n else if (conditions.shouldStepDownVebusPower) {\\n setOn({\\n stateName: 'charging_step_down_vebus_power',\\n decisionName: 'step_down_fast',\\n currentA: Math.max(\\n params.currentMinA,\\n Math.min(\\n derived.currentReferenceA - Math.max(params.currentStepDownA + 1, 3),\\n derived.physicalCapA\\n )\\n ),\\n reasonText: 'Baisse rapide: puissance VE.Bus élevée',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n\\n else if (conditions.shouldStepDownVebusVoltage) {\\n setOn({\\n stateName: 'charging_step_down_dc_voltage',\\n decisionName: 'step_down_fast',\\n currentA: Math.max(\\n params.currentMinA,\\n Math.min(\\n derived.currentReferenceA - Math.max(params.currentStepDownA + 1, 3),\\n derived.physicalCapA\\n )\\n ),\\n reasonText: 'Baisse rapide: tension DC basse',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n\\n else if (\\n activeSourceMode === 'battery_assist_night' &&\\n conditions.batteryAssistNightShouldStepDown\\n ) {\\n setOn({\\n stateName: 'charging_step_down_battery_assist_night',\\n decisionName: 'step_down',\\n currentA: Math.max(\\n params.currentMinA,\\n Math.min(\\n derived.currentReferenceA - params.currentStepDownA,\\n derived.physicalCapA\\n )\\n ),\\n reasonText:\\n nightEvcs?.batteryRecoveryUrgent\\n ? 'Réduction: approche départ, préparation takeover réseau'\\n : 'Réduction: assistance batterie nuit devient trop coûteuse',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n\\n else if (\\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&\\n conditions.gridShouldStepDownBattery\\n ) {\\n setOn({\\n stateName:\\n activeSourceMode === 'grid_forced'\\n ? 'charging_step_down_grid_forced_guard'\\n : 'charging_step_down_grid_guard',\\n decisionName: 'step_down',\\n currentA: Math.max(\\n params.currentMinA,\\n Math.min(\\n derived.currentReferenceA - params.currentStepDownA,\\n derived.physicalCapA\\n )\\n ),\\n reasonText:\\n activeSourceMode === 'grid_forced'\\n ? 'Réduction réseau forcé: la batterie commence à participer'\\n : 'Réduction réseau: la batterie commence à participer',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n\\n else if (\\n (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&\\n conditions.solarShouldStepDownCloudDebt\\n ) {\\n setOn({\\n stateName: 'charging_step_down_cloud_debt',\\n decisionName: 'step_down',\\n currentA: Math.max(\\n params.currentMinA,\\n Math.min(\\n derived.currentReferenceA - Math.max(params.currentStepDownA, 2),\\n derived.physicalCapA\\n )\\n ),\\n reasonText: 'Réduction: dette nuage trop élevée',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n\\n else if (\\n (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&\\n conditions.solarShouldStepDownBattery\\n ) {\\n setOn({\\n stateName: 'charging_step_down_battery',\\n decisionName: 'step_down',\\n currentA: Math.max(\\n params.currentMinA,\\n Math.min(\\n derived.currentReferenceA - params.currentStepDownA,\\n derived.physicalCapA\\n )\\n ),\\n reasonText: 'Réduction: batterie trop sollicitée',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n\\n else if (\\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&\\n conditions.gridShouldStepUp\\n ) {\\n setOn({\\n stateName:\\n activeSourceMode === 'grid_forced'\\n ? (\\n nightEvcs?.batteryRecoveryUrgent\\n ? 'night_departure_deadline_grid_forced'\\n : 'charging_step_up_grid_forced'\\n )\\n : 'charging_step_up_grid_hc',\\n decisionName: 'step_up',\\n currentA: clamp(\\n derived.currentReferenceA + params.currentStepUpA,\\n params.currentMinA,\\n derived.physicalCapA\\n ),\\n reasonText:\\n activeSourceMode === 'grid_forced'\\n ? (\\n nightEvcs?.batteryRecoveryUrgent\\n ? 'Hausse prudente: priorité départ, réseau forcé'\\n : 'Hausse prudente du courant en charge réseau forcée'\\n )\\n : 'Hausse prudente du courant en HC réseau',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n\\n else if (\\n activeSourceMode === 'battery_assist_night' &&\\n conditions.batteryAssistNightShouldStepUp\\n ) {\\n setOn({\\n stateName: 'charging_step_up_battery_assist_night',\\n decisionName: 'step_up',\\n currentA: clamp(\\n derived.currentReferenceA + params.currentStepUpA,\\n params.currentMinA,\\n derived.physicalCapA\\n ),\\n reasonText: 'Hausse prudente du courant en assistance batterie nuit',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n\\n else if (\\n (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&\\n conditions.solarShouldStepUp\\n ) {\\n setOn({\\n stateName:\\n conditions.probeSuccessNow\\n ? 'probe_success_step_up'\\n : (modes.batteryPriorityMode\\n ? 'charging_step_up_battery'\\n : 'charging_step_up_forecast'),\\n decisionName: 'step_up',\\n currentA: clamp(\\n derived.currentReferenceA + params.currentStepUpA,\\n params.currentMinA,\\n derived.physicalCapA\\n ),\\n reasonText: 'Hausse prudente du courant',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n\\n else {\\n setOn({\\n stateName:\\n manualLocalHoldOverride ? 'charging_hold_manual_local' :\\n conditions.probeSuccessNow ? 'probe_success_hold' :\\n activeSourceMode === 'battery_assist_night' ? 'charging_hold_battery_assist_night' :\\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') ? 'charging_hold_grid' :\\n cloud.cloudDebtActive ? 'charging_hold_cloud_tolerance' :\\n 'charging_hold',\\n decisionName: 'hold',\\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\\n reasonText:\\n manualLocalHoldOverride\\n ? 'Maintien manuel local du courant'\\n : activeSourceMode === 'battery_assist_night'\\n ? 'Maintien du courant en assistance batterie nuit'\\n : 'Maintien du courant',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n }\\n}\\n\\n// =====================================================\\n// START BRANCH\\n// =====================================================\\nelse {\\n const startConditionsOk =\\n (context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced')\\n ? conditions.gridStartOk\\n : (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential')\\n ? conditions.solarStartOk\\n : context.sourceMode === 'hybrid_probe'\\n ? conditions.probeStartOk\\n : context.sourceMode === 'battery_assist_night'\\n ? conditions.batteryAssistNightStartOk\\n : false;\\n\\n const manualLocalStartOverride =\\n manualLocalChargeAllowed &&\\n (\\n context.sourceMode === 'solar_only' ||\\n context.sourceMode === 'solar_potential' ||\\n context.sourceMode === 'blocked'\\n );\\n\\n if (!derived.canRunAtMinCurrent) {\\n setOff({\\n stateName: 'blocked_by_capacity_min_current',\\n decisionName: 'hold',\\n reasonText: 'Capacité insuffisante pour courant mini',\\n dynamic: false,\\n mustStopFlag: false,\\n blockReason: 'system_capacity_stop',\\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\\n });\\n }\\n\\n else if (manualLocalStartOverride) {\\n setOn({\\n stateName: 'start_manual_local_min_current',\\n decisionName: 'start',\\n currentA: params.currentMinA,\\n reasonText:\\n context.profile === 'outside_window'\\n ? 'Démarrage manuel local hors fenêtre sans réseau'\\n : 'Démarrage manuel local sans réseau',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n\\n nextBlockReason = 'none';\\n nextBlockedUntilStamp = now.stamp;\\n setProbeStartedAt = false;\\n }\\n\\n else if (context.sourceMode === 'blocked') {\\n setOff({\\n stateName:\\n context.profile === 'outside_window'\\n ? 'blocked_outside_window'\\n : (context.profile === 'night_hc'\\n ? 'night_no_grid_hc_lock'\\n : 'idle_wait_source'),\\n decisionName: 'hold',\\n reasonText:\\n context.profile === 'outside_window'\\n ? 'Hors fenêtre préférée sans source suffisante'\\n : context.profile === 'night_hc'\\n ? 'Nuit sans autorisation HC réseau'\\n : 'Source non autorisée ou insuffisante',\\n dynamic: false,\\n mustStopFlag: false,\\n blockReason:\\n context.profile === 'outside_window'\\n ? 'outside_window'\\n : context.profile === 'night_hc'\\n ? 'grid_hc_unavailable'\\n : 'source_unavailable',\\n blockedUntilStamp:\\n Number.isFinite(locks.nextAllowedWindowStartMs)\\n ? formatParisStampFromMs(locks.nextAllowedWindowStartMs)\\n : formatParisStampFromMs(nowMs + 15 * 60000),\\n });\\n }\\n\\n else if (\\n (context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced') &&\\n !parsed.gridAvailable\\n ) {\\n setOff({\\n stateName:\\n context.profile === 'night_hc'\\n ? 'night_wait_grid_takeover'\\n : 'wait_grid_source',\\n decisionName: 'hold',\\n reasonText:\\n context.profile === 'night_hc'\\n ? (\\n nightEvcs?.batteryRecoveryUrgent\\n ? 'Réseau demandé: urgence recharge batterie avant départ'\\n : 'Prise de relais réseau demandée pour session nuit EVCS'\\n )\\n : 'Source réseau demandée mais pas encore disponible',\\n dynamic: false,\\n mustStopFlag: false,\\n blockReason:\\n context.profile === 'night_hc'\\n ? 'grid_hc_unavailable'\\n : 'grid_unavailable',\\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\\n });\\n }\\n\\n else if (locks.startBlockedByReason) {\\n setOff({\\n stateName: 'wait_reason_lock',\\n decisionName: 'hold',\\n reasonText: `Blocage anti-cliquetis: ${history.lastBlockReason}`,\\n dynamic: false,\\n mustStopFlag: false,\\n blockReason: history.lastBlockReason,\\n blockedUntilStamp: history.blockedUntilAt || now.stamp,\\n });\\n }\\n\\n else if (!startConditionsOk) {\\n setOff({\\n stateName: 'idle_wait_conditions',\\n decisionName: 'hold',\\n reasonText:\\n context.sourceMode === 'battery_assist_night'\\n ? 'Conditions de démarrage nuit batterie non réunies'\\n : 'Conditions de démarrage non réunies',\\n dynamic: false,\\n mustStopFlag: false,\\n blockReason: 'none',\\n blockedUntilStamp: now.stamp,\\n });\\n }\\n\\n else if (!lifecycle.minStopElapsed) {\\n setOff({\\n stateName: 'wait_min_stop',\\n decisionName: 'hold',\\n reasonText: 'Temps mini OFF non écoulé',\\n dynamic: false,\\n mustStopFlag: false,\\n blockReason: 'none',\\n blockedUntilStamp: now.stamp,\\n });\\n }\\n\\n else {\\n const startCurrentA =\\n context.sourceMode === 'hybrid_probe'\\n ? clamp(params.probeCurrentA, params.currentMinA, derived.physicalCapA)\\n : params.currentMinA;\\n\\n setOn({\\n stateName:\\n context.sourceMode === 'grid_forced' ? (\\n nightEvcs?.batteryRecoveryUrgent\\n ? 'night_departure_deadline_grid_forced'\\n : 'start_grid_forced_min_current'\\n ) :\\n context.sourceMode === 'grid_hc_allowed' ? 'start_grid_hc_min_current' :\\n context.sourceMode === 'battery_assist_night' ? 'start_battery_assist_night_min_current' :\\n context.sourceMode === 'hybrid_probe' ? 'probe_start_6a' :\\n context.sourceMode === 'solar_potential' ? 'start_forecast_solar_potential' :\\n modes.batteryFullHarvestMode ? 'start_min_current_battery_full_harvest' :\\n modes.forecastCommitMode ? 'start_min_current_forecast_commit' :\\n 'start_min_current',\\n decisionName: 'start',\\n currentA: startCurrentA,\\n reasonText:\\n context.sourceMode === 'grid_forced'\\n ? (\\n nightEvcs?.batteryRecoveryUrgent\\n ? 'Démarrage réseau forcé: priorité départ / recharge batterie'\\n : 'Démarrage en charge réseau forcée'\\n )\\n : context.sourceMode === 'grid_hc_allowed'\\n ? 'Démarrage en fenêtre HC réseau'\\n : context.sourceMode === 'battery_assist_night'\\n ? 'Démarrage nuit en assistance batterie contrôlée'\\n : context.sourceMode === 'hybrid_probe'\\n ? 'Démarrage en mode sonde'\\n : context.sourceMode === 'solar_potential'\\n ? 'Démarrage par potentiel solaire confirmé'\\n : modes.batteryFullHarvestMode\\n ? 'Démarrage harvest solaire'\\n : modes.forecastCommitMode\\n ? 'Démarrage forecast commit'\\n : 'Démarrage prudent à courant mini',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n\\n nextBlockReason = 'none';\\n nextBlockedUntilStamp = now.stamp;\\n setProbeStartedAt = context.sourceMode === 'hybrid_probe';\\n }\\n}\\n\\n// =====================================================\\n// CONTEXT REASONS\\n// =====================================================\\nif (nightEvcs?.nightSessionActive) {\\n reason.push(\\n `Nuit EVCS: target=${round1(nightEvcs.batteryMorningTargetPct)}% | trigger réseau=${round1(nightEvcs.gridTakeoverTriggerPct)}%`\\n );\\n\\n if (Number.isFinite(nightEvcs.nightSessionStartSocPct)) {\\n reason.push(\\n `SOC départ session nuit=${round1(nightEvcs.nightSessionStartSocPct)}% | décharge autorisée=${round1(nightEvcs.maxBatteryDischargePoints)} pts`\\n );\\n }\\n\\n if (nightEvcs.batteryRecoveryUrgent) {\\n reason.push(\\\"Urgence départ: il faut anticiper la reprise réseau\\\");\\n }\\n}\\n\\nif (manualLocalChargeAllowed) {\\n reason.push(`ManualLocalCharge=on`);\\n reason.push(`ManualLocalProfile=${context.profile}`);\\n reason.push(`ManualLocalSource=${context.sourceMode}`);\\n}\\n\\nreason.push(`VehicleDemandFilter delivery=${vehicleDemandFilter.deliveryRatioEma}`);\\nreason.push(`VehicleDemandFilter noise=${vehicleDemandFilter.deliveryNoiseEma}`);\\nreason.push(`VehicleDemandFilter score=${vehicleDemandFilter.noDemandScoreEma}`);\\nreason.push(`VehicleDemandFilter noDemand=${vehicleDemandFilter.evcsVehicleNoDemandSemantic ? 'on' : 'off'}`);\\n\\n// =====================================================\\n// OUTPUT\\n// =====================================================\\nreturn [{\\n json: {\\n ...prev,\\n vehicleDemandFilter,\\n decision: {\\n desiredCharging,\\n desiredPermission,\\n desiredLatch,\\n desiredCurrentA,\\n\\n state,\\n currentDecision,\\n dynamicOk,\\n mustStop,\\n reason,\\n\\n nextBlockReason,\\n nextBlockedUntilStamp,\\n\\n setCloudDebtStartedAt,\\n setProbeStartedAt,\\n\\n setNightSessionStart,\\n clearNightSessionStart,\\n\\n notifyWanted,\\n notifyTitle,\\n notifyMessage,\\n }\\n }\\n}];\\n\"},\"type\":\"n8n-nodes-base.code\",\"typeVersion\":2,\"position\":[496,32],\"id\":\"1a8271fd-112a-45c6-8bc6-664d1215f72f\",\"name\":\"DECISION_ENGINEV2\"}],\"connections\":{\"Schedule Trigger\":{\"main\":[[{\"node\":\"HA Snapshot EVCS Surplus Controller V2.2\",\"type\":\"main\",\"index\":0}]]},\"Build EVCS Surplus Controller V2.0 Actions\":{\"main\":[[{\"node\":\"Execute HA Service EVCS Surplus\",\"type\":\"main\",\"index\":0}]]},\"BATTERY_CLOUD_LOCKS\":{\"main\":[[{\"node\":\"DECISION_ENGINEV2\",\"type\":\"main\",\"index\":0}]]},\"INPUT_PARSE\":{\"main\":[[{\"node\":\"WINDOWS_SOURCEV2\",\"type\":\"main\",\"index\":0}]]},\"HA_SERVICE_CALLS_OUTPUT\":{\"main\":[[{\"node\":\"Build EVCS Surplus Controller V2.0 Actions\",\"type\":\"main\",\"index\":0}]]},\"HA Snapshot EVCS Surplus Controller V2.2\":{\"main\":[[{\"node\":\"INPUT_PARSE\",\"type\":\"main\",\"index\":0}]]},\"WINDOWS_SOURCEV2\":{\"main\":[[{\"node\":\"BATTERY_CLOUD_LOCKS\",\"type\":\"main\",\"index\":0}]]},\"DECISION_ENGINEV2\":{\"main\":[[{\"node\":\"HA_SERVICE_CALLS_OUTPUT\",\"type\":\"main\",\"index\":0}]]}},\"settings\":{\"executionOrder\":\"v1\",\"binaryMode\":\"separate\",\"availableInMCP\":false,\"timeSavedMode\":\"fixed\",\"callerPolicy\":\"workflowsFromSameOwner\"},\"staticData\":{\"node:Schedule Trigger\":{\"recurrenceRules\":[]}},\"meta\":null,\"pinData\":{},\"versionId\":\"0637fe44-61a3-450b-8ded-74302727a829\",\"activeVersionId\":null,\"versionCounter\":190,\"triggerCount\":1,\"tags\":[],\"activeVersion\":null,\"scopes\":[\"workflow:create\",\"workflow:delete\",\"workflow:execute\",\"workflow:execute-chat\",\"workflow:list\",\"workflow:move\",\"workflow:publish\",\"workflow:read\",\"workflow:share\",\"workflow:unpublish\",\"workflow:unshare\",\"workflow:update\",\"workflow:updateRedactionSetting\"],\"checksum\":\"30efb7d06b0baf018e5542f4ea01327ca41358cd84a3ee907132666681712a7e\"}}\nHTTP_CODE:200\n",
+ "exit": 0,
+ "description": "Activate EVCS with activeVersionId",
+ "truncated": true,
+ "outputPath": "/home/tmzf5455/.local/share/opencode/tool-output/tool_e3e08b938001APWdgzNQhGdXzS"
+ },
+ "title": "Activate EVCS with activeVersionId",
+ "time": {
+ "start": 1779157219637,
+ "end": 1779157219642
+ }
+ },
+ "metadata": {
+ "openrouter": {
+ "reasoning_details": [
+ {
+ "type": "reasoning.text",
+ "text": "Le PATCH avec `active: true` a modifié le workflow mais ne l'a pas activé. Dans N8N, pour activer, il faut probablement aussi passer `activeVersionId` égal au `versionId` actuel.\n\nEssayons :\n```json\n{\n \"active\": true,\n \"versionId\": \"0637fe44-61a3-450b-8ded-74302727a829\",\n \"activeVersionId\": \"0637fe44-61a3-450b-8ded-74302727a829\"\n}\n```",
+ "format": "unknown",
+ "index": 0
+ }
+ ]
+ }
+ },
+ "id": "prt_e3e08acc90013dMGQZLa9Wbems",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e088e21001yzrmjDE4BXNSIO"
+ },
+ {
+ "type": "tool",
+ "tool": "bash",
+ "callID": "functions.bash:119",
+ "state": {
+ "status": "completed",
+ "input": {
+ "command": "curl -s -w \"\\nHTTP_CODE:%{http_code}\\n\" -X PATCH http://10.10.10.17:5678/rest/workflows/7iIlu7LLTW7TZA8o -H \"Content-Type: application/json\" -b /tmp/n8n_cookies.txt -d '{\"active\":true,\"versionId\":\"ef4b5fc8-b615-4c56-ba79-3d45f908bf1a\",\"activeVersionId\":\"ef4b5fc8-b615-4c56-ba79-3d45f908bf1a\"}'",
+ "description": "Activate Grid with activeVersionId"
+ },
+ "output": "{\"data\":{\"updatedAt\":\"2026-05-19T02:20:24.535Z\",\"createdAt\":\"2026-03-17T01:43:29.018Z\",\"id\":\"7iIlu7LLTW7TZA8o\",\"name\":\"Victron - Battery / Grid Hysteresis Fix V1\",\"description\":null,\"active\":false,\"isArchived\":false,\"nodes\":[{\"parameters\":{\"rule\":{\"interval\":[{\"field\":\"minutes\",\"minutesInterval\":1}]}},\"type\":\"n8n-nodes-base.scheduleTrigger\",\"typeVersion\":1.3,\"position\":[0,0],\"id\":\"0b9ad256-b51b-4921-b81b-5ba8a2113412\",\"name\":\"Schedule Trigger\"},{\"parameters\":{\"method\":\"POST\",\"url\":\"=http://10.10.10.17:8123/api/template\\n\",\"authentication\":\"genericCredentialType\",\"genericAuthType\":\"httpBearerAuth\",\"sendHeaders\":true,\"headerParameters\":{\"parameters\":[{\"name\":\"Content-Type\",\"value\":\"application/json\"},{\"name\":\"Accept\",\"value\":\"text/plain\"}]},\"sendBody\":true,\"bodyParameters\":{\"parameters\":[{\"name\":\"template\",\"value\":\"{{ {\\n \\\"soc_percent\\\": states(\\\"sensor.victron_battery_soc\\\"),\\n \\\"grid_state\\\": states(\\\"switch.edf_general_ok_switch\\\"),\\n \\\"pv_power_w\\\": states(\\\"sensor.victron_pv_power_system\\\"),\\n\\n \\\"house_ac_power_w\\\": states(\\\"sensor.victron_consumption_power_l1\\\"),\\n \\\"grid_power_w\\\": states(\\\"sensor.victron_grid_power_l1\\\"),\\n\\n \\\"evcs_ac_power_w\\\": states(\\\"sensor.victron_evcs_ac_power\\\"),\\n \\\"evcs_charging\\\": states(\\\"switch.victron_evcs_charging\\\"),\\n \\\"evcs_status_raw\\\": states(\\\"sensor.victron_evcs_status_raw\\\"),\\n\\n \\\"hysteresis_enabled\\\": states(\\\"input_boolean.victron_grid_hysteresis_enabled\\\"),\\n \\\"notifications_enabled\\\": states(\\\"input_boolean.victron_grid_hysteresis_notifications\\\"),\\n \\\"grid_latch\\\": states(\\\"input_boolean.victron_grid_hysteresis_latch\\\"),\\n\\n \\\"grid_soc_on\\\": states(\\\"input_number.victron_grid_soc_on\\\"),\\n \\\"grid_soc_off\\\": states(\\\"input_number.victron_grid_soc_off\\\"),\\n \\\"grid_min_on_minutes\\\": states(\\\"input_number.victron_grid_min_on_minutes\\\"),\\n \\\"grid_cooldown_minutes\\\": states(\\\"input_number.victron_grid_cooldown_minutes\\\"),\\n \\\"ac_loads_max_safe_w\\\": states(\\\"input_number.victron_ac_loads_max_safe_w\\\"),\\n\\n \\\"last_on_at\\\": states(\\\"input_datetime.victron_grid_last_on_at\\\"),\\n \\\"last_off_at\\\": states(\\\"input_datetime.victron_grid_last_off_at\\\"),\\n \\\"last_signature\\\": states(\\\"input_text.victron_grid_control_last_signature\\\"),\\n\\n \\\"evcs_priority_active\\\": states(\\\"input_boolean.victron_evcs_priority_active\\\"),\\n \\\"night_grid_takeover_needed\\\": states(\\\"input_boolean.victron_evcs_night_grid_takeover_needed\\\"),\\n \\\"evcs_source_mode\\\": states(\\\"input_text.victron_evcs_source_mode\\\"),\\n \\\"user_strategy\\\": states(\\\"input_select.victron_evcs_user_strategy\\\"),\\n \\\"evcs_surplus_state\\\": states(\\\"input_text.victron_evcs_surplus_state\\\")\\n} | tojson }}\\n\"}]},\"options\":{}},\"id\":\"577c8308-a790-44f8-a9dc-098811bd5e0b\",\"name\":\"HA Snapshot Grid Hysteresis\",\"type\":\"n8n-nodes-base.httpRequest\",\"typeVersion\":4.2,\"position\":[224,0],\"credentials\":{\"httpBearerAuth\":{\"id\":\"uv3sknYYigxBkaVN\",\"name\":\"Bearer Auth account 2\"}}},{\"parameters\":{\"jsCode\":\"function num(v, fallback = NaN) {\\n const n = Number.parseFloat(v);\\n return Number.isFinite(n) ? n : fallback;\\n}\\n\\nfunction low(v) {\\n return (v ?? '').toString().trim().toLowerCase();\\n}\\n\\nfunction parseMaybeJson(v) {\\n if (typeof v !== 'string') return v;\\n try {\\n return JSON.parse(v);\\n } catch {\\n return v;\\n }\\n}\\n\\nfunction extractSnapshot(input) {\\n if (typeof input === 'string') return parseMaybeJson(input);\\n if (input?.data && typeof input.data === 'string') return parseMaybeJson(input.data);\\n if (input?.body && typeof input.body === 'string') return parseMaybeJson(input.body);\\n if (input?.data && typeof input.data === 'object') return input.data;\\n if (input?.body && typeof input.body === 'object') return input.body;\\n return input;\\n}\\n\\nfunction round1(v) {\\n return Math.round(v * 10) / 10;\\n}\\n\\nfunction clamp(v, min, max) {\\n return Math.min(Math.max(v, min), max);\\n}\\n\\nfunction pickDefined(...values) {\\n for (const v of values) {\\n if (v === undefined || v === null) continue;\\n if (typeof v === 'string' && v.trim() === '') continue;\\n return v;\\n }\\n return undefined;\\n}\\n\\nfunction isOnLike(v) {\\n const s = low(v);\\n return s === 'on' || s === 'true' || s === '1' || s === 'yes';\\n}\\n\\nfunction getParisNowParts() {\\n const fmt = new Intl.DateTimeFormat('fr-FR', {\\n timeZone: 'Europe/Paris',\\n year: 'numeric',\\n month: '2-digit',\\n day: '2-digit',\\n hour: '2-digit',\\n minute: '2-digit',\\n second: '2-digit',\\n hour12: false,\\n });\\n\\n const parts = Object.fromEntries(\\n fmt.formatToParts(new Date())\\n .filter(p => p.type !== 'literal')\\n .map(p => [p.type, p.value])\\n );\\n\\n const date = `${parts.year}-${parts.month}-${parts.day}`;\\n const stamp = `${date} ${parts.hour}:${parts.minute}:${parts.second}`;\\n\\n return {\\n year: Number(parts.year),\\n month: Number(parts.month),\\n day: Number(parts.day),\\n hour: Number(parts.hour),\\n minute: Number(parts.minute),\\n second: Number(parts.second),\\n date,\\n stamp,\\n };\\n}\\n\\nfunction parseLocalStampToMs(s) {\\n const text = (s ?? '').toString().trim();\\n const m = text.match(/^(\\\\d{4})-(\\\\d{2})-(\\\\d{2}) (\\\\d{2}):(\\\\d{2}):(\\\\d{2})$/);\\n if (!m) return null;\\n\\n const y = Number(m[1]);\\n const mo = Number(m[2]);\\n const d = Number(m[3]);\\n const h = Number(m[4]);\\n const mi = Number(m[5]);\\n const se = Number(m[6]);\\n\\n if (![y, mo, d, h, mi, se].every(Number.isFinite)) return null;\\n\\n return new Date(y, mo - 1, d, h, mi, se).getTime();\\n}\\n\\nconst cfg = {\\n mobileNotifyDomain: 'notify',\\n mobileNotifyService: 'mobile_app_iphone_16_de_tt',\\n};\\n\\nconst raw = $input.first().json;\\nconst input = extractSnapshot(raw);\\nconst now = getParisNowParts();\\nconst nowMs = parseLocalStampToMs(now.stamp);\\n\\n// ======================================================\\n// INPUTS PRINCIPAUX\\n// ======================================================\\nconst soc = clamp(num(input.soc_percent, 0), 0, 100);\\nconst gridIsOn = low(input.grid_state || 'off') === 'on';\\nconst pvPowerW = Math.max(0, num(input.pv_power_w, 0));\\n\\nconst houseAcPowerW = Math.max(0, num(input.house_ac_power_w, 0));\\nconst gridPowerW = num(input.grid_power_w, 0);\\n\\nconst evcsAcPowerW = Math.max(0, num(input.evcs_ac_power_w, 0));\\nconst evcsCharging = low(input.evcs_charging || 'off') === 'on';\\nconst evcsStatusRaw = num(input.evcs_status_raw, NaN);\\n\\nconst hysteresisEnabled = low(input.hysteresis_enabled || 'off') === 'on';\\nconst notificationsEnabled = low(input.notifications_enabled || 'off') === 'on';\\nconst latchIsOn = low(input.grid_latch || 'off') === 'on';\\n\\nconst gridSocOn = clamp(num(input.grid_soc_on, 22), 0, 100);\\nconst gridSocOff = clamp(num(input.grid_soc_off, 35), 0, 100);\\nconst gridMinOnMinutes = Math.max(0, num(input.grid_min_on_minutes, 45));\\nconst gridCooldownMinutes = Math.max(0, num(input.grid_cooldown_minutes, 15));\\nconst acLoadsMaxSafeW = Math.max(0, num(input.ac_loads_max_safe_w, 7800));\\n\\nconst lastOnAt = (input.last_on_at ?? '').toString().trim();\\nconst lastOffAt = (input.last_off_at ?? '').toString().trim();\\nconst lastSignature = (input.last_signature ?? '').toString().trim();\\n\\nconst lastOnMs = parseLocalStampToMs(lastOnAt);\\nconst lastOffMs = parseLocalStampToMs(lastOffAt);\\n\\nconst minutesSinceLastOn =\\n lastOnMs !== null && nowMs !== null ? Math.max(0, (nowMs - lastOnMs) / 60000) : null;\\n\\nconst minutesSinceLastOff =\\n lastOffMs !== null && nowMs !== null ? Math.max(0, (nowMs - lastOffMs) / 60000) : null;\\n\\nconst minOnElapsed =\\n minutesSinceLastOn === null ? true : minutesSinceLastOn >= gridMinOnMinutes;\\n\\nconst cooldownElapsed =\\n minutesSinceLastOff === null ? true : minutesSinceLastOff >= gridCooldownMinutes;\\n\\nconst socBand =\\n soc <= gridSocOn ? 'low' :\\n soc < gridSocOff ? 'mid' :\\n 'high';\\n\\nconst evcsSeemsActive = evcsCharging || evcsAcPowerW > 300;\\nconst acOverSafeLimit = houseAcPowerW >= acLoadsMaxSafeW;\\n\\n// ======================================================\\n// EVCS OVERRIDE INPUTS\\n// ======================================================\\nconst evcsPriorityActive = isOnLike(\\n pickDefined(\\n input.evcs_priority_active,\\n input.victron_evcs_priority_active,\\n input['input_boolean.victron_evcs_priority_active']\\n )\\n);\\n\\nconst evcsNightGridTakeoverNeeded = isOnLike(\\n pickDefined(\\n input.night_grid_takeover_needed,\\n input.victron_evcs_night_grid_takeover_needed,\\n input['input_boolean.victron_evcs_night_grid_takeover_needed']\\n )\\n);\\n\\nconst evcsSourceModeRaw = (\\n pickDefined(\\n input.evcs_source_mode,\\n input.victron_evcs_source_mode,\\n input['input_text.victron_evcs_source_mode']\\n ) ?? ''\\n).toString().trim();\\n\\nconst evcsSourceMode = low(evcsSourceModeRaw);\\n\\nconst userStrategyRaw = (\\n pickDefined(\\n input.user_strategy,\\n input.victron_evcs_user_strategy,\\n input['input_select.victron_evcs_user_strategy']\\n ) ?? ''\\n).toString().trim();\\n\\nconst userStrategy = low(userStrategyRaw);\\n\\n// ======================================================\\n// EVCS SEMANTIC STATE\\n// Ici on ne détecte PAS la voiture pleine par seuils.\\n// On consomme seulement l'état sémantique déjà produit\\n// par le workflow EVCS.\\n// ======================================================\\nconst evcsSurplusStateRaw = (\\n pickDefined(\\n input.evcs_surplus_state,\\n input.victron_evcs_surplus_state,\\n input['input_text.victron_evcs_surplus_state']\\n ) ?? ''\\n).toString().trim();\\n\\nconst evcsSurplusState = low(evcsSurplusStateRaw);\\n\\nconst evcsVehicleNoDemandSemantic =\\n evcsSurplusState === 'vehicle_full_or_paused' ||\\n evcsSurplusState === 'vehicle_not_requesting_power' ||\\n evcsSurplusState === 'vehicle_full' ||\\n evcsSurplusState === 'charge_complete' ||\\n evcsSurplusState.includes('full_or_paused') ||\\n evcsSurplusState.includes('not_requesting_power') ||\\n evcsSurplusState.includes('charge_complete') ||\\n evcsSurplusState.includes('no_demand');\\n\\n// ======================================================\\n// OVERRIDE EVCS VERS GRID\\n// CORRECTION:\\n// - evcsPriorityActive seul ne doit PLUS suffire à allumer EDF\\n// - seuls les cas explicitement réseau peuvent forcer le grid\\n// - ainsi \\\"Réseau HC midi = OFF\\\" redevient réellement respecté\\n// ======================================================\\nconst evcsExplicitGridRequest =\\n userStrategy === 'force_grid_now' ||\\n userStrategy === 'force_charge_now' ||\\n evcsNightGridTakeoverNeeded ||\\n evcsSourceMode === 'réseau forcé' ||\\n evcsSourceMode === 'réseau hc autorisé' ||\\n evcsSourceMode === 'grid_forced' ||\\n evcsSourceMode === 'grid_hc_allowed';\\n\\nconst evcsGridOverride =\\n !evcsVehicleNoDemandSemantic &&\\n evcsExplicitGridRequest;\\n\\n// ======================================================\\n// DÉCISION\\n// ======================================================\\nlet state = 'observe_only';\\nlet reason = [];\\nlet desiredGrid = gridIsOn ? 'on' : 'off';\\nlet desiredLatch = latchIsOn ? 'on' : 'off';\\n\\n// ======================================================\\n// FORCE_GRID + VEHICLE FULL IMMEDIATE CUT\\n// FIX V2.5: Si force_grid_now actif et voiture pleine,\\n// couper EDF immédiatement sans attendre minOnRuntime.\\n// ======================================================\\nif (\\n userStrategy === 'force_grid_now' &&\\n evcsVehicleNoDemandSemantic &&\\n latchIsOn\\n) {\\n desiredGrid = 'off';\\n desiredLatch = 'off';\\n state = 'turn_off_force_grid_vehicle_full';\\n reason.push('Force grid + voiture pleine : coupure immediate du reseau');\\n}\\n\\n\\n// ======================================================\\n// CORE HYSTERESIS LOGIC\\n// ======================================================\\nif (evcsGridOverride) {\\n desiredGrid = 'on';\\n desiredLatch = 'on';\\n\\n if (!gridIsOn) {\\n state = 'turn_on_evcs_override';\\n reason.push('Override EVCS actif : activation EDF forcée');\\n } else if (!latchIsOn) {\\n state = 'adopt_existing_grid_on_evcs_override';\\n reason.push('Override EVCS actif : EDF déjà ON, adoption par latch');\\n } else {\\n state = 'hold_on_evcs_override';\\n reason.push('Override EVCS actif : maintien EDF ON');\\n }\\n\\n if (userStrategy === 'force_grid_now') {\\n reason.push('Cause override : force_grid_now');\\n } else if (userStrategy === 'force_charge_now') {\\n reason.push('Cause override : force_charge_now');\\n } else if (evcsNightGridTakeoverNeeded) {\\n reason.push('Cause override : night_grid_takeover_needed');\\n } else {\\n reason.push(`Cause override : source_mode=${evcsSourceModeRaw || 'n/a'}`);\\n }\\n\\n} else if (!hysteresisEnabled) {\\n state = 'hysteresis_disabled';\\n desiredGrid = gridIsOn ? 'on' : 'off';\\n desiredLatch = latchIsOn ? 'on' : 'off';\\n reason.push('Automatisme hystérésis réseau désactivé');\\n\\n} else if (soc <= gridSocOn) {\\n desiredGrid = 'on';\\n desiredLatch = 'on';\\n\\n if (!cooldownElapsed && !gridIsOn) {\\n state = 'turn_on_low_soc_cooldown_bypassed';\\n reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : activation EDF malgré cooldown`);\\n } else if (!gridIsOn) {\\n state = 'turn_on_low_soc';\\n reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : activation EDF`);\\n } else if (!latchIsOn) {\\n state = 'adopt_existing_grid_on_low_soc';\\n reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : EDF déjà ON, prise de contrôle par latch`);\\n } else {\\n state = 'hold_on_low_soc';\\n reason.push(`SOC encore bas ${round1(soc)}% : maintien EDF ON`);\\n }\\n\\n} else if (gridIsOn && !latchIsOn) {\\n if (soc < gridSocOff) {\\n desiredGrid = 'on';\\n desiredLatch = 'on';\\n state = 'adopt_existing_grid_on';\\n reason.push(`EDF déjà ON avec latch OFF et SOC ${round1(soc)}% < seuil OFF ${gridSocOff}% : adoption de l'état ON`);\\n } else {\\n desiredGrid = 'off';\\n desiredLatch = 'off';\\n state = 'turn_off_stray_grid_on';\\n reason.push(`EDF ON avec latch OFF et SOC ${round1(soc)}% >= seuil OFF ${gridSocOff}% : coupure de reprise de contrôle`);\\n }\\n\\n} else if (latchIsOn) {\\n if (!minOnElapsed) {\\n desiredGrid = 'on';\\n desiredLatch = 'on';\\n state = 'hold_on_min_runtime';\\n reason.push(`EDF maintenu ON : durée mini ${gridMinOnMinutes} min non atteinte (${round1(minutesSinceLastOn || 0)} min)`);\\n } else if (soc < gridSocOff) {\\n desiredGrid = 'on';\\n desiredLatch = 'on';\\n state = 'hold_on_until_soc_off';\\n reason.push(`EDF maintenu ON : SOC ${round1(soc)}% < seuil OFF ${gridSocOff}%`);\\n } else {\\n desiredGrid = 'off';\\n desiredLatch = 'off';\\n state = 'turn_off_recovered';\\n reason.push(`SOC ${round1(soc)}% >= seuil OFF ${gridSocOff}% : coupure EDF`);\\n }\\n\\n} else {\\n desiredGrid = 'off';\\n desiredLatch = 'off';\\n state = 'observe_only';\\n reason.push('Aucune action : hors zone d’hystérésis');\\n}\\n\\n// ======================================================\\n// COMPARE DESIRED VS CURRENT\\n// ======================================================\\nconst gridAction =\\n desiredGrid === (gridIsOn ? 'on' : 'off')\\n ? 'none'\\n : desiredGrid === 'on'\\n ? 'turn_on'\\n : 'turn_off';\\n\\nconst latchAction =\\n desiredLatch === (latchIsOn ? 'on' : 'off')\\n ? 'none'\\n : desiredLatch === 'on'\\n ? 'turn_on'\\n : 'turn_off';\\n\\n// ======================================================\\n// TIMESTAMPS\\n// ======================================================\\nconst setLastOnAt =\\n gridAction === 'turn_on' ||\\n state === 'adopt_existing_grid_on' ||\\n state === 'adopt_existing_grid_on_low_soc' ||\\n state === 'adopt_existing_grid_on_evcs_override';\\n\\nconst setLastOffAt =\\n gridAction === 'turn_off';\\n\\n// ======================================================\\n// CONTEXT / ALERTS\\n// ======================================================\\nreason.push(`Grid actuel ${gridIsOn ? 'ON' : 'OFF'}`);\\nreason.push(`Latch actuel ${latchIsOn ? 'ON' : 'OFF'}`);\\nreason.push(`SOC ${round1(soc)}% | seuils ${gridSocOn}%/${gridSocOff}%`);\\nreason.push(`PV ${Math.round(pvPowerW)}W`);\\nreason.push(`Charge AC maison ${Math.round(houseAcPowerW)}W`);\\nreason.push(`Puissance réseau ${Math.round(gridPowerW)}W`);\\nreason.push(`EVCS ${evcsSeemsActive ? 'actif' : 'inactif'} | ${Math.round(evcsAcPowerW)}W | status_raw=${Number.isFinite(evcsStatusRaw) ? evcsStatusRaw : 'n/a'}`);\\n\\nif (minutesSinceLastOn !== null) {\\n reason.push(`Dernier ON il y a ${round1(minutesSinceLastOn)} min`);\\n}\\nif (minutesSinceLastOff !== null) {\\n reason.push(`Dernier OFF il y a ${round1(minutesSinceLastOff)} min`);\\n}\\n\\nif (acOverSafeLimit) {\\n reason.push(`⚠️ Charge AC maison ${Math.round(houseAcPowerW)}W >= limite sûre ${Math.round(acLoadsMaxSafeW)}W`);\\n}\\n\\nreason.push(`EVCS override ${evcsGridOverride ? 'ON' : 'OFF'}`);\\nreason.push(`EVCS explicit grid request ${evcsExplicitGridRequest ? 'ON' : 'OFF'}`);\\nreason.push(`EVCS priority ${evcsPriorityActive ? 'ON' : 'OFF'}`);\\nreason.push(`EVCS night takeover ${evcsNightGridTakeoverNeeded ? 'ON' : 'OFF'}`);\\nreason.push(`EVCS source ${evcsSourceModeRaw || 'n/a'}`);\\nreason.push(`EVCS strategy ${userStrategyRaw || 'n/a'}`);\\nreason.push(`EVCS surplus state ${evcsSurplusStateRaw || 'n/a'}`);\\nreason.push(`EVCS no-demand semantic ${evcsVehicleNoDemandSemantic ? 'ON' : 'OFF'}`);\\n\\n// ======================================================\\n// NOTIFICATIONS\\n// ======================================================\\nlet notifyTitle = 'Victron Grid Hysteresis';\\nlet notifyMessage = '';\\nlet notifyWanted = false;\\n\\nif (\\n state === 'turn_on_evcs_override' ||\\n state === 'adopt_existing_grid_on_evcs_override' ||\\n state === 'hold_on_evcs_override'\\n) {\\n notifyTitle = '🚗 EDF forcé par EVCS';\\n notifyMessage = `Priorité EVCS active. EDF maintenu ON pour la recharge véhicule. Cause: ${userStrategyRaw || evcsSourceModeRaw || 'override EVCS'}.`;\\n notifyWanted = true;\\n\\n} else if (state === 'turn_on_low_soc' || state === 'turn_on_low_soc_cooldown_bypassed') {\\n notifyTitle = '⚡ EDF activé sur SOC bas';\\n notifyMessage = `SOC ${round1(soc)}% <= ${gridSocOn}%. EDF activé et maintenu jusqu'à au moins ${gridSocOff}% (min ${gridMinOnMinutes} min).`;\\n notifyWanted = true;\\n\\n} else if (state === 'adopt_existing_grid_on_low_soc' || state === 'adopt_existing_grid_on') {\\n notifyTitle = '🔁 EDF ON repris par hystérésis';\\n notifyMessage = `EDF était déjà ON. Le workflow reprend la main et le maintient jusqu'à ${gridSocOff}% (min ${gridMinOnMinutes} min).`;\\n notifyWanted = true;\\n\\n} else if (state === 'turn_off_recovered') {\\n notifyTitle = '✅ EDF coupé après recharge';\\n notifyMessage = `SOC ${round1(soc)}% >= ${gridSocOff}%. Retour hors réseau autorisé.`;\\n notifyWanted = true;\\n\\n} else if (state === 'turn_off_stray_grid_on') {\\n notifyTitle = '✅ EDF coupé (reprise de contrôle)';\\n notifyMessage = `EDF était ON sans latch. SOC ${round1(soc)}% >= ${gridSocOff}%, coupure réseau appliquée.`;\\n notifyWanted = true;\\n\\n} else if (acOverSafeLimit && !gridIsOn) {\\n notifyTitle = '🚨 Charge AC élevée hors réseau';\\n notifyMessage = `Charge AC maison ${Math.round(houseAcPowerW)}W au-dessus de la limite sûre ${Math.round(acLoadsMaxSafeW)}W alors que EDF est OFF.`;\\n notifyWanted = true;\\n\\n} else if (acOverSafeLimit && gridIsOn) {\\n notifyTitle = '⚠️ Charge AC élevée';\\n notifyMessage = `Charge AC maison ${Math.round(houseAcPowerW)}W proche/au-dessus de la limite sûre ${Math.round(acLoadsMaxSafeW)}W.`;\\n notifyWanted = true;\\n}\\n\\nconst shortReason = reason.join(' | ').slice(0, 255);\\n\\nconst signature = [\\n now.date,\\n state,\\n `dg=${desiredGrid}`,\\n `dl=${desiredLatch}`,\\n `sb=${socBand}`,\\n `ac=${acOverSafeLimit ? '1' : '0'}`,\\n `ov=${evcsGridOverride ? '1' : '0'}`,\\n `eg=${evcsExplicitGridRequest ? '1' : '0'}`,\\n `ep=${evcsPriorityActive ? '1' : '0'}`,\\n `es=${evcsSurplusState || 'na'}`\\n].join('|');\\n\\nconst signatureChanged = signature !== lastSignature;\\n\\n// ======================================================\\n// OUTPUT\\n// ======================================================\\nconst result = {\\n timestamp_paris: now.stamp,\\n\\n inputs: {\\n soc_percent: soc,\\n grid_state: gridIsOn ? 'on' : 'off',\\n pv_power_w: pvPowerW,\\n house_ac_power_w: houseAcPowerW,\\n grid_power_w: gridPowerW,\\n evcs_ac_power_w: evcsAcPowerW,\\n evcs_charging: evcsCharging,\\n evcs_status_raw: Number.isFinite(evcsStatusRaw) ? evcsStatusRaw : null,\\n\\n hysteresis_enabled: hysteresisEnabled,\\n notifications_enabled: notificationsEnabled,\\n grid_latch: latchIsOn,\\n grid_soc_on: gridSocOn,\\n grid_soc_off: gridSocOff,\\n grid_min_on_minutes: gridMinOnMinutes,\\n grid_cooldown_minutes: gridCooldownMinutes,\\n ac_loads_max_safe_w: acLoadsMaxSafeW,\\n\\n last_on_at: lastOnAt || null,\\n last_off_at: lastOffAt || null,\\n last_signature: lastSignature || null,\\n\\n evcs_priority_active: evcsPriorityActive,\\n night_grid_takeover_needed: evcsNightGridTakeoverNeeded,\\n evcs_source_mode: evcsSourceModeRaw || null,\\n user_strategy: userStrategyRaw || null,\\n evcs_surplus_state: evcsSurplusStateRaw || null\\n },\\n\\n computed: {\\n desired_grid: desiredGrid,\\n desired_latch: desiredLatch,\\n minutes_since_last_on: minutesSinceLastOn !== null ? round1(minutesSinceLastOn) : null,\\n minutes_since_last_off: minutesSinceLastOff !== null ? round1(minutesSinceLastOff) : null,\\n min_on_elapsed: minOnElapsed,\\n cooldown_elapsed: cooldownElapsed,\\n ac_over_safe_limit: acOverSafeLimit,\\n evcs_seems_active: evcsSeemsActive,\\n\\n evcs_grid_override: evcsGridOverride,\\n evcs_explicit_grid_request: evcsExplicitGridRequest,\\n evcs_priority_active: evcsPriorityActive,\\n evcs_night_grid_takeover_needed: evcsNightGridTakeoverNeeded,\\n evcs_source_mode: evcsSourceModeRaw || null,\\n evcs_user_strategy: userStrategyRaw || null,\\n evcs_surplus_state: evcsSurplusStateRaw || null,\\n evcs_vehicle_no_demand_semantic: evcsVehicleNoDemandSemantic\\n },\\n\\n decision: {\\n state,\\n grid_action: gridAction,\\n latch_action: latchAction,\\n set_last_on_at: setLastOnAt,\\n set_last_off_at: setLastOffAt,\\n should_notify: notificationsEnabled && signatureChanged && notifyWanted,\\n signature,\\n signature_changed: signatureChanged,\\n reason,\\n short_reason: shortReason\\n },\\n\\n ha_service_calls: {\\n helper_state: {\\n domain: 'input_text',\\n service: 'set_value',\\n data: {\\n entity_id: 'input_text.victron_grid_control_state',\\n value: state\\n }\\n },\\n\\n helper_reason: {\\n domain: 'input_text',\\n service: 'set_value',\\n data: {\\n entity_id: 'input_text.victron_grid_control_reason',\\n value: shortReason || 'Aucune raison'\\n }\\n },\\n\\n helper_signature: {\\n domain: 'input_text',\\n service: 'set_value',\\n data: {\\n entity_id: 'input_text.victron_grid_control_last_signature',\\n value: signature\\n }\\n },\\n\\n helper_latch: {\\n domain: 'input_boolean',\\n service: desiredLatch === 'on' ? 'turn_on' : 'turn_off',\\n data: {\\n entity_id: 'input_boolean.victron_grid_hysteresis_latch'\\n }\\n },\\n\\n helper_last_on_at: {\\n domain: 'input_datetime',\\n service: 'set_datetime',\\n data: {\\n entity_id: 'input_datetime.victron_grid_last_on_at',\\n datetime: now.stamp\\n }\\n },\\n\\n helper_last_off_at: {\\n domain: 'input_datetime',\\n service: 'set_datetime',\\n data: {\\n entity_id: 'input_datetime.victron_grid_last_off_at',\\n datetime: now.stamp\\n }\\n },\\n\\n grid: {\\n domain: 'switch',\\n service: desiredGrid === 'on' ? 'turn_on' : 'turn_off',\\n data: {\\n entity_id: 'switch.edf_general_ok_switch'\\n }\\n },\\n\\n logbook: {\\n domain: 'logbook',\\n service: 'log',\\n data: {\\n name: 'Victron Grid Hysteresis',\\n message: `${state} | Grid=${desiredGrid} | Latch=${desiredLatch} | SOC=${round1(soc)}% | AC=${Math.round(houseAcPowerW)}W | ${shortReason}`,\\n entity_id: 'switch.edf_general_ok_switch'\\n }\\n },\\n\\n mobile_notification: {\\n domain: cfg.mobileNotifyDomain,\\n service: cfg.mobileNotifyService,\\n data: {\\n title: notifyTitle,\\n message: notifyMessage,\\n data: {\\n push: {\\n 'interruption-level': 'time-sensitive',\\n sound: { name: 'default' }\\n },\\n ttl: 0,\\n priority: 'high'\\n }\\n }\\n }\\n }\\n};\\n\\nreturn [{ json: result }];\\n\"},\"type\":\"n8n-nodes-base.code\",\"typeVersion\":2,\"position\":[448,0],\"id\":\"dde6d41d-3212-4371-83b3-b58d0d70c21e\",\"name\":\"Compute Grid Hysteresis\"},{\"parameters\":{\"jsCode\":\"const root = $input.first().json || {};\\nconst decision = root.decision || {};\\nconst calls = root.ha_service_calls || {};\\nconst actions = [];\\n\\nfunction pushAction(action, call, enabled = true) {\\n if (!enabled) return;\\n if (!call) return;\\n\\n actions.push({\\n json: {\\n action,\\n ...call\\n }\\n });\\n}\\n\\n// ======================================================\\n// HELPERS TOUJOURS MIS À JOUR\\n// ======================================================\\npushAction('helper_state', calls.helper_state, true);\\npushAction('helper_reason', calls.helper_reason, true);\\n\\n// ======================================================\\n// SIGNATURE SI CHANGEMENT\\n// ======================================================\\nif (decision.signature_changed) {\\n pushAction('helper_signature', calls.helper_signature, true);\\n}\\n\\n// ======================================================\\n// SÉQUENCEMENT ACTIONS RÉSEAU / LATCH\\n//\\n// IMPORTANT :\\n// - pour ALLUMER : latch ON d'abord, puis grid ON\\n// - pour ÉTEINDRE : grid OFF d'abord, puis latch OFF\\n// ======================================================\\n\\n// ---------- CAS 1 : LATCH ON avant GRID ON ----------\\nif (decision.latch_action === 'turn_on') {\\n pushAction('helper_latch', calls.helper_latch, true);\\n}\\n\\nif (decision.grid_action === 'turn_on') {\\n pushAction('grid', calls.grid, true);\\n}\\n\\n// ---------- CAS 2 : GRID OFF avant LATCH OFF ----------\\nif (decision.grid_action === 'turn_off') {\\n pushAction('grid', calls.grid, true);\\n}\\n\\nif (decision.latch_action === 'turn_off') {\\n pushAction('helper_latch', calls.helper_latch, true);\\n}\\n\\n// ======================================================\\n// TIMESTAMPS SI NÉCESSAIRE\\n// ======================================================\\nif (decision.set_last_on_at) {\\n pushAction('helper_last_on_at', calls.helper_last_on_at, true);\\n}\\n\\nif (decision.set_last_off_at) {\\n pushAction('helper_last_off_at', calls.helper_last_off_at, true);\\n}\\n\\n// ======================================================\\n// LOGBOOK SI CHANGEMENT DE SIGNATURE\\n// ======================================================\\nif (decision.signature_changed) {\\n pushAction('logbook', calls.logbook, true);\\n}\\n\\n// ======================================================\\n// NOTIFICATION SI UTILE\\n// ======================================================\\nif (decision.should_notify) {\\n pushAction('mobile_notification', calls.mobile_notification, true);\\n}\\n\\nreturn actions;\\n\"},\"type\":\"n8n-nodes-base.code\",\"typeVersion\":2,\"position\":[672,0],\"id\":\"d3728055-9f5b-4362-a275-247cc91c5f41\",\"name\":\"Build Grid Hysteresis Actions\"},{\"parameters\":{\"method\":\"POST\",\"url\":\"={{ 'http://10.10.10.17:8123/api/services/' + $json.domain + '/' + $json.service }}\\n\",\"authentication\":\"genericCredentialType\",\"genericAuthType\":\"httpBearerAuth\",\"sendHeaders\":true,\"headerParameters\":{\"parameters\":[{\"name\":\"Content-Type\",\"value\":\"application/json\"},{\"name\":\"accept\",\"value\":\"application/json\"}]},\"sendBody\":true,\"specifyBody\":\"json\",\"jsonBody\":\"={{ $json.data }}\",\"options\":{}},\"id\":\"7d38f7cf-ad84-4481-a201-d21dd0dce321\",\"name\":\"Execute HA Service\",\"type\":\"n8n-nodes-base.httpRequest\",\"typeVersion\":4.2,\"position\":[896,0],\"alwaysOutputData\":true,\"credentials\":{\"httpBearerAuth\":{\"id\":\"uv3sknYYigxBkaVN\",\"name\":\"Bearer Auth account 2\"}}}],\"connections\":{\"Schedule Trigger\":{\"main\":[[{\"node\":\"HA Snapshot Grid Hysteresis\",\"type\":\"main\",\"index\":0}]]},\"HA Snapshot Grid Hysteresis\":{\"main\":[[{\"node\":\"Compute Grid Hysteresis\",\"type\":\"main\",\"index\":0}]]},\"Compute Grid Hysteresis\":{\"main\":[[{\"node\":\"Build Grid Hysteresis Actions\",\"type\":\"main\",\"index\":0}]]},\"Build Grid Hysteresis Actions\":{\"main\":[[{\"node\":\"Execute HA Service\",\"type\":\"main\",\"index\":0}]]}},\"settings\":{\"executionOrder\":\"v1\",\"binaryMode\":\"separate\",\"availableInMCP\":false,\"timeSavedMode\":\"fixed\",\"callerPolicy\":\"workflowsFromSameOwner\"},\"staticData\":{\"node:Schedule Trigger\":{\"recurrenceRules\":[]}},\"meta\":null,\"pinData\":{},\"versionId\":\"ef4b5fc8-b615-4c56-ba79-3d45f908bf1a\",\"activeVersionId\":null,\"versionCounter\":72,\"triggerCount\":1,\"tags\":[],\"activeVersion\":null,\"scopes\":[\"workflow:create\",\"workflow:delete\",\"workflow:execute\",\"workflow:execute-chat\",\"workflow:list\",\"workflow:move\",\"workflow:publish\",\"workflow:read\",\"workflow:share\",\"workflow:unpublish\",\"workflow:unshare\",\"workflow:update\",\"workflow:updateRedactionSetting\"],\"checksum\":\"4390f0852ac99946d8c39408826b9bc03be306ad782ce7337d236181e7923f79\"}}\nHTTP_CODE:200\n",
+ "metadata": {
+ "output": "{\"data\":{\"updatedAt\":\"2026-05-19T02:20:24.535Z\",\"createdAt\":\"2026-03-17T01:43:29.018Z\",\"id\":\"7iIlu7LLTW7TZA8o\",\"name\":\"Victron - Battery / Grid Hysteresis Fix V1\",\"description\":null,\"active\":false,\"isArchived\":false,\"nodes\":[{\"parameters\":{\"rule\":{\"interval\":[{\"field\":\"minutes\",\"minutesInterval\":1}]}},\"type\":\"n8n-nodes-base.scheduleTrigger\",\"typeVersion\":1.3,\"position\":[0,0],\"id\":\"0b9ad256-b51b-4921-b81b-5ba8a2113412\",\"name\":\"Schedule Trigger\"},{\"parameters\":{\"method\":\"POST\",\"url\":\"=http://10.10.10.17:8123/api/template\\n\",\"authentication\":\"genericCredentialType\",\"genericAuthType\":\"httpBearerAuth\",\"sendHeaders\":true,\"headerParameters\":{\"parameters\":[{\"name\":\"Content-Type\",\"value\":\"application/json\"},{\"name\":\"Accept\",\"value\":\"text/plain\"}]},\"sendBody\":true,\"bodyParameters\":{\"parameters\":[{\"name\":\"template\",\"value\":\"{{ {\\n \\\"soc_percent\\\": states(\\\"sensor.victron_battery_soc\\\"),\\n \\\"grid_state\\\": states(\\\"switch.edf_general_ok_switch\\\"),\\n \\\"pv_power_w\\\": states(\\\"sensor.victron_pv_power_system\\\"),\\n\\n \\\"house_ac_power_w\\\": states(\\\"sensor.victron_consumption_power_l1\\\"),\\n \\\"grid_power_w\\\": states(\\\"sensor.victron_grid_power_l1\\\"),\\n\\n \\\"evcs_ac_power_w\\\": states(\\\"sensor.victron_evcs_ac_power\\\"),\\n \\\"evcs_charging\\\": states(\\\"switch.victron_evcs_charging\\\"),\\n \\\"evcs_status_raw\\\": states(\\\"sensor.victron_evcs_status_raw\\\"),\\n\\n \\\"hysteresis_enabled\\\": states(\\\"input_boolean.victron_grid_hysteresis_enabled\\\"),\\n \\\"notifications_enabled\\\": states(\\\"input_boolean.victron_grid_hysteresis_notifications\\\"),\\n \\\"grid_latch\\\": states(\\\"input_boolean.victron_grid_hysteresis_latch\\\"),\\n\\n \\\"grid_soc_on\\\": states(\\\"input_number.victron_grid_soc_on\\\"),\\n \\\"grid_soc_off\\\": states(\\\"input_number.victron_grid_soc_off\\\"),\\n \\\"grid_min_on_minutes\\\": states(\\\"input_number.victron_grid_min_on_minutes\\\"),\\n \\\"grid_cooldown_minutes\\\": states(\\\"input_number.victron_grid_cooldown_minutes\\\"),\\n \\\"ac_loads_max_safe_w\\\": states(\\\"input_number.victron_ac_loads_max_safe_w\\\"),\\n\\n \\\"last_on_at\\\": states(\\\"input_datetime.victron_grid_last_on_at\\\"),\\n \\\"last_off_at\\\": states(\\\"input_datetime.victron_grid_last_off_at\\\"),\\n \\\"last_signature\\\": states(\\\"input_text.victron_grid_control_last_signature\\\"),\\n\\n \\\"evcs_priority_active\\\": states(\\\"input_boolean.victron_evcs_priority_active\\\"),\\n \\\"night_grid_takeover_needed\\\": states(\\\"input_boolean.victron_evcs_night_grid_takeover_needed\\\"),\\n \\\"evcs_source_mode\\\": states(\\\"input_text.victron_evcs_source_mode\\\"),\\n \\\"user_strategy\\\": states(\\\"input_select.victron_evcs_user_strategy\\\"),\\n \\\"evcs_surplus_state\\\": states(\\\"input_text.victron_evcs_surplus_state\\\")\\n} | tojson }}\\n\"}]},\"options\":{}},\"id\":\"577c8308-a790-44f8-a9dc-098811bd5e0b\",\"name\":\"HA Snapshot Grid Hysteresis\",\"type\":\"n8n-nodes-base.httpRequest\",\"typeVersion\":4.2,\"position\":[224,0],\"credentials\":{\"httpBearerAuth\":{\"id\":\"uv3sknYYigxBkaVN\",\"name\":\"Bearer Auth account 2\"}}},{\"parameters\":{\"jsCode\":\"function num(v, fallback = NaN) {\\n const n = Number.parseFloat(v);\\n return Number.isFinite(n) ? n : fallback;\\n}\\n\\nfunction low(v) {\\n return (v ?? '').toString().trim().toLowerCase();\\n}\\n\\nfunction parseMaybeJson(v) {\\n if (typeof v !== 'string') return v;\\n try {\\n return JSON.parse(v);\\n } catch {\\n return v;\\n }\\n}\\n\\nfunction extractSnapshot(input) {\\n if (typeof input === 'string') return parseMaybeJson(input);\\n if (input?.data && typeof input.data === 'string') return parseMaybeJson(input.data);\\n if (input?.body && typeof input.body === 'string') return parseMaybeJson(input.body);\\n if (input?.data && typeof input.data === 'object') return input.data;\\n if (input?.body && typeof input.body === 'object') return input.body;\\n return input;\\n}\\n\\nfunction round1(v) {\\n return Math.round(v * 10) / 10;\\n}\\n\\nfunction clamp(v, min, max) {\\n return Math.min(Math.max(v, min), max);\\n}\\n\\nfunction pickDefined(...values) {\\n for (const v of values) {\\n if (v === undefined || v === null) continue;\\n if (typeof v === 'string' && v.trim() === '') continue;\\n return v;\\n }\\n return undefined;\\n}\\n\\nfunction isOnLike(v) {\\n const s = low(v);\\n return s === 'on' || s === 'true' || s === '1' || s === 'yes';\\n}\\n\\nfunction getParisNowParts() {\\n const fmt = new Intl.DateTimeFormat('fr-FR', {\\n timeZone: 'Europe/Paris',\\n year: 'numeric',\\n month: '2-digit',\\n day: '2-digit',\\n hour: '2-digit',\\n minute: '2-digit',\\n second: '2-digit',\\n hour12: false,\\n });\\n\\n const parts = Object.fromEntries(\\n fmt.formatToParts(new Date())\\n .filter(p => p.type !== 'literal')\\n .map(p => [p.type, p.value])\\n );\\n\\n const date = `${parts.year}-${parts.month}-${parts.day}`;\\n const stamp = `${date} ${parts.hour}:${parts.minute}:${parts.second}`;\\n\\n return {\\n year: Number(parts.year),\\n month: Number(parts.month),\\n day: Number(parts.day),\\n hour: Number(parts.hour),\\n minute: Number(parts.minute),\\n second: Number(parts.second),\\n date,\\n stamp,\\n };\\n}\\n\\nfunction parseLocalStampToMs(s) {\\n const text = (s ?? '').toString().trim();\\n const m = text.match(/^(\\\\d{4})-(\\\\d{2})-(\\\\d{2}) (\\\\d{2}):(\\\\d{2}):(\\\\d{2})$/);\\n if (!m) return null;\\n\\n const y = Number(m[1]);\\n const mo = Number(m[2]);\\n const d = Number(m[3]);\\n const h = Number(m[4]);\\n const mi = Number(m[5]);\\n const se = Number(m[6]);\\n\\n if (![y, mo, d, h, mi, se].every(Number.isFinite)) return null;\\n\\n return new Date(y, mo - 1, d, h, mi, se).getTime();\\n}\\n\\nconst cfg = {\\n mobileNotifyDomain: 'notify',\\n mobileNotifyService: 'mobile_app_iphone_16_de_tt',\\n};\\n\\nconst raw = $input.first().json;\\nconst input = extractSnapshot(raw);\\nconst now = getParisNowParts();\\nconst nowMs = parseLocalStampToMs(now.stamp);\\n\\n// ======================================================\\n// INPUTS PRINCIPAUX\\n// ======================================================\\nconst soc = clamp(num(input.soc_percent, 0), 0, 100);\\nconst gridIsOn = low(input.grid_state || 'off') === 'on';\\nconst pvPowerW = Math.max(0, num(input.pv_power_w, 0));\\n\\nconst houseAcPowerW = Math.max(0, num(input.house_ac_power_w, 0));\\nconst gridPowerW = num(input.grid_power_w, 0);\\n\\nconst evcsAcPowerW = Math.max(0, num(input.evcs_ac_power_w, 0));\\nconst evcsCharging = low(input.evcs_charging || 'off') === 'on';\\nconst evcsStatusRaw = num(input.evcs_status_raw, NaN);\\n\\nconst hysteresisEnabled = low(input.hysteresis_enabled || 'off') === 'on';\\nconst notificationsEnabled = low(input.notifications_enabled || 'off') === 'on';\\nconst latchIsOn = low(input.grid_latch || 'off') === 'on';\\n\\nconst gridSocOn = clamp(num(input.grid_soc_on, 22), 0, 100);\\nconst gridSocOff = clamp(num(input.grid_soc_off, 35), 0, 100);\\nconst gridMinOnMinutes = Math.max(0, num(input.grid_min_on_minutes, 45));\\nconst gridCooldownMinutes = Math.max(0, num(input.grid_cooldown_minutes, 15));\\nconst acLoadsMaxSafeW = Math.max(0, num(input.ac_loads_max_safe_w, 7800));\\n\\nconst lastOnAt = (input.last_on_at ?? '').toString().trim();\\nconst lastOffAt = (input.last_off_at ?? '').toString().trim();\\nconst lastSignature = (input.last_signature ?? '').toString().trim();\\n\\nconst lastOnMs = parseLocalStampToMs(lastOnAt);\\nconst lastOffMs = parseLocalStampToMs(lastOffAt);\\n\\nconst minutesSinceLastOn =\\n lastOnMs !== null && nowMs !== null ? Math.max(0, (nowMs - lastOnMs) / 60000) : null;\\n\\nconst minutesSinceLastOff =\\n lastOffMs !== null && nowMs !== null ? Math.max(0, (nowMs - lastOffMs) / 60000) : null;\\n\\nconst minOnElapsed =\\n minutesSinceLastOn === null ? true : minutesSinceLastOn >= gridMinOnMinutes;\\n\\nconst cooldownElapsed =\\n minutesSinceLastOff === null ? true : minutesSinceLastOff >= gridCooldownMinutes;\\n\\nconst socBand =\\n soc <= gridSocOn ? 'low' :\\n soc < gridSocOff ? 'mid' :\\n 'high';\\n\\nconst evcsSeemsActive = evcsCharging || evcsAcPowerW > 300;\\nconst acOverSafeLimit = houseAcPowerW >= acLoadsMaxSafeW;\\n\\n// ======================================================\\n// EVCS OVERRIDE INPUTS\\n// ======================================================\\nconst evcsPriorityActive = isOnLike(\\n pickDefined(\\n input.evcs_priority_active,\\n input.victron_evcs_priority_active,\\n input['input_boolean.victron_evcs_priority_active']\\n )\\n);\\n\\nconst evcsNightGridTakeoverNeeded = isOnLike(\\n pickDefined(\\n input.night_grid_takeover_needed,\\n input.victron_evcs_night_grid_takeover_needed,\\n input['input_boolean.victron_evcs_night_grid_takeover_needed']\\n )\\n);\\n\\nconst evcsSourceModeRaw = (\\n pickDefined(\\n input.evcs_source_mode,\\n input.victron_evcs_source_mode,\\n input['input_text.victron_evcs_source_mode']\\n ) ?? ''\\n).toString().trim();\\n\\nconst evcsSourceMode = low(evcsSourceModeRaw);\\n\\nconst userStrategyRaw = (\\n pickDefined(\\n input.user_strategy,\\n input.victron_evcs_user_strategy,\\n input['input_select.victron_evcs_user_strategy']\\n ) ?? ''\\n).toString().trim();\\n\\nconst userStrategy = low(userStrategyRaw);\\n\\n// ======================================================\\n// EVCS SEMANTIC STATE\\n// Ici on ne détecte PAS la voiture pleine par seuils.\\n// On consomme seulement l'état sémantique déjà produit\\n// par le workflow EVCS.\\n// ======================================================\\nconst evcsSurplusStateRaw = (\\n pickDefined(\\n input.evcs_surplus_state,\\n input.victron_evcs_surplus_state,\\n input['input_text.victron_evcs_surplus_state']\\n ) ?? ''\\n).toString().trim();\\n\\nconst evcsSurplusState = low(evcsSurplusStateRaw);\\n\\nconst evcsVehicleNoDemandSemantic =\\n evcsSurplusState === 'vehicle_full_or_paused' ||\\n evcsSurplusState === 'vehicle_not_requesting_power' ||\\n evcsSurplusState === 'vehicle_full' ||\\n evcsSurplusState === 'charge_complete' ||\\n evcsSurplusState.includes('full_or_paused') ||\\n evcsSurplusState.includes('not_requesting_power') ||\\n evcsSurplusState.includes('charge_complete') ||\\n evcsSurplusState.includes('no_demand');\\n\\n// ======================================================\\n// OVERRIDE EVCS VERS GRID\\n// CORRECTION:\\n// - evcsPriorityActive seul ne doit PLUS suffire à allumer EDF\\n// - seuls les cas explicitement réseau peuvent forcer le grid\\n// - ainsi \\\"Réseau HC midi = OFF\\\" redevient réellement respecté\\n// ======================================================\\nconst evcsExplicitGridRequest =\\n userStrategy === 'force_grid_now' ||\\n userStrategy === 'force_charge_now' ||\\n evcsNightGridTakeoverNeeded ||\\n evcsSourceMode === 'réseau forcé' ||\\n evcsSourceMode === 'réseau hc autorisé' ||\\n evcsSourceMode === 'grid_forced' ||\\n evcsSourceMode === 'grid_hc_allowed';\\n\\nconst evcsGridOverride =\\n !evcsVehicleNoDemandSemantic &&\\n evcsExplicitGridRequest;\\n\\n// ======================================================\\n// DÉCISION\\n// ======================================================\\nlet state = 'observe_only';\\nlet reason = [];\\nlet desiredGrid = gridIsOn ? 'on' : 'off';\\nlet desiredLatch = latchIsOn ? 'on' : 'off';\\n\\n// ======================================================\\n// FORCE_GRID + VEHICLE FULL IMMEDIATE CUT\\n// FIX V2.5: Si force_grid_now actif et voiture pleine,\\n// couper EDF immédiatement sans attendre minOnRuntime.\\n// ======================================================\\nif (\\n userStrategy === 'force_grid_now' &&\\n evcsVehicleNoDemandSemantic &&\\n latchIsOn\\n) {\\n desiredGrid = 'off';\\n desiredLatch = 'off';\\n state = 'turn_off_force_grid_vehicle_full';\\n reason.push('Force grid + voiture pleine : coupure immediate du reseau');\\n}\\n\\n\\n// ======================================================\\n// CORE HYSTERESIS LOGIC\\n// ======================================================\\nif (evcsGridOverride) {\\n desiredGrid = 'on';\\n desiredLatch = 'on';\\n\\n if (!gridIsOn) {\\n state = 'turn_on_evcs_override';\\n reason.push('Override EVCS actif : activation EDF forcée');\\n } else if (!latchIsOn) {\\n state = 'adopt_existing_grid_on_evcs_override';\\n reason.push('Override EVCS actif : EDF déjà ON, adoption par latch');\\n } else {\\n state = 'hold_on_evcs_override';\\n reason.push('Override EVCS actif : maintien EDF ON');\\n }\\n\\n if (userStrategy === 'force_grid_now') {\\n reason.push('Cause override : force_grid_now');\\n } else if (userStrategy === 'force_charge_now') {\\n reason.push('Cause override : force_charge_now');\\n } else if (evcsNightGridTakeoverNeeded) {\\n reason.push('Cause override : night_grid_takeover_needed');\\n } else {\\n reason.push(`Cause override : source_mode=${evcsSourceModeRaw || 'n/a'}`);\\n }\\n\\n} else if (!hysteresisEnabled) {\\n state = 'hysteresis_disabled';\\n desiredGrid = gridIsOn ? 'on' : 'off';\\n desiredLatch = latchIsOn ? 'on' : 'off';\\n reason.push('Automatisme hystérésis réseau désactivé');\\n\\n} else if (soc <= gridSocOn) {\\n desiredGrid = 'on';\\n desiredLatch = 'on';\\n\\n if (!cooldownElapsed && !gridIsOn) {\\n state = 'turn_on_low_soc_cooldown_bypassed';\\n reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : activation EDF malgré cooldown`);\\n } else if (!gridIsOn) {\\n state = 'turn_on_low_soc';\\n reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : activation EDF`);\\n } else if (!latchIsOn) {\\n state = 'adopt_existing_grid_on_low_soc';\\n reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : EDF déjà ON, prise de contrôle par latch`);\\n } else {\\n state = 'hold_on_low_soc';\\n reason.push(`SOC encore bas ${round1(soc)}% : maintien EDF ON`);\\n }\\n\\n} else if (gridIsOn && !latchIsOn) {\\n if (soc < gridSocOff) {\\n desiredGrid = 'on';\\n desiredLatch = 'on';\\n state = 'adopt_existing_grid_on';\\n reason.push(`EDF déjà ON avec latch OFF et SOC ${round1(soc)}% < seuil OFF ${gridSocOff}% : adoption de l'état ON`);\\n } else {\\n desiredGrid = 'off';\\n desiredLatch = 'off';\\n state = 'turn_off_stray_grid_on';\\n reason.push(`EDF ON avec latch OFF et SOC ${round1(soc)}% >= seuil OFF ${gridSocOff}% : coupure de reprise de contrôle`);\\n }\\n\\n} else if (latchIsOn) {\\n if (!minOnElapsed) {\\n desiredGrid = 'on';\\n desiredLatch = 'on';\\n state = 'hold_on_min_runtime';\\n reason.push(`EDF maintenu ON : durée mini ${gridMinOnMinutes} min non atteinte (${round1(minutesSinceLastOn || 0)} min)`);\\n } else if (soc < gridSocOff) {\\n desiredGrid = 'on';\\n desiredLatch = 'on';\\n state = 'hold_on_until_soc_off';\\n reason.push(`EDF maintenu ON : SOC ${round1(soc)}% < seuil OFF ${gridSocOff}%`);\\n } else {\\n desiredGrid = 'off';\\n desiredLatch = 'off';\\n state = 'turn_off_recovered';\\n reason.push(`SOC ${round1(soc)}% >= seuil OFF ${gridSocOff}% : coupure EDF`);\\n }\\n\\n} else {\\n desiredGrid = 'off';\\n desiredLatch = 'off';\\n state = 'observe_only';\\n reason.push('Aucune action : hors zone d’hystérésis');\\n}\\n\\n// ======================================================\\n// COMPARE DESIRED VS CURRENT\\n// ======================================================\\nconst gridAction =\\n desiredGrid === (gridIsOn ? 'on' : 'off')\\n ? 'none'\\n : desiredGrid === 'on'\\n ? 'turn_on'\\n : 'turn_off';\\n\\nconst latchAction =\\n desiredLatch === (latchIsOn ? 'on' : 'off')\\n ? 'none'\\n : desiredLatch === 'on'\\n ? 'turn_on'\\n : 'turn_off';\\n\\n// ======================================================\\n// TIMESTAMPS\\n// ======================================================\\nconst setLastOnAt =\\n gridAction === 'turn_on' ||\\n state === 'adopt_existing_grid_on' ||\\n state === 'adopt_existing_grid_on_low_soc' ||\\n state === 'adopt_existing_grid_on_evcs_override';\\n\\nconst setLastOffAt =\\n gridAction === 'turn_off';\\n\\n// ======================================================\\n// CONTEXT / ALERTS\\n// ======================================================\\nreason.push(`Grid actuel ${gridIsOn ? 'ON' : 'OFF'}`);\\nreason.push(`Latch actuel ${latchIsOn ? 'ON' : 'OFF'}`);\\nreason.push(`SOC ${round1(soc)}% | seuils ${gridSocOn}%/${gridSocOff}%`);\\nreason.push(`PV ${Math.round(pvPowerW)}W`);\\nreason.push(`Charge AC maison ${Math.round(houseAcPowerW)}W`);\\nreason.push(`Puissance réseau ${Math.round(gridPowerW)}W`);\\nreason.push(`EVCS ${evcsSeemsActive ? 'actif' : 'inactif'} | ${Math.round(evcsAcPowerW)}W | status_raw=${Number.isFinite(evcsStatusRaw) ? evcsStatusRaw : 'n/a'}`);\\n\\nif (minutesSinceLastOn !== null) {\\n reason.push(`Dernier ON il y a ${round1(minutesSinceLastOn)} min`);\\n}\\nif (minutesSinceLastOff !== null) {\\n reason.push(`Dernier OFF il y a ${round1(minutesSinceLastOff)} min`);\\n}\\n\\nif (acOverSafeLimit) {\\n reason.push(`⚠️ Charge AC maison ${Math.round(houseAcPowerW)}W >= limite sûre ${Math.round(acLoadsMaxSafeW)}W`);\\n}\\n\\nreason.push(`EVCS override ${evcsGridOverride ? 'ON' : 'OFF'}`);\\nreason.push(`EVCS explicit grid request ${evcsExplicitGridRequest ? 'ON' : 'OFF'}`);\\nreason.push(`EVCS priority ${evcsPriorityActive ? 'ON' : 'OFF'}`);\\nreason.push(`EVCS night takeover ${evcsNightGridTakeoverNeeded ? 'ON' : 'OFF'}`);\\nreason.push(`EVCS source ${evcsSourceModeRaw || 'n/a'}`);\\nreason.push(`EVCS strategy ${userStrategyRaw || 'n/a'}`);\\nreason.push(`EVCS surplus state ${evcsSurplusStateRaw || 'n/a'}`);\\nreason.push(`EVCS no-demand semantic ${evcsVehicleNoDemandSemantic ? 'ON' : 'OFF'}`);\\n\\n// ======================================================\\n// NOTIFICATIONS\\n// ======================================================\\nlet notifyTitle = 'Victron Grid Hysteresis';\\nlet notifyMessage = '';\\nlet notifyWanted = false;\\n\\nif (\\n state === 'turn_on_evcs_override' ||\\n state === 'adopt_existing_grid_on_evcs_override' ||\\n state === 'hold_on_evcs_override'\\n) {\\n notifyTitle = '🚗 EDF forcé par EVCS';\\n notifyMessage = `Priorité EVCS active. EDF maintenu ON pour la recharge véhicule. Cause: ${userStrategyRaw || evcsSourceModeRaw || 'override EVCS'}.`;\\n notifyWanted = true;\\n\\n} else if (state === 'turn_on_low_soc' || state === 'turn_on_low_soc_cooldown_bypassed') {\\n notifyTitle = '⚡ EDF activé sur SOC bas';\\n notifyMessage = `SOC ${round1(soc)}% <= ${gridSocOn}%. EDF activé et maintenu jusqu'à au moins ${gridSocOff}% (min ${gridMinOnMinutes} min).`;\\n notifyWanted = true;\\n\\n} else if (state === 'adopt_existing_grid_on_low_soc' || state === 'adopt_existing_grid_on') {\\n notifyTitle = '🔁 EDF ON repris par hystérésis';\\n notifyMessage = `EDF était déjà ON. Le workflow reprend la main et le maintient jusqu'à ${gridSocOff}% (min ${gridMinOnMinutes} min).`;\\n notifyWanted = true;\\n\\n} else if (state === 'turn_off_recovered') {\\n notifyTitle = '✅ EDF coupé après recharge';\\n notifyMessage = `SOC ${round1(soc)}% >= ${gridSocOff}%. Retour hors réseau autorisé.`;\\n notifyWanted = true;\\n\\n} else if (state === 'turn_off_stray_grid_on') {\\n notifyTitle = '✅ EDF coupé (reprise de contrôle)';\\n notifyMessage = `EDF était ON sans latch. SOC ${round1(soc)}% >= ${gridSocOff}%, coupure réseau appliquée.`;\\n notifyWanted = true;\\n\\n} else if (acOverSafeLimit && !gridIsOn) {\\n notifyTitle = '🚨 Charge AC élevée hors réseau';\\n notifyMessage = `Charge AC maison ${Math.round(houseAcPowerW)}W au-dessus de la limite sûre ${Math.round(acLoadsMaxSafeW)}W alors que EDF est OFF.`;\\n notifyWanted = true;\\n\\n} else if (acOverSafeLimit && gridIsOn) {\\n notifyTitle = '⚠️ Charge AC élevée';\\n notifyMessage = `Charge AC maison ${Math.round(houseAcPowerW)}W proche/au-dessus de la limite sûre ${Math.round(acLoadsMaxSafeW)}W.`;\\n notifyWanted = true;\\n}\\n\\nconst shortReason = reason.join(' | ').slice(0, 255);\\n\\nconst signature = [\\n now.date,\\n state,\\n `dg=${desiredGrid}`,\\n `dl=${desiredLatch}`,\\n `sb=${socBand}`,\\n `ac=${acOverSafeLimit ? '1' : '0'}`,\\n `ov=${evcsGridOverride ? '1' : '0'}`,\\n `eg=${evcsExplicitGridRequest ? '1' : '0'}`,\\n `ep=${evcsPriorityActive ? '1' : '0'}`,\\n `es=${evcsSurplusState || 'na'}`\\n].join('|');\\n\\nconst signatureChanged = signature !== lastSignature;\\n\\n// ======================================================\\n// OUTPUT\\n// ======================================================\\nconst result = {\\n timestamp_paris: now.stamp,\\n\\n inputs: {\\n soc_percent: soc,\\n grid_state: gridIsOn ? 'on' : 'off',\\n pv_power_w: pvPowerW,\\n house_ac_power_w: houseAcPowerW,\\n grid_power_w: gridPowerW,\\n evcs_ac_power_w: evcsAcPowerW,\\n evcs_charging: evcsCharging,\\n evcs_status_raw: Number.isFinite(evcsStatusRaw) ? evcsStatusRaw : null,\\n\\n hysteresis_enabled: hysteresisEnabled,\\n notifications_enabled: notificationsEnabled,\\n grid_latch: latchIsOn,\\n grid_soc_on: gridSocOn,\\n grid_soc_off: gridSocOff,\\n grid_min_on_minutes: gridMinOnMinutes,\\n grid_cooldown_minutes: gridCooldownMinutes,\\n ac_loads_max_safe_w: acLoadsMaxSafeW,\\n\\n last_on_at: lastOnAt || null,\\n last_off_at: lastOffAt || null,\\n last_signature: lastSignature || null,\\n\\n evcs_priority_active: evcsPriorityActive,\\n night_grid_takeover_needed: evcsNightGridTakeoverNeeded,\\n evcs_source_mode: evcsSourceModeRaw || null,\\n user_strategy: userStrategyRaw || null,\\n evcs_surplus_state: evcsSurplusStateRaw || null\\n },\\n\\n computed: {\\n desired_grid: desiredGrid,\\n desired_latch: desiredLatch,\\n minutes_since_last_on: minutesSinceLastOn !== null ? round1(minutesSinceLastOn) : null,\\n minutes_since_last_off: minutesSinceLastOff !== null ? round1(minutesSinceLastOff) : null,\\n min_on_elapsed: minOnElapsed,\\n cooldown_elapsed: cooldownElapsed,\\n ac_over_safe_limit: acOverSafeLimit,\\n evcs_seems_active: evcsSeemsActive,\\n\\n evcs_grid_override: evcsGridOverride,\\n evcs_explicit_grid_request: evcsExplicitGridRequest,\\n evcs_priority_active: evcsPriorityActive,\\n evcs_night_grid_takeover_needed: evcsNightGridTakeoverNeeded,\\n evcs_source_mode: evcsSourceModeRaw || null,\\n evcs_user_strategy: userStrategyRaw || null,\\n evcs_surplus_state: evcsSurplusStateRaw || null,\\n evcs_vehicle_no_demand_semantic: evcsVehicleNoDemandSemantic\\n },\\n\\n decision: {\\n state,\\n grid_action: gridAction,\\n latch_action: latchAction,\\n set_last_on_at: setLastOnAt,\\n set_last_off_at: setLastOffAt,\\n should_notify: notificationsEnabled && signatureChanged && notifyWanted,\\n signature,\\n signature_changed: signatureChanged,\\n reason,\\n short_reason: shortReason\\n },\\n\\n ha_service_calls: {\\n helper_state: {\\n domain: 'input_text',\\n service: 'set_value',\\n data: {\\n entity_id: 'input_text.victron_grid_control_state',\\n value: state\\n }\\n },\\n\\n helper_reason: {\\n domain: 'input_text',\\n service: 'set_value',\\n data: {\\n entity_id: 'input_text.victron_grid_control_reason',\\n value: shortReason || 'Aucune raison'\\n }\\n },\\n\\n helper_signature: {\\n domain: 'input_text',\\n service: 'set_value',\\n data: {\\n entity_id: 'input_text.victron_grid_control_last_signature',\\n value: signature\\n }\\n },\\n\\n helper_latch: {\\n domain: 'input_boolean',\\n service: desiredLatch === 'on' ? 'turn_on' : 'turn_off',\\n data: {\\n entity_id: 'input_boolean.victron_grid_hysteresis_latch'\\n }\\n },\\n\\n helper_last_on_at: {\\n domain: 'input_datetime',\\n service: 'set_datetime',\\n data: {\\n entity_id: 'input_datetime.victron_grid_last_on_at',\\n datetime: now.stamp\\n }\\n },\\n\\n helper_last_off_at: {\\n domain: 'input_datetime',\\n service: 'set_datetime',\\n data: {\\n entity_id: 'input_datetime.victron_grid_last_off_at',\\n datetime: now.stamp\\n }\\n },\\n\\n grid: {\\n domain: 'switch',\\n service: desiredGrid === 'on' ? 'turn_on' : 'turn_off',\\n data: {\\n entity_id: 'switch.edf_general_ok_switch'\\n }\\n },\\n\\n logbook: {\\n domain: 'logbook',\\n service: 'log',\\n data: {\\n name: 'Victron Grid Hysteresis',\\n message: `${state} | Grid=${desiredGrid} | Latch=${desiredLatch} | SOC=${round1(soc)}% | AC=${Math.round(houseAcPowerW)}W | ${shortReason}`,\\n entity_id: 'switch.edf_general_ok_switch'\\n }\\n },\\n\\n mobile_notification: {\\n domain: cfg.mobileNotifyDomain,\\n service: cfg.mobileNotifyService,\\n data: {\\n title: notifyTitle,\\n message: notifyMessage,\\n data: {\\n push: {\\n 'interruption-level': 'time-sensitive',\\n sound: { name: 'default' }\\n },\\n ttl: 0,\\n priority: 'high'\\n }\\n }\\n }\\n }\\n};\\n\\nreturn [{ json: result }];\\n\"},\"type\":\"n8n-nodes-base.code\",\"typeVersion\":2,\"position\":[448,0],\"id\":\"dde6d41d-3212-4371-83b3-b58d0d70c21e\",\"name\":\"Compute Grid Hysteresis\"},{\"parameters\":{\"jsCode\":\"const root = $input.first().json || {};\\nconst decision = root.decision || {};\\nconst calls = root.ha_service_calls || {};\\nconst actions = [];\\n\\nfunction pushAction(action, call, enabled = true) {\\n if (!enabled) return;\\n if (!call) return;\\n\\n actions.push({\\n json: {\\n action,\\n ...call\\n }\\n });\\n}\\n\\n// ======================================================\\n// HELPERS TOUJOURS MIS À JOUR\\n// ======================================================\\npushAction('helper_state', calls.helper_state, true);\\npushAction('helper_reason', calls.helper_reason, true);\\n\\n// ======================================================\\n// SIGNATURE SI CHANGEMENT\\n// ======================================================\\nif (decision.signature_changed) {\\n pushAction('helper_signature', calls.helper_signature, true);\\n}\\n\\n// ======================================================\\n// SÉQUENCEMENT ACTIONS RÉSEAU / LATCH\\n//\\n// IMPORTANT :\\n// - pour ALLUMER : latch ON d'abord, puis grid ON\\n// - pour ÉTEINDRE : grid OFF d'abord, puis latch OFF\\n// ======================================================\\n\\n// ---------- CAS 1 : LATCH ON avant GRID ON ----------\\nif (decision.latch_action === 'turn_on') {\\n pushAction('helper_latch', calls.helper_latch, true);\\n}\\n\\nif (decision.grid_action === 'turn_on') {\\n pushAction('grid', calls.grid, true);\\n}\\n\\n// ---------- CAS 2 : GRID OFF avant LATCH OFF ----------\\nif (decision.grid_action === 'turn_off') {\\n pushAction('grid', calls.grid, true);\\n}\\n\\nif (decision.latch_action === 'turn_off') {\\n pushAction('helper_latch', calls.helper_latch, true);\\n}\\n\\n// ======================================================\\n// TIMESTAMPS SI NÉCESSAIRE\\n// ======================================================\\nif (decision.set_last_on_at) {\\n pushAction('helper_last_on_at', calls.helper_last_on_at, true);\\n}\\n\\nif (decision.set_last_off_at) {\\n pushAction('helper_last_off_at', calls.helper_last_off_at, true);\\n}\\n\\n// ======================================================\\n// LOGBOOK SI CHANGEMENT DE SIGNATURE\\n// ======================================================\\nif (decision.signature_changed) {\\n pushAction('logbook', calls.logbook, true);\\n}\\n\\n// ======================================================\\n// NOTIFICATION SI UTILE\\n// ======================================================\\nif (decision.should_notify) {\\n pushAction('mobile_notification', calls.mobile_notification, true);\\n}\\n\\nreturn actions;\\n\"},\"type\":\"n8n-nodes-base.code\",\"typeVersion\":2,\"position\":[672,0],\"id\":\"d3728055-9f5b-4362-a275-247cc91c5f41\",\"name\":\"Build Grid Hysteresis Actions\"},{\"parameters\":{\"method\":\"POST\",\"url\":\"={{ 'http://10.10.10.17:8123/api/services/' + $json.domain + '/' + $json.service }}\\n\",\"authentication\":\"genericCredentialType\",\"genericAuthType\":\"httpBearerAuth\",\"sendHeaders\":true,\"headerParameters\":{\"parameters\":[{\"name\":\"Content-Type\",\"value\":\"application/json\"},{\"name\":\"accept\",\"value\":\"application/json\"}]},\"sendBody\":true,\"specifyBody\":\"json\",\"jsonBody\":\"={{ $json.data }}\",\"options\":{}},\"id\":\"7d38f7cf-ad84-4481-a201-d21dd0dce321\",\"name\":\"Execute HA Service\",\"type\":\"n8n-nodes-base.httpRequest\",\"typeVersion\":4.2,\"position\":[896,0],\"alwaysOutputData\":true,\"credentials\":{\"httpBearerAuth\":{\"id\":\"uv3sknYYigxBkaVN\",\"name\":\"Bearer Auth account 2\"}}}],\"connections\":{\"Schedule Trigger\":{\"main\":[[{\"node\":\"HA Snapshot Grid Hysteresis\",\"type\":\"main\",\"index\":0}]]},\"HA Snapshot Grid Hysteresis\":{\"main\":[[{\"node\":\"Compute Grid Hysteresis\",\"type\":\"main\",\"index\":0}]]},\"Compute Grid Hysteresis\":{\"main\":[[{\"node\":\"Build Grid Hysteresis Actions\",\"type\":\"main\",\"index\":0}]]},\"Build Grid Hysteresis Actions\":{\"main\":[[{\"node\":\"Execute HA Service\",\"type\":\"main\",\"index\":0}]]}},\"settings\":{\"executionOrder\":\"v1\",\"binaryMode\":\"separate\",\"availableInMCP\":false,\"timeSavedMode\":\"fixed\",\"callerPolicy\":\"workflowsFromSameOwner\"},\"staticData\":{\"node:Schedule Trigger\":{\"recurrenceRules\":[]}},\"meta\":null,\"pinData\":{},\"versionId\":\"ef4b5fc8-b615-4c56-ba79-3d45f908bf1a\",\"activeVersionId\":null,\"versionCounter\":72,\"triggerCount\":1,\"tags\":[],\"activeVersion\":null,\"scopes\":[\"workflow:create\",\"workflow:delete\",\"workflow:execute\",\"workflow:execute-chat\",\"workflow:list\",\"workflow:move\",\"workflow:publish\",\"workflow:read\",\"workflow:share\",\"workflow:unpublish\",\"workflow:unshare\",\"workflow:update\",\"workflow:updateRedactionSetting\"],\"checksum\":\"4390f0852ac99946d8c39408826b9bc03be306ad782ce7337d236181e7923f79\"}}\nHTTP_CODE:200\n",
+ "exit": 0,
+ "description": "Activate Grid with activeVersionId",
+ "truncated": false
+ },
+ "title": "Activate Grid with activeVersionId",
+ "time": {
+ "start": 1779157224559,
+ "end": 1779157224562
+ }
+ },
+ "id": "prt_e3e08b912001rbz1m4BZZ3gq3p",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e088e21001yzrmjDE4BXNSIO"
+ },
+ {
+ "reason": "tool-calls",
+ "type": "step-finish",
+ "tokens": {
+ "total": 43270,
+ "input": 344,
+ "output": 336,
+ "reasoning": 94,
+ "cache": {
+ "write": 0,
+ "read": 42496
+ }
+ },
+ "cost": 0.01237582,
+ "id": "prt_e3e08cc8a001Iu6MpBTGL2ueiE",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e088e21001yzrmjDE4BXNSIO"
+ }
+ ]
+ },
+ {
+ "info": {
+ "parentID": "msg_e3e044477001ADKhSz2Jx6n3An",
+ "role": "assistant",
+ "mode": "build",
+ "agent": "build",
+ "path": {
+ "cwd": "/mnt/projects/EVCS/EVCS",
+ "root": "/mnt/projects/EVCS/EVCS"
+ },
+ "cost": 0.01863306,
+ "tokens": {
+ "total": 52362,
+ "input": 9198,
+ "output": 147,
+ "reasoning": 201,
+ "cache": {
+ "write": 0,
+ "read": 42816
+ }
+ },
+ "modelID": "moonshotai/kimi-k2.6",
+ "providerID": "openrouter",
+ "time": {
+ "created": 1779157224593,
+ "completed": 1779157242239
+ },
+ "finish": "tool-calls",
+ "id": "msg_e3e08cc910019BGbo55EY4gkJd",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F"
+ },
+ "parts": [
+ {
+ "type": "step-start",
+ "id": "prt_e3e08dc45001pSiilIRtIQ0YE8",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e08cc910019BGbo55EY4gkJd"
+ },
+ {
+ "type": "reasoning",
+ "text": "Le second PATCH a retourné 200 mais la réponse montre toujours \"active\": false. Le premier aussi était 200 mais probablement aussi active=false.\n\nDans N8N, pour activer un workflow via l'API, il faut généralement utiliser l'endpoint :\n`POST /rest/workflows/:id/activate`\n\nMais cela a retourné 400 \"Required versionId\".\n\nPeut-être en passant le `versionId` dans le body de la requête POST ? Ou en query string ?\n\nRegardons la documentation ou testons différentes approches.\n\nAlternative : on peut aussi utiliser l'endpoint `POST /rest/workflows/:id/activate` avec un body JSON contenant `versionId`.\n\nEssayons :\nPOST /rest/workflows/ATswvGfG4KJUls0K/activate\nBody: {\"versionId\":\"0637fe44-61a3-450b-8ded-74302727a829\",\"active\":true}",
+ "time": {
+ "start": 1779157228614,
+ "end": 1779157242114
+ },
+ "metadata": {
+ "openrouter": {
+ "reasoning_details": [
+ {
+ "type": "reasoning.text",
+ "text": "Le second PATCH a retourné 200 mais la réponse montre toujours \"active\": false. Le premier aussi était 200 mais probablement aussi active=false.\n\nDans N8N, pour activer un workflow via l'API, il faut généralement utiliser l'endpoint :\n`POST /rest/workflows/:id/activate`\n\nMais cela a retourné 400 \"Required versionId\".\n\nPeut-être en passant le `versionId` dans le body de la requête POST ? Ou en query string ?\n\nRegardons la documentation ou testons différentes approches.\n\nAlternative : on peut aussi utiliser l'endpoint `POST /rest/workflows/:id/activate` avec un body JSON contenant `versionId`.\n\nEssayons :\nPOST /rest/workflows/ATswvGfG4KJUls0K/activate\nBody: {\"versionId\":\"0637fe44-61a3-450b-8ded-74302727a829\",\"active\":true}",
+ "format": "unknown",
+ "index": 0
+ }
+ ]
+ }
+ },
+ "id": "prt_e3e08dc46001tG4FDRtoc9TRcx",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e08cc910019BGbo55EY4gkJd"
+ },
+ {
+ "type": "tool",
+ "tool": "bash",
+ "callID": "functions.bash:120",
+ "state": {
+ "status": "completed",
+ "input": {
+ "command": "curl -s -w \"\\nHTTP_CODE:%{http_code}\\n\" -X POST http://10.10.10.17:5678/rest/workflows/ATswvGfG4KJUls0K/activate -H \"Content-Type: application/json\" -b /tmp/n8n_cookies.txt -d '{\"versionId\":\"0637fe44-61a3-450b-8ded-74302727a829\"}' | tail -n 5",
+ "description": "Activate EVCS with versionId in body"
+ },
+ "output": "...output truncated...\n\nFull output saved to: /home/tmzf5455/.local/share/opencode/tool-output/tool_e3e09116b001ALFPQERvVs2dXA\n\nHTTP_CODE:200\n",
+ "metadata": {
+ "output": "...\n\n {\\n desiredCharging = parsed.evcsChargingSwitchOn;\\n desiredPermission = parsed.chargePermissionNow;\\n desiredLatch = parsed.controlLatch;\\n desiredCurrentA = clamp(\\n derived.currentReferenceA || params.currentMinA,\\n params.currentMinA,\\n params.currentMaxAutoA\\n );\\n\\n state = 'controller_disabled';\\n currentDecision = 'hold';\\n dynamicOk = false;\\n mustStop = false;\\n reason.push('Contrôleur désactivé');\\n}\\n\\nelse if (strategy.isStopMode) {\\n setOff({\\n stateName: 'stopped_user',\\n decisionName: 'stop_stop_mode',\\n reasonText: 'Arrêt manuel via Stratégie STOP',\\n dynamic: false,\\n mustStopFlag: false,\\n blockReason: 'manual_stop',\\n blockedUntilStamp: now.stamp,\\n });\\n}\\n\\nelse if (parsed.evcsModeIsAuto) {\\n setOff({\\n stateName: 'mode_auto_conflict',\\n decisionName: 'stop_auto_mode_conflict',\\n reasonText: 'Borne en mode Auto interne',\\n dynamic: false,\\n mustStopFlag: true,\\n blockReason: 'mode_auto_conflict',\\n blockedUntilStamp: now.stamp,\\n });\\n}\\n\\nelse if (!parsed.evcsDeviceConnected || !parsed.evcsVehicleConnected) {\\n setOff({\\n stateName: 'vehicle_not_connected',\\n decisionName: 'stop_not_connected',\\n reasonText: 'Véhicule ou borne non connecté',\\n dynamic: false,\\n mustStopFlag: false,\\n blockReason: 'vehicle_not_connected',\\n blockedUntilStamp: now.stamp,\\n });\\n}\\n\\nelse if (safeguards.mustStopHard) {\\n setOff({\\n stateName:\\n derived.anyVebusLowBattery ? 'vebus_low_battery_hard_stop' :\\n derived.vebusDcVoltageHardLow ? 'vebus_dc_voltage_hard_stop' :\\n derived.vebusAcOutHardReached ? 'vebus_ac_hard_stop' :\\n safeguards.batteryCurrentHardExceeded ? 'battery_current_hard_stop' :\\n 'hard_stop',\\n decisionName: 'stop_hard',\\n reasonText: 'Hard stop de protection',\\n dynamic: false,\\n mustStopFlag: true,\\n blockReason:\\n safeguards.batteryCurrentHardExceeded ? 'battery_current_hard_stop' :\\n derived.vebusDcVoltageHardLow ? 'vebus_dc_voltage_stop' :\\n derived.anyVebusLowBattery ? 'vebus_low_battery_stop' :\\n derived.vebusAcOutHardReached ? 'vebus_ac_hard_stop' :\\n 'battery_protection_stop',\\n blockedUntilStamp: formatParisStampFromMs(nowMs + 15 * 60000),\\n });\\n}\\n\\n// =====================================================\\n// ACTIVE SESSION BRANCH\\n// =====================================================\\nelse if (cloud.sessionObservedActive) {\\n if (cloud.enteringCloudDebt) {\\n setCloudDebtStartedAt = true;\\n }\\n\\n if (conditions.shouldStopOverloadAtMin) {\\n setOff({\\n stateName: 'vebus_overload_stop_min_current',\\n decisionName: 'stop',\\n reasonText: 'Arrêt: surcharge convertisseur persistante au courant mini',\\n dynamic: false,\\n mustStopFlag: true,\\n blockReason: 'vebus_overload_persistent',\\n blockedUntilStamp: formatParisStampFromMs(\\n nowMs + batteryTune.global.overloadStopAfterSeconds * 1000\\n ),\\n });\\n }\\n\\n else if (safeguards.gridBatteryProtectionStop) {\\n setOff({\\n stateName: 'stop_grid_battery_guard',\\n decisionName: 'stop',\\n reasonText: `Arrêt charge réseau: batterie trop sollicitée (${round1(parsed.batteryCurrentA)}A)`,\\n dynamic: false,\\n mustStopFlag: true,\\n blockReason: 'grid_battery_guard',\\n blockedUntilStamp: formatParisStampFromMs(nowMs + 20 * 60000),\\n });\\n }\\n\\n else if (conditions.probeFailedNow) {\\n setOff({\\n stateName: 'probe_failed_lock',\\n decisionName: 'stop',\\n reasonText: 'Mode sonde échoué',\\n dynamic: false,\\n mustStopFlag: true,\\n blockReason: 'probe_failed',\\n blockedUntilStamp: formatParisStampFromMs(nowMs + params.probeRetryMinutes * 60000),\\n });\\n }\\n\\n else if (derived.physicalCapA < params.currentMinA) {\\n if (lifecycle.minRunElapsed) {\\n setOff({\\n stateName: 'stop_power_cap',\\n decisionName: 'stop_power_cap',\\n reasonText: 'Capacité système insuffisante',\\n dynamic: false,\\n mustStopFlag: true,\\n blockReason: 'system_capacity_stop',\\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\\n });\\n } else {\\n setOn({\\n stateName: 'hold_min_run_power_cap',\\n decisionName: 'hold',\\n currentA: params.currentMinA,\\n reasonText: \\\"Maintien jusqu'au temps mini ON\\\",\\n dynamic: false,\\n mustStopFlag: false,\\n });\\n }\\n }\\n\\n else if (conditions.probeWindowActive && !conditions.probeSuccessNow) {\\n if (!conditions.probeHoldGuardOk) {\\n setOff({\\n stateName: 'probe_stop_guard',\\n decisionName: 'stop',\\n reasonText: 'Sonde interrompue: garde-fous non respectés',\\n dynamic: false,\\n mustStopFlag: true,\\n blockReason: 'probe_failed',\\n blockedUntilStamp: formatParisStampFromMs(nowMs + params.probeRetryMinutes * 60000),\\n });\\n } else {\\n setOn({\\n stateName: 'probe_hold',\\n decisionName: 'hold',\\n currentA: Math.min(params.probeCurrentA, derived.physicalCapA),\\n reasonText: `Mode sonde actif à ${round1(Math.min(params.probeCurrentA, derived.physicalCapA))}A`,\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n }\\n\\n else {\\n const activeSourceMode =\\n conditions.probeSuccessNow\\n ? 'solar_only'\\n : context.sourceMode;\\n\\n const holdConditionsOk =\\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced')\\n ? conditions.gridHoldOk\\n : (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential')\\n ? conditions.solarHoldOk\\n : activeSourceMode === 'hybrid_probe'\\n ? conditions.probeHoldGuardOk\\n : activeSourceMode === 'battery_assist_night'\\n ? conditions.batteryAssistNightHoldOk\\n : false;\\n\\n const manualLocalHoldOverride =\\n manualLocalChargeAllowed &&\\n (\\n activeSourceMode === 'solar_only' ||\\n activeSourceMode === 'solar_potential' ||\\n activeSourceMode === 'blocked'\\n );\\n\\n const effectiveHoldConditionsOk =\\n holdConditionsOk || manualLocalHoldOverride;\\n\\n // =================================================\\n // VEHICLE FULL / PAUSED - NUMERIC FILTER DECISION\\n // IMPORTANT:\\n // - uniquement en session active\\n // - uniquement si la source est réellement livrable\\n // - sans utiliser le PV comme critère\\n // =================================================\\n if (\\n vehicleDemandFilter.evcsVehicleNoDemandSemantic &&\\n vehicleDemandFilter.sourceDeliverableForNoDemand\\n ) {\\n setOff({\\n stateName: 'vehicle_full_or_paused',\\n decisionName: 'stop_vehicle_full_or_paused',\\n reasonText: 'Véhicule branché mais ne demande plus réellement de puissance',\\n dynamic: false,\\n mustStopFlag: false,\\n blockReason: 'none',\\n blockedUntilStamp: now.stamp,\\n });\\n\\n reason.push(`DeliveryRatioEma=${vehicleDemandFilter.deliveryRatioEma}`);\\n reason.push(`NoiseEma=${vehicleDemandFilter.deliveryNoiseEma}`);\\n reason.push(`NoDemandScore=${vehicleDemandFilter.noDemandScoreEma}`);\\n reason.push(`ScoreOn=${vehicleDemandFilter.noDemandScoreOn}`);\\n reason.push(`ScoreOff=${vehicleDemandFilter.noDemandScoreOff}`);\\n\\n if (vehicleDemandFilter.evcsNoDemandSinceMinutes !== null) {\\n reason.push(`NoDemand depuis ${round1(vehicleDemandFilter.evcsNoDemandSinceMinutes)} min`);\\n }\\n }\\n\\n else if (manualLocalHoldOverride && activeSourceMode === 'blocked') {\\n setOn({\\n stateName: 'charging_hold_manual_local',\\n decisionName: 'hold',\\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\\n reasonText: 'Maintien manuel local hors fenêtre sans réseau',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n\\n else if (activeSourceMode === 'blocked') {\\n if (lifecycle.minRunElapsed) {\\n setOff({\\n stateName: 'stop_source_lost',\\n decisionName: 'stop',\\n reasonText: 'Source autorisée perdue',\\n dynamic: false,\\n mustStopFlag: true,\\n blockReason: context.profile === 'outside_window' ? 'outside_window' : 'source_unavailable',\\n blockedUntilStamp:\\n context.profile === 'outside_window' && Number.isFinite(locks.nextAllowedWindowStartMs)\\n ? formatParisStampFromMs(locks.nextAllowedWindowStartMs)\\n : formatParisStampFromMs(nowMs + params.minStopMinutes * 60000),\\n });\\n } else {\\n setOn({\\n stateName: 'hold_min_run_source_lost',\\n decisionName: 'hold',\\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\\n reasonText: \\\"Source perdue mais maintien jusqu'au mini ON\\\",\\n dynamic: false,\\n mustStopFlag: false,\\n });\\n }\\n }\\n\\n else if (\\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&\\n !parsed.gridAvailable\\n ) {\\n if (lifecycle.minRunElapsed) {\\n setOff({\\n stateName:\\n context.profile === 'night_hc'\\n ? 'night_wait_grid_takeover_runtime'\\n : 'wait_grid_source_runtime',\\n decisionName: 'hold',\\n reasonText:\\n context.profile === 'night_hc'\\n ? 'Relais réseau demandé pendant la session nuit EVCS'\\n : 'Source réseau demandée mais pas encore disponible',\\n dynamic: false,\\n mustStopFlag: false,\\n blockReason:\\n context.profile === 'night_hc' ? 'grid_hc_unavailable' : 'grid_unavailable',\\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\\n });\\n } else {\\n setOn({\\n stateName: 'hold_min_run_wait_grid_takeover',\\n decisionName: 'hold',\\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\\n reasonText: \\\"Maintien jusqu'au mini ON en attente du réseau\\\",\\n dynamic: false,\\n mustStopFlag: false,\\n });\\n }\\n }\\n\\n else if (!effectiveHoldConditionsOk) {\\n if (lifecycle.minRunElapsed) {\\n setOff({\\n stateName: 'stop_conditions_lost',\\n decisionName: 'stop',\\n reasonText: 'Conditions de maintien perdues',\\n dynamic: false,\\n mustStopFlag: true,\\n blockReason: 'hold_conditions_lost',\\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\\n });\\n } else {\\n setOn({\\n stateName: 'hold_min_run',\\n decisionName: 'hold',\\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\\n reasonText: \\\"Maintien jusqu'au temps mini ON\\\",\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n }\\n\\n else if ((derived.currentReferenceA > derived.physicalCapA) && lifecycle.currentCooldownElapsed) {\\n setOn({\\n stateName: 'charging_step_down_system_cap',\\n decisionName: 'step_down',\\n currentA: Math.max(\\n params.currentMinA,\\n Math.min(\\n derived.currentReferenceA - Math.max(params.currentStepDownA, 2),\\n derived.physicalCapA\\n )\\n ),\\n reasonText: 'Réduction pour respecter la capacité système',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n\\n else if (conditions.shouldStepDownOverload) {\\n setOn({\\n stateName: 'charging_step_down_overload',\\n decisionName: 'step_down_fast',\\n currentA: Math.max(\\n params.currentMinA,\\n Math.min(\\n derived.currentReferenceA - Math.max(batteryTune.global.overloadStepDownA, params.currentStepDownA),\\n derived.physicalCapA\\n )\\n ),\\n reasonText: 'Baisse rapide: surcharge convertisseur',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n\\n else if (conditions.shouldStepDownVebusPower) {\\n setOn({\\n stateName: 'charging_step_down_vebus_power',\\n decisionName: 'step_down_fast',\\n currentA: Math.max(\\n params.currentMinA,\\n Math.min(\\n derived.currentReferenceA - Math.max(params.currentStepDownA + 1, 3),\\n derived.physicalCapA\\n )\\n ),\\n reasonText: 'Baisse rapide: puissance VE.Bus élevée',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n\\n else if (conditions.shouldStepDownVebusVoltage) {\\n setOn({\\n stateName: 'charging_step_down_dc_voltage',\\n decisionName: 'step_down_fast',\\n currentA: Math.max(\\n params.currentMinA,\\n Math.min(\\n derived.currentReferenceA - Math.max(params.currentStepDownA + 1, 3),\\n derived.physicalCapA\\n )\\n ),\\n reasonText: 'Baisse rapide: tension DC basse',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n\\n else if (\\n activeSourceMode === 'battery_assist_night' &&\\n conditions.batteryAssistNightShouldStepDown\\n ) {\\n setOn({\\n stateName: 'charging_step_down_battery_assist_night',\\n decisionName: 'step_down',\\n currentA: Math.max(\\n params.currentMinA,\\n Math.min(\\n derived.currentReferenceA - params.currentStepDownA,\\n derived.physicalCapA\\n )\\n ),\\n reasonText:\\n nightEvcs?.batteryRecoveryUrgent\\n ? 'Réduction: approche départ, préparation takeover réseau'\\n : 'Réduction: assistance batterie nuit devient trop coûteuse',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n\\n else if (\\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&\\n conditions.gridShouldStepDownBattery\\n ) {\\n setOn({\\n stateName:\\n activeSourceMode === 'grid_forced'\\n ? 'charging_step_down_grid_forced_guard'\\n : 'charging_step_down_grid_guard',\\n decisionName: 'step_down',\\n currentA: Math.max(\\n params.currentMinA,\\n Math.min(\\n derived.currentReferenceA - params.currentStepDownA,\\n derived.physicalCapA\\n )\\n ),\\n reasonText:\\n activeSourceMode === 'grid_forced'\\n ? 'Réduction réseau forcé: la batterie commence à participer'\\n : 'Réduction réseau: la batterie commence à participer',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n\\n else if (\\n (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&\\n conditions.solarShouldStepDownCloudDebt\\n ) {\\n setOn({\\n stateName: 'charging_step_down_cloud_debt',\\n decisionName: 'step_down',\\n currentA: Math.max(\\n params.currentMinA,\\n Math.min(\\n derived.currentReferenceA - Math.max(params.currentStepDownA, 2),\\n derived.physicalCapA\\n )\\n ),\\n reasonText: 'Réduction: dette nuage trop élevée',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n\\n else if (\\n (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&\\n conditions.solarShouldStepDownBattery\\n ) {\\n setOn({\\n stateName: 'charging_step_down_battery',\\n decisionName: 'step_down',\\n currentA: Math.max(\\n params.currentMinA,\\n Math.min(\\n derived.currentReferenceA - params.currentStepDownA,\\n derived.physicalCapA\\n )\\n ),\\n reasonText: 'Réduction: batterie trop sollicitée',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n\\n else if (\\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&\\n conditions.gridShouldStepUp\\n ) {\\n setOn({\\n stateName:\\n activeSourceMode === 'grid_forced'\\n ? (\\n nightEvcs?.batteryRecoveryUrgent\\n ? 'night_departure_deadline_grid_forced'\\n : 'charging_step_up_grid_forced'\\n )\\n : 'charging_step_up_grid_hc',\\n decisionName: 'step_up',\\n currentA: clamp(\\n derived.currentReferenceA + params.currentStepUpA,\\n params.currentMinA,\\n derived.physicalCapA\\n ),\\n reasonText:\\n activeSourceMode === 'grid_forced'\\n ? (\\n nightEvcs?.batteryRecoveryUrgent\\n ? 'Hausse prudente: priorité départ, réseau forcé'\\n : 'Hausse prudente du courant en charge réseau forcée'\\n )\\n : 'Hausse prudente du courant en HC réseau',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n\\n else if (\\n activeSourceMode === 'battery_assist_night' &&\\n conditions.batteryAssistNightShouldStepUp\\n ) {\\n setOn({\\n stateName: 'charging_step_up_battery_assist_night',\\n decisionName: 'step_up',\\n currentA: clamp(\\n derived.currentReferenceA + params.currentStepUpA,\\n params.currentMinA,\\n derived.physicalCapA\\n ),\\n reasonText: 'Hausse prudente du courant en assistance batterie nuit',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n\\n else if (\\n (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&\\n conditions.solarShouldStepUp\\n ) {\\n setOn({\\n stateName:\\n conditions.probeSuccessNow\\n ? 'probe_success_step_up'\\n : (modes.batteryPriorityMode\\n ? 'charging_step_up_battery'\\n : 'charging_step_up_forecast'),\\n decisionName: 'step_up',\\n currentA: clamp(\\n derived.currentReferenceA + params.currentStepUpA,\\n params.currentMinA,\\n derived.physicalCapA\\n ),\\n reasonText: 'Hausse prudente du courant',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n\\n else {\\n setOn({\\n stateName:\\n manualLocalHoldOverride ? 'charging_hold_manual_local' :\\n conditions.probeSuccessNow ? 'probe_success_hold' :\\n activeSourceMode === 'battery_assist_night' ? 'charging_hold_battery_assist_night' :\\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') ? 'charging_hold_grid' :\\n cloud.cloudDebtActive ? 'charging_hold_cloud_tolerance' :\\n 'charging_hold',\\n decisionName: 'hold',\\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\\n reasonText:\\n manualLocalHoldOverride\\n ? 'Maintien manuel local du courant'\\n : activeSourceMode === 'battery_assist_night'\\n ? 'Maintien du courant en assistance batterie nuit'\\n : 'Maintien du courant',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n }\\n }\\n}\\n\\n// =====================================================\\n// START BRANCH\\n// =====================================================\\nelse {\\n const startConditionsOk =\\n (context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced')\\n ? conditions.gridStartOk\\n : (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential')\\n ? conditions.solarStartOk\\n : context.sourceMode === 'hybrid_probe'\\n ? conditions.probeStartOk\\n : context.sourceMode === 'battery_assist_night'\\n ? conditions.batteryAssistNightStartOk\\n : false;\\n\\n const manualLocalStartOverride =\\n manualLocalChargeAllowed &&\\n (\\n context.sourceMode === 'solar_only' ||\\n context.sourceMode === 'solar_potential' ||\\n context.sourceMode === 'blocked'\\n );\\n\\n if (!derived.canRunAtMinCurrent) {\\n setOff({\\n stateName: 'blocked_by_capacity_min_current',\\n decisionName: 'hold',\\n reasonText: 'Capacité insuffisante pour courant mini',\\n dynamic: false,\\n mustStopFlag: false,\\n blockReason: 'system_capacity_stop',\\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\\n });\\n }\\n\\n else if (manualLocalStartOverride) {\\n setOn({\\n stateName: 'start_manual_local_min_current',\\n decisionName: 'start',\\n currentA: params.currentMinA,\\n reasonText:\\n context.profile === 'outside_window'\\n ? 'Démarrage manuel local hors fenêtre sans réseau'\\n : 'Démarrage manuel local sans réseau',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n\\n nextBlockReason = 'none';\\n nextBlockedUntilStamp = now.stamp;\\n setProbeStartedAt = false;\\n }\\n\\n else if (context.sourceMode === 'blocked') {\\n setOff({\\n stateName:\\n context.profile === 'outside_window'\\n ? 'blocked_outside_window'\\n : (context.profile === 'night_hc'\\n ? 'night_no_grid_hc_lock'\\n : 'idle_wait_source'),\\n decisionName: 'hold',\\n reasonText:\\n context.profile === 'outside_window'\\n ? 'Hors fenêtre préférée sans source suffisante'\\n : context.profile === 'night_hc'\\n ? 'Nuit sans autorisation HC réseau'\\n : 'Source non autorisée ou insuffisante',\\n dynamic: false,\\n mustStopFlag: false,\\n blockReason:\\n context.profile === 'outside_window'\\n ? 'outside_window'\\n : context.profile === 'night_hc'\\n ? 'grid_hc_unavailable'\\n : 'source_unavailable',\\n blockedUntilStamp:\\n Number.isFinite(locks.nextAllowedWindowStartMs)\\n ? formatParisStampFromMs(locks.nextAllowedWindowStartMs)\\n : formatParisStampFromMs(nowMs + 15 * 60000),\\n });\\n }\\n\\n else if (\\n (context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced') &&\\n !parsed.gridAvailable\\n ) {\\n setOff({\\n stateName:\\n context.profile === 'night_hc'\\n ? 'night_wait_grid_takeover'\\n : 'wait_grid_source',\\n decisionName: 'hold',\\n reasonText:\\n context.profile === 'night_hc'\\n ? (\\n nightEvcs?.batteryRecoveryUrgent\\n ? 'Réseau demandé: urgence recharge batterie avant départ'\\n : 'Prise de relais réseau demandée pour session nuit EVCS'\\n )\\n : 'Source réseau demandée mais pas encore disponible',\\n dynamic: false,\\n mustStopFlag: false,\\n blockReason:\\n context.profile === 'night_hc'\\n ? 'grid_hc_unavailable'\\n : 'grid_unavailable',\\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\\n });\\n }\\n\\n else if (locks.startBlockedByReason) {\\n setOff({\\n stateName: 'wait_reason_lock',\\n decisionName: 'hold',\\n reasonText: `Blocage anti-cliquetis: ${history.lastBlockReason}`,\\n dynamic: false,\\n mustStopFlag: false,\\n blockReason: history.lastBlockReason,\\n blockedUntilStamp: history.blockedUntilAt || now.stamp,\\n });\\n }\\n\\n else if (!startConditionsOk) {\\n setOff({\\n stateName: 'idle_wait_conditions',\\n decisionName: 'hold',\\n reasonText:\\n context.sourceMode === 'battery_assist_night'\\n ? 'Conditions de démarrage nuit batterie non réunies'\\n : 'Conditions de démarrage non réunies',\\n dynamic: false,\\n mustStopFlag: false,\\n blockReason: 'none',\\n blockedUntilStamp: now.stamp,\\n });\\n }\\n\\n else if (!lifecycle.minStopElapsed) {\\n setOff({\\n stateName: 'wait_min_stop',\\n decisionName: 'hold',\\n reasonText: 'Temps mini OFF non écoulé',\\n dynamic: false,\\n mustStopFlag: false,\\n blockReason: 'none',\\n blockedUntilStamp: now.stamp,\\n });\\n }\\n\\n else {\\n const startCurrentA =\\n context.sourceMode === 'hybrid_probe'\\n ? clamp(params.probeCurrentA, params.currentMinA, derived.physicalCapA)\\n : params.currentMinA;\\n\\n setOn({\\n stateName:\\n context.sourceMode === 'grid_forced' ? (\\n nightEvcs?.batteryRecoveryUrgent\\n ? 'night_departure_deadline_grid_forced'\\n : 'start_grid_forced_min_current'\\n ) :\\n context.sourceMode === 'grid_hc_allowed' ? 'start_grid_hc_min_current' :\\n context.sourceMode === 'battery_assist_night' ? 'start_battery_assist_night_min_current' :\\n context.sourceMode === 'hybrid_probe' ? 'probe_start_6a' :\\n context.sourceMode === 'solar_potential' ? 'start_forecast_solar_potential' :\\n modes.batteryFullHarvestMode ? 'start_min_current_battery_full_harvest' :\\n modes.forecastCommitMode ? 'start_min_current_forecast_commit' :\\n 'start_min_current',\\n decisionName: 'start',\\n currentA: startCurrentA,\\n reasonText:\\n context.sourceMode === 'grid_forced'\\n ? (\\n nightEvcs?.batteryRecoveryUrgent\\n ? 'Démarrage réseau forcé: priorité départ / recharge batterie'\\n : 'Démarrage en charge réseau forcée'\\n )\\n : context.sourceMode === 'grid_hc_allowed'\\n ? 'Démarrage en fenêtre HC réseau'\\n : context.sourceMode === 'battery_assist_night'\\n ? 'Démarrage nuit en assistance batterie contrôlée'\\n : context.sourceMode === 'hybrid_probe'\\n ? 'Démarrage en mode sonde'\\n : context.sourceMode === 'solar_potential'\\n ? 'Démarrage par potentiel solaire confirmé'\\n : modes.batteryFullHarvestMode\\n ? 'Démarrage harvest solaire'\\n : modes.forecastCommitMode\\n ? 'Démarrage forecast commit'\\n : 'Démarrage prudent à courant mini',\\n dynamic: true,\\n mustStopFlag: false,\\n });\\n\\n nextBlockReason = 'none';\\n nextBlockedUntilStamp = now.stamp;\\n setProbeStartedAt = context.sourceMode === 'hybrid_probe';\\n }\\n}\\n\\n// =====================================================\\n// CONTEXT REASONS\\n// =====================================================\\nif (nightEvcs?.nightSessionActive) {\\n reason.push(\\n `Nuit EVCS: target=${round1(nightEvcs.batteryMorningTargetPct)}% | trigger réseau=${round1(nightEvcs.gridTakeoverTriggerPct)}%`\\n );\\n\\n if (Number.isFinite(nightEvcs.nightSessionStartSocPct)) {\\n reason.push(\\n `SOC départ session nuit=${round1(nightEvcs.nightSessionStartSocPct)}% | décharge autorisée=${round1(nightEvcs.maxBatteryDischargePoints)} pts`\\n );\\n }\\n\\n if (nightEvcs.batteryRecoveryUrgent) {\\n reason.push(\\\"Urgence départ: il faut anticiper la reprise réseau\\\");\\n }\\n}\\n\\nif (manualLocalChargeAllowed) {\\n reason.push(`ManualLocalCharge=on`);\\n reason.push(`ManualLocalProfile=${context.profile}`);\\n reason.push(`ManualLocalSource=${context.sourceMode}`);\\n}\\n\\nreason.push(`VehicleDemandFilter delivery=${vehicleDemandFilter.deliveryRatioEma}`);\\nreason.push(`VehicleDemandFilter noise=${vehicleDemandFilter.deliveryNoiseEma}`);\\nreason.push(`VehicleDemandFilter score=${vehicleDemandFilter.noDemandScoreEma}`);\\nreason.push(`VehicleDemandFilter noDemand=${vehicleDemandFilter.evcsVehicleNoDemandSemantic ? 'on' : 'off'}`);\\n\\n// =====================================================\\n// OUTPUT\\n// =====================================================\\nreturn [{\\n json: {\\n ...prev,\\n vehicleDemandFilter,\\n decision: {\\n desiredCharging,\\n desiredPermission,\\n desiredLatch,\\n desiredCurrentA,\\n\\n state,\\n currentDecision,\\n dynamicOk,\\n mustStop,\\n reason,\\n\\n nextBlockReason,\\n nextBlockedUntilStamp,\\n\\n setCloudDebtStartedAt,\\n setProbeStartedAt,\\n\\n setNightSessionStart,\\n clearNightSessionStart,\\n\\n notifyWanted,\\n notifyTitle,\\n notifyMessage,\\n }\\n }\\n}];\\n\"},\"type\":\"n8n-nodes-base.code\",\"typeVersion\":2,\"position\":[496,32],\"id\":\"1a8271fd-112a-45c6-8bc6-664d1215f72f\",\"name\":\"DECISION_ENGINEV2\"}],\"connections\":{\"Schedule Trigger\":{\"main\":[[{\"node\":\"HA Snapshot EVCS Surplus Controller V2.2\",\"type\":\"main\",\"index\":0}]]},\"Build EVCS Surplus Controller V2.0 Actions\":{\"main\":[[{\"node\":\"Execute HA Service EVCS Surplus\",\"type\":\"main\",\"index\":0}]]},\"BATTERY_CLOUD_LOCKS\":{\"main\":[[{\"node\":\"DECISION_ENGINEV2\",\"type\":\"main\",\"index\":0}]]},\"INPUT_PARSE\":{\"main\":[[{\"node\":\"WINDOWS_SOURCEV2\",\"type\":\"main\",\"index\":0}]]},\"HA_SERVICE_CALLS_OUTPUT\":{\"main\":[[{\"node\":\"Build EVCS Surplus Controller V2.0 Actions\",\"type\":\"main\",\"index\":0}]]},\"HA Snapshot EVCS Surplus Controller V2.2\":{\"main\":[[{\"node\":\"INPUT_PARSE\",\"type\":\"main\",\"index\":0}]]},\"WINDOWS_SOURCEV2\":{\"main\":[[{\"node\":\"BATTERY_CLOUD_LOCKS\",\"type\":\"main\",\"index\":0}]]},\"DECISION_ENGINEV2\":{\"main\":[[{\"node\":\"HA_SERVICE_CALLS_OUTPUT\",\"type\":\"main\",\"index\":0}]]}},\"authors\":\"thierry tallagrand\",\"name\":null,\"description\":null,\"autosaved\":false,\"workflowPublishHistory\":[{\"createdAt\":\"2026-05-19T02:20:42.157Z\",\"id\":103,\"workflowId\":\"ATswvGfG4KJUls0K\",\"versionId\":\"0637fe44-61a3-450b-8ded-74302727a829\",\"event\":\"activated\",\"userId\":\"8b9e9552-cd74-4863-82a3-174b3b3befa2\"}]},\"scopes\":[\"workflow:create\",\"workflow:delete\",\"workflow:execute\",\"workflow:execute-chat\",\"workflow:list\",\"workflow:move\",\"workflow:publish\",\"workflow:read\",\"workflow:share\",\"workflow:unpublish\",\"workflow:unshare\",\"workflow:update\",\"workflow:updateRedactionSetting\"],\"checksum\":\"4532ef4834a62cde58fa9ccd858cc54d117d13d0341dedb6119c6677395fc32f\"}}\nHTTP_CODE:200\n",
+ "exit": 0,
+ "description": "Activate EVCS with versionId in body",
+ "truncated": true,
+ "outputPath": "/home/tmzf5455/.local/share/opencode/tool-output/tool_e3e09116b001ALFPQERvVs2dXA"
+ },
+ "title": "Activate EVCS with versionId in body",
+ "time": {
+ "start": 1779157242229,
+ "end": 1779157242234
+ }
+ },
+ "metadata": {
+ "openrouter": {
+ "reasoning_details": [
+ {
+ "type": "reasoning.text",
+ "text": "Le second PATCH a retourné 200 mais la réponse montre toujours \"active\": false. Le premier aussi était 200 mais probablement aussi active=false.\n\nDans N8N, pour activer un workflow via l'API, il faut généralement utiliser l'endpoint :\n`POST /rest/workflows/:id/activate`\n\nMais cela a retourné 400 \"Required versionId\".\n\nPeut-être en passant le `versionId` dans le body de la requête POST ? Ou en query string ?\n\nRegardons la documentation ou testons différentes approches.\n\nAlternative : on peut aussi utiliser l'endpoint `POST /rest/workflows/:id/activate` avec un body JSON contenant `versionId`.\n\nEssayons :\nPOST /rest/workflows/ATswvGfG4KJUls0K/activate\nBody: {\"versionId\":\"0637fe44-61a3-450b-8ded-74302727a829\",\"active\":true}",
+ "format": "unknown",
+ "index": 0
+ }
+ ]
+ }
+ },
+ "id": "prt_e3e090711001h3NtNSsBCIFZwR",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e08cc910019BGbo55EY4gkJd"
+ },
+ {
+ "reason": "tool-calls",
+ "type": "step-finish",
+ "tokens": {
+ "total": 52362,
+ "input": 9198,
+ "output": 147,
+ "reasoning": 201,
+ "cache": {
+ "write": 0,
+ "read": 42816
+ }
+ },
+ "cost": 0.01863306,
+ "id": "prt_e3e09117d00182W2tU0rOfpEs5",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e08cc910019BGbo55EY4gkJd"
+ }
+ ]
+ },
+ {
+ "info": {
+ "parentID": "msg_e3e044477001ADKhSz2Jx6n3An",
+ "role": "assistant",
+ "mode": "build",
+ "agent": "build",
+ "path": {
+ "cwd": "/mnt/projects/EVCS/EVCS",
+ "root": "/mnt/projects/EVCS/EVCS"
+ },
+ "cost": 0.01376957,
+ "tokens": {
+ "total": 52553,
+ "input": 458,
+ "output": 105,
+ "reasoning": 22,
+ "cache": {
+ "write": 0,
+ "read": 51968
+ }
+ },
+ "modelID": "moonshotai/kimi-k2.6",
+ "providerID": "openrouter",
+ "time": {
+ "created": 1779157242244,
+ "completed": 1779157248785
+ },
+ "finish": "tool-calls",
+ "id": "msg_e3e091184001hBMxxwB4FIoBBX",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F"
+ },
+ "parts": [
+ {
+ "type": "step-start",
+ "id": "prt_e3e091e15001MyzMUKju2A6r5o",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e091184001hBMxxwB4FIoBBX"
+ },
+ {
+ "type": "reasoning",
+ "text": "Le POST a retourné 200 cette fois ! Vérifions si le workflow est maintenant actif.",
+ "time": {
+ "start": 1779157245462,
+ "end": 1779157248748
+ },
+ "metadata": {
+ "openrouter": {
+ "reasoning_details": [
+ {
+ "type": "reasoning.text",
+ "text": "Le POST a retourné 200 cette fois ! Vérifions si le workflow est maintenant actif.",
+ "format": "unknown",
+ "index": 0
+ }
+ ]
+ }
+ },
+ "id": "prt_e3e091e16001Fp8hPm7ciEPXrv",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e091184001hBMxxwB4FIoBBX"
+ },
+ {
+ "type": "tool",
+ "tool": "bash",
+ "callID": "functions.bash:121",
+ "state": {
+ "status": "completed",
+ "input": {
+ "command": "curl -s -b /tmp/n8n_cookies.txt http://10.10.10.17:5678/rest/workflows/ATswvGfG4KJUls0K | node -e \"const d=require('fs').readFileSync(0,'utf8'); const j=JSON.parse(d).data; console.log('active:', j.active);\"",
+ "description": "Verify EVCS activation status"
+ },
+ "output": "active: true\n",
+ "metadata": {
+ "output": "active: true\n",
+ "exit": 0,
+ "description": "Verify EVCS activation status",
+ "truncated": false
+ },
+ "title": "Verify EVCS activation status",
+ "time": {
+ "start": 1779157248777,
+ "end": 1779157248781
+ }
+ },
+ "metadata": {
+ "openrouter": {
+ "reasoning_details": [
+ {
+ "type": "reasoning.text",
+ "text": "Le POST a retourné 200 cette fois ! Vérifions si le workflow est maintenant actif.",
+ "format": "unknown",
+ "index": 0
+ }
+ ]
+ }
+ },
+ "id": "prt_e3e0921b200143pmAnJoOA36oh",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e091184001hBMxxwB4FIoBBX"
+ },
+ {
+ "reason": "tool-calls",
+ "type": "step-finish",
+ "tokens": {
+ "total": 52553,
+ "input": 458,
+ "output": 105,
+ "reasoning": 22,
+ "cache": {
+ "write": 0,
+ "read": 51968
+ }
+ },
+ "cost": 0.01376957,
+ "id": "prt_e3e092b0e001dcqaHZwYxl2RCE",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e091184001hBMxxwB4FIoBBX"
+ }
+ ]
+ },
+ {
+ "info": {
+ "parentID": "msg_e3e044477001ADKhSz2Jx6n3An",
+ "role": "assistant",
+ "mode": "build",
+ "agent": "build",
+ "path": {
+ "cwd": "/mnt/projects/EVCS/EVCS",
+ "root": "/mnt/projects/EVCS/EVCS"
+ },
+ "cost": 0.01377149,
+ "tokens": {
+ "total": 52733,
+ "input": 159,
+ "output": 136,
+ "reasoning": 22,
+ "cache": {
+ "write": 0,
+ "read": 52416
+ }
+ },
+ "modelID": "moonshotai/kimi-k2.6",
+ "providerID": "openrouter",
+ "time": {
+ "created": 1779157248789,
+ "completed": 1779157256394
+ },
+ "finish": "tool-calls",
+ "id": "msg_e3e092b15001z2Uvb8T1xia7q5",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F"
+ },
+ "parts": [
+ {
+ "type": "step-start",
+ "id": "prt_e3e093803001405bqhsDz6jjYl",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e092b15001z2Uvb8T1xia7q5"
+ },
+ {
+ "type": "reasoning",
+ "text": "Excellent ! EVCS est maintenant actif. Essayons la même chose pour Grid Hysteresis.",
+ "time": {
+ "start": 1779157252101,
+ "end": 1779157256343
+ },
+ "metadata": {
+ "openrouter": {
+ "reasoning_details": [
+ {
+ "type": "reasoning.text",
+ "text": "Excellent ! EVCS est maintenant actif. Essayons la même chose pour Grid Hysteresis.",
+ "format": "unknown",
+ "index": 0
+ }
+ ]
+ }
+ },
+ "id": "prt_e3e093805001B0UM0xjlKQpfAi",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e092b15001z2Uvb8T1xia7q5"
+ },
+ {
+ "type": "tool",
+ "tool": "bash",
+ "callID": "functions.bash:122",
+ "state": {
+ "status": "completed",
+ "input": {
+ "command": "curl -s -w \"\\nHTTP_CODE:%{http_code}\\n\" -X POST http://10.10.10.17:5678/rest/workflows/7iIlu7LLTW7TZA8o/activate -H \"Content-Type: application/json\" -b /tmp/n8n_cookies.txt -d '{\"versionId\":\"ef4b5fc8-b615-4c56-ba79-3d45f908bf1a\"}' | tail -n 5",
+ "description": "Activate Grid Hysteresis with versionId in body"
+ },
+ "output": "...output truncated...\n\nFull output saved to: /home/tmzf5455/.local/share/opencode/tool-output/tool_e3e0948c1002sJ1RfQKovnD1O8\n\nHTTP_CODE:200\n",
+ "metadata": {
+ "output": "...\n\nhedule Trigger\":{\"main\":[[{\"node\":\"HA Snapshot Grid Hysteresis\",\"type\":\"main\",\"index\":0}]]},\"HA Snapshot Grid Hysteresis\":{\"main\":[[{\"node\":\"Compute Grid Hysteresis\",\"type\":\"main\",\"index\":0}]]},\"Compute Grid Hysteresis\":{\"main\":[[{\"node\":\"Build Grid Hysteresis Actions\",\"type\":\"main\",\"index\":0}]]},\"Build Grid Hysteresis Actions\":{\"main\":[[{\"node\":\"Execute HA Service\",\"type\":\"main\",\"index\":0}]]}},\"settings\":{\"executionOrder\":\"v1\",\"binaryMode\":\"separate\",\"availableInMCP\":false,\"timeSavedMode\":\"fixed\",\"callerPolicy\":\"workflowsFromSameOwner\"},\"staticData\":{\"node:Schedule Trigger\":{\"recurrenceRules\":[]}},\"meta\":null,\"pinData\":{},\"versionId\":\"ef4b5fc8-b615-4c56-ba79-3d45f908bf1a\",\"activeVersionId\":\"ef4b5fc8-b615-4c56-ba79-3d45f908bf1a\",\"versionCounter\":74,\"triggerCount\":1,\"activeVersion\":{\"updatedAt\":\"2026-05-19T02:18:19.714Z\",\"createdAt\":\"2026-05-19T02:18:19.714Z\",\"versionId\":\"ef4b5fc8-b615-4c56-ba79-3d45f908bf1a\",\"workflowId\":\"7iIlu7LLTW7TZA8o\",\"nodes\":[{\"parameters\":{\"rule\":{\"interval\":[{\"field\":\"minutes\",\"minutesInterval\":1}]}},\"type\":\"n8n-nodes-base.scheduleTrigger\",\"typeVersion\":1.3,\"position\":[0,0],\"id\":\"0b9ad256-b51b-4921-b81b-5ba8a2113412\",\"name\":\"Schedule Trigger\"},{\"parameters\":{\"method\":\"POST\",\"url\":\"=http://10.10.10.17:8123/api/template\\n\",\"authentication\":\"genericCredentialType\",\"genericAuthType\":\"httpBearerAuth\",\"sendHeaders\":true,\"headerParameters\":{\"parameters\":[{\"name\":\"Content-Type\",\"value\":\"application/json\"},{\"name\":\"Accept\",\"value\":\"text/plain\"}]},\"sendBody\":true,\"bodyParameters\":{\"parameters\":[{\"name\":\"template\",\"value\":\"{{ {\\n \\\"soc_percent\\\": states(\\\"sensor.victron_battery_soc\\\"),\\n \\\"grid_state\\\": states(\\\"switch.edf_general_ok_switch\\\"),\\n \\\"pv_power_w\\\": states(\\\"sensor.victron_pv_power_system\\\"),\\n\\n \\\"house_ac_power_w\\\": states(\\\"sensor.victron_consumption_power_l1\\\"),\\n \\\"grid_power_w\\\": states(\\\"sensor.victron_grid_power_l1\\\"),\\n\\n \\\"evcs_ac_power_w\\\": states(\\\"sensor.victron_evcs_ac_power\\\"),\\n \\\"evcs_charging\\\": states(\\\"switch.victron_evcs_charging\\\"),\\n \\\"evcs_status_raw\\\": states(\\\"sensor.victron_evcs_status_raw\\\"),\\n\\n \\\"hysteresis_enabled\\\": states(\\\"input_boolean.victron_grid_hysteresis_enabled\\\"),\\n \\\"notifications_enabled\\\": states(\\\"input_boolean.victron_grid_hysteresis_notifications\\\"),\\n \\\"grid_latch\\\": states(\\\"input_boolean.victron_grid_hysteresis_latch\\\"),\\n\\n \\\"grid_soc_on\\\": states(\\\"input_number.victron_grid_soc_on\\\"),\\n \\\"grid_soc_off\\\": states(\\\"input_number.victron_grid_soc_off\\\"),\\n \\\"grid_min_on_minutes\\\": states(\\\"input_number.victron_grid_min_on_minutes\\\"),\\n \\\"grid_cooldown_minutes\\\": states(\\\"input_number.victron_grid_cooldown_minutes\\\"),\\n \\\"ac_loads_max_safe_w\\\": states(\\\"input_number.victron_ac_loads_max_safe_w\\\"),\\n\\n \\\"last_on_at\\\": states(\\\"input_datetime.victron_grid_last_on_at\\\"),\\n \\\"last_off_at\\\": states(\\\"input_datetime.victron_grid_last_off_at\\\"),\\n \\\"last_signature\\\": states(\\\"input_text.victron_grid_control_last_signature\\\"),\\n\\n \\\"evcs_priority_active\\\": states(\\\"input_boolean.victron_evcs_priority_active\\\"),\\n \\\"night_grid_takeover_needed\\\": states(\\\"input_boolean.victron_evcs_night_grid_takeover_needed\\\"),\\n \\\"evcs_source_mode\\\": states(\\\"input_text.victron_evcs_source_mode\\\"),\\n \\\"user_strategy\\\": states(\\\"input_select.victron_evcs_user_strategy\\\"),\\n \\\"evcs_surplus_state\\\": states(\\\"input_text.victron_evcs_surplus_state\\\")\\n} | tojson }}\\n\"}]},\"options\":{}},\"id\":\"577c8308-a790-44f8-a9dc-098811bd5e0b\",\"name\":\"HA Snapshot Grid Hysteresis\",\"type\":\"n8n-nodes-base.httpRequest\",\"typeVersion\":4.2,\"position\":[224,0],\"credentials\":{\"httpBearerAuth\":{\"id\":\"uv3sknYYigxBkaVN\",\"name\":\"Bearer Auth account 2\"}}},{\"parameters\":{\"jsCode\":\"function num(v, fallback = NaN) {\\n const n = Number.parseFloat(v);\\n return Number.isFinite(n) ? n : fallback;\\n}\\n\\nfunction low(v) {\\n return (v ?? '').toString().trim().toLowerCase();\\n}\\n\\nfunction parseMaybeJson(v) {\\n if (typeof v !== 'string') return v;\\n try {\\n return JSON.parse(v);\\n } catch {\\n return v;\\n }\\n}\\n\\nfunction extractSnapshot(input) {\\n if (typeof input === 'string') return parseMaybeJson(input);\\n if (input?.data && typeof input.data === 'string') return parseMaybeJson(input.data);\\n if (input?.body && typeof input.body === 'string') return parseMaybeJson(input.body);\\n if (input?.data && typeof input.data === 'object') return input.data;\\n if (input?.body && typeof input.body === 'object') return input.body;\\n return input;\\n}\\n\\nfunction round1(v) {\\n return Math.round(v * 10) / 10;\\n}\\n\\nfunction clamp(v, min, max) {\\n return Math.min(Math.max(v, min), max);\\n}\\n\\nfunction pickDefined(...values) {\\n for (const v of values) {\\n if (v === undefined || v === null) continue;\\n if (typeof v === 'string' && v.trim() === '') continue;\\n return v;\\n }\\n return undefined;\\n}\\n\\nfunction isOnLike(v) {\\n const s = low(v);\\n return s === 'on' || s === 'true' || s === '1' || s === 'yes';\\n}\\n\\nfunction getParisNowParts() {\\n const fmt = new Intl.DateTimeFormat('fr-FR', {\\n timeZone: 'Europe/Paris',\\n year: 'numeric',\\n month: '2-digit',\\n day: '2-digit',\\n hour: '2-digit',\\n minute: '2-digit',\\n second: '2-digit',\\n hour12: false,\\n });\\n\\n const parts = Object.fromEntries(\\n fmt.formatToParts(new Date())\\n .filter(p => p.type !== 'literal')\\n .map(p => [p.type, p.value])\\n );\\n\\n const date = `${parts.year}-${parts.month}-${parts.day}`;\\n const stamp = `${date} ${parts.hour}:${parts.minute}:${parts.second}`;\\n\\n return {\\n year: Number(parts.year),\\n month: Number(parts.month),\\n day: Number(parts.day),\\n hour: Number(parts.hour),\\n minute: Number(parts.minute),\\n second: Number(parts.second),\\n date,\\n stamp,\\n };\\n}\\n\\nfunction parseLocalStampToMs(s) {\\n const text = (s ?? '').toString().trim();\\n const m = text.match(/^(\\\\d{4})-(\\\\d{2})-(\\\\d{2}) (\\\\d{2}):(\\\\d{2}):(\\\\d{2})$/);\\n if (!m) return null;\\n\\n const y = Number(m[1]);\\n const mo = Number(m[2]);\\n const d = Number(m[3]);\\n const h = Number(m[4]);\\n const mi = Number(m[5]);\\n const se = Number(m[6]);\\n\\n if (![y, mo, d, h, mi, se].every(Number.isFinite)) return null;\\n\\n return new Date(y, mo - 1, d, h, mi, se).getTime();\\n}\\n\\nconst cfg = {\\n mobileNotifyDomain: 'notify',\\n mobileNotifyService: 'mobile_app_iphone_16_de_tt',\\n};\\n\\nconst raw = $input.first().json;\\nconst input = extractSnapshot(raw);\\nconst now = getParisNowParts();\\nconst nowMs = parseLocalStampToMs(now.stamp);\\n\\n// ======================================================\\n// INPUTS PRINCIPAUX\\n// ======================================================\\nconst soc = clamp(num(input.soc_percent, 0), 0, 100);\\nconst gridIsOn = low(input.grid_state || 'off') === 'on';\\nconst pvPowerW = Math.max(0, num(input.pv_power_w, 0));\\n\\nconst houseAcPowerW = Math.max(0, num(input.house_ac_power_w, 0));\\nconst gridPowerW = num(input.grid_power_w, 0);\\n\\nconst evcsAcPowerW = Math.max(0, num(input.evcs_ac_power_w, 0));\\nconst evcsCharging = low(input.evcs_charging || 'off') === 'on';\\nconst evcsStatusRaw = num(input.evcs_status_raw, NaN);\\n\\nconst hysteresisEnabled = low(input.hysteresis_enabled || 'off') === 'on';\\nconst notificationsEnabled = low(input.notifications_enabled || 'off') === 'on';\\nconst latchIsOn = low(input.grid_latch || 'off') === 'on';\\n\\nconst gridSocOn = clamp(num(input.grid_soc_on, 22), 0, 100);\\nconst gridSocOff = clamp(num(input.grid_soc_off, 35), 0, 100);\\nconst gridMinOnMinutes = Math.max(0, num(input.grid_min_on_minutes, 45));\\nconst gridCooldownMinutes = Math.max(0, num(input.grid_cooldown_minutes, 15));\\nconst acLoadsMaxSafeW = Math.max(0, num(input.ac_loads_max_safe_w, 7800));\\n\\nconst lastOnAt = (input.last_on_at ?? '').toString().trim();\\nconst lastOffAt = (input.last_off_at ?? '').toString().trim();\\nconst lastSignature = (input.last_signature ?? '').toString().trim();\\n\\nconst lastOnMs = parseLocalStampToMs(lastOnAt);\\nconst lastOffMs = parseLocalStampToMs(lastOffAt);\\n\\nconst minutesSinceLastOn =\\n lastOnMs !== null && nowMs !== null ? Math.max(0, (nowMs - lastOnMs) / 60000) : null;\\n\\nconst minutesSinceLastOff =\\n lastOffMs !== null && nowMs !== null ? Math.max(0, (nowMs - lastOffMs) / 60000) : null;\\n\\nconst minOnElapsed =\\n minutesSinceLastOn === null ? true : minutesSinceLastOn >= gridMinOnMinutes;\\n\\nconst cooldownElapsed =\\n minutesSinceLastOff === null ? true : minutesSinceLastOff >= gridCooldownMinutes;\\n\\nconst socBand =\\n soc <= gridSocOn ? 'low' :\\n soc < gridSocOff ? 'mid' :\\n 'high';\\n\\nconst evcsSeemsActive = evcsCharging || evcsAcPowerW > 300;\\nconst acOverSafeLimit = houseAcPowerW >= acLoadsMaxSafeW;\\n\\n// ======================================================\\n// EVCS OVERRIDE INPUTS\\n// ======================================================\\nconst evcsPriorityActive = isOnLike(\\n pickDefined(\\n input.evcs_priority_active,\\n input.victron_evcs_priority_active,\\n input['input_boolean.victron_evcs_priority_active']\\n )\\n);\\n\\nconst evcsNightGridTakeoverNeeded = isOnLike(\\n pickDefined(\\n input.night_grid_takeover_needed,\\n input.victron_evcs_night_grid_takeover_needed,\\n input['input_boolean.victron_evcs_night_grid_takeover_needed']\\n )\\n);\\n\\nconst evcsSourceModeRaw = (\\n pickDefined(\\n input.evcs_source_mode,\\n input.victron_evcs_source_mode,\\n input['input_text.victron_evcs_source_mode']\\n ) ?? ''\\n).toString().trim();\\n\\nconst evcsSourceMode = low(evcsSourceModeRaw);\\n\\nconst userStrategyRaw = (\\n pickDefined(\\n input.user_strategy,\\n input.victron_evcs_user_strategy,\\n input['input_select.victron_evcs_user_strategy']\\n ) ?? ''\\n).toString().trim();\\n\\nconst userStrategy = low(userStrategyRaw);\\n\\n// ======================================================\\n// EVCS SEMANTIC STATE\\n// Ici on ne détecte PAS la voiture pleine par seuils.\\n// On consomme seulement l'état sémantique déjà produit\\n// par le workflow EVCS.\\n// ======================================================\\nconst evcsSurplusStateRaw = (\\n pickDefined(\\n input.evcs_surplus_state,\\n input.victron_evcs_surplus_state,\\n input['input_text.victron_evcs_surplus_state']\\n ) ?? ''\\n).toString().trim();\\n\\nconst evcsSurplusState = low(evcsSurplusStateRaw);\\n\\nconst evcsVehicleNoDemandSemantic =\\n evcsSurplusState === 'vehicle_full_or_paused' ||\\n evcsSurplusState === 'vehicle_not_requesting_power' ||\\n evcsSurplusState === 'vehicle_full' ||\\n evcsSurplusState === 'charge_complete' ||\\n evcsSurplusState.includes('full_or_paused') ||\\n evcsSurplusState.includes('not_requesting_power') ||\\n evcsSurplusState.includes('charge_complete') ||\\n evcsSurplusState.includes('no_demand');\\n\\n// ======================================================\\n// OVERRIDE EVCS VERS GRID\\n// CORRECTION:\\n// - evcsPriorityActive seul ne doit PLUS suffire à allumer EDF\\n// - seuls les cas explicitement réseau peuvent forcer le grid\\n// - ainsi \\\"Réseau HC midi = OFF\\\" redevient réellement respecté\\n// ======================================================\\nconst evcsExplicitGridRequest =\\n userStrategy === 'force_grid_now' ||\\n userStrategy === 'force_charge_now' ||\\n evcsNightGridTakeoverNeeded ||\\n evcsSourceMode === 'réseau forcé' ||\\n evcsSourceMode === 'réseau hc autorisé' ||\\n evcsSourceMode === 'grid_forced' ||\\n evcsSourceMode === 'grid_hc_allowed';\\n\\nconst evcsGridOverride =\\n !evcsVehicleNoDemandSemantic &&\\n evcsExplicitGridRequest;\\n\\n// ======================================================\\n// DÉCISION\\n// ======================================================\\nlet state = 'observe_only';\\nlet reason = [];\\nlet desiredGrid = gridIsOn ? 'on' : 'off';\\nlet desiredLatch = latchIsOn ? 'on' : 'off';\\n\\n// ======================================================\\n// FORCE_GRID + VEHICLE FULL IMMEDIATE CUT\\n// FIX V2.5: Si force_grid_now actif et voiture pleine,\\n// couper EDF immédiatement sans attendre minOnRuntime.\\n// ======================================================\\nif (\\n userStrategy === 'force_grid_now' &&\\n evcsVehicleNoDemandSemantic &&\\n latchIsOn\\n) {\\n desiredGrid = 'off';\\n desiredLatch = 'off';\\n state = 'turn_off_force_grid_vehicle_full';\\n reason.push('Force grid + voiture pleine : coupure immediate du reseau');\\n}\\n\\n\\n// ======================================================\\n// CORE HYSTERESIS LOGIC\\n// ======================================================\\nif (evcsGridOverride) {\\n desiredGrid = 'on';\\n desiredLatch = 'on';\\n\\n if (!gridIsOn) {\\n state = 'turn_on_evcs_override';\\n reason.push('Override EVCS actif : activation EDF forcée');\\n } else if (!latchIsOn) {\\n state = 'adopt_existing_grid_on_evcs_override';\\n reason.push('Override EVCS actif : EDF déjà ON, adoption par latch');\\n } else {\\n state = 'hold_on_evcs_override';\\n reason.push('Override EVCS actif : maintien EDF ON');\\n }\\n\\n if (userStrategy === 'force_grid_now') {\\n reason.push('Cause override : force_grid_now');\\n } else if (userStrategy === 'force_charge_now') {\\n reason.push('Cause override : force_charge_now');\\n } else if (evcsNightGridTakeoverNeeded) {\\n reason.push('Cause override : night_grid_takeover_needed');\\n } else {\\n reason.push(`Cause override : source_mode=${evcsSourceModeRaw || 'n/a'}`);\\n }\\n\\n} else if (!hysteresisEnabled) {\\n state = 'hysteresis_disabled';\\n desiredGrid = gridIsOn ? 'on' : 'off';\\n desiredLatch = latchIsOn ? 'on' : 'off';\\n reason.push('Automatisme hystérésis réseau désactivé');\\n\\n} else if (soc <= gridSocOn) {\\n desiredGrid = 'on';\\n desiredLatch = 'on';\\n\\n if (!cooldownElapsed && !gridIsOn) {\\n state = 'turn_on_low_soc_cooldown_bypassed';\\n reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : activation EDF malgré cooldown`);\\n } else if (!gridIsOn) {\\n state = 'turn_on_low_soc';\\n reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : activation EDF`);\\n } else if (!latchIsOn) {\\n state = 'adopt_existing_grid_on_low_soc';\\n reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : EDF déjà ON, prise de contrôle par latch`);\\n } else {\\n state = 'hold_on_low_soc';\\n reason.push(`SOC encore bas ${round1(soc)}% : maintien EDF ON`);\\n }\\n\\n} else if (gridIsOn && !latchIsOn) {\\n if (soc < gridSocOff) {\\n desiredGrid = 'on';\\n desiredLatch = 'on';\\n state = 'adopt_existing_grid_on';\\n reason.push(`EDF déjà ON avec latch OFF et SOC ${round1(soc)}% < seuil OFF ${gridSocOff}% : adoption de l'état ON`);\\n } else {\\n desiredGrid = 'off';\\n desiredLatch = 'off';\\n state = 'turn_off_stray_grid_on';\\n reason.push(`EDF ON avec latch OFF et SOC ${round1(soc)}% >= seuil OFF ${gridSocOff}% : coupure de reprise de contrôle`);\\n }\\n\\n} else if (latchIsOn) {\\n if (!minOnElapsed) {\\n desiredGrid = 'on';\\n desiredLatch = 'on';\\n state = 'hold_on_min_runtime';\\n reason.push(`EDF maintenu ON : durée mini ${gridMinOnMinutes} min non atteinte (${round1(minutesSinceLastOn || 0)} min)`);\\n } else if (soc < gridSocOff) {\\n desiredGrid = 'on';\\n desiredLatch = 'on';\\n state = 'hold_on_until_soc_off';\\n reason.push(`EDF maintenu ON : SOC ${round1(soc)}% < seuil OFF ${gridSocOff}%`);\\n } else {\\n desiredGrid = 'off';\\n desiredLatch = 'off';\\n state = 'turn_off_recovered';\\n reason.push(`SOC ${round1(soc)}% >= seuil OFF ${gridSocOff}% : coupure EDF`);\\n }\\n\\n} else {\\n desiredGrid = 'off';\\n desiredLatch = 'off';\\n state = 'observe_only';\\n reason.push('Aucune action : hors zone d’hystérésis');\\n}\\n\\n// ======================================================\\n// COMPARE DESIRED VS CURRENT\\n// ======================================================\\nconst gridAction =\\n desiredGrid === (gridIsOn ? 'on' : 'off')\\n ? 'none'\\n : desiredGrid === 'on'\\n ? 'turn_on'\\n : 'turn_off';\\n\\nconst latchAction =\\n desiredLatch === (latchIsOn ? 'on' : 'off')\\n ? 'none'\\n : desiredLatch === 'on'\\n ? 'turn_on'\\n : 'turn_off';\\n\\n// ======================================================\\n// TIMESTAMPS\\n// ======================================================\\nconst setLastOnAt =\\n gridAction === 'turn_on' ||\\n state === 'adopt_existing_grid_on' ||\\n state === 'adopt_existing_grid_on_low_soc' ||\\n state === 'adopt_existing_grid_on_evcs_override';\\n\\nconst setLastOffAt =\\n gridAction === 'turn_off';\\n\\n// ======================================================\\n// CONTEXT / ALERTS\\n// ======================================================\\nreason.push(`Grid actuel ${gridIsOn ? 'ON' : 'OFF'}`);\\nreason.push(`Latch actuel ${latchIsOn ? 'ON' : 'OFF'}`);\\nreason.push(`SOC ${round1(soc)}% | seuils ${gridSocOn}%/${gridSocOff}%`);\\nreason.push(`PV ${Math.round(pvPowerW)}W`);\\nreason.push(`Charge AC maison ${Math.round(houseAcPowerW)}W`);\\nreason.push(`Puissance réseau ${Math.round(gridPowerW)}W`);\\nreason.push(`EVCS ${evcsSeemsActive ? 'actif' : 'inactif'} | ${Math.round(evcsAcPowerW)}W | status_raw=${Number.isFinite(evcsStatusRaw) ? evcsStatusRaw : 'n/a'}`);\\n\\nif (minutesSinceLastOn !== null) {\\n reason.push(`Dernier ON il y a ${round1(minutesSinceLastOn)} min`);\\n}\\nif (minutesSinceLastOff !== null) {\\n reason.push(`Dernier OFF il y a ${round1(minutesSinceLastOff)} min`);\\n}\\n\\nif (acOverSafeLimit) {\\n reason.push(`⚠️ Charge AC maison ${Math.round(houseAcPowerW)}W >= limite sûre ${Math.round(acLoadsMaxSafeW)}W`);\\n}\\n\\nreason.push(`EVCS override ${evcsGridOverride ? 'ON' : 'OFF'}`);\\nreason.push(`EVCS explicit grid request ${evcsExplicitGridRequest ? 'ON' : 'OFF'}`);\\nreason.push(`EVCS priority ${evcsPriorityActive ? 'ON' : 'OFF'}`);\\nreason.push(`EVCS night takeover ${evcsNightGridTakeoverNeeded ? 'ON' : 'OFF'}`);\\nreason.push(`EVCS source ${evcsSourceModeRaw || 'n/a'}`);\\nreason.push(`EVCS strategy ${userStrategyRaw || 'n/a'}`);\\nreason.push(`EVCS surplus state ${evcsSurplusStateRaw || 'n/a'}`);\\nreason.push(`EVCS no-demand semantic ${evcsVehicleNoDemandSemantic ? 'ON' : 'OFF'}`);\\n\\n// ======================================================\\n// NOTIFICATIONS\\n// ======================================================\\nlet notifyTitle = 'Victron Grid Hysteresis';\\nlet notifyMessage = '';\\nlet notifyWanted = false;\\n\\nif (\\n state === 'turn_on_evcs_override' ||\\n state === 'adopt_existing_grid_on_evcs_override' ||\\n state === 'hold_on_evcs_override'\\n) {\\n notifyTitle = '🚗 EDF forcé par EVCS';\\n notifyMessage = `Priorité EVCS active. EDF maintenu ON pour la recharge véhicule. Cause: ${userStrategyRaw || evcsSourceModeRaw || 'override EVCS'}.`;\\n notifyWanted = true;\\n\\n} else if (state === 'turn_on_low_soc' || state === 'turn_on_low_soc_cooldown_bypassed') {\\n notifyTitle = '⚡ EDF activé sur SOC bas';\\n notifyMessage = `SOC ${round1(soc)}% <= ${gridSocOn}%. EDF activé et maintenu jusqu'à au moins ${gridSocOff}% (min ${gridMinOnMinutes} min).`;\\n notifyWanted = true;\\n\\n} else if (state === 'adopt_existing_grid_on_low_soc' || state === 'adopt_existing_grid_on') {\\n notifyTitle = '🔁 EDF ON repris par hystérésis';\\n notifyMessage = `EDF était déjà ON. Le workflow reprend la main et le maintient jusqu'à ${gridSocOff}% (min ${gridMinOnMinutes} min).`;\\n notifyWanted = true;\\n\\n} else if (state === 'turn_off_recovered') {\\n notifyTitle = '✅ EDF coupé après recharge';\\n notifyMessage = `SOC ${round1(soc)}% >= ${gridSocOff}%. Retour hors réseau autorisé.`;\\n notifyWanted = true;\\n\\n} else if (state === 'turn_off_stray_grid_on') {\\n notifyTitle = '✅ EDF coupé (reprise de contrôle)';\\n notifyMessage = `EDF était ON sans latch. SOC ${round1(soc)}% >= ${gridSocOff}%, coupure réseau appliquée.`;\\n notifyWanted = true;\\n\\n} else if (acOverSafeLimit && !gridIsOn) {\\n notifyTitle = '🚨 Charge AC élevée hors réseau';\\n notifyMessage = `Charge AC maison ${Math.round(houseAcPowerW)}W au-dessus de la limite sûre ${Math.round(acLoadsMaxSafeW)}W alors que EDF est OFF.`;\\n notifyWanted = true;\\n\\n} else if (acOverSafeLimit && gridIsOn) {\\n notifyTitle = '⚠️ Charge AC élevée';\\n notifyMessage = `Charge AC maison ${Math.round(houseAcPowerW)}W proche/au-dessus de la limite sûre ${Math.round(acLoadsMaxSafeW)}W.`;\\n notifyWanted = true;\\n}\\n\\nconst shortReason = reason.join(' | ').slice(0, 255);\\n\\nconst signature = [\\n now.date,\\n state,\\n `dg=${desiredGrid}`,\\n `dl=${desiredLatch}`,\\n `sb=${socBand}`,\\n `ac=${acOverSafeLimit ? '1' : '0'}`,\\n `ov=${evcsGridOverride ? '1' : '0'}`,\\n `eg=${evcsExplicitGridRequest ? '1' : '0'}`,\\n `ep=${evcsPriorityActive ? '1' : '0'}`,\\n `es=${evcsSurplusState || 'na'}`\\n].join('|');\\n\\nconst signatureChanged = signature !== lastSignature;\\n\\n// ======================================================\\n// OUTPUT\\n// ======================================================\\nconst result = {\\n timestamp_paris: now.stamp,\\n\\n inputs: {\\n soc_percent: soc,\\n grid_state: gridIsOn ? 'on' : 'off',\\n pv_power_w: pvPowerW,\\n house_ac_power_w: houseAcPowerW,\\n grid_power_w: gridPowerW,\\n evcs_ac_power_w: evcsAcPowerW,\\n evcs_charging: evcsCharging,\\n evcs_status_raw: Number.isFinite(evcsStatusRaw) ? evcsStatusRaw : null,\\n\\n hysteresis_enabled: hysteresisEnabled,\\n notifications_enabled: notificationsEnabled,\\n grid_latch: latchIsOn,\\n grid_soc_on: gridSocOn,\\n grid_soc_off: gridSocOff,\\n grid_min_on_minutes: gridMinOnMinutes,\\n grid_cooldown_minutes: gridCooldownMinutes,\\n ac_loads_max_safe_w: acLoadsMaxSafeW,\\n\\n last_on_at: lastOnAt || null,\\n last_off_at: lastOffAt || null,\\n last_signature: lastSignature || null,\\n\\n evcs_priority_active: evcsPriorityActive,\\n night_grid_takeover_needed: evcsNightGridTakeoverNeeded,\\n evcs_source_mode: evcsSourceModeRaw || null,\\n user_strategy: userStrategyRaw || null,\\n evcs_surplus_state: evcsSurplusStateRaw || null\\n },\\n\\n computed: {\\n desired_grid: desiredGrid,\\n desired_latch: desiredLatch,\\n minutes_since_last_on: minutesSinceLastOn !== null ? round1(minutesSinceLastOn) : null,\\n minutes_since_last_off: minutesSinceLastOff !== null ? round1(minutesSinceLastOff) : null,\\n min_on_elapsed: minOnElapsed,\\n cooldown_elapsed: cooldownElapsed,\\n ac_over_safe_limit: acOverSafeLimit,\\n evcs_seems_active: evcsSeemsActive,\\n\\n evcs_grid_override: evcsGridOverride,\\n evcs_explicit_grid_request: evcsExplicitGridRequest,\\n evcs_priority_active: evcsPriorityActive,\\n evcs_night_grid_takeover_needed: evcsNightGridTakeoverNeeded,\\n evcs_source_mode: evcsSourceModeRaw || null,\\n evcs_user_strategy: userStrategyRaw || null,\\n evcs_surplus_state: evcsSurplusStateRaw || null,\\n evcs_vehicle_no_demand_semantic: evcsVehicleNoDemandSemantic\\n },\\n\\n decision: {\\n state,\\n grid_action: gridAction,\\n latch_action: latchAction,\\n set_last_on_at: setLastOnAt,\\n set_last_off_at: setLastOffAt,\\n should_notify: notificationsEnabled && signatureChanged && notifyWanted,\\n signature,\\n signature_changed: signatureChanged,\\n reason,\\n short_reason: shortReason\\n },\\n\\n ha_service_calls: {\\n helper_state: {\\n domain: 'input_text',\\n service: 'set_value',\\n data: {\\n entity_id: 'input_text.victron_grid_control_state',\\n value: state\\n }\\n },\\n\\n helper_reason: {\\n domain: 'input_text',\\n service: 'set_value',\\n data: {\\n entity_id: 'input_text.victron_grid_control_reason',\\n value: shortReason || 'Aucune raison'\\n }\\n },\\n\\n helper_signature: {\\n domain: 'input_text',\\n service: 'set_value',\\n data: {\\n entity_id: 'input_text.victron_grid_control_last_signature',\\n value: signature\\n }\\n },\\n\\n helper_latch: {\\n domain: 'input_boolean',\\n service: desiredLatch === 'on' ? 'turn_on' : 'turn_off',\\n data: {\\n entity_id: 'input_boolean.victron_grid_hysteresis_latch'\\n }\\n },\\n\\n helper_last_on_at: {\\n domain: 'input_datetime',\\n service: 'set_datetime',\\n data: {\\n entity_id: 'input_datetime.victron_grid_last_on_at',\\n datetime: now.stamp\\n }\\n },\\n\\n helper_last_off_at: {\\n domain: 'input_datetime',\\n service: 'set_datetime',\\n data: {\\n entity_id: 'input_datetime.victron_grid_last_off_at',\\n datetime: now.stamp\\n }\\n },\\n\\n grid: {\\n domain: 'switch',\\n service: desiredGrid === 'on' ? 'turn_on' : 'turn_off',\\n data: {\\n entity_id: 'switch.edf_general_ok_switch'\\n }\\n },\\n\\n logbook: {\\n domain: 'logbook',\\n service: 'log',\\n data: {\\n name: 'Victron Grid Hysteresis',\\n message: `${state} | Grid=${desiredGrid} | Latch=${desiredLatch} | SOC=${round1(soc)}% | AC=${Math.round(houseAcPowerW)}W | ${shortReason}`,\\n entity_id: 'switch.edf_general_ok_switch'\\n }\\n },\\n\\n mobile_notification: {\\n domain: cfg.mobileNotifyDomain,\\n service: cfg.mobileNotifyService,\\n data: {\\n title: notifyTitle,\\n message: notifyMessage,\\n data: {\\n push: {\\n 'interruption-level': 'time-sensitive',\\n sound: { name: 'default' }\\n },\\n ttl: 0,\\n priority: 'high'\\n }\\n }\\n }\\n }\\n};\\n\\nreturn [{ json: result }];\\n\"},\"type\":\"n8n-nodes-base.code\",\"typeVersion\":2,\"position\":[448,0],\"id\":\"dde6d41d-3212-4371-83b3-b58d0d70c21e\",\"name\":\"Compute Grid Hysteresis\"},{\"parameters\":{\"jsCode\":\"const root = $input.first().json || {};\\nconst decision = root.decision || {};\\nconst calls = root.ha_service_calls || {};\\nconst actions = [];\\n\\nfunction pushAction(action, call, enabled = true) {\\n if (!enabled) return;\\n if (!call) return;\\n\\n actions.push({\\n json: {\\n action,\\n ...call\\n }\\n });\\n}\\n\\n// ======================================================\\n// HELPERS TOUJOURS MIS À JOUR\\n// ======================================================\\npushAction('helper_state', calls.helper_state, true);\\npushAction('helper_reason', calls.helper_reason, true);\\n\\n// ======================================================\\n// SIGNATURE SI CHANGEMENT\\n// ======================================================\\nif (decision.signature_changed) {\\n pushAction('helper_signature', calls.helper_signature, true);\\n}\\n\\n// ======================================================\\n// SÉQUENCEMENT ACTIONS RÉSEAU / LATCH\\n//\\n// IMPORTANT :\\n// - pour ALLUMER : latch ON d'abord, puis grid ON\\n// - pour ÉTEINDRE : grid OFF d'abord, puis latch OFF\\n// ======================================================\\n\\n// ---------- CAS 1 : LATCH ON avant GRID ON ----------\\nif (decision.latch_action === 'turn_on') {\\n pushAction('helper_latch', calls.helper_latch, true);\\n}\\n\\nif (decision.grid_action === 'turn_on') {\\n pushAction('grid', calls.grid, true);\\n}\\n\\n// ---------- CAS 2 : GRID OFF avant LATCH OFF ----------\\nif (decision.grid_action === 'turn_off') {\\n pushAction('grid', calls.grid, true);\\n}\\n\\nif (decision.latch_action === 'turn_off') {\\n pushAction('helper_latch', calls.helper_latch, true);\\n}\\n\\n// ======================================================\\n// TIMESTAMPS SI NÉCESSAIRE\\n// ======================================================\\nif (decision.set_last_on_at) {\\n pushAction('helper_last_on_at', calls.helper_last_on_at, true);\\n}\\n\\nif (decision.set_last_off_at) {\\n pushAction('helper_last_off_at', calls.helper_last_off_at, true);\\n}\\n\\n// ======================================================\\n// LOGBOOK SI CHANGEMENT DE SIGNATURE\\n// ======================================================\\nif (decision.signature_changed) {\\n pushAction('logbook', calls.logbook, true);\\n}\\n\\n// ======================================================\\n// NOTIFICATION SI UTILE\\n// ======================================================\\nif (decision.should_notify) {\\n pushAction('mobile_notification', calls.mobile_notification, true);\\n}\\n\\nreturn actions;\\n\"},\"type\":\"n8n-nodes-base.code\",\"typeVersion\":2,\"position\":[672,0],\"id\":\"d3728055-9f5b-4362-a275-247cc91c5f41\",\"name\":\"Build Grid Hysteresis Actions\"},{\"parameters\":{\"method\":\"POST\",\"url\":\"={{ 'http://10.10.10.17:8123/api/services/' + $json.domain + '/' + $json.service }}\\n\",\"authentication\":\"genericCredentialType\",\"genericAuthType\":\"httpBearerAuth\",\"sendHeaders\":true,\"headerParameters\":{\"parameters\":[{\"name\":\"Content-Type\",\"value\":\"application/json\"},{\"name\":\"accept\",\"value\":\"application/json\"}]},\"sendBody\":true,\"specifyBody\":\"json\",\"jsonBody\":\"={{ $json.data }}\",\"options\":{}},\"id\":\"7d38f7cf-ad84-4481-a201-d21dd0dce321\",\"name\":\"Execute HA Service\",\"type\":\"n8n-nodes-base.httpRequest\",\"typeVersion\":4.2,\"position\":[896,0],\"alwaysOutputData\":true,\"credentials\":{\"httpBearerAuth\":{\"id\":\"uv3sknYYigxBkaVN\",\"name\":\"Bearer Auth account 2\"}}}],\"connections\":{\"Schedule Trigger\":{\"main\":[[{\"node\":\"HA Snapshot Grid Hysteresis\",\"type\":\"main\",\"index\":0}]]},\"HA Snapshot Grid Hysteresis\":{\"main\":[[{\"node\":\"Compute Grid Hysteresis\",\"type\":\"main\",\"index\":0}]]},\"Compute Grid Hysteresis\":{\"main\":[[{\"node\":\"Build Grid Hysteresis Actions\",\"type\":\"main\",\"index\":0}]]},\"Build Grid Hysteresis Actions\":{\"main\":[[{\"node\":\"Execute HA Service\",\"type\":\"main\",\"index\":0}]]}},\"authors\":\"thierry tallagrand\",\"name\":null,\"description\":null,\"autosaved\":false,\"workflowPublishHistory\":[{\"createdAt\":\"2026-05-19T02:20:56.366Z\",\"id\":104,\"workflowId\":\"7iIlu7LLTW7TZA8o\",\"versionId\":\"ef4b5fc8-b615-4c56-ba79-3d45f908bf1a\",\"event\":\"activated\",\"userId\":\"8b9e9552-cd74-4863-82a3-174b3b3befa2\"}]},\"scopes\":[\"workflow:create\",\"workflow:delete\",\"workflow:execute\",\"workflow:execute-chat\",\"workflow:list\",\"workflow:move\",\"workflow:publish\",\"workflow:read\",\"workflow:share\",\"workflow:unpublish\",\"workflow:unshare\",\"workflow:update\",\"workflow:updateRedactionSetting\"],\"checksum\":\"4bc47d54982a3111e5114d518386d677777281bc0069572bde1f60e7503ca7c1\"}}\nHTTP_CODE:200\n",
+ "exit": 0,
+ "description": "Activate Grid Hysteresis with versionId in body",
+ "truncated": true,
+ "outputPath": "/home/tmzf5455/.local/share/opencode/tool-output/tool_e3e0948c1002sJ1RfQKovnD1O8"
+ },
+ "title": "Activate Grid Hysteresis with versionId in body",
+ "time": {
+ "start": 1779157256296,
+ "end": 1779157256389
+ }
+ },
+ "metadata": {
+ "openrouter": {
+ "reasoning_details": [
+ {
+ "type": "reasoning.text",
+ "text": "Excellent ! EVCS est maintenant actif. Essayons la même chose pour Grid Hysteresis.",
+ "format": "unknown",
+ "index": 0
+ }
+ ]
+ }
+ },
+ "id": "prt_e3e093c12001A3dtKy6YILaZYF",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e092b15001z2Uvb8T1xia7q5"
+ },
+ {
+ "reason": "tool-calls",
+ "type": "step-finish",
+ "tokens": {
+ "total": 52733,
+ "input": 159,
+ "output": 136,
+ "reasoning": 22,
+ "cache": {
+ "write": 0,
+ "read": 52416
+ }
+ },
+ "cost": 0.01377149,
+ "id": "prt_e3e0948c8001B99yG740QgrBMf",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e092b15001z2Uvb8T1xia7q5"
+ }
+ ]
+ },
+ {
+ "info": {
+ "parentID": "msg_e3e044477001ADKhSz2Jx6n3An",
+ "role": "assistant",
+ "mode": "build",
+ "agent": "build",
+ "path": {
+ "cwd": "/mnt/projects/EVCS/EVCS",
+ "root": "/mnt/projects/EVCS/EVCS"
+ },
+ "cost": 0.0137459,
+ "tokens": {
+ "total": 52922,
+ "input": 257,
+ "output": 106,
+ "reasoning": 15,
+ "cache": {
+ "write": 0,
+ "read": 52544
+ }
+ },
+ "modelID": "moonshotai/kimi-k2.6",
+ "providerID": "openrouter",
+ "time": {
+ "created": 1779157256398,
+ "completed": 1779157263918
+ },
+ "finish": "tool-calls",
+ "id": "msg_e3e0948ce001deZUVfzQhyL0is",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F"
+ },
+ "parts": [
+ {
+ "type": "step-start",
+ "id": "prt_e3e09554e001maJN3TlX8v1kK1",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e0948ce001deZUVfzQhyL0is"
+ },
+ {
+ "type": "reasoning",
+ "text": "Vérifions que Grid Hysteresis est maintenant actif aussi.",
+ "time": {
+ "start": 1779157259600,
+ "end": 1779157263890
+ },
+ "metadata": {
+ "openrouter": {
+ "reasoning_details": [
+ {
+ "type": "reasoning.text",
+ "text": "Vérifions que Grid Hysteresis est maintenant actif aussi.",
+ "format": "unknown",
+ "index": 0
+ }
+ ]
+ }
+ },
+ "id": "prt_e3e095550001EAfRvueBY0NDIg",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e0948ce001deZUVfzQhyL0is"
+ },
+ {
+ "type": "tool",
+ "tool": "bash",
+ "callID": "functions.bash:123",
+ "state": {
+ "status": "completed",
+ "input": {
+ "command": "curl -s -b /tmp/n8n_cookies.txt http://10.10.10.17:5678/rest/workflows/7iIlu7LLTW7TZA8o | node -e \"const d=require('fs').readFileSync(0,'utf8'); const j=JSON.parse(d).data; console.log('active:', j.active);\"",
+ "description": "Verify Grid Hysteresis activation status"
+ },
+ "output": "active: true\n",
+ "metadata": {
+ "output": "active: true\n",
+ "exit": 0,
+ "description": "Verify Grid Hysteresis activation status",
+ "truncated": false
+ },
+ "title": "Verify Grid Hysteresis activation status",
+ "time": {
+ "start": 1779157263910,
+ "end": 1779157263914
+ }
+ },
+ "metadata": {
+ "openrouter": {
+ "reasoning_details": [
+ {
+ "type": "reasoning.text",
+ "text": "Vérifions que Grid Hysteresis est maintenant actif aussi.",
+ "format": "unknown",
+ "index": 0
+ }
+ ]
+ }
+ },
+ "id": "prt_e3e095d7c001IDcsHJJVhpltJg",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e0948ce001deZUVfzQhyL0is"
+ },
+ {
+ "reason": "tool-calls",
+ "type": "step-finish",
+ "tokens": {
+ "total": 52922,
+ "input": 257,
+ "output": 106,
+ "reasoning": 15,
+ "cache": {
+ "write": 0,
+ "read": 52544
+ }
+ },
+ "cost": 0.0137459,
+ "id": "prt_e3e09662c001Gr6xVE4F6KMzHW",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e0948ce001deZUVfzQhyL0is"
+ }
+ ]
+ },
+ {
+ "info": {
+ "parentID": "msg_e3e044477001ADKhSz2Jx6n3An",
+ "role": "assistant",
+ "mode": "build",
+ "agent": "build",
+ "path": {
+ "cwd": "/mnt/projects/EVCS/EVCS",
+ "root": "/mnt/projects/EVCS/EVCS"
+ },
+ "cost": 0.01542355,
+ "tokens": {
+ "total": 53551,
+ "input": 144,
+ "output": 568,
+ "reasoning": 39,
+ "cache": {
+ "write": 0,
+ "read": 52800
+ }
+ },
+ "modelID": "moonshotai/kimi-k2.6",
+ "providerID": "openrouter",
+ "time": {
+ "created": 1779157263924,
+ "completed": 1779157287662
+ },
+ "finish": "stop",
+ "id": "msg_e3e096634001Z0nBxJfjUplPRs",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F"
+ },
+ "parts": [
+ {
+ "type": "step-start",
+ "id": "prt_e3e09711e00105sSR1xa05551u",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e096634001Z0nBxJfjUplPRs"
+ },
+ {
+ "type": "reasoning",
+ "text": "Parfait ! Les deux workflows sont maintenant actifs et patchés. Résumons tout ce qu'on a fait et demandons à l'utilisateur de réactiver la MFA.",
+ "time": {
+ "start": 1779157266720,
+ "end": 1779157269037
+ },
+ "metadata": {
+ "openrouter": {
+ "reasoning_details": [
+ {
+ "type": "reasoning.text",
+ "text": "Parfait ! Les deux workflows sont maintenant actifs et patchés. Résumons tout ce qu'on a fait et demandons à l'utilisateur de réactiver la MFA.",
+ "format": "unknown",
+ "index": 0
+ }
+ ]
+ }
+ },
+ "id": "prt_e3e0971200018xV0e05S5VA20o",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e096634001Z0nBxJfjUplPRs"
+ },
+ {
+ "type": "text",
+ "text": "C'est bon, tout est en ligne ! 🎉\n\nVoici le récapitulatif des actions réalisées :\n\n### 1. Connexion N8N\n- MFA désactivée temporairement → login réussi → token récupéré.\n\n### 2. Patch du workflow **EVCS Surplus Controller V2.5**\n- **WINDOWS_SOURCEV2** :\n - `outside_window` + `auto_solar_plus` → autorise `solar_potential` quand SOC ≥ 85 % et peu de charge maison (provocation PV pour éviter l'injection).\n - `force_grid_now` reste autorisé hors fenêtre.\n - `force_charge_now` ne demande jamais implicitement le réseau ; s'il n'y a pas de soleil ni batterie, il reste en attente locale.\n- **BATTERY_CLOUD_LOCKS** :\n - Garde SOC ajoutée : `gridStart` et `gridHold` sont bloqués si SOC > 35 %, sauf si `force_grid_now` est actif. Empêche le réseau de s'allumer à tort à 70 % SOC.\n\n### 3. Patch du workflow **Grid Hysteresis**\n- **HA Snapshot** : ajout de `evcs_surplus_state` dans le payload pour que l'état de l'EVCS soit visible par Hysteresis.\n- **Compute Grid Hysteresis** :\n - Si `force_grid_now` est actif et que la voiture est pleine (`evcsVehicleNoDemandSemantic`), le relais EDF coupe **immédiatement** sans attendre la `minOnRunTime`.\n\n### 4. Réactivation\n- Les deux workflows ont été repoussés vers N8N via l'API REST.\n- EVCS V2.5 : **actif**\n- Grid Hysteresis : **actif**\n\n---\n\n### ⚠️ Action immédiate recommandée\nPenses à **réactiver la MFA** dans ton N8N dès maintenant (Settings → Personal Settings → Multi-Factor Authentication) pour ne pas laisser l'instance exposée.\n\nTu peux vérifier dès le prochain cycle que :\n- Le réseau ne s'allume plus quand le SOC est > 35 % sans `force_grid_now`.\n- `force_grid_now` coupe le réseau immédiatement si la voiture est pleine.\n- `auto_solar_plus` joue le rôle de \"provocation PV\" quand la batterie est haute.\n\nTu as besoin d'autre chose sur ces workflows ?",
+ "time": {
+ "start": 1779157269038,
+ "end": 1779157287656
+ },
+ "id": "prt_e3e097a2e002mwNQ6ZUsPXOolK",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e096634001Z0nBxJfjUplPRs"
+ },
+ {
+ "reason": "stop",
+ "type": "step-finish",
+ "tokens": {
+ "total": 53551,
+ "input": 144,
+ "output": 568,
+ "reasoning": 39,
+ "cache": {
+ "write": 0,
+ "read": 52800
+ }
+ },
+ "cost": 0.01542355,
+ "id": "prt_e3e09c2eb0016v0op6Jd4NkRh4",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e096634001Z0nBxJfjUplPRs"
+ }
+ ]
+ },
+ {
+ "info": {
+ "role": "user",
+ "time": {
+ "created": 1779162805507
+ },
+ "agent": "build",
+ "model": {
+ "providerID": "openrouter",
+ "modelID": "moonshotai/kimi-k2.6"
+ },
+ "summary": {
+ "diffs": []
+ },
+ "id": "msg_e3e5df4a2001z2kFhDinGGizgC",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F"
+ },
+ "parts": [
+ {
+ "type": "text",
+ "text": "je propose te tester sur au moins 24h et ensuite corriger si besoin avant d'attaquer la phase 2",
+ "id": "prt_e3e5df4a2002vHKi0OmLuLmUVE",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e5df4a2001z2kFhDinGGizgC"
+ }
+ ]
+ },
+ {
+ "info": {
+ "parentID": "msg_e3e5df4a2001z2kFhDinGGizgC",
+ "role": "assistant",
+ "mode": "build",
+ "agent": "build",
+ "path": {
+ "cwd": "/mnt/projects/EVCS/EVCS",
+ "root": "/mnt/projects/EVCS/EVCS"
+ },
+ "cost": 0.03846697,
+ "tokens": {
+ "total": 51361,
+ "input": 48529,
+ "output": 555,
+ "reasoning": 165,
+ "cache": {
+ "write": 0,
+ "read": 2112
+ }
+ },
+ "modelID": "moonshotai/kimi-k2.6",
+ "providerID": "openrouter",
+ "time": {
+ "created": 1779162805518,
+ "completed": 1779162849490
+ },
+ "finish": "stop",
+ "id": "msg_e3e5df50e001pCYiXxEOlNpAGF",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F"
+ },
+ "parts": [
+ {
+ "type": "step-start",
+ "id": "prt_e3e5e1b96001pY0TfwgKD5niJv",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e5df50e001pCYiXxEOlNpAGF"
+ },
+ {
+ "type": "reasoning",
+ "text": " L'utilisateur me propose un plan de test de 24h avant de passer à la phase 2 (routine task list). C'est un bon plan prudent. Je dois me mettre d'accord avec lui sur ce plan.\n\nJe devrais :\n1. Confirmer que c'est une excellente approche\n2. Réactiver la MFA pour sécuriser son instance\n3. Documenter ce qu'il faut surveiller pendant les 24h\n4. Préparer un template de rapport pour le feedback\n\nJe ne dois PAS faire plus de modifications à N8N pour l'instant. Je dois juste confirmer le plan et peut-être préparer les éléments de suivi.\n\nIl n'y a pas de tools requis ici, c'est une discussion/confirmation.",
+ "time": {
+ "start": 1779162815383,
+ "end": 1779162828182
+ },
+ "metadata": {
+ "openrouter": {
+ "reasoning_details": [
+ {
+ "type": "reasoning.text",
+ "text": " L'utilisateur me propose un plan de test de 24h avant de passer à la phase 2 (routine task list). C'est un bon plan prudent. Je dois me mettre d'accord avec lui sur ce plan.\n\nJe devrais :\n1. Confirmer que c'est une excellente approche\n2. Réactiver la MFA pour sécuriser son instance\n3. Documenter ce qu'il faut surveiller pendant les 24h\n4. Préparer un template de rapport pour le feedback\n\nJe ne dois PAS faire plus de modifications à N8N pour l'instant. Je dois juste confirmer le plan et peut-être préparer les éléments de suivi.\n\nIl n'y a pas de tools requis ici, c'est une discussion/confirmation.",
+ "format": "unknown",
+ "index": 0
+ }
+ ]
+ }
+ },
+ "id": "prt_e3e5e1b97001WQtv4IZYW3mwAa",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e5df50e001pCYiXxEOlNpAGF"
+ },
+ {
+ "type": "text",
+ "text": " Excellente approche. Voici le plan sur lequel je te propose qu'on se calle :\n\n### ✅ Ce qu'on a fait\nLes 2 workflows sont patchés et actifs.\n\n### ⚠️ Action maintenant\n1. **Réactive la MFA** dans N8N (Settings → Personal Settings → Multi-Factor Authentication). \n L'instance est actuellement exposée.\n\n### 📋 Ce qu'il faut surveiller pendant 24h\nVoici les scénarios à vérifier. Tu peux les noter et me faire un retour demain :\n\n| # | Scénario | Attendu | Si ça marche pas |\n|---|----------|---------|------------------|\n| 1 | Matin, SOC à 70%, pas de véhicule | Grid reste OFF | Note l'heure et le state dans `input_text.victron_grid_control_state` |\n| 2 | Tu passes en `force_grid_now` | Grid s'allume immédiatement | Vérifie si state = `turn_on_evcs_override` |\n| 3 | Tu restes en `force_grid_now` et la voiture devient pleine | Grid s'éteint immédiatement | Vérifie le state et le logbook |\n| 4 | Tu passes en `force_charge_now`, pas de soleil | EVCS reste en attente local (pas de grid) | Vérifie `source_mode` = `solar_potential` |\n| 5 | Tu passes en `auto_solar_plus`, SOC ≥ 85%, peu de charge | EVCS passe en `solar_potential` | Vérifie que l'EVCS essaie de provoquer le PV |\n| 6 | Fenêtre HC midi, pas de soleil, SOC < 22% | Grid s'allume normalement | Vérifie le state |\n| 7 | Fenêtre HC midi, pas de soleil, SOC > 35% | Grid reste OFF sauf si `force_grid_now` | Vérifie le state |\n\n### 📝 Demain : ton retour\nTu me dis ce qui a marché / pas marché, avec si possible les valeurs de state loguées dans Home Assistant. Je corrige en fonction, puis on passe à la **Phase 2** (c'est-à-dire les prochains chantiers qu'on a identifiés).\n\nÇa te va comme plan ? Tu réactives la MFA et on se retrouve demain pour le bilan ?",
+ "time": {
+ "start": 1779162828184,
+ "end": 1779162849483
+ },
+ "id": "prt_e3e5e4d98001wafm4Js5ZSV0an",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e5df50e001pCYiXxEOlNpAGF"
+ },
+ {
+ "reason": "stop",
+ "type": "step-finish",
+ "tokens": {
+ "total": 51361,
+ "input": 48529,
+ "output": 555,
+ "reasoning": 165,
+ "cache": {
+ "write": 0,
+ "read": 2112
+ }
+ },
+ "cost": 0.03846697,
+ "id": "prt_e3e5ea0cf001jQcu989XnLYbo9",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e5df50e001pCYiXxEOlNpAGF"
+ }
+ ]
+ },
+ {
+ "info": {
+ "role": "user",
+ "time": {
+ "created": 1779163190852
+ },
+ "agent": "build",
+ "model": {
+ "providerID": "openrouter",
+ "modelID": "moonshotai/kimi-k2.6"
+ },
+ "summary": {
+ "diffs": []
+ },
+ "id": "msg_e3e63d5e6001I2ipWQGflyy8GJ",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F"
+ },
+ "parts": [
+ {
+ "type": "text",
+ "text": "j'ai réactivé le MFA et on se retrouve demain pour la suite",
+ "id": "prt_e3e63d5e7001eAOxhBC8h1BMpu",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e63d5e6001I2ipWQGflyy8GJ"
+ }
+ ]
+ },
+ {
+ "info": {
+ "parentID": "msg_e3e63d5e6001I2ipWQGflyy8GJ",
+ "role": "assistant",
+ "mode": "build",
+ "agent": "build",
+ "path": {
+ "cwd": "/mnt/projects/EVCS/EVCS",
+ "root": "/mnt/projects/EVCS/EVCS"
+ },
+ "cost": 0.03734138,
+ "tokens": {
+ "total": 51494,
+ "input": 49105,
+ "output": 202,
+ "reasoning": 75,
+ "cache": {
+ "write": 0,
+ "read": 2112
+ }
+ },
+ "modelID": "moonshotai/kimi-k2.6",
+ "providerID": "openrouter",
+ "time": {
+ "created": 1779163190862,
+ "completed": 1779163203957
+ },
+ "finish": "stop",
+ "id": "msg_e3e63d64e001EOxqFU6s2nVWr8",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F"
+ },
+ "parts": [
+ {
+ "type": "step-start",
+ "id": "prt_e3e63ed7f001gtbYWgd3XHEcR8",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e63d64e001EOxqFU6s2nVWr8"
+ },
+ {
+ "type": "reasoning",
+ "text": " L'utilisateur a confirmé qu'il a réactivé la MFA et qu'on se retrouve demain. Je dois juste confirmer la fin de la session et résumer ce qui est en place pour demain.\n\nC'est une conversation en français, donc je réponds en français.\n\nJe dois être concis et clair.",
+ "time": {
+ "start": 1779163196801,
+ "end": 1779163198723
+ },
+ "metadata": {
+ "openrouter": {
+ "reasoning_details": [
+ {
+ "type": "reasoning.text",
+ "text": " L'utilisateur a confirmé qu'il a réactivé la MFA et qu'on se retrouve demain. Je dois juste confirmer la fin de la session et résumer ce qui est en place pour demain.\n\nC'est une conversation en français, donc je réponds en français.\n\nJe dois être concis et clair.",
+ "format": "unknown",
+ "index": 0
+ }
+ ]
+ }
+ },
+ "id": "prt_e3e63ed81001bdZaRYvEQRVkQ4",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e63d64e001EOxqFU6s2nVWr8"
+ },
+ {
+ "type": "text",
+ "text": " Parfait. Résumé de ce qu'on laisse en place pour demain :\n\n**Workflows actifs et patchés :**\n- **EVCS V2.5** : garde SOC 35 % sur le grid, `auto_solar_plus` provoque le PV hors fenêtre, `force_charge_now` reste local.\n- **Grid Hysteresis** : coupure immédiate du grid si `force_grid_now` + voiture pleine.\n\n**Ton job :**\nSurveiller les 7 scénarios qu'on a listés et me donner les valeurs de `victron_grid_control_state` / `victron_evcs_source_mode` si un comportement est inattendu.\n\n**MFA :** ✅ Sécurisé.\n\nOn se retrouve demain pour le bilan et les corrections éventuelles avant la Phase 2. Bonne journée et bon test ! 🚗☀️",
+ "time": {
+ "start": 1779163198724,
+ "end": 1779163203952
+ },
+ "id": "prt_e3e63f504001mrL1piozU1I5ek",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e63d64e001EOxqFU6s2nVWr8"
+ },
+ {
+ "reason": "stop",
+ "type": "step-finish",
+ "tokens": {
+ "total": 51494,
+ "input": 49105,
+ "output": 202,
+ "reasoning": 75,
+ "cache": {
+ "write": 0,
+ "read": 2112
+ }
+ },
+ "cost": 0.03734138,
+ "id": "prt_e3e640973001Ak057NAE9BbYVc",
+ "sessionID": "ses_1c28291d4ffeMMHtlPwS1L350F",
+ "messageID": "msg_e3e63d64e001EOxqFU6s2nVWr8"
+ }
+ ]
+ }
+ ]
+}
diff --git a/modified/BATTERY_CLOUD_LOCKS_patched.js b/modified/BATTERY_CLOUD_LOCKS_patched.js
new file mode 100644
index 0000000..440ce19
--- /dev/null
+++ b/modified/BATTERY_CLOUD_LOCKS_patched.js
@@ -0,0 +1,955 @@
+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
+// =====================================================
+const nightSessionActive =
+ context.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;
+
+const batteryMorningTargetPct =
+ parsed.todayRemainingKwh >= params.evcsNightStrongForecastKwh
+ ? params.evcsNightTargetStrongForecastPct
+ : parsed.todayRemainingKwh >= 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 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(lifecycle.minutesUntilDeparture) &&
+ lifecycle.minutesUntilDeparture >= 0 &&
+ (
+ batteryRecoveryMinutesNeeded + params.evcsNightDepartureSafetyMin
+ >= lifecycle.minutesUntilDeparture
+ );
+
+const batteryBelowHardFloor =
+ parsed.soc <= batteryHardFloorPct;
+
+const batteryBelowMorningTarget =
+ parsed.soc < batteryMorningTargetPct;
+
+const batteryAtOrBelowGridTakeoverTrigger =
+ parsed.soc <= gridTakeoverTriggerPct;
+
+const batteryAssistAllowed =
+ nightSessionActive &&
+ !batteryBelowHardFloor &&
+ !batteryRecoveryUrgent &&
+ parsed.soc > gridTakeoverTriggerPct;
+
+const nightGridTakeoverNeeded =
+ nightSessionActive &&
+ (
+ batteryBelowHardFloor ||
+ batteryAtOrBelowGridTakeoverTrigger ||
+ batteryRecoveryUrgent
+ );
+
+const nightPreferredSourceMode =
+ !nightSessionActive
+ ? '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;
+
+// =====================================================
+// 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;
+
+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 >= profileSocStartFloor &&
+ 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 = parsed.soc <= 35 || strategy.forceGridRequested;
+
+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);
+
+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,
+ nightPreferredSourceMode,
+ },
+
+ safeguards: {
+ isGridSource,
+ commonSafetyStartOk,
+ commonSafetyHoldOk,
+ profileSocStartFloor,
+ 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/modified/EVCS_V2.5_PATCHED.json b/modified/EVCS_V2.5_PATCHED.json
new file mode 100644
index 0000000..030bbcc
--- /dev/null
+++ b/modified/EVCS_V2.5_PATCHED.json
@@ -0,0 +1,615 @@
+{
+ "updatedAt": "2026-05-06T13:46:20.942Z",
+ "createdAt": "2026-04-12T02:09:30.372Z",
+ "id": "ATswvGfG4KJUls0K",
+ "name": "HA Snapshot EVCS Surplus Controller V2.5",
+ "description": null,
+ "active": true,
+ "isArchived": false,
+ "nodes": [
+ {
+ "parameters": {
+ "rule": {
+ "interval": [
+ {
+ "field": "seconds"
+ }
+ ]
+ }
+ },
+ "type": "n8n-nodes-base.scheduleTrigger",
+ "typeVersion": 1.3,
+ "position": [
+ -624,
+ 32
+ ],
+ "id": "eb8c183b-a99e-40e5-8b34-3b727b097027",
+ "name": "Schedule Trigger"
+ },
+ {
+ "parameters": {
+ "method": "POST",
+ "url": "={{ 'http://10.10.10.17:8123/api/services/' + $json.domain + '/' + $json.service }}\n",
+ "authentication": "genericCredentialType",
+ "genericAuthType": "httpBearerAuth",
+ "sendHeaders": true,
+ "headerParameters": {
+ "parameters": [
+ {
+ "name": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "name": "accept",
+ "value": "application/json"
+ }
+ ]
+ },
+ "sendBody": true,
+ "specifyBody": "json",
+ "jsonBody": "={{ $json.data }}",
+ "options": {}
+ },
+ "id": "eaaba434-a4d1-4ac7-8248-28980a0308d1",
+ "name": "Execute HA Service EVCS Surplus",
+ "type": "n8n-nodes-base.httpRequest",
+ "typeVersion": 4.2,
+ "position": [
+ 1168,
+ 32
+ ],
+ "alwaysOutputData": true,
+ "credentials": {
+ "httpBearerAuth": {
+ "id": "uv3sknYYigxBkaVN",
+ "name": "Bearer Auth account 2"
+ }
+ }
+ },
+ {
+ "parameters": {
+ "jsCode": "const root = $input.first().json || {};\nconst decision = root.decision || {};\nconst calls = root.ha_service_calls || {};\nconst actions = [];\n\nfunction pushAction(action, call, enabled = true) {\n if (!enabled) return;\n if (!call) return;\n if (call.execute === false) return;\n\n const { execute, ...rest } = call;\n\n actions.push({\n json: {\n action,\n ...rest\n }\n });\n}\n\n// =====================================================\n// HELPERS TOUJOURS MIS À JOUR\n// =====================================================\n[\n 'helper_state',\n 'helper_current_decision',\n 'helper_reason',\n 'helper_operating_mode',\n 'helper_source_mode',\n 'helper_target_current',\n 'helper_budget_remaining',\n 'helper_projected_soc',\n 'helper_cloud_debt',\n 'helper_dynamic_ok',\n 'helper_must_stop',\n 'helper_evcs_priority_active',\n 'helper_energy_priority_mode',\n 'helper_energy_priority_reason',\n 'helper_evcs_priority_until',\n\n // ===================================================\n // NIGHT EVCS HELPERS\n // ===================================================\n 'helper_night_target_soc',\n 'helper_night_grid_trigger_soc',\n 'helper_night_preferred_source_mode',\n 'helper_night_grid_takeover_needed',\n 'helper_night_battery_recovery_urgent',\n 'helper_night_session_active',\n\n // ===================================================\n // VEHICLE DEMAND FILTER HISTORY HELPERS\n // ===================================================\n 'helper_evcs_delivery_ratio_ema',\n 'helper_evcs_delivery_noise_ema',\n 'helper_evcs_no_demand_score_ema',\n 'helper_evcs_no_demand_semantic_active',\n].forEach(name => pushAction(name, calls[name], true));\n\n// =====================================================\n// HELPERS / TIMESTAMPS CONDITIONNELS\n// =====================================================\npushAction(\n 'helper_cloud_debt_started_at',\n calls.helper_cloud_debt_started_at,\n !!decision.set_cloud_debt_started_at\n);\n\npushAction(\n 'helper_block_reason',\n calls.helper_block_reason,\n !!decision.set_block_reason\n);\n\npushAction(\n 'helper_blocked_until',\n calls.helper_blocked_until,\n !!decision.set_blocked_until\n);\n\npushAction(\n 'helper_probe_started_at',\n calls.helper_probe_started_at,\n !!decision.set_probe_started_at\n);\n\n// =====================================================\n// NIGHT SESSION MEMORY\n// =====================================================\npushAction(\n 'helper_night_session_start_soc',\n calls.helper_night_session_start_soc,\n !!decision.set_night_session_start || !!decision.clear_night_session_start\n);\n\npushAction(\n 'helper_night_session_start_at',\n calls.helper_night_session_start_at,\n !!decision.set_night_session_start || !!decision.clear_night_session_start\n);\n\n// =====================================================\n// VEHICLE DEMAND FILTER TIMESTAMPS\n// =====================================================\npushAction(\n 'helper_evcs_no_demand_since_at',\n calls.helper_evcs_no_demand_since_at,\n !!calls.helper_evcs_no_demand_since_at?.execute\n);\n\npushAction(\n 'helper_evcs_no_demand_filter_eval_at',\n calls.helper_evcs_no_demand_filter_eval_at,\n !!calls.helper_evcs_no_demand_filter_eval_at?.execute\n);\n\n// =====================================================\n// DEMANDE RÉSEAU EDF SI SOURCE GRID\n// =====================================================\npushAction(\n 'edf_general_ok_on',\n calls.edf_general_ok_on,\n true\n);\n\n// =====================================================\n// AUTO START OFF SI BESOIN\n// =====================================================\npushAction(\n 'evcs_auto_start_off',\n calls.evcs_auto_start_off,\n decision.auto_start_action === 'turn_off'\n);\n\n// =====================================================\n// SI ON ARRÊTE : CHARGING OFF D'ABORD\n// =====================================================\npushAction(\n 'evcs_charging',\n calls.evcs_charging,\n decision.charging_action === 'turn_off'\n);\n\n// =====================================================\n// PERMISSION / LATCH\n// =====================================================\npushAction(\n 'helper_permission',\n calls.helper_permission,\n decision.permission_action !== 'none'\n);\n\npushAction(\n 'helper_latch',\n calls.helper_latch,\n decision.latch_action !== 'none'\n);\n\n// =====================================================\n// COURANT SI NÉCESSAIRE\n// =====================================================\npushAction(\n 'evcs_set_current',\n calls.evcs_set_current,\n decision.current_action === 'set'\n);\n\npushAction(\n 'helper_last_current_change_at',\n calls.helper_last_current_change_at,\n !!decision.set_last_current_change_at\n);\n\n// =====================================================\n// SI ON DÉMARRE : CHARGING ON À LA FIN\n// =====================================================\npushAction(\n 'evcs_charging',\n calls.evcs_charging,\n decision.charging_action === 'turn_on'\n);\n\n// =====================================================\n// TIMESTAMPS START / STOP\n// =====================================================\npushAction(\n 'helper_last_start_at',\n calls.helper_last_start_at,\n !!decision.set_last_start_at\n);\n\npushAction(\n 'helper_last_stop_at',\n calls.helper_last_stop_at,\n !!decision.set_last_stop_at\n);\n\n// =====================================================\n// SIGNATURE + LOGBOOK SI CHANGEMENT\n// =====================================================\npushAction(\n 'helper_signature',\n calls.helper_signature,\n !!decision.signature_changed\n);\n\npushAction(\n 'logbook',\n calls.logbook,\n !!decision.signature_changed\n);\n\n// =====================================================\n// NOTIFICATIONS\n// =====================================================\npushAction(\n 'mobile_notification',\n calls.mobile_notification,\n !!decision.should_notify\n);\n\nreturn actions;\n"
+ },
+ "type": "n8n-nodes-base.code",
+ "typeVersion": 2,
+ "position": [
+ 944,
+ 32
+ ],
+ "id": "9c99f070-66ae-4e9e-aa82-340ae14f2d71",
+ "name": "Build EVCS Surplus Controller V2.0 Actions"
+ },
+ {
+ "parameters": {
+ "jsCode": "function clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nfunction to2(n) {\n return String(n).padStart(2, '0');\n}\n\nfunction getParisParts(ms = Date.now()) {\n const fmt = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris',\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n fmt.formatToParts(new Date(ms))\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour);\n const date = `${parts.year}-${parts.month}-${parts.day}`;\n\n return {\n date,\n hour,\n minute: Number(parts.minute),\n second: Number(parts.second),\n };\n}\n\nfunction getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') {\n const dtf = new Intl.DateTimeFormat('en-US', {\n timeZone,\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n dtf.formatToParts(new Date(utcTs))\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const asIfUtc = Date.UTC(\n Number(parts.year),\n Number(parts.month) - 1,\n Number(parts.day),\n Number(parts.hour) === 24 ? 0 : Number(parts.hour),\n Number(parts.minute),\n Number(parts.second)\n );\n\n const d = new Date(utcTs);\n const actualUtc = Date.UTC(\n d.getUTCFullYear(),\n d.getUTCMonth(),\n d.getUTCDate(),\n d.getUTCHours(),\n d.getUTCMinutes(),\n d.getUTCSeconds()\n );\n\n return asIfUtc - actualUtc;\n}\n\nfunction parseParisStampToMs(s) {\n const text = (s ?? '').toString().trim();\n const m = text.match(/^(\\d{4})-(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2})$/);\n if (!m) return null;\n\n const y = Number(m[1]);\n const mo = Number(m[2]);\n const d = Number(m[3]);\n const h = Number(m[4]);\n const mi = Number(m[5]);\n const se = Number(m[6]);\n\n const guessUtc = Date.UTC(y, mo - 1, d, h, mi, se);\n const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris');\n let ts = guessUtc - off1;\n const off2 = getTzOffsetMs(ts, 'Europe/Paris');\n\n if (off2 !== off1) ts = guessUtc - off2;\n return ts;\n}\n\nfunction hhmmssFromMinutes(minOfDay) {\n const mm = ((minOfDay % 1440) + 1440) % 1440;\n const hh = Math.floor(mm / 60);\n const mi = mm % 60;\n return `${to2(hh)}:${to2(mi)}:00`;\n}\n\nfunction nextDailyOccurrenceMs(startMin, refMs) {\n if (!Number.isFinite(startMin)) return null;\n\n const today = getParisParts(refMs).date;\n const todayStamp = `${today} ${hhmmssFromMinutes(startMin)}`;\n const todayMs = parseParisStampToMs(todayStamp);\n\n if (todayMs !== null && todayMs > refMs + 1000) return todayMs;\n\n const tomorrow = getParisParts(refMs + 36 * 3600 * 1000).date;\n const tomorrowStamp = `${tomorrow} ${hhmmssFromMinutes(startMin)}`;\n return parseParisStampToMs(tomorrowStamp);\n}\n\nconst prev = $input.first().json;\n\nconst {\n nowMs,\n cfg,\n parsed,\n params,\n history,\n batteryTune,\n strategy,\n derived,\n context,\n lifecycle,\n} = prev;\n\n// =====================================================\n// SESSION / SOURCE\n// =====================================================\nconst sessionObservedActive =\n parsed.evcsChargingSwitchOn ||\n parsed.controlLatch ||\n parsed.chargePermissionNow;\n\nconst isGridSource =\n context.sourceMode === 'grid_hc_allowed' ||\n context.sourceMode === 'grid_forced';\n\nconst isLocalSolarSource =\n context.sourceMode === 'solar_only' ||\n context.sourceMode === 'solar_potential';\n\n// =====================================================\n// SOC BAND / BATTERY DYNAMIC POLICY\n// =====================================================\nconst socBand =\n parsed.soc > 90 ? 'gt90' :\n parsed.soc > 80 ? '80_90' :\n parsed.soc > 60 ? '60_80' :\n parsed.soc > 40 ? '40_60' :\n parsed.soc > 25 ? '25_40' :\n 'lt25';\n\nconst band = batteryTune.bands[socBand];\n\nconst dischargeModeFactor =\n strategy.forceChargeRequested ? 1.2 :\n strategy.forceGridRequested ? 0.7 :\n strategy.userStrategy === 'auto_solar' ? 0.8 :\n 1.0;\n\nconst chargeReserveModeFactor =\n strategy.forceChargeRequested ? 0.8 :\n strategy.forceGridRequested ? 1.1 :\n strategy.userStrategy === 'auto_solar' ? 1.1 :\n 1.0;\n\nconst toleranceModeFactor =\n strategy.forceChargeRequested ? 1.25 :\n strategy.forceGridRequested ? 0.8 :\n strategy.userStrategy === 'auto_solar' ? 0.8 :\n 1.0;\n\nconst batteryDischargeSoftDynamicA = band.dischargeSoftA * dischargeModeFactor;\nconst batteryDischargeHardDynamicA = band.dischargeHardA * dischargeModeFactor;\nconst batteryChargeReserveDynamicA = Math.max(0, band.chargeReserveA * chargeReserveModeFactor);\nconst batterySoftToleranceMinutes = band.softToleranceMin * toleranceModeFactor;\nconst batteryHardToleranceSeconds = band.hardToleranceSec;\nconst batteryRecoveryMinutes = band.recoveryMin;\n\n// =====================================================\n// CONTEXT HELPERS\n// =====================================================\nconst houseSpikeThresholdW = 1500;\nconst houseSpikeDetected =\n (parsed.houseAcPowerW - parsed.houseAcPowerPrevW) >= houseSpikeThresholdW;\n\nconst solarPotentialContext =\n context.sourceMode === 'solar_potential' ||\n (\n parsed.soc >= 80 &&\n derived.forecastBufferKwh > 0 &&\n parsed.pvPowerW < context.effectivePvStartMinW &&\n derived.houseNonEvcsW < 1200\n );\n\n// =====================================================\n// OUTSIDE-WINDOW LOCAL BYPASS\n// IMPORTANT:\n// en auto_solar_plus ou force_charge_now, un contexte local solaire\n// ne doit plus rester bloqué juste parce qu'on est hors fenêtre.\n// =====================================================\nconst outsideWindowLocalBypass =\n context.profile === 'outside_window' &&\n !strategy.forceGridRequested &&\n (\n strategy.forceChargeRequested ||\n strategy.userStrategy === 'auto_solar_plus'\n ) &&\n isLocalSolarSource;\n\n// =====================================================\n// REASON-BASED LOCKS\n// =====================================================\nconst nextWindowCandidates = [\n nextDailyOccurrenceMs(params.morningStartMin, nowMs),\n nextDailyOccurrenceMs(params.hcMiddayStartMin, nowMs),\n nextDailyOccurrenceMs(params.eveningStartMin, nowMs),\n nextDailyOccurrenceMs(params.hcNightStartMin, nowMs),\n].filter(v => Number.isFinite(v));\n\nconst nextAllowedWindowStartMs =\n nextWindowCandidates.length > 0 ? Math.min(...nextWindowCandidates) : null;\n\nconst blockActive =\n history.blockedUntilMs !== null &&\n history.blockedUntilMs > nowMs + 1000;\n\nconst blockStillRelevant =\n history.lastBlockReason === 'outside_window'\n ? (context.profile === 'outside_window' && !outsideWindowLocalBypass)\n : history.lastBlockReason === 'probe_failed'\n ? context.sourceMode === 'hybrid_probe'\n : history.lastBlockReason === 'battery_protection_stop'\n ? (\n parsed.batteryCurrentA < -params.batteryNeutralBandA ||\n derived.anyVebusLowBattery ||\n derived.vebusDcVoltageSoftLow\n )\n : history.lastBlockReason === 'grid_battery_guard'\n ? isGridSource\n : history.lastBlockReason === 'vebus_overload_persistent'\n ? derived.anyVebusOverload\n : false;\n\nconst startBlockedByReason = blockActive && blockStillRelevant;\n\n// =====================================================\n// CLOUD DEBT\n// =====================================================\nconst batteryNetPowerW = parsed.batteryCurrentA * derived.batteryVoltageRef;\nlet cloudDebtNextKwh = params.cloudDebtPrevKwh;\n\nif (sessionObservedActive) {\n if (batteryNetPowerW < 0 && !isGridSource) {\n cloudDebtNextKwh += (Math.abs(batteryNetPowerW) * cfg.sampleSeconds) / 3600000;\n } else if (batteryNetPowerW > 0) {\n cloudDebtNextKwh -= (batteryNetPowerW * cfg.sampleSeconds) / 3600000;\n }\n} else if (batteryNetPowerW > 0) {\n cloudDebtNextKwh -= (batteryNetPowerW * cfg.sampleSeconds) / 3600000;\n}\n\ncloudDebtNextKwh = clamp(cloudDebtNextKwh, 0, params.cloudDebtMaxKwh * 4);\n\nconst cloudDebtMeaningful =\n cloudDebtNextKwh > cfg.cloudDebtMeaningfulThresholdKwh;\n\nconst cloudEventDischargingNow =\n sessionObservedActive &&\n !isGridSource &&\n parsed.batteryCurrentA < -Math.max(1, params.batteryNeutralBandA) &&\n cloudDebtMeaningful;\n\nconst continuingCloudEvent =\n cloudEventDischargingNow &&\n history.cloudDebtStartedMsPrev !== null &&\n params.cloudDebtPrevKwh > cfg.cloudDebtRecoveryThresholdKwh;\n\nconst enteringCloudDebt =\n cloudEventDischargingNow && !continuingCloudEvent;\n\nconst cloudDebtActive = cloudEventDischargingNow;\n\nconst cloudDebtStartedMs =\n cloudEventDischargingNow\n ? (continuingCloudEvent ? history.cloudDebtStartedMsPrev : nowMs)\n : null;\n\nconst cloudDebtDurationMinutes =\n cloudDebtActive && cloudDebtStartedMs !== null\n ? Math.max(0, (nowMs - cloudDebtStartedMs) / 60000)\n : 0;\n\nconst effectiveCloudToleranceMinutes =\n params.cloudToleranceMinutes +\n (solarPotentialContext ? batteryTune.global.solarPotentialGraceMinutes : 0) +\n (houseSpikeDetected ? batteryTune.global.houseSpikeGraceMinutes : 0);\n\nconst cloudToleranceOk =\n cloudDebtNextKwh <= params.cloudDebtMaxKwh &&\n (\n !cloudDebtActive ||\n cloudDebtDurationMinutes <= effectiveCloudToleranceMinutes\n );\n\n// =====================================================\n// LEGACY / FORECAST MODES\n// =====================================================\nconst batteryFullHarvestMode =\n parsed.forecastModeEnabled &&\n context.profile !== 'night_hc' &&\n context.profile !== 'outside_window' &&\n parsed.soc >= params.batteryFullSoc &&\n parsed.todayRemainingKwh >= params.batteryFullForecastMinKwh &&\n derived.projectedEndSocAfterCharge >= (derived.endDayGuardSoc - 2);\n\nconst forecastCommitMode =\n parsed.forecastModeEnabled &&\n context.profile !== 'night_hc' &&\n context.profile !== 'outside_window' &&\n parsed.soc >= params.forecastCommitSocFloor &&\n parsed.todayRemainingKwh >= params.forecastCommitMinKwh &&\n derived.projectedEndSocAfterCharge >= derived.endDayGuardSoc;\n\nconst batteryPriorityMode =\n !batteryFullHarvestMode && !forecastCommitMode;\n\n// =====================================================\n// EVCS NIGHT ENGINE\n// =====================================================\nconst nightSessionActive =\n context.profile === 'night_hc' &&\n parsed.evcsVehicleConnected;\n\nconst nightSessionStartSocPct =\n nightSessionActive\n ? (\n Number.isFinite(history.nightSessionStartSocPct)\n ? history.nightSessionStartSocPct\n : parsed.soc\n )\n : null;\n\nconst nightSessionStartAt =\n nightSessionActive\n ? (history.nightSessionStartAt || '')\n : '';\n\nconst nightSessionStartMs =\n nightSessionActive\n ? (history.nightSessionStartMs ?? null)\n : null;\n\nconst nightSessionNeedsCapture =\n nightSessionActive &&\n (\n !Number.isFinite(history.nightSessionStartSocPct) ||\n !nightSessionStartAt ||\n nightSessionStartMs === null\n );\n\nconst nightSessionNeedsClear =\n !nightSessionActive &&\n (\n Number.isFinite(history.nightSessionStartSocPct) ||\n !!history.nightSessionStartAt\n );\n\nconst batteryHardFloorPct =\n params.evcsNightBatteryHardFloorPct;\n\nconst batteryMorningTargetPct =\n parsed.todayRemainingKwh >= params.evcsNightStrongForecastKwh\n ? params.evcsNightTargetStrongForecastPct\n : parsed.todayRemainingKwh >= params.evcsNightMediumForecastKwh\n ? params.evcsNightTargetMediumForecastPct\n : params.evcsNightTargetWeakForecastPct;\n\nconst maxBatteryDischargePoints =\n nightSessionActive && Number.isFinite(nightSessionStartSocPct)\n ? Math.max(0, nightSessionStartSocPct - batteryMorningTargetPct)\n : 0;\n\nconst gridTakeoverTriggerPct =\n Math.max(batteryHardFloorPct, batteryMorningTargetPct);\n\nconst batteryKwhMissingToTarget =\n nightSessionActive\n ? Math.max(\n 0,\n ((batteryMorningTargetPct - parsed.soc) / 100) * cfg.batteryUsableCapacityKwh\n )\n : 0;\n\nconst batteryRecoveryMinutesNeeded =\n nightSessionActive && params.evcsNightBatteryRecoveryPowerKw > 0\n ? (batteryKwhMissingToTarget / params.evcsNightBatteryRecoveryPowerKw) * 60\n : 0;\n\nconst batteryRecoveryUrgent =\n nightSessionActive &&\n Number.isFinite(lifecycle.minutesUntilDeparture) &&\n lifecycle.minutesUntilDeparture >= 0 &&\n (\n batteryRecoveryMinutesNeeded + params.evcsNightDepartureSafetyMin\n >= lifecycle.minutesUntilDeparture\n );\n\nconst batteryBelowHardFloor =\n parsed.soc <= batteryHardFloorPct;\n\nconst batteryBelowMorningTarget =\n parsed.soc < batteryMorningTargetPct;\n\nconst batteryAtOrBelowGridTakeoverTrigger =\n parsed.soc <= gridTakeoverTriggerPct;\n\nconst batteryAssistAllowed =\n nightSessionActive &&\n !batteryBelowHardFloor &&\n !batteryRecoveryUrgent &&\n parsed.soc > gridTakeoverTriggerPct;\n\nconst nightGridTakeoverNeeded =\n nightSessionActive &&\n (\n batteryBelowHardFloor ||\n batteryAtOrBelowGridTakeoverTrigger ||\n batteryRecoveryUrgent\n );\n\nconst nightPreferredSourceMode =\n !nightSessionActive\n ? 'blocked'\n : batteryAssistAllowed\n ? 'battery_assist_night'\n : (\n batteryBelowHardFloor ||\n batteryBelowMorningTarget ||\n batteryRecoveryUrgent\n )\n ? 'grid_forced'\n : 'grid_hc_allowed';\n\nconst batteryRecoveryAllowedNow =\n nightSessionActive &&\n nightGridTakeoverNeeded &&\n (\n batteryBelowMorningTarget ||\n batteryRecoveryUrgent\n );\n\nconst batteryTargetReached =\n nightSessionActive &&\n parsed.soc >= batteryMorningTargetPct;\n\n// =====================================================\n// PROFILE-BASED THRESHOLDS\n// =====================================================\nconst profileSocStartFloor =\n context.profile === 'morning_solar' ? params.morningSocMin :\n context.profile === 'midday_hc_or_solar' ? params.middaySocMin :\n context.profile === 'evening_high_soc' ? params.eveningSocStartMin :\n params.socStartMin;\n\nconst profileSocHoldFloor =\n context.profile === 'morning_solar' ? Math.max(params.socStopMin, params.morningSocMin - 10) :\n context.profile === 'midday_hc_or_solar' ? Math.max(params.socStopMin, params.middaySocMin - 15) :\n context.profile === 'evening_high_soc' ? params.eveningSocStopMin :\n params.socStopMin;\n\nconst profileSpecificDischargeFloorA =\n context.profile === 'morning_solar' ? params.morningBatteryMaxDischargeA :\n context.profile === 'midday_hc_or_solar' ? params.middayBatteryMaxDischargeA :\n context.profile === 'evening_high_soc' ? params.eveningBatteryMaxDischargeA :\n params.batteryDischargeSoftA;\n\nconst profileBatteryDischargeFloorA =\n Math.max(profileSpecificDischargeFloorA, batteryDischargeSoftDynamicA);\n\n// =====================================================\n// GRID RELAX IN force_charge_now\n// =====================================================\n// Assouplissement uniquement pour la garde réseau,\n// sans toucher aux sécurités dures globales.\nconst gridForceChargeRelaxSoftA =\n strategy.forceChargeRequested && isGridSource\n ? (\n parsed.soc >= 90 ? 28 :\n parsed.soc >= 80 ? 16 :\n parsed.soc >= 60 ? 8 :\n parsed.soc >= 40 ? 4 :\n 0\n )\n : 0;\n\nconst gridForceChargeRelaxHardA =\n strategy.forceChargeRequested && isGridSource\n ? (\n parsed.soc >= 90 ? 39 :\n parsed.soc >= 80 ? 29 :\n parsed.soc >= 60 ? 18 :\n parsed.soc >= 40 ? 8 :\n 0\n )\n : 0;\n\nconst gridBatterySoftDynamicABase =\n Math.max(params.gridBatterySoftA, batteryDischargeSoftDynamicA);\n\nconst gridBatteryHardDynamicABase =\n Math.max(params.gridBatteryHardA, batteryDischargeHardDynamicA);\n\nlet gridBatterySoftDynamicA =\n gridBatterySoftDynamicABase - gridForceChargeRelaxSoftA;\n\nlet gridBatteryHardDynamicA =\n gridBatteryHardDynamicABase - gridForceChargeRelaxHardA;\n\n// Garantit que le hard reste plus permissif que le soft d'au moins 3 A\nif (gridBatteryHardDynamicA > (gridBatterySoftDynamicA - 3)) {\n gridBatteryHardDynamicA = gridBatterySoftDynamicA - 3;\n}\n\n// =====================================================\n// BASE SAFETY\n// =====================================================\nconst commonSafetyStartOk =\n derived.canRunAtMinCurrent &&\n !derived.anyVebusOverload &&\n !derived.anyVebusLowBattery &&\n !derived.vebusDcVoltageSoftLow &&\n !derived.vebusAcOutSoftReached &&\n parsed.evcsDeviceConnected &&\n parsed.evcsVehicleConnected &&\n !parsed.evcsModeIsAuto;\n\nconst commonSafetyHoldOk =\n derived.canRunAtMinCurrent &&\n !derived.anyVebusLowBattery &&\n !derived.vebusDcVoltageHardLow &&\n parsed.evcsDeviceConnected &&\n parsed.evcsVehicleConnected &&\n !parsed.evcsModeIsAuto;\n\n// =====================================================\n// BATTERY-ASSISTED SOLAR START/HOLD\n// IMPORTANT:\n// Installation sans injection:\n// le PV visible peut rester bas tant que l'EVCS ne démarre pas.\n// On autorise donc un démarrage/maintien local si la batterie est haute\n// et que la dette nuage reste faible.\n// =====================================================\nconst batteryAssistSolarPolicyEligible =\n !strategy.forceGridRequested &&\n (\n strategy.forceChargeRequested ||\n strategy.userStrategy === 'auto_solar_plus'\n ) &&\n isLocalSolarSource;\n\nconst batteryAssistSolarStartSocMin = 90;\nconst batteryAssistSolarHoldSocMin = 85;\n\nconst batteryAssistSolarDebtStartMaxKwh = Math.max(\n cfg.cloudDebtRecoveryThresholdKwh,\n Math.min(params.cloudDebtMaxKwh, 0.05)\n);\n\nconst batteryAssistSolarStartOk =\n batteryAssistSolarPolicyEligible &&\n commonSafetyStartOk &&\n !isGridSource &&\n parsed.soc >= Math.max(profileSocStartFloor, batteryAssistSolarStartSocMin) &&\n parsed.soc > batteryAssistSolarHoldSocMin &&\n parsed.batteryCurrentA >= profileBatteryDischargeFloorA &&\n cloudDebtNextKwh <= batteryAssistSolarDebtStartMaxKwh &&\n !cloudDebtActive;\n\nconst batteryAssistSolarHoldOk =\n batteryAssistSolarPolicyEligible &&\n commonSafetyHoldOk &&\n !isGridSource &&\n parsed.soc >= Math.max(profileSocHoldFloor, batteryAssistSolarHoldSocMin) &&\n parsed.batteryCurrentA >= profileBatteryDischargeFloorA &&\n cloudToleranceOk;\n\n// =====================================================\n// START / HOLD CONDITIONS\n// =====================================================\nconst rawSolarStartOk =\n (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') &&\n commonSafetyStartOk &&\n parsed.soc >= profileSocStartFloor &&\n parsed.batteryCurrentA >= profileBatteryDischargeFloorA &&\n (\n context.sourceMode === 'solar_potential' ||\n parsed.pvPowerW >= context.effectivePvStartMinW\n );\n\nconst solarStartOk =\n rawSolarStartOk || batteryAssistSolarStartOk;\n\nconst rawSolarHoldOk =\n (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') &&\n commonSafetyHoldOk &&\n parsed.soc >= profileSocHoldFloor &&\n parsed.batteryCurrentA >= profileBatteryDischargeFloorA &&\n (\n parsed.pvPowerW >= params.pvHoldMinW ||\n cloudToleranceOk\n );\n\nconst solarHoldOk =\n rawSolarHoldOk || batteryAssistSolarHoldOk;\n\n// FIX V2.5: Garde SOC - pas de grid pour EVCS si SOC > 35% sauf force_grid_now\nconst gridAllowedBySoc = parsed.soc <= 35 || strategy.forceGridRequested;\n\nconst gridStartOk =\n gridAllowedBySoc &&\n isGridSource &&\n commonSafetyStartOk &&\n parsed.gridAvailable &&\n parsed.batteryCurrentA >= gridBatterySoftDynamicA;\n\nconst gridHoldOk =\n gridAllowedBySoc &&\n isGridSource &&\n commonSafetyHoldOk &&\n parsed.gridAvailable &&\n parsed.batteryCurrentA >= gridBatteryHardDynamicA;\n\nconst batteryAssistNightStartOk =\n context.sourceMode === 'battery_assist_night' &&\n commonSafetyStartOk &&\n nightSessionActive &&\n batteryAssistAllowed &&\n parsed.soc > batteryHardFloorPct &&\n parsed.soc > gridTakeoverTriggerPct &&\n parsed.batteryCurrentA >= profileBatteryDischargeFloorA;\n\nconst batteryAssistNightHoldOk =\n context.sourceMode === 'battery_assist_night' &&\n commonSafetyHoldOk &&\n nightSessionActive &&\n batteryAssistAllowed &&\n parsed.soc > batteryHardFloorPct &&\n parsed.soc > gridTakeoverTriggerPct &&\n parsed.batteryCurrentA >= batteryDischargeHardDynamicA;\n\n// =====================================================\n// PROBE LOGIC — STRICTEMENT LIMITÉE AU MODE PROBE\n// =====================================================\nconst probeModeActive = context.sourceMode === 'hybrid_probe';\n\nconst probeStartOk =\n probeModeActive &&\n commonSafetyStartOk &&\n (context.morningProbeEligible || context.middayProbeEligible);\n\nconst probeHoldGuardOk =\n probeModeActive &&\n commonSafetyHoldOk &&\n parsed.batteryCurrentA >= profileBatteryDischargeFloorA;\n\nconst probeWindowActive =\n probeModeActive &&\n sessionObservedActive &&\n history.probeStartedMs !== null &&\n lifecycle.minutesSinceProbeStart !== null &&\n lifecycle.minutesSinceProbeStart < params.probeMinutes;\n\nconst probeElapsedReached =\n probeModeActive &&\n sessionObservedActive &&\n history.probeStartedMs !== null &&\n lifecycle.minutesSinceProbeStart !== null &&\n lifecycle.minutesSinceProbeStart >= params.probeMinutes;\n\nconst probeSuccessNow =\n probeModeActive &&\n sessionObservedActive &&\n history.probeStartedMs !== null &&\n (\n parsed.pvPowerW >= params.pvHoldMinW &&\n parsed.batteryCurrentA >= Math.min(0, batteryDischargeSoftDynamicA)\n );\n\nconst probeFailedNow =\n probeModeActive &&\n probeElapsedReached &&\n !probeSuccessNow;\n\n// =====================================================\n// STEP-UP LOGIC\n// =====================================================\nconst stepUpByBatteryReserve =\n batteryPriorityMode &&\n parsed.batteryCurrentA >= batteryChargeReserveDynamicA;\n\nconst stepUpByForecastMode =\n !batteryPriorityMode &&\n (\n parsed.pvPowerW >= params.pvHoldMinW ||\n parsed.pvPowerAvg10mW >= params.pvHoldMinW\n ) &&\n derived.forecastBufferKwh >= -1 &&\n derived.projectedEndSocAfterCharge >= (derived.endDayGuardSoc - 5) &&\n cloudToleranceOk;\n\nconst solarShouldStepUp =\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA < derived.physicalCapA &&\n derived.physicalCapA >= params.currentMinA &&\n !derived.vebusAcOutSoftReached &&\n !derived.vebusDcVoltageSoftLow &&\n !derived.houseLoadHighVsHeadroom &&\n (\n stepUpByBatteryReserve ||\n stepUpByForecastMode ||\n parsed.pvPowerW >= (context.effectivePvStartMinW + 400)\n );\n\nconst gridShouldStepUp =\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA < derived.physicalCapA &&\n derived.physicalCapA >= params.currentMinA &&\n !derived.vebusAcOutSoftReached &&\n !derived.vebusDcVoltageSoftLow &&\n parsed.batteryCurrentA >= gridBatterySoftDynamicA &&\n !derived.houseLoadHighVsHeadroom;\n\nconst batteryAssistNightShouldStepUp =\n context.sourceMode === 'battery_assist_night' &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA < derived.physicalCapA &&\n derived.physicalCapA >= params.currentMinA &&\n !derived.vebusAcOutSoftReached &&\n !derived.vebusDcVoltageSoftLow &&\n !derived.houseLoadHighVsHeadroom &&\n parsed.batteryCurrentA >= batteryChargeReserveDynamicA &&\n batteryAssistAllowed;\n\n// =====================================================\n// STEP-DOWN LOGIC\n// =====================================================\nconst solarShouldStepDownBattery =\n parsed.batteryCurrentA <= batteryDischargeSoftDynamicA &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst solarShouldStepDownCloudDebt =\n !cloudToleranceOk &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst gridShouldStepDownBattery =\n parsed.batteryCurrentA < gridBatterySoftDynamicA &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst batteryAssistNightShouldStepDown =\n context.sourceMode === 'battery_assist_night' &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA &&\n (\n parsed.batteryCurrentA <= batteryDischargeSoftDynamicA ||\n parsed.soc <= gridTakeoverTriggerPct ||\n batteryRecoveryUrgent\n );\n\nconst shouldStepDownVebusPower =\n derived.vebusAcOutSoftReached &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst shouldStepDownVebusVoltage =\n derived.vebusDcVoltageSoftLow &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst shouldStepDownOverload =\n derived.anyVebusOverload &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst shouldStopOverloadAtMin =\n derived.anyVebusOverload &&\n derived.currentReferenceA <= params.currentMinA &&\n sessionObservedActive;\n\n// =====================================================\n// HARD STOPS / PROTECTIONS\n// =====================================================\nconst batteryCurrentHardExceeded =\n parsed.batteryCurrentA <= batteryDischargeHardDynamicA;\n\n// overload volontairement exclu du hard stop immédiat\nconst mustStopHard =\n derived.anyVebusLowBattery ||\n derived.vebusDcVoltageHardLow ||\n derived.vebusAcOutHardReached ||\n batteryCurrentHardExceeded;\n\nconst gridBatteryProtectionStop =\n sessionObservedActive &&\n isGridSource &&\n parsed.batteryCurrentA < gridBatteryHardDynamicA;\n\n// =====================================================\n// OUTPUT\n// =====================================================\nreturn [{\n json: {\n ...prev,\n\n batteryPolicy: {\n socBand,\n batteryDischargeSoftDynamicA,\n batteryDischargeHardDynamicA,\n batteryChargeReserveDynamicA,\n batterySoftToleranceMinutes,\n batteryHardToleranceSeconds,\n batteryRecoveryMinutes,\n\n gridBatterySoftDynamicA,\n gridBatteryHardDynamicA,\n gridForceChargeRelaxSoftA,\n gridForceChargeRelaxHardA,\n\n batteryAssistSolarStartSocMin,\n batteryAssistSolarHoldSocMin,\n batteryAssistSolarDebtStartMaxKwh,\n\n houseSpikeDetected,\n solarPotentialContext,\n outsideWindowLocalBypass,\n },\n\n locks: {\n nextAllowedWindowStartMs,\n blockActive,\n blockStillRelevant,\n startBlockedByReason,\n },\n\n cloud: {\n sessionObservedActive,\n batteryNetPowerW,\n cloudDebtNextKwh,\n cloudDebtMeaningful,\n cloudEventDischargingNow,\n continuingCloudEvent,\n enteringCloudDebt,\n cloudDebtActive,\n cloudDebtStartedMs,\n cloudDebtDurationMinutes,\n effectiveCloudToleranceMinutes,\n cloudToleranceOk,\n },\n\n modes: {\n batteryFullHarvestMode,\n forecastCommitMode,\n batteryPriorityMode,\n },\n\n nightEvcs: {\n nightSessionActive,\n nightSessionNeedsCapture,\n nightSessionNeedsClear,\n nightSessionStartSocPct,\n nightSessionStartAt,\n nightSessionStartMs,\n\n batteryHardFloorPct,\n batteryMorningTargetPct,\n maxBatteryDischargePoints,\n gridTakeoverTriggerPct,\n\n batteryBelowHardFloor,\n batteryBelowMorningTarget,\n batteryAtOrBelowGridTakeoverTrigger,\n\n batteryKwhMissingToTarget,\n batteryRecoveryMinutesNeeded,\n batteryRecoveryUrgent,\n batteryRecoveryAllowedNow,\n batteryTargetReached,\n\n batteryAssistAllowed,\n nightGridTakeoverNeeded,\n nightPreferredSourceMode,\n },\n\n safeguards: {\n isGridSource,\n commonSafetyStartOk,\n commonSafetyHoldOk,\n profileSocStartFloor,\n profileSocHoldFloor,\n profileBatteryDischargeFloorA,\n batteryCurrentHardExceeded,\n mustStopHard,\n gridBatteryProtectionStop,\n },\n\n conditions: {\n rawSolarStartOk,\n rawSolarHoldOk,\n batteryAssistSolarPolicyEligible,\n batteryAssistSolarStartOk,\n batteryAssistSolarHoldOk,\n\n solarStartOk,\n solarHoldOk,\n gridStartOk,\n gridHoldOk,\n\n batteryAssistNightStartOk,\n batteryAssistNightHoldOk,\n\n probeStartOk,\n probeHoldGuardOk,\n probeWindowActive,\n probeElapsedReached,\n probeSuccessNow,\n probeFailedNow,\n\n solarShouldStepUp,\n gridShouldStepUp,\n batteryAssistNightShouldStepUp,\n\n solarShouldStepDownBattery,\n solarShouldStepDownCloudDebt,\n gridShouldStepDownBattery,\n batteryAssistNightShouldStepDown,\n\n shouldStepDownVebusPower,\n shouldStepDownVebusVoltage,\n shouldStepDownOverload,\n shouldStopOverloadAtMin,\n }\n }\n}];\n"
+ },
+ "type": "n8n-nodes-base.code",
+ "typeVersion": 2,
+ "position": [
+ 272,
+ 32
+ ],
+ "id": "26501959-fa1a-498d-9e41-a2a5fa77b18d",
+ "name": "BATTERY_CLOUD_LOCKS"
+ },
+ {
+ "parameters": {
+ "jsCode": "function num(v, fallback = NaN) {\n const n = Number.parseFloat(v);\n return Number.isFinite(n) ? n : fallback;\n}\n\nfunction low(v) {\n return (v ?? '').toString().trim().toLowerCase();\n}\n\nfunction clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nfunction round1(v) {\n return Math.round(v * 10) / 10;\n}\n\nfunction round2(v) {\n return Math.round(v * 100) / 100;\n}\n\nfunction to2(n) {\n return String(n).padStart(2, '0');\n}\n\nfunction parseMaybeJson(v) {\n if (typeof v !== 'string') return v;\n try {\n return JSON.parse(v);\n } catch {\n return v;\n }\n}\n\nfunction extractSnapshot(input) {\n if (typeof input === 'string') return parseMaybeJson(input);\n if (input?.data && typeof input.data === 'string') return parseMaybeJson(input.data);\n if (input?.body && typeof input.body === 'string') return parseMaybeJson(input.body);\n if (input?.data && typeof input.data === 'object') return input.data;\n if (input?.body && typeof input.body === 'object') return input.body;\n return input;\n}\n\nfunction parseTimeToMinutes(v, fallback = null) {\n const s = (v ?? '').toString().trim();\n const m = s.match(/^(\\d{1,2}):(\\d{2})(?::(\\d{2}))?$/);\n if (!m) return fallback;\n\n const hh = Number(m[1]);\n const mm = Number(m[2]);\n\n if (!Number.isFinite(hh) || !Number.isFinite(mm)) return fallback;\n return (hh % 24) * 60 + mm;\n}\n\nfunction getParisParts(ms = Date.now()) {\n const fmt = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris',\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n fmt.formatToParts(new Date(ms))\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour);\n const date = `${parts.year}-${parts.month}-${parts.day}`;\n const stamp = `${date} ${to2(hour)}:${parts.minute}:${parts.second}`;\n\n return {\n year: Number(parts.year),\n month: Number(parts.month),\n day: Number(parts.day),\n hour,\n minute: Number(parts.minute),\n second: Number(parts.second),\n date,\n stamp,\n minNow: hour * 60 + Number(parts.minute),\n };\n}\n\nfunction getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') {\n const dtf = new Intl.DateTimeFormat('en-US', {\n timeZone,\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n dtf.formatToParts(new Date(utcTs))\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const asIfUtc = Date.UTC(\n Number(parts.year),\n Number(parts.month) - 1,\n Number(parts.day),\n Number(parts.hour) === 24 ? 0 : Number(parts.hour),\n Number(parts.minute),\n Number(parts.second)\n );\n\n const d = new Date(utcTs);\n const actualUtc = Date.UTC(\n d.getUTCFullYear(),\n d.getUTCMonth(),\n d.getUTCDate(),\n d.getUTCHours(),\n d.getUTCMinutes(),\n d.getUTCSeconds()\n );\n\n return asIfUtc - actualUtc;\n}\n\nfunction parseParisStampToMs(s) {\n const text = (s ?? '').toString().trim();\n const m = text.match(/^(\\d{4})-(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2})$/);\n if (!m) return null;\n\n const y = Number(m[1]);\n const mo = Number(m[2]);\n const d = Number(m[3]);\n const h = Number(m[4]);\n const mi = Number(m[5]);\n const se = Number(m[6]);\n\n if (![y, mo, d, h, mi, se].every(Number.isFinite)) return null;\n\n const guessUtc = Date.UTC(y, mo - 1, d, h, mi, se);\n const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris');\n let ts = guessUtc - off1;\n const off2 = getTzOffsetMs(ts, 'Europe/Paris');\n\n if (off2 !== off1) ts = guessUtc - off2;\n return ts;\n}\n\nfunction cleanText(v, fallback = '') {\n const s = (v ?? '').toString().trim();\n const x = s.toLowerCase();\n if (['unknown', 'unavailable', 'none', 'null', 'undefined'].includes(x)) return fallback;\n return s;\n}\n\nfunction finiteOrNull(v) {\n return Number.isFinite(v) ? v : null;\n}\n\nconst raw = $input.first().json;\nconst input = extractSnapshot(raw);\n\nconst nowMs = Date.now();\nconst now = getParisParts(nowMs);\n\nconst CFG = {\n mobileNotifyDomain: 'notify',\n mobileNotifyService: 'mobile_app_iphone_16_de_tt',\n\n batteryUsableCapacityKwh: 17.5,\n nominalAcVoltageV: 230,\n evcsDedicatedMaxW: 4900,\n systemReserveW: 300,\n\n vebusDcVoltageSoftFloorV: 49.0,\n vebusDcVoltageHardFloorV: 48.6,\n\n departurePriorityWindowMinutes: 240,\n sampleSeconds: 30,\n\n cloudDebtRecoveryThresholdKwh: 0.05,\n cloudDebtMeaningfulThresholdKwh: 0.01,\n};\n\n// =====================================================\n// PARSED SNAPSHOT\n// =====================================================\nconst parsed = {\n soc: clamp(num(input.soc_percent, 0), 0, 100),\n batteryCurrentA: num(input.battery_current_a, 0),\n\n pvPowerW: Math.max(0, num(input.pv_power_w, 0)),\n pvPowerAvg10mW: Math.max(0, num(input.pv_power_10m_avg_w, num(input.pv_power_w, 0))),\n pvTrendW: num(input.pv_trend_w, 0),\n\n houseAcPowerW: Math.max(0, num(input.house_ac_power_w, 0)),\n houseAcPowerPrevW: Math.max(0, num(input.house_ac_power_prev_w, num(input.house_ac_power_w, 0))),\n gridPowerW: num(input.grid_power_w, 0),\n\n gridIsOn: low(input.grid_state || 'off') === 'on',\n acInAvailable: num(input.victron_acin1_available ?? input.ac_in1_available, 0) === 1,\n gridAvailable:\n (low(input.grid_state || 'off') === 'on') ||\n (num(input.victron_acin1_available ?? input.ac_in1_available, 0) === 1),\n\n todayRemainingKwh: Math.max(0, num(input.today_remaining_kwh, 0)),\n\n evcsDeviceConnected: low(input.evcs_device_connected || 'off') === 'on',\n evcsVehicleConnected: low(input.evcs_vehicle_connected || 'off') === 'on',\n evcsStatusRaw: cleanText(input.evcs_status_raw),\n evcsModeRaw: [\n input.evcs_mode_sensor,\n input.evcs_mode_fallback,\n input.evcs_mode\n ]\n .map(v => cleanText(v))\n .find(v => v) || 'unknown',\n\n evcsSetCurrentA: num(input.evcs_set_current_a, NaN),\n evcsCommandCurrentA: num(input.evcs_command_current_a, NaN),\n\n // Mesure réelle côté borne\n evcsCurrentA: Math.max(0, num(input.evcs_current_a, 0)),\n evcsCurrentRealA: Math.max(0, num(input.evcs_current_a, 0)),\n\n evcsAcPowerW: Math.max(0, num(input.evcs_ac_power_w, 0)),\n evcsChargingSwitchOn: low(input.evcs_charging_switch || 'off') === 'on',\n evcsAutoStartSwitchOn: low(input.evcs_auto_start_switch || 'off') === 'on',\n\n controllerEnabled: low(input.controller_enabled || 'off') === 'on',\n notificationsEnabled: low(input.notifications_enabled || 'off') === 'on',\n hardStopEnabledLegacy: low(input.hard_stop_enabled || 'off') === 'on',\n chargePermissionNow: low(input.charge_permission_now || 'off') === 'on',\n controlLatch: low(input.control_latch || 'off') === 'on',\n departurePriorityEnabled: low(input.departure_priority_enabled || 'off') === 'on',\n forecastModeEnabled: low(input.forecast_mode_enabled || 'off') === 'on',\n\n evcsPriorityManualOverride: low(input.evcs_priority_manual_override || 'off') === 'on',\n evcsPriorityManualMode: cleanText(input.evcs_priority_manual_mode || 'normal', 'normal').toLowerCase(),\n evcsPriorityManualReason: cleanText(input.evcs_priority_manual_reason),\n\n vebusOverload: low(input.vebus_overload || 'off') === 'on',\n vebusL1Overload: low(input.vebus_l1_overload || 'off') === 'on',\n vebusLowBattery: low(input.vebus_low_battery || 'off') === 'on',\n vebusL1LowBattery: low(input.vebus_l1_low_battery || 'off') === 'on',\n\n vebusAcOutPowerW: Math.max(0, num(input.vebus_ac_out_l1_power_w, 0)),\n vebusAcOutCurrentA: Math.max(0, num(input.vebus_ac_out_l1_current_a, 0)),\n vebusAcOutVoltageV: num(input.vebus_ac_out_l1_voltage_v, CFG.nominalAcVoltageV),\n\n vebusDcVoltageV: num(input.vebus_dc_voltage_v, NaN),\n vebusDcCurrentA: num(input.vebus_dc_current_a, NaN),\n vebusDcPowerW: num(input.vebus_dc_power_w, NaN),\n\n vebusState: num(input.vebus_state, NaN),\n vebusMainState: num(input.vebus_main_state, NaN),\n};\n\nparsed.evcsModeIsAuto = low(parsed.evcsModeRaw).includes('auto');\n\n// =====================================================\n// PARAMETERS\n// =====================================================\nconst params = {\n userStrategyRaw: input.victron_evcs_strategy || 'auto_solar',\n\n targetCurrentAutoA: clamp(num(input.target_current_auto_a, 6), 0, 32),\n pvStartMinW: Math.max(0, num(input.pv_start_min_w, 1800)),\n pvHoldMinW: Math.max(0, num(input.pv_hold_min_w, 1200)),\n houseAcHeadroomW: Math.max(0, num(input.house_ac_headroom_w, 1200)),\n\n batteryDischargeSoftA: num(input.battery_discharge_soft_a, -5),\n batteryDischargeHardA: num(input.battery_discharge_hard_a, -15),\n batteryReserveUpA: Math.max(0, num(input.battery_reserve_up_a, 8)),\n batteryNeutralBandA: Math.max(0, num(input.battery_neutral_band_a, 3)),\n\n socStartMin: clamp(num(input.soc_start_min, 60), 0, 100),\n socStopMin: clamp(num(input.soc_stop_min, 35), 0, 100),\n\n currentMinA: clamp(num(input.current_min_a, 6), 6, 32),\n currentMaxAutoA: clamp(\n num(input.current_max_auto_a, 16),\n clamp(num(input.current_min_a, 6), 6, 32),\n 32\n ),\n currentStepUpA: clamp(num(input.current_step_up_a, 1), 1, 5),\n currentStepDownA: clamp(num(input.current_step_down_a, 2), 1, 8),\n\n currentChangeCooldownSeconds: Math.max(5, num(input.current_change_cooldown_seconds, 60)),\n minRunMinutes: Math.max(0, num(input.min_run_minutes, 3)),\n minStopMinutes: Math.max(0, num(input.min_stop_minutes, 10)),\n\n requiredEnergyKwh: Math.max(0, num(input.required_energy_kwh, 0)),\n socComfortTarget: clamp(num(input.soc_comfort_target, 85), 0, 100),\n endOfDaySocGuard: clamp(num(input.end_of_day_soc_guard, 85), 0, 100),\n baseLoadRemainingKwh: Math.max(0, num(input.base_load_remaining_kwh, 0)),\n flexibleSafetyMarginKwh: Math.max(0, num(input.flexible_safety_margin_kwh, 0)),\n endOfDaySocTarget: clamp(num(input.end_of_day_soc_target, 85), 0, 100),\n\n acLoadsMaxSafeW: Math.max(0, num(input.ac_loads_max_safe_w, 8500)),\n batteryFullSoc: clamp(num(input.battery_full_soc, 90), 0, 100),\n batteryFullForecastMinKwh: Math.max(0, num(input.battery_full_forecast_min_kwh, 8)),\n forecastCommitSocFloor: clamp(num(input.forecast_commit_soc_floor, 35), 0, 100),\n forecastCommitMinKwh: Math.max(0, num(input.forecast_commit_min_kwh, 25)),\n\n cloudDebtPrevKwh: Math.max(0, num(input.cloud_debt_kwh, 0)),\n cloudDebtMaxKwh: Math.max(0.05, num(input.cloud_debt_max_kwh, 1.2)),\n cloudToleranceMinutes: Math.max(1, num(input.cloud_tolerance_minutes, 20)),\n\n departureTimeMin: parseTimeToMinutes(input.departure_time, null),\n dayStartTimeMin: parseTimeToMinutes(input.day_start_time, 7 * 60 + 30),\n dayStopTimeMin: parseTimeToMinutes(input.day_stop_time, 20 * 60 + 30),\n\n hcNightStartMin: parseTimeToMinutes(input.hc_night_start_time, 23 * 60),\n hcNightStopMin: parseTimeToMinutes(input.hc_night_stop_time, 6 * 60),\n morningStartMin: parseTimeToMinutes(\n input.morning_start_time,\n parseTimeToMinutes(input.day_start_time, 7 * 60 + 30)\n ),\n morningStopMin: parseTimeToMinutes(input.morning_stop_time, 11 * 60),\n hcMiddayStartMin: parseTimeToMinutes(input.hc_midday_start_time, 12 * 60 + 21),\n hcMiddayStopMin: parseTimeToMinutes(input.hc_midday_stop_time, 14 * 60 + 21),\n eveningStartMin: parseTimeToMinutes(input.evening_start_time, 17 * 60),\n eveningStopMin: parseTimeToMinutes(\n input.evening_stop_time,\n parseTimeToMinutes(input.day_stop_time, 20 * 60 + 30)\n ),\n\n allowGridHcNight: low(input.allow_grid_hc_night || 'off') === 'on',\n allowGridHcMidday: low(input.allow_grid_hc_midday || 'off') === 'on',\n\n probeEnabled: low(input.probe_enabled || 'off') === 'on',\n probeMinutes: Math.max(1, num(input.probe_minutes, 3)),\n probeRetryMinutes: Math.max(5, num(input.probe_retry_minutes, 45)),\n probeCurrentA: clamp(\n num(input.probe_current_a, clamp(num(input.current_min_a, 6), 6, 32)),\n clamp(num(input.current_min_a, 6), 6, 32),\n clamp(num(input.current_max_auto_a, 16), clamp(num(input.current_min_a, 6), 6, 32), 32)\n ),\n\n gridBatterySoftA: num(input.grid_battery_soft_a, -3),\n gridBatteryHardA: num(input.grid_battery_hard_a, -6),\n\n morningProbePvAvgMinW: Math.max(0, num(input.morning_probe_pv_avg_min_w, 1500)),\n morningProbePvTrendMinW: num(input.morning_probe_pv_trend_min_w, 0),\n middayProbePvAvgMinW: Math.max(0, num(input.midday_probe_pv_avg_min_w, 1200)),\n\n morningSocMin: clamp(num(input.morning_soc_min, 35), 0, 100),\n middaySocMin: clamp(num(input.midday_soc_min, 75), 0, 100),\n eveningSocStartMin: clamp(num(input.evening_soc_start_min, 85), 0, 100),\n eveningSocStopMin: clamp(num(input.evening_soc_stop_min, 80), 0, 100),\n\n morningBatteryMaxDischargeA: num(input.morning_battery_max_discharge_a, -20),\n middayBatteryMaxDischargeA: num(input.midday_battery_max_discharge_a, -40),\n eveningBatteryMaxDischargeA: num(input.evening_battery_max_discharge_a, -30),\n\n // =====================================================\n // EVCS NIGHT ENGINE — NEW PARAMETERS\n // =====================================================\n evcsNightBatteryHardFloorPct: clamp(\n num(input.evcs_night_battery_hard_floor_pct, 22),\n 0,\n 100\n ),\n\n evcsNightStrongForecastKwh: Math.max(\n 0,\n num(input.evcs_night_strong_forecast_kwh, 18)\n ),\n\n evcsNightMediumForecastKwh: Math.max(\n 0,\n num(input.evcs_night_medium_forecast_kwh, 10)\n ),\n\n evcsNightTargetStrongForecastPct: clamp(\n num(input.evcs_night_target_strong_forecast_pct, 35),\n 0,\n 100\n ),\n\n evcsNightTargetMediumForecastPct: clamp(\n num(input.evcs_night_target_medium_forecast_pct, 55),\n 0,\n 100\n ),\n\n evcsNightTargetWeakForecastPct: clamp(\n num(input.evcs_night_target_weak_forecast_pct, 85),\n 0,\n 100\n ),\n\n evcsNightBatteryRecoveryPowerKw: Math.max(\n 0.1,\n num(input.evcs_night_battery_recovery_power_kw, 2.5)\n ),\n\n evcsNightDepartureSafetyMin: Math.max(\n 0,\n num(input.evcs_night_departure_safety_min, 30)\n ),\n};\n\n// =====================================================\n// HISTORY / PERSISTED HELPERS\n// =====================================================\nconst nightSessionStartSocRaw = num(\n input.evcs_night_session_start_soc ?? input.night_session_start_soc,\n NaN\n);\n\nconst nightSessionStartAtRaw = cleanText(\n input.evcs_night_session_start_at ?? input.night_session_start_at\n);\n\n// =====================================================\n// VEHICLE DEMAND FILTER HISTORY RAW INPUTS\n// =====================================================\nconst evcsNoDemandSinceAtRaw = cleanText(input.evcs_no_demand_since_at);\nconst evcsNoDemandFilterEvalAtRaw = cleanText(input.evcs_no_demand_filter_eval_at);\n\nconst history = {\n lastBlockReason: cleanText(input.last_block_reason, 'none') || 'none',\n blockedUntilAt: cleanText(input.blocked_until),\n probeStartedAt: cleanText(input.probe_started_at),\n lastStartAt: cleanText(input.last_start_at),\n lastStopAt: cleanText(input.last_stop_at),\n lastCurrentChangeAt: cleanText(input.last_current_change_at),\n lastSignature: cleanText(input.last_signature),\n cloudDebtStartedAt: cleanText(input.cloud_debt_started_at),\n evcsPriorityManualUntilAt: cleanText(input.evcs_priority_manual_until),\n\n blockedUntilMs: parseParisStampToMs(cleanText(input.blocked_until)),\n probeStartedMs: parseParisStampToMs(cleanText(input.probe_started_at)),\n lastStartMs: parseParisStampToMs(cleanText(input.last_start_at)),\n lastStopMs: parseParisStampToMs(cleanText(input.last_stop_at)),\n lastCurrentChangeMs: parseParisStampToMs(cleanText(input.last_current_change_at)),\n cloudDebtStartedMsPrev: parseParisStampToMs(cleanText(input.cloud_debt_started_at)),\n evcsPriorityManualUntilMs: parseParisStampToMs(cleanText(input.evcs_priority_manual_until)),\n\n // =====================================================\n // EVCS NIGHT SESSION MEMORY\n // =====================================================\n nightSessionStartSocPct: Number.isFinite(nightSessionStartSocRaw)\n ? clamp(nightSessionStartSocRaw, 0, 100)\n : null,\n\n nightSessionStartAt: nightSessionStartAtRaw || '',\n nightSessionStartMs: parseParisStampToMs(nightSessionStartAtRaw),\n\n // =====================================================\n // VEHICLE DEMAND FILTER PERSISTENCE\n // =====================================================\n evcsDeliveryRatioEma: num(input.evcs_delivery_ratio_ema, NaN),\n evcsDeliveryNoiseEma: num(input.evcs_delivery_noise_ema, NaN),\n evcsNoDemandScoreEma: num(input.evcs_no_demand_score_ema, NaN),\n evcsNoDemandSemanticActive: low(input.evcs_no_demand_semantic_active || 'off') === 'on',\n\n evcsNoDemandSinceAt: evcsNoDemandSinceAtRaw || '',\n evcsNoDemandSinceMs: parseParisStampToMs(evcsNoDemandSinceAtRaw),\n\n evcsNoDemandFilterEvalAt: evcsNoDemandFilterEvalAtRaw || '',\n evcsNoDemandFilterEvalMs: parseParisStampToMs(evcsNoDemandFilterEvalAtRaw),\n};\n\n// =====================================================\n// BATTERY TUNING\n// =====================================================\nconst batteryTune = {\n global: {\n houseSpikeGraceMinutes: Math.max(0, num(input.house_spike_grace_minutes, 10)),\n solarPotentialGraceMinutes: Math.max(0, num(input.solar_potential_grace_minutes, 12)),\n stepUpCooldownSeconds: Math.max(5, num(input.step_up_cooldown_seconds, params.currentChangeCooldownSeconds)),\n stepDownCooldownSeconds: Math.max(5, num(input.step_down_cooldown_seconds, params.currentChangeCooldownSeconds)),\n forceModeAutoRevertMinutes: Math.max(0, num(input.force_mode_auto_revert_minutes, 120)),\n overloadStepDownA: Math.max(1, num(input.overload_step_down_a, 2)),\n overloadHoldSeconds: Math.max(5, num(input.overload_hold_seconds, 30)),\n overloadStopAfterSeconds: Math.max(10, num(input.overload_stop_after_seconds, 120)),\n },\n\n bands: {\n gt90: {\n dischargeSoftA: num(input.batt_discharge_soft_gt90_a, -50),\n dischargeHardA: num(input.batt_discharge_hard_gt90_a, -100),\n chargeReserveA: Math.max(0, num(input.batt_charge_reserve_gt90_a, 5)),\n softToleranceMin: Math.max(0, num(input.batt_soft_tol_gt90_min, 12)),\n hardToleranceSec: Math.max(0, num(input.batt_hard_tol_gt90_sec, 20)),\n recoveryMin: Math.max(0, num(input.batt_recovery_gt90_min, 6)),\n },\n\n '80_90': {\n dischargeSoftA: num(input.batt_discharge_soft_80_90_a, -40),\n dischargeHardA: num(input.batt_discharge_hard_80_90_a, -80),\n chargeReserveA: Math.max(0, num(input.batt_charge_reserve_80_90_a, 10)),\n softToleranceMin: Math.max(0, num(input.batt_soft_tol_80_90_min, 10)),\n hardToleranceSec: Math.max(0, num(input.batt_hard_tol_80_90_sec, 20)),\n recoveryMin: Math.max(0, num(input.batt_recovery_80_90_min, 6)),\n },\n\n '60_80': {\n dischargeSoftA: num(input.batt_discharge_soft_60_80_a, -20),\n dischargeHardA: num(input.batt_discharge_hard_60_80_a, -50),\n chargeReserveA: Math.max(0, num(input.batt_charge_reserve_60_80_a, 40)),\n softToleranceMin: Math.max(0, num(input.batt_soft_tol_60_80_min, 6)),\n hardToleranceSec: Math.max(0, num(input.batt_hard_tol_60_80_sec, 15)),\n recoveryMin: Math.max(0, num(input.batt_recovery_60_80_min, 8)),\n },\n\n '40_60': {\n dischargeSoftA: num(input.batt_discharge_soft_40_60_a, -12),\n dischargeHardA: num(input.batt_discharge_hard_40_60_a, -30),\n chargeReserveA: Math.max(0, num(input.batt_charge_reserve_40_60_a, 70)),\n softToleranceMin: Math.max(0, num(input.batt_soft_tol_40_60_min, 4)),\n hardToleranceSec: Math.max(0, num(input.batt_hard_tol_40_60_sec, 10)),\n recoveryMin: Math.max(0, num(input.batt_recovery_40_60_min, 10)),\n },\n\n '25_40': {\n dischargeSoftA: num(input.batt_discharge_soft_25_40_a, -5),\n dischargeHardA: num(input.batt_discharge_hard_25_40_a, -15),\n chargeReserveA: Math.max(0, num(input.batt_charge_reserve_25_40_a, 110)),\n softToleranceMin: Math.max(0, num(input.batt_soft_tol_25_40_min, 2)),\n hardToleranceSec: Math.max(0, num(input.batt_hard_tol_25_40_sec, 5)),\n recoveryMin: Math.max(0, num(input.batt_recovery_25_40_min, 12)),\n },\n\n lt25: {\n dischargeSoftA: num(input.batt_discharge_soft_lt25_a, -3),\n dischargeHardA: num(input.batt_discharge_hard_lt25_a, -8),\n chargeReserveA: Math.max(0, num(input.batt_charge_reserve_lt25_a, 150)),\n softToleranceMin: Math.max(0, num(input.batt_soft_tol_lt25_min, 1)),\n hardToleranceSec: Math.max(0, num(input.batt_hard_tol_lt25_sec, 2)),\n recoveryMin: Math.max(0, num(input.batt_recovery_lt25_min, 15)),\n },\n },\n};\n\n// =====================================================\n// OPTIONAL DEBUG / SUMMARY\n// =====================================================\nconst evcsNightConfig = {\n batteryHardFloorPct: params.evcsNightBatteryHardFloorPct,\n strongForecastKwh: params.evcsNightStrongForecastKwh,\n mediumForecastKwh: params.evcsNightMediumForecastKwh,\n targetStrongForecastPct: params.evcsNightTargetStrongForecastPct,\n targetMediumForecastPct: params.evcsNightTargetMediumForecastPct,\n targetWeakForecastPct: params.evcsNightTargetWeakForecastPct,\n batteryRecoveryPowerKw: params.evcsNightBatteryRecoveryPowerKw,\n departureSafetyMin: params.evcsNightDepartureSafetyMin,\n sessionStartSocPct: history.nightSessionStartSocPct,\n sessionStartAt: history.nightSessionStartAt || null,\n};\n\nconst vehicleDemandHistoryDebug = {\n evcsDeliveryRatioEma: finiteOrNull(history.evcsDeliveryRatioEma),\n evcsDeliveryNoiseEma: finiteOrNull(history.evcsDeliveryNoiseEma),\n evcsNoDemandScoreEma: finiteOrNull(history.evcsNoDemandScoreEma),\n evcsNoDemandSemanticActive: history.evcsNoDemandSemanticActive,\n evcsNoDemandSinceAt: history.evcsNoDemandSinceAt || null,\n evcsNoDemandSinceMs: finiteOrNull(history.evcsNoDemandSinceMs),\n evcsNoDemandFilterEvalAt: history.evcsNoDemandFilterEvalAt || null,\n evcsNoDemandFilterEvalMs: finiteOrNull(history.evcsNoDemandFilterEvalMs),\n};\n\n// =====================================================\n// OUTPUT\n// =====================================================\nreturn [{\n json: {\n raw,\n input,\n nowMs,\n now,\n cfg: CFG,\n parsed,\n params,\n history,\n batteryTune,\n evcsNightConfig,\n vehicleDemandHistoryDebug,\n }\n}];\n"
+ },
+ "type": "n8n-nodes-base.code",
+ "typeVersion": 2,
+ "position": [
+ -176,
+ 32
+ ],
+ "id": "72f0e7a5-f86c-4451-96e4-b44dccd3fe90",
+ "name": "INPUT_PARSE"
+ },
+ {
+ "parameters": {
+ "jsCode": "function round1(v) {\n return Math.round(v * 10) / 10;\n}\n\nfunction round2(v) {\n return Math.round(v * 100) / 100;\n}\n\nfunction round3(v) {\n return Math.round(v * 1000) / 1000;\n}\n\nfunction clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nfunction to2(n) {\n return String(n).padStart(2, '0');\n}\n\nfunction formatParisStampFromMs(ms) {\n const fmt = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris',\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n fmt.formatToParts(new Date(ms))\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour);\n return `${parts.year}-${parts.month}-${parts.day} ${to2(hour)}:${parts.minute}:${parts.second}`;\n}\n\nconst prev = $input.first().json;\n\nconst {\n input,\n now,\n nowMs,\n cfg,\n parsed,\n params,\n history,\n strategy,\n windows,\n lifecycle,\n context,\n derived,\n batteryTune,\n batteryPolicy,\n locks,\n cloud,\n modes,\n safeguards,\n conditions,\n decision,\n nightEvcs,\n vehicleDemandFilter,\n} = prev;\n\n// =====================================================\n// SAFE ACCESS NIGHT EVCS\n// =====================================================\nconst night = nightEvcs ?? {\n nightSessionActive: false,\n nightSessionNeedsCapture: false,\n nightSessionNeedsClear: false,\n nightSessionStartSocPct: null,\n nightSessionStartAt: '',\n batteryHardFloorPct: null,\n batteryMorningTargetPct: null,\n maxBatteryDischargePoints: null,\n gridTakeoverTriggerPct: null,\n batteryRecoveryUrgent: false,\n nightGridTakeoverNeeded: false,\n nightPreferredSourceMode: 'blocked',\n};\n\n// =====================================================\n// SAFE ACCESS VEHICLE DEMAND FILTER\n// =====================================================\nconst vdf = vehicleDemandFilter ?? {\n dtSec: null,\n commandCurrentA: null,\n realCurrentA: null,\n expectedPowerW: null,\n realPowerW: null,\n currentDeliveryRatioRaw: null,\n powerDeliveryRatioRaw: null,\n deliveryRatioRaw: null,\n deliveryRatioEma: null,\n deliveryNoiseEma: null,\n adaptiveQuietBand: null,\n noDemandEvidenceRaw: null,\n sessionElapsedSec: null,\n sessionArmFactor: null,\n commandFactor: null,\n sourceDeliverableForNoDemand: false,\n evcsSessionArmedForNoDemand: false,\n noDemandScoreRaw: null,\n noDemandScoreEma: null,\n noDemandScoreOn: null,\n noDemandScoreOff: null,\n evcsVehicleNoDemandSemantic: false,\n evcsNoDemandSinceMs: null,\n evcsNoDemandSinceMinutes: null,\n nextHistory: {\n evcsDeliveryRatioEma: null,\n evcsDeliveryNoiseEma: null,\n evcsNoDemandScoreEma: null,\n evcsNoDemandSemanticActive: false,\n evcsNoDemandSinceMs: null,\n evcsNoDemandFilterEvalMs: null\n }\n};\n\n// =====================================================\n// FINAL ACTIONS\n// =====================================================\nlet desiredCurrentA = decision.desiredCurrentA;\n\ndesiredCurrentA = decision.desiredCharging\n ? clamp(\n desiredCurrentA,\n params.currentMinA,\n Math.max(params.currentMinA, derived.physicalCapA || params.currentMinA)\n )\n : params.currentMinA;\n\nconst chargingAction =\n decision.desiredCharging === parsed.evcsChargingSwitchOn\n ? 'none'\n : decision.desiredCharging\n ? 'turn_on'\n : 'turn_off';\n\nconst permissionAction =\n decision.desiredPermission === parsed.chargePermissionNow\n ? 'none'\n : decision.desiredPermission\n ? 'turn_on'\n : 'turn_off';\n\nconst latchAction =\n decision.desiredLatch === parsed.controlLatch\n ? 'none'\n : decision.desiredLatch\n ? 'turn_on'\n : 'turn_off';\n\nconst autoStartAction =\n parsed.controllerEnabled &&\n parsed.evcsVehicleConnected &&\n parsed.evcsAutoStartSwitchOn\n ? 'turn_off'\n : 'none';\n\nconst currentAction =\n decision.desiredCharging &&\n (\n !Number.isFinite(derived.currentReferenceA) ||\n Math.abs(desiredCurrentA - derived.currentReferenceA) >= 0.5\n )\n ? 'set'\n : 'none';\n\nconst setLastStartAt = chargingAction === 'turn_on';\nconst setLastStopAt = chargingAction === 'turn_off';\nconst setLastCurrentChangeAt = currentAction === 'set';\n\n// =====================================================\n// CLEAN BLOCKING LOGIC\n// =====================================================\nconst blockedUntilToWrite =\n decision.nextBlockReason === 'none'\n ? now.stamp\n : decision.nextBlockedUntilStamp;\n\nconst needsBlockReasonUpdate =\n decision.nextBlockReason !== history.lastBlockReason;\n\n// =====================================================\n// GLOBAL EVCS PRIORITY\n// AUTO + OVERRIDE MANUEL\n// =====================================================\nconst forceWindowMinutes = Math.max(\n 30,\n batteryTune?.global?.forceModeAutoRevertMinutes ?? 120\n);\n\nconst activeChargeIntent =\n decision.desiredCharging ||\n ['start', 'hold', 'step_up', 'step_down', 'step_down_fast'].includes(decision.currentDecision);\n\nconst evcsVehicleNotRequestingPowerState =\n decision.state === 'vehicle_full_or_paused' ||\n decision.currentDecision === 'stop_vehicle_full_or_paused';\n\n// -------------------------\n// PRIORITÉ AUTO\n// -------------------------\nlet autoEvcsPriorityActive = false;\nlet autoEnergyPriorityMode = 'normal';\nlet autoEnergyPriorityReason = 'Aucune priorité EVCS';\nlet autoEvcsPriorityUntil = now.stamp;\n\nif (\n parsed.evcsVehicleConnected &&\n !strategy.isStopMode &&\n !evcsVehicleNotRequestingPowerState\n) {\n if (strategy.forceChargeRequested || strategy.forceGridRequested) {\n autoEvcsPriorityActive = true;\n autoEnergyPriorityMode = 'evcs_priority';\n autoEnergyPriorityReason =\n strategy.forceGridRequested\n ? 'EVCS prioritaire : force_grid_now'\n : 'EVCS prioritaire : force_charge_now';\n autoEvcsPriorityUntil = formatParisStampFromMs(nowMs + forceWindowMinutes * 60000);\n }\n\n else if (night.nightSessionActive) {\n autoEvcsPriorityActive = true;\n autoEnergyPriorityMode = 'evcs_priority';\n autoEnergyPriorityReason =\n night.batteryRecoveryUrgent\n ? 'EVCS prioritaire : session nuit + urgence départ'\n : night.nightGridTakeoverNeeded\n ? 'EVCS prioritaire : session nuit + takeover réseau'\n : 'EVCS prioritaire : session nuit active';\n\n autoEvcsPriorityUntil =\n Number.isFinite(lifecycle.minutesUntilDeparture)\n ? formatParisStampFromMs(\n nowMs + Math.max(1, lifecycle.minutesUntilDeparture) * 60000\n )\n : formatParisStampFromMs(nowMs + forceWindowMinutes * 60000);\n }\n\n else if (context.departurePriorityActive && Number.isFinite(lifecycle.minutesUntilDeparture)) {\n autoEvcsPriorityActive = true;\n autoEnergyPriorityMode = 'evcs_priority';\n autoEnergyPriorityReason = `EVCS prioritaire : départ dans ${Math.round(lifecycle.minutesUntilDeparture)} min`;\n autoEvcsPriorityUntil = formatParisStampFromMs(\n nowMs + Math.max(1, lifecycle.minutesUntilDeparture) * 60000\n );\n }\n\n else if (activeChargeIntent && context.profile === 'midday_hc_or_solar') {\n autoEvcsPriorityActive = true;\n autoEnergyPriorityMode = 'evcs_priority';\n autoEnergyPriorityReason = 'EVCS prioritaire : recharge active fenêtre midi';\n autoEvcsPriorityUntil = formatParisStampFromMs(nowMs + 90 * 60000);\n }\n}\n\n// -------------------------\n// OVERRIDE MANUEL\n// -------------------------\nconst manualPriorityOverrideActive =\n parsed.evcsPriorityManualOverride &&\n parsed.evcsPriorityManualMode === 'evcs_priority' &&\n history.evcsPriorityManualUntilMs !== null &&\n history.evcsPriorityManualUntilMs > nowMs + 1000;\n\nlet evcsPriorityActive = autoEvcsPriorityActive;\nlet energyPriorityMode = autoEnergyPriorityMode;\nlet energyPriorityReason = autoEnergyPriorityReason;\nlet evcsPriorityUntil = autoEvcsPriorityUntil;\nlet evcsPrioritySource = 'auto';\n\nif (manualPriorityOverrideActive && !evcsVehicleNotRequestingPowerState) {\n evcsPriorityActive = true;\n energyPriorityMode = 'evcs_priority';\n energyPriorityReason =\n parsed.evcsPriorityManualReason || 'EVCS prioritaire : override manuel';\n evcsPriorityUntil =\n history.evcsPriorityManualUntilAt || autoEvcsPriorityUntil;\n evcsPrioritySource = 'manual';\n}\n\n// =====================================================\n// REASONS / LABELS / SIGNATURE\n// =====================================================\nconst reason = [...decision.reason];\n\nreason.push(`Stratégie=${strategy.userStrategy}`);\nreason.push(`Profil=${context.profile}`);\nreason.push(`Source=${context.sourceMode}`);\nreason.push(\n `Mode=${\n modes.batteryFullHarvestMode\n ? 'battery_full_harvest_mode'\n : modes.forecastCommitMode\n ? 'forecast_commit_mode'\n : 'battery_priority_mode'\n }`\n);\nreason.push(`SOC=${round1(parsed.soc)}%`);\nreason.push(`Batt=${round1(parsed.batteryCurrentA)}A`);\nreason.push(`PV=${Math.round(parsed.pvPowerW)}W`);\nreason.push(`PV10=${Math.round(parsed.pvPowerAvg10mW)}W`);\nreason.push(`Grid=${parsed.gridAvailable ? 'on' : 'off'} (${Math.round(parsed.gridPowerW)}W)`);\nreason.push(`Cap=${derived.physicalCapA}A`);\nreason.push(`Ref=${round1(derived.currentReferenceA)}A`);\nreason.push(`Target=${round1(desiredCurrentA)}A`);\nreason.push(`Debt=${round2(cloud.cloudDebtNextKwh)}kWh`);\nreason.push(`Vehicle=${parsed.evcsVehicleConnected ? 'on' : 'off'}`);\nreason.push(`EVCS mode=${parsed.evcsModeRaw}`);\nreason.push(`VE.Bus AC=${Math.round(parsed.vebusAcOutPowerW)}W`);\nreason.push(\n `VE.Bus DC=${\n Number.isFinite(parsed.vebusDcVoltageV)\n ? round1(parsed.vebusDcVoltageV)\n : 'n/a'\n }V`\n);\nreason.push(`PrioEVCS=${evcsPriorityActive ? 'on' : 'off'}(${evcsPrioritySource})`);\nreason.push(`VDF delivery=${Number.isFinite(vdf.deliveryRatioEma) ? round3(vdf.deliveryRatioEma) : 'na'}`);\nreason.push(`VDF noise=${Number.isFinite(vdf.deliveryNoiseEma) ? round3(vdf.deliveryNoiseEma) : 'na'}`);\nreason.push(`VDF score=${Number.isFinite(vdf.noDemandScoreEma) ? round3(vdf.noDemandScoreEma) : 'na'}`);\nreason.push(`VDF noDemand=${vdf.evcsVehicleNoDemandSemantic ? 'on' : 'off'}`);\n\nif (night.nightSessionActive) {\n reason.push(`NightTarget=${round1(night.batteryMorningTargetPct ?? 0)}%`);\n reason.push(`NightTrigger=${round1(night.gridTakeoverTriggerPct ?? 0)}%`);\n\n if (Number.isFinite(night.nightSessionStartSocPct)) {\n reason.push(`StartSOC=${round1(night.nightSessionStartSocPct)}%`);\n reason.push(`AllowDrop=${round1(night.maxBatteryDischargePoints ?? 0)}pts`);\n }\n\n reason.push(`NightPreferred=${night.nightPreferredSourceMode ?? 'blocked'}`);\n reason.push(`NightTakeover=${night.nightGridTakeoverNeeded ? 'on' : 'off'}`);\n reason.push(`NightUrgent=${night.batteryRecoveryUrgent ? 'on' : 'off'}`);\n}\n\nconst shortReason = reason.join(' | ').slice(0, 255);\n\nconst sourceModeLabel =\n context.sourceMode === 'blocked' ? 'Bloqué' :\n context.sourceMode === 'solar_only' ? 'Solaire' :\n context.sourceMode === 'solar_potential' ? 'Potentiel solaire' :\n context.sourceMode === 'hybrid_probe' ? 'Probe hybride' :\n context.sourceMode === 'grid_hc_allowed' ? 'Réseau HC autorisé' :\n context.sourceMode === 'grid_forced' ? 'Réseau forcé' :\n context.sourceMode === 'battery_assist_night' ? 'Assistance batterie nuit' :\n context.sourceMode;\n\nconst signature = [\n decision.state,\n `profile=${context.profile}`,\n `source=${context.sourceMode}`,\n `chg=${decision.desiredCharging ? '1' : '0'}`,\n `perm=${decision.desiredPermission ? '1' : '0'}`,\n `l=${decision.desiredLatch ? '1' : '0'}`,\n `cur=${round1(desiredCurrentA)}`,\n `block=${decision.nextBlockReason}`,\n `stop=${decision.mustStop ? '1' : '0'}`,\n `prio=${evcsPriorityActive ? '1' : '0'}`,\n `night=${night.nightSessionActive ? '1' : '0'}`,\n `ntrg=${Number.isFinite(night.gridTakeoverTriggerPct) ? round1(night.gridTakeoverTriggerPct) : 'na'}`,\n `nurg=${night.batteryRecoveryUrgent ? '1' : '0'}`,\n `vdf=${vdf.evcsVehicleNoDemandSemantic ? '1' : '0'}`,\n `vdfs=${Number.isFinite(vdf.noDemandScoreEma) ? round3(vdf.noDemandScoreEma) : 'na'}`\n].join('|');\n\nconst signatureChanged = signature !== history.lastSignature;\n\n// =====================================================\n// NOTIFICATIONS\n// =====================================================\nlet notifyWanted = false;\nlet notifyTitle = 'Victron EVCS Surplus';\nlet notifyMessage = shortReason;\n\nif (parsed.notificationsEnabled && signatureChanged) {\n if (decision.currentDecision === 'start') {\n notifyWanted = true;\n notifyTitle = '⚡ EVCS démarrage';\n } else if (decision.currentDecision === 'step_up') {\n notifyWanted = true;\n notifyTitle = '⬆️ EVCS hausse courant';\n } else if (\n decision.currentDecision === 'step_down' ||\n decision.currentDecision === 'step_down_fast'\n ) {\n notifyWanted = true;\n notifyTitle = '⬇️ EVCS baisse courant';\n } else if (\n decision.mustStop ||\n decision.currentDecision === 'stop' ||\n decision.currentDecision === 'stop_hard' ||\n decision.currentDecision === 'stop_power_cap'\n ) {\n notifyWanted = true;\n notifyTitle = '⛔ EVCS arrêt';\n } else if (decision.state === 'mode_auto_conflict') {\n notifyWanted = true;\n notifyTitle = '⚠️ EVCS mode Auto';\n } else if (decision.state === 'vehicle_full_or_paused') {\n notifyWanted = true;\n notifyTitle = '🚗 EVCS véhicule plein ou en pause';\n }\n}\n\n// =====================================================\n// TIMESTAMPS TO WRITE\n// =====================================================\nconst cloudDebtStartedAtToWrite =\n cloud.cloudDebtStartedMs\n ? formatParisStampFromMs(cloud.cloudDebtStartedMs)\n : now.stamp;\n\nconst probeStartedAtToWrite = now.stamp;\nconst lastStartAtToWrite = now.stamp;\nconst lastStopAtToWrite = now.stamp;\nconst lastCurrentChangeAtToWrite = now.stamp;\n\n// =====================================================\n// NIGHT SESSION MEMORY\n// =====================================================\nconst nightSessionStartSocToWrite =\n decision.clearNightSessionStart\n ? ''\n : decision.setNightSessionStart\n ? String(round1(parsed.soc))\n : (\n Number.isFinite(history.nightSessionStartSocPct)\n ? String(round1(history.nightSessionStartSocPct))\n : ''\n );\n\nconst nightSessionStartAtToWrite =\n decision.clearNightSessionStart\n ? ''\n : decision.setNightSessionStart\n ? now.stamp\n : (history.nightSessionStartAt || '');\n\n// =====================================================\n// OUTPUT\n// =====================================================\nreturn [{\n json: {\n timestamp_paris: now.stamp,\n inputs: input,\n\n computed: {\n operating_mode: context.operatingMode,\n profile: context.profile,\n source_mode: context.sourceMode,\n source_mode_label: sourceModeLabel,\n user_strategy: strategy.userStrategy,\n\n in_night_hc_window: windows.inNightHcWindow,\n in_morning_window: windows.inMorningWindow,\n in_midday_window: windows.inMiddayWindow,\n in_evening_window: windows.inEveningWindow,\n\n departure_priority_active: context.departurePriorityActive,\n minutes_until_departure: lifecycle.minutesUntilDeparture,\n\n battery_priority_mode: modes.batteryPriorityMode,\n battery_full_harvest_mode: modes.batteryFullHarvestMode,\n forecast_commit_mode: modes.forecastCommitMode,\n\n physical_cap_current_a: derived.physicalCapA,\n can_run_at_min_current: derived.canRunAtMinCurrent,\n\n forecast_buffer_kwh: round2(derived.forecastBufferKwh),\n projected_end_soc_after_charge: round1(derived.projectedEndSocAfterCharge),\n\n cloud_debt_next_kwh: round2(cloud.cloudDebtNextKwh),\n cloud_debt_duration_minutes: round1(cloud.cloudDebtDurationMinutes),\n cloud_debt_active: cloud.cloudDebtActive,\n cloud_tolerance_ok: cloud.cloudToleranceOk,\n\n start_blocked_by_reason: locks.startBlockedByReason,\n\n probe_window_active: conditions.probeWindowActive,\n probe_success_now: conditions.probeSuccessNow,\n probe_failed_now: conditions.probeFailedNow,\n\n any_vebus_overload: derived.anyVebusOverload,\n any_vebus_low_battery: derived.anyVebusLowBattery,\n vebus_ac_out_soft_reached: derived.vebusAcOutSoftReached,\n vebus_ac_out_hard_reached: derived.vebusAcOutHardReached,\n vebus_dc_voltage_soft_low: derived.vebusDcVoltageSoftLow,\n vebus_dc_voltage_hard_low: derived.vebusDcVoltageHardLow,\n\n soc_band: batteryPolicy.socBand,\n batt_discharge_soft_dynamic_a: round1(batteryPolicy.batteryDischargeSoftDynamicA),\n batt_discharge_hard_dynamic_a: round1(batteryPolicy.batteryDischargeHardDynamicA),\n batt_charge_reserve_dynamic_a: round1(batteryPolicy.batteryChargeReserveDynamicA),\n batt_soft_tolerance_min: round1(batteryPolicy.batterySoftToleranceMinutes),\n batt_hard_tolerance_sec: round1(batteryPolicy.batteryHardToleranceSeconds),\n batt_recovery_min: round1(batteryPolicy.batteryRecoveryMinutes),\n grid_battery_soft_dynamic_a: round1(batteryPolicy.gridBatterySoftDynamicA),\n grid_battery_hard_dynamic_a: round1(batteryPolicy.gridBatteryHardDynamicA),\n house_spike_detected: batteryPolicy.houseSpikeDetected,\n solar_potential_context: batteryPolicy.solarPotentialContext,\n\n evcs_priority_active: evcsPriorityActive,\n energy_priority_mode: energyPriorityMode,\n energy_priority_reason: energyPriorityReason,\n evcs_priority_until: evcsPriorityUntil,\n evcs_priority_source: evcsPrioritySource,\n evcs_priority_manual_override_active: manualPriorityOverrideActive,\n\n night_session_active: night.nightSessionActive,\n night_session_needs_capture: !!night.nightSessionNeedsCapture,\n night_session_needs_clear: !!night.nightSessionNeedsClear,\n night_session_start_soc_pct: Number.isFinite(night.nightSessionStartSocPct)\n ? round1(night.nightSessionStartSocPct)\n : null,\n night_session_start_at: night.nightSessionStartAt || null,\n\n battery_hard_floor_pct: Number.isFinite(night.batteryHardFloorPct)\n ? round1(night.batteryHardFloorPct)\n : null,\n battery_morning_target_pct: Number.isFinite(night.batteryMorningTargetPct)\n ? round1(night.batteryMorningTargetPct)\n : null,\n max_battery_discharge_points: Number.isFinite(night.maxBatteryDischargePoints)\n ? round1(night.maxBatteryDischargePoints)\n : null,\n grid_takeover_trigger_pct: Number.isFinite(night.gridTakeoverTriggerPct)\n ? round1(night.gridTakeoverTriggerPct)\n : null,\n\n battery_recovery_urgent: !!night.batteryRecoveryUrgent,\n night_grid_takeover_needed: !!night.nightGridTakeoverNeeded,\n night_preferred_source_mode: night.nightPreferredSourceMode || 'blocked',\n\n evcs_delivery_ratio_ema: Number.isFinite(vdf.deliveryRatioEma) ? round3(vdf.deliveryRatioEma) : null,\n evcs_delivery_noise_ema: Number.isFinite(vdf.deliveryNoiseEma) ? round3(vdf.deliveryNoiseEma) : null,\n evcs_no_demand_score_ema: Number.isFinite(vdf.noDemandScoreEma) ? round3(vdf.noDemandScoreEma) : null,\n evcs_vehicle_no_demand_semantic: !!vdf.evcsVehicleNoDemandSemantic,\n evcs_no_demand_since_minutes: Number.isFinite(vdf.evcsNoDemandSinceMinutes) ? round3(vdf.evcsNoDemandSinceMinutes) : null,\n evcs_source_deliverable_for_no_demand: !!vdf.sourceDeliverableForNoDemand,\n evcs_session_armed_for_no_demand: !!vdf.evcsSessionArmedForNoDemand,\n evcs_no_demand_score_on: Number.isFinite(vdf.noDemandScoreOn) ? round3(vdf.noDemandScoreOn) : null,\n evcs_no_demand_score_off: Number.isFinite(vdf.noDemandScoreOff) ? round3(vdf.noDemandScoreOff) : null\n },\n\n decision: {\n state: decision.state,\n current_decision: decision.currentDecision,\n desired_charging: decision.desiredCharging,\n desired_permission: decision.desiredPermission,\n desired_latch: decision.desiredLatch,\n desired_current_a: round1(desiredCurrentA),\n\n charging_action: chargingAction,\n permission_action: permissionAction,\n latch_action: latchAction,\n current_action: currentAction,\n auto_start_action: autoStartAction,\n\n set_last_start_at: setLastStartAt,\n set_last_stop_at: setLastStopAt,\n set_last_current_change_at: setLastCurrentChangeAt,\n set_cloud_debt_started_at: decision.setCloudDebtStartedAt,\n set_probe_started_at: decision.setProbeStartedAt,\n\n set_night_session_start: !!decision.setNightSessionStart,\n clear_night_session_start: !!decision.clearNightSessionStart,\n\n set_block_reason: needsBlockReasonUpdate || !parsed.controllerEnabled,\n block_reason: decision.nextBlockReason,\n\n set_blocked_until: true,\n blocked_until: blockedUntilToWrite,\n\n dynamic_ok: decision.dynamicOk,\n must_stop: decision.mustStop,\n\n short_reason: shortReason,\n reason,\n signature,\n signature_changed: signatureChanged,\n\n should_notify: notifyWanted,\n notify_title: notifyTitle,\n notify_message: notifyMessage,\n\n evcs_priority_active: evcsPriorityActive,\n energy_priority_mode: energyPriorityMode,\n energy_priority_reason: energyPriorityReason,\n evcs_priority_until: evcsPriorityUntil,\n evcs_priority_source: evcsPrioritySource\n },\n\n ha_service_calls: {\n helper_state: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_surplus_state',\n value: decision.state\n }\n },\n\n helper_current_decision: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_surplus_current_decision',\n value: decision.currentDecision\n }\n },\n\n helper_reason: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_surplus_reason',\n value: shortReason\n }\n },\n\n helper_operating_mode: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_operating_mode',\n value: context.operatingMode\n }\n },\n\n helper_source_mode: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_source_mode',\n value: sourceModeLabel\n }\n },\n\n helper_target_current: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_target_current_auto',\n value: round1(desiredCurrentA)\n }\n },\n\n helper_budget_remaining: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_budget_remaining_kwh',\n value: round2(derived.forecastBufferKwh)\n }\n },\n\n helper_projected_soc: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_projected_end_soc_after_charge',\n value: round1(derived.projectedEndSocAfterCharge)\n }\n },\n\n helper_cloud_debt: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_cloud_debt_kwh',\n value: round2(cloud.cloudDebtNextKwh)\n }\n },\n\n helper_dynamic_ok: {\n execute: true,\n domain: 'input_boolean',\n service: decision.dynamicOk ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_dynamic_ok'\n }\n },\n\n helper_must_stop: {\n execute: true,\n domain: 'input_boolean',\n service: decision.mustStop ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_must_stop'\n }\n },\n\n helper_cloud_debt_started_at: {\n execute: true,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_cloud_debt_started_at',\n datetime: cloudDebtStartedAtToWrite\n }\n },\n\n helper_last_current_change_at: {\n execute: setLastCurrentChangeAt,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_last_current_change_at',\n datetime: lastCurrentChangeAtToWrite\n }\n },\n\n helper_last_start_at: {\n execute: setLastStartAt,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_last_start_at',\n datetime: lastStartAtToWrite\n }\n },\n\n helper_last_stop_at: {\n execute: setLastStopAt,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_last_stop_at',\n datetime: lastStopAtToWrite\n }\n },\n\n helper_signature: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_control_last_signature',\n value: signature\n }\n },\n\n helper_block_reason: {\n execute: needsBlockReasonUpdate || !parsed.controllerEnabled,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_last_block_reason',\n value: decision.nextBlockReason\n }\n },\n\n helper_blocked_until: {\n execute: true,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_blocked_until',\n datetime: blockedUntilToWrite\n }\n },\n\n helper_probe_started_at: {\n execute: decision.setProbeStartedAt,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_probe_started_at',\n datetime: probeStartedAtToWrite\n }\n },\n\n helper_evcs_priority_active: {\n execute: true,\n domain: 'input_boolean',\n service: evcsPriorityActive ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_priority_active'\n }\n },\n\n helper_energy_priority_mode: {\n execute: true,\n domain: 'input_select',\n service: 'select_option',\n data: {\n entity_id: 'input_select.victron_energy_priority_mode',\n option: energyPriorityMode\n }\n },\n\n helper_energy_priority_reason: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_energy_priority_reason',\n value: energyPriorityReason\n }\n },\n\n helper_evcs_priority_until: {\n execute: true,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_priority_until',\n datetime: evcsPriorityUntil\n }\n },\n\n helper_night_target_soc: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_night_target_soc',\n value: round1(\n Number.isFinite(night.batteryMorningTargetPct)\n ? night.batteryMorningTargetPct\n : 0\n )\n }\n },\n\n helper_night_grid_trigger_soc: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_night_grid_trigger_soc',\n value: round1(\n Number.isFinite(night.gridTakeoverTriggerPct)\n ? night.gridTakeoverTriggerPct\n : 0\n )\n }\n },\n\n helper_night_preferred_source_mode: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_night_preferred_source_mode',\n value: night.nightPreferredSourceMode || 'blocked'\n }\n },\n\n helper_night_grid_takeover_needed: {\n execute: true,\n domain: 'input_boolean',\n service: night.nightGridTakeoverNeeded ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_night_grid_takeover_needed'\n }\n },\n\n helper_night_battery_recovery_urgent: {\n execute: true,\n domain: 'input_boolean',\n service: night.batteryRecoveryUrgent ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_night_battery_recovery_urgent'\n }\n },\n\n helper_night_session_active: {\n execute: true,\n domain: 'input_boolean',\n service: night.nightSessionActive ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_night_session_active'\n }\n },\n\n helper_night_session_start_soc: {\n execute: decision.setNightSessionStart || decision.clearNightSessionStart,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_night_session_start_soc',\n value: nightSessionStartSocToWrite\n }\n },\n\n helper_night_session_start_at: {\n execute: decision.setNightSessionStart || decision.clearNightSessionStart,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_night_session_start_at',\n value: nightSessionStartAtToWrite\n }\n },\n\n // =================================================\n // VEHICLE DEMAND FILTER HISTORY PERSISTENCE\n // =================================================\n helper_evcs_delivery_ratio_ema: {\n execute: Number.isFinite(vdf?.nextHistory?.evcsDeliveryRatioEma),\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_delivery_ratio_ema',\n value: round3(vdf.nextHistory.evcsDeliveryRatioEma)\n }\n },\n\n helper_evcs_delivery_noise_ema: {\n execute: Number.isFinite(vdf?.nextHistory?.evcsDeliveryNoiseEma),\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_delivery_noise_ema',\n value: round3(vdf.nextHistory.evcsDeliveryNoiseEma)\n }\n },\n\n helper_evcs_no_demand_score_ema: {\n execute: Number.isFinite(vdf?.nextHistory?.evcsNoDemandScoreEma),\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_no_demand_score_ema',\n value: round3(vdf.nextHistory.evcsNoDemandScoreEma)\n }\n },\n\n helper_evcs_no_demand_semantic_active: {\n execute: true,\n domain: 'input_boolean',\n service: vdf?.nextHistory?.evcsNoDemandSemanticActive ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_no_demand_semantic_active'\n }\n },\n\n helper_evcs_no_demand_since_at: {\n execute: Number.isFinite(vdf?.nextHistory?.evcsNoDemandSinceMs),\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_no_demand_since_at',\n datetime: formatParisStampFromMs(vdf.nextHistory.evcsNoDemandSinceMs)\n }\n },\n\n helper_evcs_no_demand_filter_eval_at: {\n execute: Number.isFinite(vdf?.nextHistory?.evcsNoDemandFilterEvalMs),\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_no_demand_filter_eval_at',\n datetime: formatParisStampFromMs(vdf.nextHistory.evcsNoDemandFilterEvalMs)\n }\n },\n\n // =================================================\n // PHYSICAL EDF GRID TAKEOVER\n // =================================================\n edf_general_ok_on: {\n execute:\n ['grid_forced', 'grid_hc_allowed'].includes(context.sourceMode) &&\n !parsed.gridAvailable,\n domain: 'switch',\n service: 'turn_on',\n data: {\n entity_id: 'switch.edf_general_ok_switch'\n }\n },\n\n evcs_auto_start_off: {\n execute: autoStartAction === 'turn_off',\n domain: 'switch',\n service: 'turn_off',\n data: {\n entity_id: 'switch.victron_evcs_auto_start'\n }\n },\n\n evcs_charging: {\n execute: chargingAction !== 'none',\n domain: 'switch',\n service: decision.desiredCharging ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'switch.victron_evcs_charging'\n }\n },\n\n helper_permission: {\n execute: permissionAction !== 'none',\n domain: 'input_boolean',\n service: decision.desiredPermission ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_charge_permission_now'\n }\n },\n\n helper_latch: {\n execute: latchAction !== 'none',\n domain: 'input_boolean',\n service: decision.desiredLatch ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_control_latch'\n }\n },\n\n evcs_set_current: {\n execute: currentAction === 'set',\n domain: 'number',\n service: 'set_value',\n data: {\n entity_id: 'number.victron_evcs_set_current_command',\n value: round1(desiredCurrentA)\n }\n },\n\n logbook: {\n execute: signatureChanged,\n domain: 'logbook',\n service: 'log',\n data: {\n name: 'Victron EVCS Surplus',\n message: shortReason\n }\n },\n\n mobile_notification: {\n execute: notifyWanted,\n domain: cfg.mobileNotifyDomain,\n service: cfg.mobileNotifyService,\n data: {\n title: notifyTitle,\n message: notifyMessage || shortReason\n }\n }\n }\n }\n}];\n"
+ },
+ "type": "n8n-nodes-base.code",
+ "typeVersion": 2,
+ "position": [
+ 720,
+ 32
+ ],
+ "id": "7395a494-03b8-40a3-870d-242fb1f0b381",
+ "name": "HA_SERVICE_CALLS_OUTPUT"
+ },
+ {
+ "parameters": {
+ "method": "POST",
+ "url": "=http://10.10.10.17:8123/api/template\n",
+ "authentication": "genericCredentialType",
+ "genericAuthType": "httpBearerAuth",
+ "sendHeaders": true,
+ "headerParameters": {
+ "parameters": [
+ {
+ "name": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "name": "Accept",
+ "value": "text/plain"
+ }
+ ]
+ },
+ "sendBody": true,
+ "bodyParameters": {
+ "parameters": [
+ {
+ "name": "template",
+ "value": "{{ {\n \"soc_percent\": states(\"sensor.victron_battery_soc\"),\n \"battery_current_a\": states(\"sensor.victron_battery_current\"),\n\n \"pv_power_w\": states(\"sensor.victron_pv_power_system\"),\n \"pv_power_10m_avg_w\": states(\"sensor.victron_pv_power_10m_avg\"),\n \"pv_trend_w\": states(\"sensor.victron_pv_power_trend_w\"),\n\n \"house_ac_power_w\": states(\"sensor.victron_consumption_power_l1\"),\n \"house_ac_power_prev_w\": states(\"sensor.victron_consumption_power_l1\"),\n\n \"grid_power_w\": states(\"sensor.victron_grid_power_l1\"),\n \"grid_state\": states(\"switch.edf_general_ok_switch\"),\n \"victron_acin1_available\": states(\"sensor.victron_ac_in1_available\"),\n\n \"today_remaining_kwh\": states(\"sensor.energy_production_today_remaining\"),\n\n \"evcs_device_connected\": states(\"binary_sensor.victron_evcs_device_connected\"),\n \"evcs_vehicle_connected\": states(\"binary_sensor.victron_evcs_vehicle_connected\"),\n \"evcs_status_raw\": states(\"sensor.victron_evcs_status_raw\"),\n \"evcs_mode_sensor\": states(\"sensor.victron_evcs_mode\"),\n \"evcs_mode_fallback\": states(\"input_text.sensor_victron_evcs_mode\"),\n \"evcs_set_current_a\": states(\"sensor.victron_evcs_set_current\"),\n \"evcs_command_current_a\": states(\"number.victron_evcs_set_current_command\"),\n \"evcs_current_a\": states(\"sensor.victron_evcs_current\"),\n \"evcs_ac_power_w\": states(\"sensor.victron_evcs_ac_power\"),\n \"evcs_charging_switch\": states(\"switch.victron_evcs_charging\"),\n \"evcs_auto_start_switch\": states(\"switch.victron_evcs_auto_start\"),\n\n \"controller_enabled\": states(\"input_boolean.victron_evcs_surplus_auto_enabled\"),\n \"notifications_enabled\": states(\"input_boolean.victron_evcs_surplus_notifications\"),\n \"hard_stop_enabled\": states(\"input_boolean.victron_evcs_hard_stop_enabled\"),\n \"charge_permission_now\": states(\"input_boolean.victron_evcs_charge_permission_now\"),\n \"control_latch\": states(\"input_boolean.victron_evcs_control_latch\"),\n \"departure_priority_enabled\": states(\"input_boolean.victron_evcs_departure_priority_enabled\"),\n \"forecast_mode_enabled\": states(\"input_boolean.victron_evcs_forecast_mode_enabled\"),\n\n \"target_current_auto_a\": states(\"input_number.victron_evcs_target_current_auto\"),\n \"budget_remaining_kwh\": states(\"input_number.victron_evcs_budget_remaining_kwh\"),\n \"projected_end_soc_after_charge\": states(\"input_number.victron_evcs_projected_end_soc_after_charge\"),\n\n \"pv_start_min_w\": states(\"input_number.victron_evcs_pv_start_min_w\"),\n \"pv_hold_min_w\": states(\"input_number.victron_evcs_pv_hold_min_w\"),\n \"house_ac_headroom_w\": states(\"input_number.victron_evcs_house_ac_headroom_w\"),\n \"battery_discharge_soft_a\": states(\"input_number.victron_evcs_battery_discharge_soft_a\"),\n \"battery_discharge_hard_a\": states(\"input_number.victron_evcs_battery_discharge_hard_a\"),\n \"battery_reserve_up_a\": states(\"input_number.victron_evcs_battery_reserve_up_a\"),\n \"battery_neutral_band_a\": states(\"input_number.victron_evcs_battery_neutral_band_a\"),\n \"soc_start_min\": states(\"input_number.victron_evcs_soc_start_min\"),\n \"soc_stop_min\": states(\"input_number.victron_evcs_soc_stop_min\"),\n \"current_min_a\": states(\"input_number.victron_evcs_current_min_a\"),\n \"current_max_auto_a\": states(\"input_number.victron_evcs_current_max_auto_a\"),\n \"current_step_up_a\": states(\"input_number.victron_evcs_current_step_up_a\"),\n \"current_step_down_a\": states(\"input_number.victron_evcs_current_step_down_a\"),\n \"current_change_cooldown_seconds\": states(\"input_number.victron_evcs_current_change_cooldown_seconds\"),\n \"min_run_minutes\": states(\"input_number.victron_evcs_min_run_minutes\"),\n \"min_stop_minutes\": states(\"input_number.victron_evcs_min_stop_minutes\"),\n\n \"required_energy_kwh\": states(\"input_number.victron_evcs_required_energy_kwh\"),\n \"soc_comfort_target\": states(\"input_number.victron_evcs_soc_comfort_target\"),\n \"end_of_day_soc_guard\": states(\"input_number.victron_evcs_end_of_day_soc_guard\"),\n\n \"base_load_remaining_kwh\": states(\"input_number.victron_base_load_remaining_kwh\"),\n \"flexible_safety_margin_kwh\": states(\"input_number.victron_flexible_safety_margin_kwh\"),\n \"end_of_day_soc_target\": states(\"input_number.victron_end_of_day_soc_target\"),\n \"ac_loads_max_safe_w\": states(\"input_number.victron_ac_loads_max_safe_w\"),\n\n \"battery_full_soc\": states(\"input_number.victron_evcs_battery_full_soc\"),\n \"battery_full_forecast_min_kwh\": states(\"input_number.victron_evcs_battery_full_forecast_min_kwh\"),\n \"forecast_commit_soc_floor\": states(\"input_number.victron_evcs_forecast_commit_soc_floor\"),\n \"forecast_commit_min_kwh\": states(\"input_number.victron_evcs_forecast_commit_min_kwh\"),\n\n \"cloud_debt_kwh\": states(\"input_number.victron_evcs_cloud_debt_kwh\"),\n \"cloud_debt_max_kwh\": states(\"input_number.victron_evcs_cloud_debt_max_kwh\"),\n \"cloud_tolerance_minutes\": states(\"input_number.victron_evcs_cloud_tolerance_minutes\"),\n \"cloud_debt_started_at\": states(\"input_datetime.victron_evcs_cloud_debt_started_at\"),\n \"operating_mode_helper\": states(\"input_text.victron_evcs_operating_mode\"),\n\n \"departure_time\": states(\"input_datetime.victron_evcs_departure_time\"),\n \"last_start_at\": states(\"input_datetime.victron_evcs_last_start_at\"),\n \"last_stop_at\": states(\"input_datetime.victron_evcs_last_stop_at\"),\n \"last_current_change_at\": states(\"input_datetime.victron_evcs_last_current_change_at\"),\n\n \"day_start_time\": states(\"input_datetime.victron_flexible_day_start_time\"),\n \"day_stop_time\": states(\"input_datetime.victron_flexible_day_stop_time\"),\n\n \"vebus_overload\": states(\"binary_sensor.victron_ve_bus_overload\"),\n \"vebus_l1_overload\": states(\"binary_sensor.victron_ve_bus_l1_overload\"),\n \"vebus_low_battery\": states(\"binary_sensor.victron_ve_bus_low_battery\"),\n \"vebus_l1_low_battery\": states(\"binary_sensor.victron_ve_bus_l1_low_battery\"),\n\n \"vebus_ac_out_l1_power_w\": states(\"sensor.victron_ve_bus_ac_out_l1_power\"),\n \"vebus_ac_out_l1_current_a\": states(\"sensor.victron_ve_bus_ac_out_l1_current\"),\n \"vebus_ac_out_l1_voltage_v\": states(\"sensor.victron_ve_bus_ac_out_l1_voltage\"),\n\n \"vebus_dc_voltage_v\": states(\"sensor.victron_ve_bus_dc_voltage\"),\n \"vebus_dc_current_a\": states(\"sensor.victron_ve_bus_dc_current\"),\n \"vebus_dc_power_w\": states(\"sensor.victron_ve_bus_dc_power_system\"),\n\n \"vebus_state\": states(\"sensor.victron_ve_bus_state\"),\n \"vebus_main_state\": states(\"sensor.victron_ve_bus_main_state\"),\n\n \"last_signature\": states(\"input_text.victron_evcs_control_last_signature\"),\n\n \"hc_night_start_time\": states(\"input_datetime.victron_evcs_hc_night_start\"),\n \"hc_night_stop_time\": states(\"input_datetime.victron_evcs_hc_night_stop\"),\n \"morning_start_time\": states(\"input_datetime.victron_evcs_morning_start\"),\n \"morning_stop_time\": states(\"input_datetime.victron_evcs_morning_stop\"),\n \"hc_midday_start_time\": states(\"input_datetime.victron_evcs_hc_midday_start\"),\n \"hc_midday_stop_time\": states(\"input_datetime.victron_evcs_hc_midday_stop\"),\n \"evening_start_time\": states(\"input_datetime.victron_evcs_evening_start\"),\n \"evening_stop_time\": states(\"input_datetime.victron_evcs_evening_stop\"),\n\n \"allow_grid_hc_night\": states(\"input_boolean.victron_evcs_allow_grid_hc_night\"),\n \"allow_grid_hc_midday\": states(\"input_boolean.victron_evcs_allow_grid_hc_midday\"),\n \"probe_enabled\": states(\"input_boolean.victron_evcs_probe_enabled\"),\n\n \"probe_minutes\": states(\"input_number.victron_evcs_probe_minutes\"),\n \"probe_retry_minutes\": states(\"input_number.victron_evcs_probe_retry_minutes\"),\n \"probe_current_a\": states(\"input_number.victron_evcs_probe_current_a\"),\n\n \"grid_battery_soft_a\": states(\"input_number.victron_evcs_grid_battery_soft_a\"),\n \"grid_battery_hard_a\": states(\"input_number.victron_evcs_grid_battery_hard_a\"),\n\n \"morning_probe_pv_avg_min_w\": states(\"input_number.victron_evcs_morning_probe_pv_avg_min_w\"),\n \"morning_probe_pv_trend_min_w\": states(\"input_number.victron_evcs_morning_probe_pv_trend_min_w\"),\n \"midday_probe_pv_avg_min_w\": states(\"input_number.victron_evcs_midday_probe_pv_avg_min_w\"),\n\n \"morning_soc_min\": states(\"input_number.victron_evcs_morning_soc_min\"),\n \"midday_soc_min\": states(\"input_number.victron_evcs_midday_soc_min\"),\n \"evening_soc_start_min\": states(\"input_number.victron_evcs_evening_soc_start_min\"),\n \"evening_soc_stop_min\": states(\"input_number.victron_evcs_evening_soc_stop_min\"),\n\n \"morning_battery_max_discharge_a\": states(\"input_number.victron_evcs_morning_battery_max_discharge_a\"),\n \"midday_battery_max_discharge_a\": states(\"input_number.victron_evcs_midday_battery_max_discharge_a\"),\n \"evening_battery_max_discharge_a\": states(\"input_number.victron_evcs_evening_battery_max_discharge_a\"),\n\n \"last_block_reason\": states(\"input_text.victron_evcs_last_block_reason\"),\n \"blocked_until\": states(\"input_datetime.victron_evcs_blocked_until\"),\n \"probe_started_at\": states(\"input_datetime.victron_evcs_probe_started_at\"),\n\n \"victron_evcs_strategy\": states(\"input_select.victron_evcs_strategy\"),\n \"evcs_priority_manual_override\": states(\"input_boolean.victron_evcs_priority_manual_override\"),\n \"evcs_priority_manual_mode\": states(\"input_select.victron_evcs_priority_manual_mode\"),\n \"evcs_priority_manual_reason\": states(\"input_text.victron_evcs_priority_manual_reason\"),\n \"evcs_priority_manual_until\": states(\"input_datetime.victron_evcs_priority_manual_until\"),\n\n \"house_spike_grace_minutes\": states(\"input_number.victron_evcs_house_spike_grace_minutes\"),\n \"solar_potential_grace_minutes\": states(\"input_number.victron_evcs_solar_potential_grace_minutes\"),\n \"step_up_cooldown_seconds\": states(\"input_number.victron_evcs_step_up_cooldown_seconds\"),\n \"step_down_cooldown_seconds\": states(\"input_number.victron_evcs_step_down_cooldown_seconds\"),\n \"force_mode_auto_revert_minutes\": states(\"input_number.victron_evcs_force_mode_auto_revert_minutes\"),\n\n \"overload_step_down_a\": states(\"input_number.victron_evcs_overload_step_down_a\"),\n \"overload_hold_seconds\": states(\"input_number.victron_evcs_overload_hold_seconds\"),\n \"overload_stop_after_seconds\": states(\"input_number.victron_evcs_overload_stop_after_seconds\"),\n\n \"batt_discharge_soft_gt90_a\": states(\"input_number.victron_evcs_batt_discharge_soft_gt90_a\"),\n \"batt_discharge_hard_gt90_a\": states(\"input_number.victron_evcs_batt_discharge_hard_gt90_a\"),\n \"batt_charge_reserve_gt90_a\": states(\"input_number.victron_evcs_batt_charge_reserve_gt90_a\"),\n \"batt_soft_tol_gt90_min\": states(\"input_number.victron_evcs_batt_soft_tol_gt90_min\"),\n \"batt_hard_tol_gt90_sec\": states(\"input_number.victron_evcs_batt_hard_tol_gt90_sec\"),\n \"batt_recovery_gt90_min\": states(\"input_number.victron_evcs_batt_recovery_gt90_min\"),\n\n \"batt_discharge_soft_80_90_a\": states(\"input_number.victron_evcs_batt_discharge_soft_80_90_a\"),\n \"batt_discharge_hard_80_90_a\": states(\"input_number.victron_evcs_batt_discharge_hard_80_90_a\"),\n \"batt_charge_reserve_80_90_a\": states(\"input_number.victron_evcs_batt_charge_reserve_80_90_a\"),\n \"batt_soft_tol_80_90_min\": states(\"input_number.victron_evcs_batt_soft_tol_80_90_min\"),\n \"batt_hard_tol_80_90_sec\": states(\"input_number.victron_evcs_batt_hard_tol_80_90_sec\"),\n \"batt_recovery_80_90_min\": states(\"input_number.victron_evcs_batt_recovery_80_90_min\"),\n\n \"batt_discharge_soft_60_80_a\": states(\"input_number.victron_evcs_batt_discharge_soft_60_80_a\"),\n \"batt_discharge_hard_60_80_a\": states(\"input_number.victron_evcs_batt_discharge_hard_60_80_a\"),\n \"batt_charge_reserve_60_80_a\": states(\"input_number.victron_evcs_batt_charge_reserve_60_80_a\"),\n \"batt_soft_tol_60_80_min\": states(\"input_number.victron_evcs_batt_soft_tol_60_80_min\"),\n \"batt_hard_tol_60_80_sec\": states(\"input_number.victron_evcs_batt_hard_tol_60_80_sec\"),\n \"batt_recovery_60_80_min\": states(\"input_number.victron_evcs_batt_recovery_60_80_min\"),\n\n \"batt_discharge_soft_40_60_a\": states(\"input_number.victron_evcs_batt_discharge_soft_40_60_a\"),\n \"batt_discharge_hard_40_60_a\": states(\"input_number.victron_evcs_batt_discharge_hard_40_60_a\"),\n \"batt_charge_reserve_40_60_a\": states(\"input_number.victron_evcs_batt_charge_reserve_40_60_a\"),\n \"batt_soft_tol_40_60_min\": states(\"input_number.victron_evcs_batt_soft_tol_40_60_min\"),\n \"batt_hard_tol_40_60_sec\": states(\"input_number.victron_evcs_batt_hard_tol_40_60_sec\"),\n \"batt_recovery_40_60_min\": states(\"input_number.victron_evcs_batt_recovery_40_60_min\"),\n\n \"batt_discharge_soft_25_40_a\": states(\"input_number.victron_evcs_batt_discharge_soft_25_40_a\"),\n \"batt_discharge_hard_25_40_a\": states(\"input_number.victron_evcs_batt_discharge_hard_25_40_a\"),\n \"batt_charge_reserve_25_40_a\": states(\"input_number.victron_evcs_batt_charge_reserve_25_40_a\"),\n \"batt_soft_tol_25_40_min\": states(\"input_number.victron_evcs_batt_soft_tol_25_40_min\"),\n \"batt_hard_tol_25_40_sec\": states(\"input_number.victron_evcs_batt_hard_tol_25_40_sec\"),\n \"batt_recovery_25_40_min\": states(\"input_number.victron_evcs_batt_recovery_25_40_min\"),\n\n \"batt_discharge_soft_lt25_a\": states(\"input_number.victron_evcs_batt_discharge_soft_lt25_a\"),\n \"batt_discharge_hard_lt25_a\": states(\"input_number.victron_evcs_batt_discharge_hard_lt25_a\"),\n \"batt_charge_reserve_lt25_a\": states(\"input_number.victron_evcs_batt_charge_reserve_lt25_a\"),\n \"batt_soft_tol_lt25_min\": states(\"input_number.victron_evcs_batt_soft_tol_lt25_min\"),\n \"batt_hard_tol_lt25_sec\": states(\"input_number.victron_evcs_batt_hard_tol_lt25_sec\"),\n \"batt_recovery_lt25_min\": states(\"input_number.victron_evcs_batt_recovery_lt25_min\"),\n\n \"evcs_delivery_ratio_ema\": states(\"input_number.victron_evcs_delivery_ratio_ema\"),\n \"evcs_delivery_noise_ema\": states(\"input_number.victron_evcs_delivery_noise_ema\"),\n \"evcs_no_demand_score_ema\": states(\"input_number.victron_evcs_no_demand_score_ema\"),\n \"evcs_no_demand_semantic_active\": states(\"input_boolean.victron_evcs_no_demand_semantic_active\"),\n \"evcs_no_demand_since_at\": states(\"input_datetime.victron_evcs_no_demand_since_at\"),\n \"evcs_no_demand_filter_eval_at\": states(\"input_datetime.victron_evcs_no_demand_filter_eval_at\")\n} | tojson }}\n"
+ }
+ ]
+ },
+ "options": {}
+ },
+ "id": "3492fd88-8eb4-4cb2-85f2-68299d133630",
+ "name": "HA Snapshot EVCS Surplus Controller V2.2",
+ "type": "n8n-nodes-base.httpRequest",
+ "typeVersion": 4.2,
+ "position": [
+ -400,
+ 32
+ ],
+ "credentials": {
+ "httpBearerAuth": {
+ "id": "uv3sknYYigxBkaVN",
+ "name": "Bearer Auth account 2"
+ }
+ }
+ },
+ {
+ "parameters": {
+ "jsCode": "function minutesSince(ts, nowMs) {\n if (ts === null || nowMs === null) return null;\n return Math.max(0, (nowMs - ts) / 60000);\n}\n\nfunction secondsSince(ts, nowMs) {\n if (ts === null || nowMs === null) return null;\n return Math.max(0, (nowMs - ts) / 1000);\n}\n\nfunction isWithinWindow(nowMin, startMin, stopMin) {\n if (!Number.isFinite(startMin) || !Number.isFinite(stopMin)) return false;\n if (startMin === stopMin) return false;\n if (startMin < stopMin) return nowMin >= startMin && nowMin < stopMin;\n return nowMin >= startMin || nowMin < stopMin;\n}\n\nfunction clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nconst prev = $input.first().json;\nconst {\n input,\n nowMs,\n now,\n cfg,\n parsed,\n params,\n history,\n batteryTune\n} = prev;\n\n// =====================================================\n// STRATEGY\n// =====================================================\nconst userStrategy = (params.userStrategyRaw ?? 'auto_solar').toString().trim().toLowerCase();\n\nconst isStopMode = userStrategy === 'stop';\nconst forceGridRequested = userStrategy === 'force_grid_now';\nconst forceChargeRequested = userStrategy === 'force_charge_now';\nconst isForceMode = forceGridRequested || forceChargeRequested;\n\n// =====================================================\n// LIFECYCLE / TIMING\n// =====================================================\nconst minutesSinceStart = minutesSince(history.lastStartMs, nowMs);\nconst minutesSinceStop = minutesSince(history.lastStopMs, nowMs);\nconst secondsSinceCurrentChange = secondsSince(history.lastCurrentChangeMs, nowMs);\nconst minutesSinceProbeStart = minutesSince(history.probeStartedMs, nowMs);\n\nconst minRunElapsed =\n minutesSinceStart === null ? true : minutesSinceStart >= params.minRunMinutes;\n\nconst minStopElapsed =\n minutesSinceStop === null ? true : minutesSinceStop >= params.minStopMinutes;\n\nconst currentCooldownBaseSec = parsed.evcsChargingSwitchOn\n ? batteryTune.global.stepDownCooldownSeconds\n : batteryTune.global.stepUpCooldownSeconds;\n\nconst currentCooldownElapsed =\n secondsSinceCurrentChange === null\n ? true\n : secondsSinceCurrentChange >= Math.max(\n params.currentChangeCooldownSeconds,\n currentCooldownBaseSec\n );\n\n// =====================================================\n// DEPARTURE PRIORITY\n// =====================================================\nconst minutesUntilDeparture =\n params.departureTimeMin === null\n ? null\n : (() => {\n const rawDiff = params.departureTimeMin - now.minNow;\n return rawDiff >= 0 ? rawDiff : (1440 - now.minNow + params.departureTimeMin);\n })();\n\nconst departurePriorityActive =\n parsed.departurePriorityEnabled &&\n Number.isFinite(minutesUntilDeparture) &&\n minutesUntilDeparture >= 0 &&\n minutesUntilDeparture <= cfg.departurePriorityWindowMinutes &&\n parsed.evcsVehicleConnected;\n\n// =====================================================\n// MANUAL CHARGE SEMANTICS\n// IMPORTANT:\n// force_charge_now = bypass fenêtres pour la charge,\n// MAIS PAS demande implicite de réseau.\n// =====================================================\nconst manualChargeBypassWindows = forceChargeRequested;\nconst manualChargeLocalRequested = forceChargeRequested && !forceGridRequested;\n\n// Compatibilité ascendante : on garde le champ dans context,\n// mais on le neutralise volontairement.\nconst forceChargeDepartureGridOverride = false;\n\n// =====================================================\n// SYSTEM DERIVED VALUES\n// =====================================================\nconst acVoltageRef =\n Number.isFinite(parsed.vebusAcOutVoltageV) && parsed.vebusAcOutVoltageV > 180\n ? parsed.vebusAcOutVoltageV\n : cfg.nominalAcVoltageV;\n\nconst batteryVoltageRef =\n Number.isFinite(parsed.vebusDcVoltageV) && parsed.vebusDcVoltageV > 0\n ? parsed.vebusDcVoltageV\n : 51.2;\n\nconst houseNonEvcsW = Math.max(0, parsed.houseAcPowerW - parsed.evcsAcPowerW);\nconst vebusNonEvcsW = Math.max(0, parsed.vebusAcOutPowerW - parsed.evcsAcPowerW);\n\nconst totalVebusSoftCeilingW = Math.max(6500, params.acLoadsMaxSafeW || 8500);\nconst totalVebusHardCeilingW = totalVebusSoftCeilingW + 400;\n\nconst safeEvcsByDedicatedCapW = Math.max(0, cfg.evcsDedicatedMaxW - cfg.systemReserveW);\nconst safeEvcsByVebusW = Math.max(0, totalVebusSoftCeilingW - vebusNonEvcsW - cfg.systemReserveW);\nconst safeEvcsCombinedW = Math.max(0, Math.min(safeEvcsByDedicatedCapW, safeEvcsByVebusW));\n\nconst systemCapCurrentA = Math.max(0, Math.floor(safeEvcsCombinedW / acVoltageRef));\nconst physicalCapA = clamp(systemCapCurrentA, 0, params.currentMaxAutoA);\nconst canRunAtMinCurrent = physicalCapA >= params.currentMinA;\n\nconst endDayGuardSoc = Math.max(params.endOfDaySocGuard, params.endOfDaySocTarget);\n\nconst energyNeededForEndDayGuardKwh = Math.max(\n 0,\n ((endDayGuardSoc - parsed.soc) / 100) * cfg.batteryUsableCapacityKwh\n);\n\nconst forecastBufferKwh =\n parsed.todayRemainingKwh -\n params.baseLoadRemainingKwh -\n params.flexibleSafetyMarginKwh -\n params.requiredEnergyKwh -\n energyNeededForEndDayGuardKwh;\n\nconst projectedEndSocAfterCharge = clamp(\n parsed.soc + (\n (\n parsed.todayRemainingKwh -\n params.baseLoadRemainingKwh -\n params.flexibleSafetyMarginKwh -\n params.requiredEnergyKwh\n ) / cfg.batteryUsableCapacityKwh\n ) * 100,\n 0,\n 100\n);\n\nconst currentReferenceA = clamp(\n Number.isFinite(parsed.evcsCommandCurrentA)\n ? parsed.evcsCommandCurrentA\n : Number.isFinite(parsed.evcsSetCurrentA)\n ? parsed.evcsSetCurrentA\n : params.targetCurrentAutoA,\n 0,\n 32\n);\n\nconst anyVebusOverload = parsed.vebusOverload || parsed.vebusL1Overload;\nconst anyVebusLowBattery = parsed.vebusLowBattery || parsed.vebusL1LowBattery;\n\nconst vebusAcOutSoftReached = parsed.vebusAcOutPowerW >= totalVebusSoftCeilingW;\nconst vebusAcOutHardReached = parsed.vebusAcOutPowerW >= totalVebusHardCeilingW;\n\nconst vebusDcVoltageSoftLow =\n Number.isFinite(parsed.vebusDcVoltageV) &&\n parsed.vebusDcVoltageV > 0 &&\n parsed.vebusDcVoltageV <= cfg.vebusDcVoltageSoftFloorV;\n\nconst vebusDcVoltageHardLow =\n Number.isFinite(parsed.vebusDcVoltageV) &&\n parsed.vebusDcVoltageV > 0 &&\n parsed.vebusDcVoltageV <= cfg.vebusDcVoltageHardFloorV;\n\nconst houseLoadHighVsHeadroom = houseNonEvcsW > params.houseAcHeadroomW;\n\n// =====================================================\n// WINDOWS / PROFILE\n// =====================================================\nconst inNightHcWindow = isWithinWindow(now.minNow, params.hcNightStartMin, params.hcNightStopMin);\nconst inMorningWindow = isWithinWindow(now.minNow, params.morningStartMin, params.morningStopMin);\nconst inMiddayWindow = isWithinWindow(now.minNow, params.hcMiddayStartMin, params.hcMiddayStopMin);\nconst inEveningWindow = isWithinWindow(now.minNow, params.eveningStartMin, params.eveningStopMin);\n\nconst profile =\n inNightHcWindow ? 'night_hc' :\n inMorningWindow ? 'morning_solar' :\n inMiddayWindow ? 'midday_hc_or_solar' :\n inEveningWindow ? 'evening_high_soc' :\n 'outside_window';\n\n// =====================================================\n// EVCS NIGHT PREVIEW ENGINE\n// =====================================================\nconst nightSessionActive =\n profile === 'night_hc' &&\n parsed.evcsVehicleConnected;\n\nconst nightSessionStartSocPct =\n nightSessionActive\n ? (\n Number.isFinite(history.nightSessionStartSocPct)\n ? history.nightSessionStartSocPct\n : parsed.soc\n )\n : null;\n\nconst nightSessionStartAt =\n nightSessionActive\n ? (history.nightSessionStartAt || '')\n : '';\n\nconst nightSessionNeedsCapture =\n nightSessionActive &&\n (\n !Number.isFinite(history.nightSessionStartSocPct) ||\n !history.nightSessionStartAt\n );\n\nconst nightSessionNeedsClear =\n !nightSessionActive &&\n (\n Number.isFinite(history.nightSessionStartSocPct) ||\n !!history.nightSessionStartAt\n );\n\nconst batteryHardFloorPct =\n params.evcsNightBatteryHardFloorPct;\n\nconst batteryMorningTargetPct =\n parsed.todayRemainingKwh >= params.evcsNightStrongForecastKwh\n ? params.evcsNightTargetStrongForecastPct\n : parsed.todayRemainingKwh >= params.evcsNightMediumForecastKwh\n ? params.evcsNightTargetMediumForecastPct\n : params.evcsNightTargetWeakForecastPct;\n\nconst maxBatteryDischargePoints =\n nightSessionActive && Number.isFinite(nightSessionStartSocPct)\n ? Math.max(0, nightSessionStartSocPct - batteryMorningTargetPct)\n : 0;\n\nconst gridTakeoverTriggerPct =\n Math.max(batteryHardFloorPct, batteryMorningTargetPct);\n\nconst batteryBelowHardFloor =\n parsed.soc <= batteryHardFloorPct;\n\nconst batteryBelowMorningTarget =\n parsed.soc < batteryMorningTargetPct;\n\nconst batteryAtOrBelowGridTakeoverTrigger =\n parsed.soc <= gridTakeoverTriggerPct;\n\nconst batteryKwhMissingToTarget =\n nightSessionActive\n ? Math.max(\n 0,\n ((batteryMorningTargetPct - parsed.soc) / 100) * cfg.batteryUsableCapacityKwh\n )\n : 0;\n\nconst batteryRecoveryMinutesNeeded =\n nightSessionActive && params.evcsNightBatteryRecoveryPowerKw > 0\n ? (batteryKwhMissingToTarget / params.evcsNightBatteryRecoveryPowerKw) * 60\n : 0;\n\nconst batteryRecoveryUrgent =\n nightSessionActive &&\n Number.isFinite(minutesUntilDeparture) &&\n minutesUntilDeparture >= 0 &&\n (\n batteryRecoveryMinutesNeeded + params.evcsNightDepartureSafetyMin\n >= minutesUntilDeparture\n );\n\nconst batteryAssistAllowed =\n nightSessionActive &&\n parsed.soc > batteryHardFloorPct &&\n parsed.soc > gridTakeoverTriggerPct &&\n !batteryRecoveryUrgent;\n\nconst nightGridTakeoverNeeded =\n nightSessionActive &&\n (\n batteryBelowHardFloor ||\n batteryAtOrBelowGridTakeoverTrigger ||\n batteryRecoveryUrgent\n );\n\nconst nightPreferredSourceMode =\n !nightSessionActive\n ? 'blocked'\n : batteryAssistAllowed\n ? 'battery_assist_night'\n : (\n batteryBelowHardFloor ||\n batteryBelowMorningTarget ||\n batteryRecoveryUrgent\n )\n ? 'grid_forced'\n : 'grid_hc_allowed';\n\n// =====================================================\n// PROBES / SOLAR POTENTIAL\n// =====================================================\n// IMPORTANT:\n// On ne met plus effectivePvStartMinW à 0 en force_charge_now,\n// sinon la nuit 0W >= 0W ferait croire à un faux signal solaire.\nconst effectivePvStartMinW = params.pvStartMinW;\n\nconst morningProbeEligible =\n profile === 'morning_solar' &&\n params.probeEnabled &&\n parsed.pvPowerAvg10mW >= params.morningProbePvAvgMinW &&\n parsed.pvTrendW >= params.morningProbePvTrendMinW &&\n parsed.soc >= params.morningSocMin &&\n parsed.batteryCurrentA >= params.morningBatteryMaxDischargeA;\n\nconst middayProbeEligible =\n profile === 'midday_hc_or_solar' &&\n params.probeEnabled &&\n parsed.pvPowerAvg10mW >= params.middayProbePvAvgMinW &&\n parsed.soc >= params.middaySocMin &&\n parsed.batteryCurrentA >= params.middayBatteryMaxDischargeA;\n\nconst cloudDebtOkForPotential = params.cloudDebtPrevKwh <= params.cloudDebtMaxKwh;\nconst batteryOkForPotential = parsed.batteryCurrentA >= -Math.max(1, params.batteryNeutralBandA);\n\nconst isHighBatteryForPotential =\n parsed.soc >= 85 &&\n forecastBufferKwh > 0 &&\n cloudDebtOkForPotential &&\n batteryOkForPotential;\n\nconst potentialAllowed = userStrategy !== 'auto_solar';\n\n// IMPORTANT:\n// On impose un vrai PV > 0 pour le test sur pvPowerW.\n// Ça évite qu'en force_charge_now la nuit, 0W soit pris pour un signal solaire.\nconst strongSolarSignal =\n (\n parsed.pvPowerW > 0 &&\n parsed.pvPowerW >= effectivePvStartMinW\n ) ||\n parsed.pvPowerAvg10mW >= params.pvHoldMinW ||\n (departurePriorityActive && parsed.pvPowerAvg10mW >= params.pvHoldMinW);\n\n// =====================================================\n// SOURCE MODE\n// SÉMANTIQUE CORRIGÉE:\n// - force_charge_now bypass les fenêtres\n// - mais ne devient jamais grid_forced à lui seul\n// - outside_window = pas de réseau programmé\n// - la charge locale peut rester autorisée\n// =====================================================\nlet sourceMode = 'blocked';\n\nif (isStopMode) {\n sourceMode = 'blocked';\n}\n\nelse if (forceGridRequested) {\n // Demande explicite de réseau.\n sourceMode = 'grid_forced';\n}\n\nelse if (forceChargeRequested) {\n // IMPORTANT:\n // force_charge_now = intention de charger maintenant,\n // sans exiger le réseau.\n\n if (strongSolarSignal) {\n sourceMode = 'solar_only';\n }\n\n else if (nightSessionActive && batteryAssistAllowed) {\n sourceMode = 'battery_assist_night';\n }\n\n else if (isHighBatteryForPotential && potentialAllowed) {\n sourceMode = 'solar_potential';\n }\n\n else if (profile === 'midday_hc_or_solar' && middayProbeEligible) {\n sourceMode = 'hybrid_probe';\n }\n\n else if (profile === 'morning_solar' && morningProbeEligible) {\n sourceMode = 'hybrid_probe';\n }\n\n else {\n // Fallback manuel local :\n // on n'impose pas le grid, on laisse l'étage aval décider\n // selon les garde-fous physiques / batterie.\n sourceMode = 'solar_potential';\n }\n}\n\nelse if (profile === 'night_hc') {\n if (nightSessionActive) {\n sourceMode = nightPreferredSourceMode;\n } else {\n sourceMode = 'blocked';\n }\n}\n\nelse if (profile === 'midday_hc_or_solar' || profile === 'morning_solar') {\n if (strongSolarSignal) {\n sourceMode = 'solar_only';\n }\n\n else if (isHighBatteryForPotential && potentialAllowed) {\n sourceMode = 'solar_potential';\n }\n\n else if (profile === 'midday_hc_or_solar' && middayProbeEligible) {\n sourceMode = 'hybrid_probe';\n }\n\n else if (profile === 'morning_solar' && morningProbeEligible) {\n sourceMode = 'hybrid_probe';\n }\n\n else if (profile === 'midday_hc_or_solar' && params.allowGridHcMidday && parsed.gridAvailable) {\n sourceMode = 'grid_hc_allowed';\n }\n\n else {\n sourceMode = 'blocked';\n }\n}\n\nelse if (profile === 'evening_high_soc') {\n if (strongSolarSignal) {\n sourceMode = 'solar_only';\n }\n\n else if (isHighBatteryForPotential && potentialAllowed) {\n sourceMode = 'solar_potential';\n }\n\n else {\n sourceMode = 'blocked';\n }\n}\n\nelse if (profile === 'outside_window') {\n if (strongSolarSignal) {\n sourceMode = 'solar_only';\n }\n\n else if (isHighBatteryForPotential && potentialAllowed) {\n sourceMode = 'solar_potential';\n }\n\n // FIX V2.5: En auto_solar_plus, hors fenêtre, batteries hautes (>=85%),\n // et peu de charge maison : permettre solar_potential pour provoquer le PV.\n else if (userStrategy === 'auto_solar_plus' && parsed.soc >= 85 && houseNonEvcsW < 1500) {\n sourceMode = 'solar_potential';\n }\n\n else {\n // IMPORTANT:\n // hors fenêtre = pas de réseau programmé.\n // Donc on ne bascule plus vers grid_forced juste parce que le réseau existe.\n sourceMode = 'blocked';\n }\n}\n\nconst operatingMode = profile;\n\n// =====================================================\n// OUTPUT\n// =====================================================\nreturn [{\n json: {\n ...prev,\n\n strategy: {\n userStrategy,\n isStopMode,\n isForceMode,\n forceGridRequested,\n forceChargeRequested,\n },\n\n lifecycle: {\n minutesSinceStart,\n minutesSinceStop,\n secondsSinceCurrentChange,\n minutesSinceProbeStart,\n minRunElapsed,\n minStopElapsed,\n currentCooldownElapsed,\n minutesUntilDeparture,\n },\n\n windows: {\n inNightHcWindow,\n inMorningWindow,\n inMiddayWindow,\n inEveningWindow,\n },\n\n derived: {\n acVoltageRef,\n batteryVoltageRef,\n houseNonEvcsW,\n vebusNonEvcsW,\n totalVebusSoftCeilingW,\n totalVebusHardCeilingW,\n safeEvcsByDedicatedCapW,\n safeEvcsByVebusW,\n safeEvcsCombinedW,\n systemCapCurrentA,\n physicalCapA,\n canRunAtMinCurrent,\n endDayGuardSoc,\n energyNeededForEndDayGuardKwh,\n forecastBufferKwh,\n projectedEndSocAfterCharge,\n currentReferenceA,\n anyVebusOverload,\n anyVebusLowBattery,\n vebusAcOutSoftReached,\n vebusAcOutHardReached,\n vebusDcVoltageSoftLow,\n vebusDcVoltageHardLow,\n houseLoadHighVsHeadroom,\n },\n\n context: {\n departurePriorityActive,\n forceChargeDepartureGridOverride,\n manualChargeBypassWindows,\n manualChargeLocalRequested,\n morningProbeEligible,\n middayProbeEligible,\n isHighBatteryForPotential,\n strongSolarSignal,\n effectivePvStartMinW,\n profile,\n operatingMode,\n sourceMode,\n },\n\n nightEvcsPreview: {\n nightSessionActive,\n nightSessionNeedsCapture,\n nightSessionNeedsClear,\n nightSessionStartSocPct,\n nightSessionStartAt,\n\n batteryHardFloorPct,\n batteryMorningTargetPct,\n maxBatteryDischargePoints,\n gridTakeoverTriggerPct,\n\n batteryBelowHardFloor,\n batteryBelowMorningTarget,\n batteryAtOrBelowGridTakeoverTrigger,\n\n batteryKwhMissingToTarget,\n batteryRecoveryMinutesNeeded,\n batteryRecoveryUrgent,\n\n batteryAssistAllowed,\n nightGridTakeoverNeeded,\n nightPreferredSourceMode,\n }\n }\n}];\n"
+ },
+ "type": "n8n-nodes-base.code",
+ "typeVersion": 2,
+ "position": [
+ 48,
+ 32
+ ],
+ "id": "3306e8aa-5838-43af-a9af-4e346b28aac5",
+ "name": "WINDOWS_SOURCEV2"
+ },
+ {
+ "parameters": {
+ "jsCode": "function clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nfunction round1(v) {\n return Math.round(v * 10) / 10;\n}\n\nfunction round3(v) {\n return Math.round(v * 1000) / 1000;\n}\n\nfunction to2(n) {\n return String(n).padStart(2, '0');\n}\n\nfunction finiteOr(v, fallback) {\n return Number.isFinite(v) ? v : fallback;\n}\n\nfunction sigmoid(x) {\n return 1 / (1 + Math.exp(-x));\n}\n\nfunction alphaFromTau(dtSec, tauSec) {\n const dt = Math.max(1, finiteOr(dtSec, 60));\n const tau = Math.max(1, finiteOr(tauSec, 60));\n return Math.min(1, Math.max(0.001, 1 - Math.exp(-dt / tau)));\n}\n\nfunction ema(prev, sample, alpha) {\n if (!Number.isFinite(sample)) return Number.isFinite(prev) ? prev : 0;\n if (!Number.isFinite(prev)) return sample;\n return prev + alpha * (sample - prev);\n}\n\nfunction formatParisStampFromMs(ms) {\n const fmt = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris',\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n fmt.formatToParts(new Date(ms))\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour);\n return `${parts.year}-${parts.month}-${parts.day} ${to2(hour)}:${parts.minute}:${parts.second}`;\n}\n\nconst prev = $input.first().json;\n\nconst {\n now,\n nowMs,\n parsed,\n params,\n history,\n strategy,\n lifecycle,\n context,\n derived,\n locks,\n cloud,\n modes,\n safeguards,\n conditions,\n batteryTune,\n nightEvcs,\n} = prev;\n\n// =====================================================\n// DECISION INIT\n// =====================================================\nlet desiredCharging = false;\nlet desiredPermission = false;\nlet desiredLatch = false;\nlet desiredCurrentA = params.currentMinA;\n\nlet state = 'idle';\nlet currentDecision = 'hold';\nlet dynamicOk = false;\nlet mustStop = false;\n\nlet reason = [];\n\nlet setCloudDebtStartedAt = false;\nlet setProbeStartedAt = false;\n\n// EVCS night session memory\nlet setNightSessionStart = false;\nlet clearNightSessionStart = false;\n\nlet notifyWanted = false;\nlet notifyTitle = 'Victron EVCS Surplus';\nlet notifyMessage = '';\n\nlet nextBlockReason = 'none';\nlet nextBlockedUntilStamp = now.stamp;\n\n// =====================================================\n// HELPERS\n// =====================================================\nfunction setOff({\n stateName,\n decisionName = 'stop',\n reasonText,\n dynamic = false,\n mustStopFlag = false,\n blockReason = 'none',\n blockedUntilStamp = now.stamp,\n}) {\n desiredCharging = false;\n desiredPermission = false;\n desiredLatch = false;\n desiredCurrentA = params.currentMinA;\n\n state = stateName;\n currentDecision = decisionName;\n dynamicOk = dynamic;\n mustStop = mustStopFlag;\n nextBlockReason = blockReason;\n nextBlockedUntilStamp = blockedUntilStamp;\n\n if (reasonText) reason.push(reasonText);\n}\n\nfunction setOn({\n stateName,\n decisionName = 'hold',\n currentA = params.currentMinA,\n reasonText,\n dynamic = true,\n mustStopFlag = false,\n}) {\n desiredCharging = true;\n desiredPermission = true;\n desiredLatch = true;\n desiredCurrentA = clamp(\n currentA,\n params.currentMinA,\n Math.max(params.currentMinA, derived.physicalCapA || params.currentMinA)\n );\n\n state = stateName;\n currentDecision = decisionName;\n dynamicOk = dynamic;\n mustStop = mustStopFlag;\n\n if (reasonText) reason.push(reasonText);\n}\n\n// =====================================================\n// SESSION MEMORY SIDE EFFECTS\n// =====================================================\nif (nightEvcs?.nightSessionNeedsCapture) {\n setNightSessionStart = true;\n}\n\nif (nightEvcs?.nightSessionNeedsClear) {\n clearNightSessionStart = true;\n}\n\n// =====================================================\n// MANUAL LOCAL CHARGE SEMANTICS\n// IMPORTANT:\n// force_charge_now = bypass fenêtres pour la charge locale,\n// sans imposer le réseau.\n// =====================================================\nconst manualChargeBypassWindows = !!context.manualChargeBypassWindows;\nconst manualChargeLocalRequested = !!context.manualChargeLocalRequested;\n\nconst manualLocalChargeAllowed =\n !!strategy.forceChargeRequested &&\n manualChargeBypassWindows &&\n manualChargeLocalRequested &&\n !safeguards.mustStopHard &&\n !!derived.canRunAtMinCurrent;\n\n// =====================================================\n// EVCS NUMERIC NO-DEMAND FILTER\n// Objectif:\n// détecter \"voiture branchée + session armée + commande claire\n// mais plus de demande réelle\" sans seuils fixes bruts.\n// IMPORTANT:\n// on n'utilise PAS le PV comme critère.\n// =====================================================\nconst prevDeliveryRatioEma = finiteOr(history.evcsDeliveryRatioEma, NaN);\nconst prevDeliveryNoiseEma = finiteOr(history.evcsDeliveryNoiseEma, NaN);\nconst prevNoDemandScoreEma = finiteOr(history.evcsNoDemandScoreEma, NaN);\nconst prevNoDemandSemanticActive = !!history.evcsNoDemandSemanticActive;\nconst prevNoDemandSinceMs = finiteOr(history.evcsNoDemandSinceMs, NaN);\nconst prevNoDemandFilterEvalMs = finiteOr(history.evcsNoDemandFilterEvalMs, NaN);\n\nconst noDemandDtSec =\n Number.isFinite(prevNoDemandFilterEvalMs) && Number.isFinite(nowMs)\n ? clamp((nowMs - prevNoDemandFilterEvalMs) / 1000, 1, 600)\n : 60;\n\n// Constantes dynamiques du filtre = temps de réponse du filtre,\n// pas des seuils métiers bruts.\nconst deliveryTauSec = Math.max(15, finiteOr(params.evcsDeliveryTauSec, 45));\nconst noiseTauSec = Math.max(deliveryTauSec, finiteOr(params.evcsDeliveryNoiseTauSec, 180));\nconst scoreTauSec = Math.max(20, finiteOr(params.evcsNoDemandScoreTauSec, 150));\nconst armTauSec = Math.max(20, finiteOr(params.evcsNoDemandArmTauSec, 180));\n\nconst alphaDelivery = alphaFromTau(noDemandDtSec, deliveryTauSec);\nconst alphaNoise = alphaFromTau(noDemandDtSec, noiseTauSec);\nconst alphaScore = alphaFromTau(noDemandDtSec, scoreTauSec);\n\nconst acVoltageRef = finiteOr(derived.acVoltageRef, 230);\n\nconst commandCurrentA = clamp(\n finiteOr(\n derived.currentReferenceA,\n finiteOr(\n parsed.evcsCommandCurrentA,\n finiteOr(parsed.evcsSetCurrentA, finiteOr(params.currentMinA, 6))\n )\n ),\n 0,\n 32\n);\n\nconst expectedPowerW = Math.max(1, commandCurrentA * acVoltageRef);\n\nconst realCurrentA = Math.abs(\n finiteOr(\n parsed.evcsCurrentRealA,\n acVoltageRef > 180\n ? finiteOr(parsed.evcsAcPowerW, 0) / acVoltageRef\n : 0\n )\n);\n\nconst realPowerW = Math.max(0, finiteOr(parsed.evcsAcPowerW, 0));\n\nconst currentDeliveryRatioRaw = clamp(\n realCurrentA / Math.max(commandCurrentA, 0.1),\n 0,\n 1.25\n);\n\nconst powerDeliveryRatioRaw = clamp(\n realPowerW / expectedPowerW,\n 0,\n 1.25\n);\n\nconst deliveryRatioRaw = clamp(\n Math.max(currentDeliveryRatioRaw, powerDeliveryRatioRaw),\n 0,\n 1\n);\n\nconst deliveryRatioEma = ema(prevDeliveryRatioEma, deliveryRatioRaw, alphaDelivery);\n\nconst deliveryResidualAbs = Math.abs(deliveryRatioRaw - deliveryRatioEma);\nconst deliveryNoiseEma = ema(prevDeliveryNoiseEma, deliveryResidualAbs, alphaNoise);\n\nconst adaptiveQuietBand = clamp(\n 0.02 + 3.0 * deliveryNoiseEma,\n 0.02,\n 0.35\n);\n\nconst noDemandEvidenceRaw = clamp(\n (adaptiveQuietBand - deliveryRatioEma) / adaptiveQuietBand,\n 0,\n 1\n);\n\nconst sessionElapsedSec = Math.max(\n 0,\n finiteOr(lifecycle.minutesSinceStart, 0) * 60\n);\n\nconst sessionArmFactor = 1 - Math.exp(-sessionElapsedSec / armTauSec);\n\nconst currentMinRefA = Math.max(1, finiteOr(params.currentMinA, 6));\nconst commandStrength = commandCurrentA / currentMinRefA;\n\nconst commandFactor = clamp(\n sigmoid((commandStrength - 1.0) * 2.2),\n 0,\n 1\n);\n\n// Source effectivement livrable.\n// IMPORTANT:\n// on évite de conclure \"voiture pleine\" si en réalité la source n'est\n// pas disponible (ex: attente grid takeover).\nconst manualLocalNoDemandEligible =\n manualLocalChargeAllowed &&\n (\n context.sourceMode === 'solar_only' ||\n context.sourceMode === 'solar_potential' ||\n context.sourceMode === 'blocked'\n );\n\nconst sourceDeliverableForNoDemand =\n manualLocalNoDemandEligible\n ? true\n : (context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced')\n ? !!parsed.gridAvailable\n : (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential')\n ? !!conditions.solarHoldOk || !!conditions.solarStartOk\n : context.sourceMode === 'hybrid_probe'\n ? !!conditions.probeHoldGuardOk || !!conditions.probeSuccessNow || !!conditions.probeStartOk\n : context.sourceMode === 'battery_assist_night'\n ? !!conditions.batteryAssistNightHoldOk || !!conditions.batteryAssistNightStartOk\n : false;\n\nconst evcsSessionArmedForNoDemand =\n !!cloud.sessionObservedActive &&\n !!parsed.evcsVehicleConnected &&\n !!parsed.evcsChargingSwitchOn &&\n !!parsed.chargePermissionNow &&\n !!parsed.controlLatch &&\n commandCurrentA > 0 &&\n sourceDeliverableForNoDemand;\n\nconst noDemandScoreRaw = evcsSessionArmedForNoDemand\n ? clamp(\n noDemandEvidenceRaw * sessionArmFactor * commandFactor,\n 0,\n 1\n )\n : 0;\n\nconst noDemandScoreEma = ema(prevNoDemandScoreEma, noDemandScoreRaw, alphaScore);\n\nconst adaptiveDecisionCenter = clamp(\n 0.72 - 0.20 * deliveryNoiseEma,\n 0.55,\n 0.82\n);\n\nconst adaptiveDecisionBand = clamp(\n 0.16 + 0.80 * deliveryNoiseEma,\n 0.12,\n 0.34\n);\n\nconst noDemandScoreOn = clamp(\n adaptiveDecisionCenter + adaptiveDecisionBand / 2,\n 0.60,\n 0.95\n);\n\nconst noDemandScoreOff = clamp(\n adaptiveDecisionCenter - adaptiveDecisionBand / 2,\n 0.15,\n noDemandScoreOn - 0.05\n);\n\nlet evcsVehicleNoDemandSemantic = prevNoDemandSemanticActive;\n\nif (!evcsSessionArmedForNoDemand) {\n evcsVehicleNoDemandSemantic = false;\n} else if (prevNoDemandSemanticActive) {\n evcsVehicleNoDemandSemantic = noDemandScoreEma >= noDemandScoreOff;\n} else {\n evcsVehicleNoDemandSemantic = noDemandScoreEma >= noDemandScoreOn;\n}\n\nconst evcsNoDemandSinceMs =\n evcsVehicleNoDemandSemantic\n ? (\n prevNoDemandSemanticActive && Number.isFinite(prevNoDemandSinceMs)\n ? prevNoDemandSinceMs\n : nowMs\n )\n : null;\n\nconst evcsNoDemandSinceMinutes =\n Number.isFinite(evcsNoDemandSinceMs) && Number.isFinite(nowMs)\n ? Math.max(0, (nowMs - evcsNoDemandSinceMs) / 60000)\n : null;\n\nconst vehicleDemandFilter = {\n dtSec: round3(noDemandDtSec),\n\n commandCurrentA: round3(commandCurrentA),\n realCurrentA: round3(realCurrentA),\n expectedPowerW: round3(expectedPowerW),\n realPowerW: round3(realPowerW),\n\n currentDeliveryRatioRaw: round3(currentDeliveryRatioRaw),\n powerDeliveryRatioRaw: round3(powerDeliveryRatioRaw),\n deliveryRatioRaw: round3(deliveryRatioRaw),\n deliveryRatioEma: round3(deliveryRatioEma),\n deliveryNoiseEma: round3(deliveryNoiseEma),\n\n adaptiveQuietBand: round3(adaptiveQuietBand),\n noDemandEvidenceRaw: round3(noDemandEvidenceRaw),\n\n sessionElapsedSec: round3(sessionElapsedSec),\n sessionArmFactor: round3(sessionArmFactor),\n commandFactor: round3(commandFactor),\n sourceDeliverableForNoDemand,\n evcsSessionArmedForNoDemand,\n\n noDemandScoreRaw: round3(noDemandScoreRaw),\n noDemandScoreEma: round3(noDemandScoreEma),\n\n noDemandScoreOn: round3(noDemandScoreOn),\n noDemandScoreOff: round3(noDemandScoreOff),\n\n evcsVehicleNoDemandSemantic,\n evcsNoDemandSinceMs: Number.isFinite(evcsNoDemandSinceMs) ? evcsNoDemandSinceMs : null,\n evcsNoDemandSinceMinutes: Number.isFinite(evcsNoDemandSinceMinutes)\n ? round3(evcsNoDemandSinceMinutes)\n : null,\n\n nextHistory: {\n evcsDeliveryRatioEma: round3(deliveryRatioEma),\n evcsDeliveryNoiseEma: round3(deliveryNoiseEma),\n evcsNoDemandScoreEma: round3(noDemandScoreEma),\n evcsNoDemandSemanticActive: evcsVehicleNoDemandSemantic,\n evcsNoDemandSinceMs: Number.isFinite(evcsNoDemandSinceMs) ? evcsNoDemandSinceMs : null,\n evcsNoDemandFilterEvalMs: nowMs\n }\n};\n\n// =====================================================\n// DECISION TREE\n// =====================================================\nif (!parsed.controllerEnabled) {\n desiredCharging = parsed.evcsChargingSwitchOn;\n desiredPermission = parsed.chargePermissionNow;\n desiredLatch = parsed.controlLatch;\n desiredCurrentA = clamp(\n derived.currentReferenceA || params.currentMinA,\n params.currentMinA,\n params.currentMaxAutoA\n );\n\n state = 'controller_disabled';\n currentDecision = 'hold';\n dynamicOk = false;\n mustStop = false;\n reason.push('Contrôleur désactivé');\n}\n\nelse if (strategy.isStopMode) {\n setOff({\n stateName: 'stopped_user',\n decisionName: 'stop_stop_mode',\n reasonText: 'Arrêt manuel via Stratégie STOP',\n dynamic: false,\n mustStopFlag: false,\n blockReason: 'manual_stop',\n blockedUntilStamp: now.stamp,\n });\n}\n\nelse if (parsed.evcsModeIsAuto) {\n setOff({\n stateName: 'mode_auto_conflict',\n decisionName: 'stop_auto_mode_conflict',\n reasonText: 'Borne en mode Auto interne',\n dynamic: false,\n mustStopFlag: true,\n blockReason: 'mode_auto_conflict',\n blockedUntilStamp: now.stamp,\n });\n}\n\nelse if (!parsed.evcsDeviceConnected || !parsed.evcsVehicleConnected) {\n setOff({\n stateName: 'vehicle_not_connected',\n decisionName: 'stop_not_connected',\n reasonText: 'Véhicule ou borne non connecté',\n dynamic: false,\n mustStopFlag: false,\n blockReason: 'vehicle_not_connected',\n blockedUntilStamp: now.stamp,\n });\n}\n\nelse if (safeguards.mustStopHard) {\n setOff({\n stateName:\n derived.anyVebusLowBattery ? 'vebus_low_battery_hard_stop' :\n derived.vebusDcVoltageHardLow ? 'vebus_dc_voltage_hard_stop' :\n derived.vebusAcOutHardReached ? 'vebus_ac_hard_stop' :\n safeguards.batteryCurrentHardExceeded ? 'battery_current_hard_stop' :\n 'hard_stop',\n decisionName: 'stop_hard',\n reasonText: 'Hard stop de protection',\n dynamic: false,\n mustStopFlag: true,\n blockReason:\n safeguards.batteryCurrentHardExceeded ? 'battery_current_hard_stop' :\n derived.vebusDcVoltageHardLow ? 'vebus_dc_voltage_stop' :\n derived.anyVebusLowBattery ? 'vebus_low_battery_stop' :\n derived.vebusAcOutHardReached ? 'vebus_ac_hard_stop' :\n 'battery_protection_stop',\n blockedUntilStamp: formatParisStampFromMs(nowMs + 15 * 60000),\n });\n}\n\n// =====================================================\n// ACTIVE SESSION BRANCH\n// =====================================================\nelse if (cloud.sessionObservedActive) {\n if (cloud.enteringCloudDebt) {\n setCloudDebtStartedAt = true;\n }\n\n if (conditions.shouldStopOverloadAtMin) {\n setOff({\n stateName: 'vebus_overload_stop_min_current',\n decisionName: 'stop',\n reasonText: 'Arrêt: surcharge convertisseur persistante au courant mini',\n dynamic: false,\n mustStopFlag: true,\n blockReason: 'vebus_overload_persistent',\n blockedUntilStamp: formatParisStampFromMs(\n nowMs + batteryTune.global.overloadStopAfterSeconds * 1000\n ),\n });\n }\n\n else if (safeguards.gridBatteryProtectionStop) {\n setOff({\n stateName: 'stop_grid_battery_guard',\n decisionName: 'stop',\n reasonText: `Arrêt charge réseau: batterie trop sollicitée (${round1(parsed.batteryCurrentA)}A)`,\n dynamic: false,\n mustStopFlag: true,\n blockReason: 'grid_battery_guard',\n blockedUntilStamp: formatParisStampFromMs(nowMs + 20 * 60000),\n });\n }\n\n else if (conditions.probeFailedNow) {\n setOff({\n stateName: 'probe_failed_lock',\n decisionName: 'stop',\n reasonText: 'Mode sonde échoué',\n dynamic: false,\n mustStopFlag: true,\n blockReason: 'probe_failed',\n blockedUntilStamp: formatParisStampFromMs(nowMs + params.probeRetryMinutes * 60000),\n });\n }\n\n else if (derived.physicalCapA < params.currentMinA) {\n if (lifecycle.minRunElapsed) {\n setOff({\n stateName: 'stop_power_cap',\n decisionName: 'stop_power_cap',\n reasonText: 'Capacité système insuffisante',\n dynamic: false,\n mustStopFlag: true,\n blockReason: 'system_capacity_stop',\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\n });\n } else {\n setOn({\n stateName: 'hold_min_run_power_cap',\n decisionName: 'hold',\n currentA: params.currentMinA,\n reasonText: \"Maintien jusqu'au temps mini ON\",\n dynamic: false,\n mustStopFlag: false,\n });\n }\n }\n\n else if (conditions.probeWindowActive && !conditions.probeSuccessNow) {\n if (!conditions.probeHoldGuardOk) {\n setOff({\n stateName: 'probe_stop_guard',\n decisionName: 'stop',\n reasonText: 'Sonde interrompue: garde-fous non respectés',\n dynamic: false,\n mustStopFlag: true,\n blockReason: 'probe_failed',\n blockedUntilStamp: formatParisStampFromMs(nowMs + params.probeRetryMinutes * 60000),\n });\n } else {\n setOn({\n stateName: 'probe_hold',\n decisionName: 'hold',\n currentA: Math.min(params.probeCurrentA, derived.physicalCapA),\n reasonText: `Mode sonde actif à ${round1(Math.min(params.probeCurrentA, derived.physicalCapA))}A`,\n dynamic: true,\n mustStopFlag: false,\n });\n }\n }\n\n else {\n const activeSourceMode =\n conditions.probeSuccessNow\n ? 'solar_only'\n : context.sourceMode;\n\n const holdConditionsOk =\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced')\n ? conditions.gridHoldOk\n : (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential')\n ? conditions.solarHoldOk\n : activeSourceMode === 'hybrid_probe'\n ? conditions.probeHoldGuardOk\n : activeSourceMode === 'battery_assist_night'\n ? conditions.batteryAssistNightHoldOk\n : false;\n\n const manualLocalHoldOverride =\n manualLocalChargeAllowed &&\n (\n activeSourceMode === 'solar_only' ||\n activeSourceMode === 'solar_potential' ||\n activeSourceMode === 'blocked'\n );\n\n const effectiveHoldConditionsOk =\n holdConditionsOk || manualLocalHoldOverride;\n\n // =================================================\n // VEHICLE FULL / PAUSED - NUMERIC FILTER DECISION\n // IMPORTANT:\n // - uniquement en session active\n // - uniquement si la source est réellement livrable\n // - sans utiliser le PV comme critère\n // =================================================\n if (\n vehicleDemandFilter.evcsVehicleNoDemandSemantic &&\n vehicleDemandFilter.sourceDeliverableForNoDemand\n ) {\n setOff({\n stateName: 'vehicle_full_or_paused',\n decisionName: 'stop_vehicle_full_or_paused',\n reasonText: 'Véhicule branché mais ne demande plus réellement de puissance',\n dynamic: false,\n mustStopFlag: false,\n blockReason: 'none',\n blockedUntilStamp: now.stamp,\n });\n\n reason.push(`DeliveryRatioEma=${vehicleDemandFilter.deliveryRatioEma}`);\n reason.push(`NoiseEma=${vehicleDemandFilter.deliveryNoiseEma}`);\n reason.push(`NoDemandScore=${vehicleDemandFilter.noDemandScoreEma}`);\n reason.push(`ScoreOn=${vehicleDemandFilter.noDemandScoreOn}`);\n reason.push(`ScoreOff=${vehicleDemandFilter.noDemandScoreOff}`);\n\n if (vehicleDemandFilter.evcsNoDemandSinceMinutes !== null) {\n reason.push(`NoDemand depuis ${round1(vehicleDemandFilter.evcsNoDemandSinceMinutes)} min`);\n }\n }\n\n else if (manualLocalHoldOverride && activeSourceMode === 'blocked') {\n setOn({\n stateName: 'charging_hold_manual_local',\n decisionName: 'hold',\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\n reasonText: 'Maintien manuel local hors fenêtre sans réseau',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (activeSourceMode === 'blocked') {\n if (lifecycle.minRunElapsed) {\n setOff({\n stateName: 'stop_source_lost',\n decisionName: 'stop',\n reasonText: 'Source autorisée perdue',\n dynamic: false,\n mustStopFlag: true,\n blockReason: context.profile === 'outside_window' ? 'outside_window' : 'source_unavailable',\n blockedUntilStamp:\n context.profile === 'outside_window' && Number.isFinite(locks.nextAllowedWindowStartMs)\n ? formatParisStampFromMs(locks.nextAllowedWindowStartMs)\n : formatParisStampFromMs(nowMs + params.minStopMinutes * 60000),\n });\n } else {\n setOn({\n stateName: 'hold_min_run_source_lost',\n decisionName: 'hold',\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\n reasonText: \"Source perdue mais maintien jusqu'au mini ON\",\n dynamic: false,\n mustStopFlag: false,\n });\n }\n }\n\n else if (\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&\n !parsed.gridAvailable\n ) {\n if (lifecycle.minRunElapsed) {\n setOff({\n stateName:\n context.profile === 'night_hc'\n ? 'night_wait_grid_takeover_runtime'\n : 'wait_grid_source_runtime',\n decisionName: 'hold',\n reasonText:\n context.profile === 'night_hc'\n ? 'Relais réseau demandé pendant la session nuit EVCS'\n : 'Source réseau demandée mais pas encore disponible',\n dynamic: false,\n mustStopFlag: false,\n blockReason:\n context.profile === 'night_hc' ? 'grid_hc_unavailable' : 'grid_unavailable',\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\n });\n } else {\n setOn({\n stateName: 'hold_min_run_wait_grid_takeover',\n decisionName: 'hold',\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\n reasonText: \"Maintien jusqu'au mini ON en attente du réseau\",\n dynamic: false,\n mustStopFlag: false,\n });\n }\n }\n\n else if (!effectiveHoldConditionsOk) {\n if (lifecycle.minRunElapsed) {\n setOff({\n stateName: 'stop_conditions_lost',\n decisionName: 'stop',\n reasonText: 'Conditions de maintien perdues',\n dynamic: false,\n mustStopFlag: true,\n blockReason: 'hold_conditions_lost',\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\n });\n } else {\n setOn({\n stateName: 'hold_min_run',\n decisionName: 'hold',\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\n reasonText: \"Maintien jusqu'au temps mini ON\",\n dynamic: true,\n mustStopFlag: false,\n });\n }\n }\n\n else if ((derived.currentReferenceA > derived.physicalCapA) && lifecycle.currentCooldownElapsed) {\n setOn({\n stateName: 'charging_step_down_system_cap',\n decisionName: 'step_down',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - Math.max(params.currentStepDownA, 2),\n derived.physicalCapA\n )\n ),\n reasonText: 'Réduction pour respecter la capacité système',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (conditions.shouldStepDownOverload) {\n setOn({\n stateName: 'charging_step_down_overload',\n decisionName: 'step_down_fast',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - Math.max(batteryTune.global.overloadStepDownA, params.currentStepDownA),\n derived.physicalCapA\n )\n ),\n reasonText: 'Baisse rapide: surcharge convertisseur',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (conditions.shouldStepDownVebusPower) {\n setOn({\n stateName: 'charging_step_down_vebus_power',\n decisionName: 'step_down_fast',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - Math.max(params.currentStepDownA + 1, 3),\n derived.physicalCapA\n )\n ),\n reasonText: 'Baisse rapide: puissance VE.Bus élevée',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (conditions.shouldStepDownVebusVoltage) {\n setOn({\n stateName: 'charging_step_down_dc_voltage',\n decisionName: 'step_down_fast',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - Math.max(params.currentStepDownA + 1, 3),\n derived.physicalCapA\n )\n ),\n reasonText: 'Baisse rapide: tension DC basse',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (\n activeSourceMode === 'battery_assist_night' &&\n conditions.batteryAssistNightShouldStepDown\n ) {\n setOn({\n stateName: 'charging_step_down_battery_assist_night',\n decisionName: 'step_down',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - params.currentStepDownA,\n derived.physicalCapA\n )\n ),\n reasonText:\n nightEvcs?.batteryRecoveryUrgent\n ? 'Réduction: approche départ, préparation takeover réseau'\n : 'Réduction: assistance batterie nuit devient trop coûteuse',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&\n conditions.gridShouldStepDownBattery\n ) {\n setOn({\n stateName:\n activeSourceMode === 'grid_forced'\n ? 'charging_step_down_grid_forced_guard'\n : 'charging_step_down_grid_guard',\n decisionName: 'step_down',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - params.currentStepDownA,\n derived.physicalCapA\n )\n ),\n reasonText:\n activeSourceMode === 'grid_forced'\n ? 'Réduction réseau forcé: la batterie commence à participer'\n : 'Réduction réseau: la batterie commence à participer',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (\n (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&\n conditions.solarShouldStepDownCloudDebt\n ) {\n setOn({\n stateName: 'charging_step_down_cloud_debt',\n decisionName: 'step_down',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - Math.max(params.currentStepDownA, 2),\n derived.physicalCapA\n )\n ),\n reasonText: 'Réduction: dette nuage trop élevée',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (\n (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&\n conditions.solarShouldStepDownBattery\n ) {\n setOn({\n stateName: 'charging_step_down_battery',\n decisionName: 'step_down',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - params.currentStepDownA,\n derived.physicalCapA\n )\n ),\n reasonText: 'Réduction: batterie trop sollicitée',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&\n conditions.gridShouldStepUp\n ) {\n setOn({\n stateName:\n activeSourceMode === 'grid_forced'\n ? (\n nightEvcs?.batteryRecoveryUrgent\n ? 'night_departure_deadline_grid_forced'\n : 'charging_step_up_grid_forced'\n )\n : 'charging_step_up_grid_hc',\n decisionName: 'step_up',\n currentA: clamp(\n derived.currentReferenceA + params.currentStepUpA,\n params.currentMinA,\n derived.physicalCapA\n ),\n reasonText:\n activeSourceMode === 'grid_forced'\n ? (\n nightEvcs?.batteryRecoveryUrgent\n ? 'Hausse prudente: priorité départ, réseau forcé'\n : 'Hausse prudente du courant en charge réseau forcée'\n )\n : 'Hausse prudente du courant en HC réseau',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (\n activeSourceMode === 'battery_assist_night' &&\n conditions.batteryAssistNightShouldStepUp\n ) {\n setOn({\n stateName: 'charging_step_up_battery_assist_night',\n decisionName: 'step_up',\n currentA: clamp(\n derived.currentReferenceA + params.currentStepUpA,\n params.currentMinA,\n derived.physicalCapA\n ),\n reasonText: 'Hausse prudente du courant en assistance batterie nuit',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (\n (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&\n conditions.solarShouldStepUp\n ) {\n setOn({\n stateName:\n conditions.probeSuccessNow\n ? 'probe_success_step_up'\n : (modes.batteryPriorityMode\n ? 'charging_step_up_battery'\n : 'charging_step_up_forecast'),\n decisionName: 'step_up',\n currentA: clamp(\n derived.currentReferenceA + params.currentStepUpA,\n params.currentMinA,\n derived.physicalCapA\n ),\n reasonText: 'Hausse prudente du courant',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else {\n setOn({\n stateName:\n manualLocalHoldOverride ? 'charging_hold_manual_local' :\n conditions.probeSuccessNow ? 'probe_success_hold' :\n activeSourceMode === 'battery_assist_night' ? 'charging_hold_battery_assist_night' :\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') ? 'charging_hold_grid' :\n cloud.cloudDebtActive ? 'charging_hold_cloud_tolerance' :\n 'charging_hold',\n decisionName: 'hold',\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\n reasonText:\n manualLocalHoldOverride\n ? 'Maintien manuel local du courant'\n : activeSourceMode === 'battery_assist_night'\n ? 'Maintien du courant en assistance batterie nuit'\n : 'Maintien du courant',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n }\n}\n\n// =====================================================\n// START BRANCH\n// =====================================================\nelse {\n const startConditionsOk =\n (context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced')\n ? conditions.gridStartOk\n : (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential')\n ? conditions.solarStartOk\n : context.sourceMode === 'hybrid_probe'\n ? conditions.probeStartOk\n : context.sourceMode === 'battery_assist_night'\n ? conditions.batteryAssistNightStartOk\n : false;\n\n const manualLocalStartOverride =\n manualLocalChargeAllowed &&\n (\n context.sourceMode === 'solar_only' ||\n context.sourceMode === 'solar_potential' ||\n context.sourceMode === 'blocked'\n );\n\n if (!derived.canRunAtMinCurrent) {\n setOff({\n stateName: 'blocked_by_capacity_min_current',\n decisionName: 'hold',\n reasonText: 'Capacité insuffisante pour courant mini',\n dynamic: false,\n mustStopFlag: false,\n blockReason: 'system_capacity_stop',\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\n });\n }\n\n else if (manualLocalStartOverride) {\n setOn({\n stateName: 'start_manual_local_min_current',\n decisionName: 'start',\n currentA: params.currentMinA,\n reasonText:\n context.profile === 'outside_window'\n ? 'Démarrage manuel local hors fenêtre sans réseau'\n : 'Démarrage manuel local sans réseau',\n dynamic: true,\n mustStopFlag: false,\n });\n\n nextBlockReason = 'none';\n nextBlockedUntilStamp = now.stamp;\n setProbeStartedAt = false;\n }\n\n else if (context.sourceMode === 'blocked') {\n setOff({\n stateName:\n context.profile === 'outside_window'\n ? 'blocked_outside_window'\n : (context.profile === 'night_hc'\n ? 'night_no_grid_hc_lock'\n : 'idle_wait_source'),\n decisionName: 'hold',\n reasonText:\n context.profile === 'outside_window'\n ? 'Hors fenêtre préférée sans source suffisante'\n : context.profile === 'night_hc'\n ? 'Nuit sans autorisation HC réseau'\n : 'Source non autorisée ou insuffisante',\n dynamic: false,\n mustStopFlag: false,\n blockReason:\n context.profile === 'outside_window'\n ? 'outside_window'\n : context.profile === 'night_hc'\n ? 'grid_hc_unavailable'\n : 'source_unavailable',\n blockedUntilStamp:\n Number.isFinite(locks.nextAllowedWindowStartMs)\n ? formatParisStampFromMs(locks.nextAllowedWindowStartMs)\n : formatParisStampFromMs(nowMs + 15 * 60000),\n });\n }\n\n else if (\n (context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced') &&\n !parsed.gridAvailable\n ) {\n setOff({\n stateName:\n context.profile === 'night_hc'\n ? 'night_wait_grid_takeover'\n : 'wait_grid_source',\n decisionName: 'hold',\n reasonText:\n context.profile === 'night_hc'\n ? (\n nightEvcs?.batteryRecoveryUrgent\n ? 'Réseau demandé: urgence recharge batterie avant départ'\n : 'Prise de relais réseau demandée pour session nuit EVCS'\n )\n : 'Source réseau demandée mais pas encore disponible',\n dynamic: false,\n mustStopFlag: false,\n blockReason:\n context.profile === 'night_hc'\n ? 'grid_hc_unavailable'\n : 'grid_unavailable',\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\n });\n }\n\n else if (locks.startBlockedByReason) {\n setOff({\n stateName: 'wait_reason_lock',\n decisionName: 'hold',\n reasonText: `Blocage anti-cliquetis: ${history.lastBlockReason}`,\n dynamic: false,\n mustStopFlag: false,\n blockReason: history.lastBlockReason,\n blockedUntilStamp: history.blockedUntilAt || now.stamp,\n });\n }\n\n else if (!startConditionsOk) {\n setOff({\n stateName: 'idle_wait_conditions',\n decisionName: 'hold',\n reasonText:\n context.sourceMode === 'battery_assist_night'\n ? 'Conditions de démarrage nuit batterie non réunies'\n : 'Conditions de démarrage non réunies',\n dynamic: false,\n mustStopFlag: false,\n blockReason: 'none',\n blockedUntilStamp: now.stamp,\n });\n }\n\n else if (!lifecycle.minStopElapsed) {\n setOff({\n stateName: 'wait_min_stop',\n decisionName: 'hold',\n reasonText: 'Temps mini OFF non écoulé',\n dynamic: false,\n mustStopFlag: false,\n blockReason: 'none',\n blockedUntilStamp: now.stamp,\n });\n }\n\n else {\n const startCurrentA =\n context.sourceMode === 'hybrid_probe'\n ? clamp(params.probeCurrentA, params.currentMinA, derived.physicalCapA)\n : params.currentMinA;\n\n setOn({\n stateName:\n context.sourceMode === 'grid_forced' ? (\n nightEvcs?.batteryRecoveryUrgent\n ? 'night_departure_deadline_grid_forced'\n : 'start_grid_forced_min_current'\n ) :\n context.sourceMode === 'grid_hc_allowed' ? 'start_grid_hc_min_current' :\n context.sourceMode === 'battery_assist_night' ? 'start_battery_assist_night_min_current' :\n context.sourceMode === 'hybrid_probe' ? 'probe_start_6a' :\n context.sourceMode === 'solar_potential' ? 'start_forecast_solar_potential' :\n modes.batteryFullHarvestMode ? 'start_min_current_battery_full_harvest' :\n modes.forecastCommitMode ? 'start_min_current_forecast_commit' :\n 'start_min_current',\n decisionName: 'start',\n currentA: startCurrentA,\n reasonText:\n context.sourceMode === 'grid_forced'\n ? (\n nightEvcs?.batteryRecoveryUrgent\n ? 'Démarrage réseau forcé: priorité départ / recharge batterie'\n : 'Démarrage en charge réseau forcée'\n )\n : context.sourceMode === 'grid_hc_allowed'\n ? 'Démarrage en fenêtre HC réseau'\n : context.sourceMode === 'battery_assist_night'\n ? 'Démarrage nuit en assistance batterie contrôlée'\n : context.sourceMode === 'hybrid_probe'\n ? 'Démarrage en mode sonde'\n : context.sourceMode === 'solar_potential'\n ? 'Démarrage par potentiel solaire confirmé'\n : modes.batteryFullHarvestMode\n ? 'Démarrage harvest solaire'\n : modes.forecastCommitMode\n ? 'Démarrage forecast commit'\n : 'Démarrage prudent à courant mini',\n dynamic: true,\n mustStopFlag: false,\n });\n\n nextBlockReason = 'none';\n nextBlockedUntilStamp = now.stamp;\n setProbeStartedAt = context.sourceMode === 'hybrid_probe';\n }\n}\n\n// =====================================================\n// CONTEXT REASONS\n// =====================================================\nif (nightEvcs?.nightSessionActive) {\n reason.push(\n `Nuit EVCS: target=${round1(nightEvcs.batteryMorningTargetPct)}% | trigger réseau=${round1(nightEvcs.gridTakeoverTriggerPct)}%`\n );\n\n if (Number.isFinite(nightEvcs.nightSessionStartSocPct)) {\n reason.push(\n `SOC départ session nuit=${round1(nightEvcs.nightSessionStartSocPct)}% | décharge autorisée=${round1(nightEvcs.maxBatteryDischargePoints)} pts`\n );\n }\n\n if (nightEvcs.batteryRecoveryUrgent) {\n reason.push(\"Urgence départ: il faut anticiper la reprise réseau\");\n }\n}\n\nif (manualLocalChargeAllowed) {\n reason.push(`ManualLocalCharge=on`);\n reason.push(`ManualLocalProfile=${context.profile}`);\n reason.push(`ManualLocalSource=${context.sourceMode}`);\n}\n\nreason.push(`VehicleDemandFilter delivery=${vehicleDemandFilter.deliveryRatioEma}`);\nreason.push(`VehicleDemandFilter noise=${vehicleDemandFilter.deliveryNoiseEma}`);\nreason.push(`VehicleDemandFilter score=${vehicleDemandFilter.noDemandScoreEma}`);\nreason.push(`VehicleDemandFilter noDemand=${vehicleDemandFilter.evcsVehicleNoDemandSemantic ? 'on' : 'off'}`);\n\n// =====================================================\n// OUTPUT\n// =====================================================\nreturn [{\n json: {\n ...prev,\n vehicleDemandFilter,\n decision: {\n desiredCharging,\n desiredPermission,\n desiredLatch,\n desiredCurrentA,\n\n state,\n currentDecision,\n dynamicOk,\n mustStop,\n reason,\n\n nextBlockReason,\n nextBlockedUntilStamp,\n\n setCloudDebtStartedAt,\n setProbeStartedAt,\n\n setNightSessionStart,\n clearNightSessionStart,\n\n notifyWanted,\n notifyTitle,\n notifyMessage,\n }\n }\n}];\n"
+ },
+ "type": "n8n-nodes-base.code",
+ "typeVersion": 2,
+ "position": [
+ 496,
+ 32
+ ],
+ "id": "1a8271fd-112a-45c6-8bc6-664d1215f72f",
+ "name": "DECISION_ENGINEV2"
+ }
+ ],
+ "connections": {
+ "Schedule Trigger": {
+ "main": [
+ [
+ {
+ "node": "HA Snapshot EVCS Surplus Controller V2.2",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Build EVCS Surplus Controller V2.0 Actions": {
+ "main": [
+ [
+ {
+ "node": "Execute HA Service EVCS Surplus",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "BATTERY_CLOUD_LOCKS": {
+ "main": [
+ [
+ {
+ "node": "DECISION_ENGINEV2",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "INPUT_PARSE": {
+ "main": [
+ [
+ {
+ "node": "WINDOWS_SOURCEV2",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "HA_SERVICE_CALLS_OUTPUT": {
+ "main": [
+ [
+ {
+ "node": "Build EVCS Surplus Controller V2.0 Actions",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "HA Snapshot EVCS Surplus Controller V2.2": {
+ "main": [
+ [
+ {
+ "node": "INPUT_PARSE",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "WINDOWS_SOURCEV2": {
+ "main": [
+ [
+ {
+ "node": "BATTERY_CLOUD_LOCKS",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "DECISION_ENGINEV2": {
+ "main": [
+ [
+ {
+ "node": "HA_SERVICE_CALLS_OUTPUT",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ }
+ },
+ "settings": {
+ "executionOrder": "v1",
+ "binaryMode": "separate",
+ "availableInMCP": false,
+ "timeSavedMode": "fixed",
+ "callerPolicy": "workflowsFromSameOwner"
+ },
+ "staticData": {
+ "node:Schedule Trigger": {
+ "recurrenceRules": []
+ }
+ },
+ "meta": null,
+ "pinData": {},
+ "versionId": "03b032a1-b7c9-45e7-8ba0-3813f6843b40",
+ "activeVersionId": "03b032a1-b7c9-45e7-8ba0-3813f6843b40",
+ "versionCounter": 186,
+ "triggerCount": 1,
+ "shared": [
+ {
+ "updatedAt": "2026-04-12T02:09:30.379Z",
+ "createdAt": "2026-04-12T02:09:30.379Z",
+ "role": "workflow:owner",
+ "workflowId": "ATswvGfG4KJUls0K",
+ "projectId": "1q2yd8IyTc8fb25V",
+ "project": {
+ "updatedAt": "2026-02-06T16:27:24.469Z",
+ "createdAt": "2026-02-06T16:23:40.444Z",
+ "id": "1q2yd8IyTc8fb25V",
+ "name": "thierry tallagrand ",
+ "type": "personal",
+ "icon": null,
+ "description": null,
+ "creatorId": "8b9e9552-cd74-4863-82a3-174b3b3befa2"
+ }
+ }
+ ],
+ "tags": [],
+ "activeVersion": {
+ "updatedAt": "2026-05-06T13:48:38.000Z",
+ "createdAt": "2026-05-06T13:46:20.948Z",
+ "versionId": "03b032a1-b7c9-45e7-8ba0-3813f6843b40",
+ "workflowId": "ATswvGfG4KJUls0K",
+ "nodes": [
+ {
+ "parameters": {
+ "rule": {
+ "interval": [
+ {
+ "field": "seconds"
+ }
+ ]
+ }
+ },
+ "type": "n8n-nodes-base.scheduleTrigger",
+ "typeVersion": 1.3,
+ "position": [
+ -624,
+ 32
+ ],
+ "id": "eb8c183b-a99e-40e5-8b34-3b727b097027",
+ "name": "Schedule Trigger"
+ },
+ {
+ "parameters": {
+ "method": "POST",
+ "url": "={{ 'http://10.10.10.17:8123/api/services/' + $json.domain + '/' + $json.service }}\n",
+ "authentication": "genericCredentialType",
+ "genericAuthType": "httpBearerAuth",
+ "sendHeaders": true,
+ "headerParameters": {
+ "parameters": [
+ {
+ "name": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "name": "accept",
+ "value": "application/json"
+ }
+ ]
+ },
+ "sendBody": true,
+ "specifyBody": "json",
+ "jsonBody": "={{ $json.data }}",
+ "options": {}
+ },
+ "id": "eaaba434-a4d1-4ac7-8248-28980a0308d1",
+ "name": "Execute HA Service EVCS Surplus",
+ "type": "n8n-nodes-base.httpRequest",
+ "typeVersion": 4.2,
+ "position": [
+ 1168,
+ 32
+ ],
+ "alwaysOutputData": true,
+ "credentials": {
+ "httpBearerAuth": {
+ "id": "uv3sknYYigxBkaVN",
+ "name": "Bearer Auth account 2"
+ }
+ }
+ },
+ {
+ "parameters": {
+ "jsCode": "const root = $input.first().json || {};\nconst decision = root.decision || {};\nconst calls = root.ha_service_calls || {};\nconst actions = [];\n\nfunction pushAction(action, call, enabled = true) {\n if (!enabled) return;\n if (!call) return;\n if (call.execute === false) return;\n\n const { execute, ...rest } = call;\n\n actions.push({\n json: {\n action,\n ...rest\n }\n });\n}\n\n// =====================================================\n// HELPERS TOUJOURS MIS À JOUR\n// =====================================================\n[\n 'helper_state',\n 'helper_current_decision',\n 'helper_reason',\n 'helper_operating_mode',\n 'helper_source_mode',\n 'helper_target_current',\n 'helper_budget_remaining',\n 'helper_projected_soc',\n 'helper_cloud_debt',\n 'helper_dynamic_ok',\n 'helper_must_stop',\n 'helper_evcs_priority_active',\n 'helper_energy_priority_mode',\n 'helper_energy_priority_reason',\n 'helper_evcs_priority_until',\n\n // ===================================================\n // NIGHT EVCS HELPERS\n // ===================================================\n 'helper_night_target_soc',\n 'helper_night_grid_trigger_soc',\n 'helper_night_preferred_source_mode',\n 'helper_night_grid_takeover_needed',\n 'helper_night_battery_recovery_urgent',\n 'helper_night_session_active',\n\n // ===================================================\n // VEHICLE DEMAND FILTER HISTORY HELPERS\n // ===================================================\n 'helper_evcs_delivery_ratio_ema',\n 'helper_evcs_delivery_noise_ema',\n 'helper_evcs_no_demand_score_ema',\n 'helper_evcs_no_demand_semantic_active',\n].forEach(name => pushAction(name, calls[name], true));\n\n// =====================================================\n// HELPERS / TIMESTAMPS CONDITIONNELS\n// =====================================================\npushAction(\n 'helper_cloud_debt_started_at',\n calls.helper_cloud_debt_started_at,\n !!decision.set_cloud_debt_started_at\n);\n\npushAction(\n 'helper_block_reason',\n calls.helper_block_reason,\n !!decision.set_block_reason\n);\n\npushAction(\n 'helper_blocked_until',\n calls.helper_blocked_until,\n !!decision.set_blocked_until\n);\n\npushAction(\n 'helper_probe_started_at',\n calls.helper_probe_started_at,\n !!decision.set_probe_started_at\n);\n\n// =====================================================\n// NIGHT SESSION MEMORY\n// =====================================================\npushAction(\n 'helper_night_session_start_soc',\n calls.helper_night_session_start_soc,\n !!decision.set_night_session_start || !!decision.clear_night_session_start\n);\n\npushAction(\n 'helper_night_session_start_at',\n calls.helper_night_session_start_at,\n !!decision.set_night_session_start || !!decision.clear_night_session_start\n);\n\n// =====================================================\n// VEHICLE DEMAND FILTER TIMESTAMPS\n// =====================================================\npushAction(\n 'helper_evcs_no_demand_since_at',\n calls.helper_evcs_no_demand_since_at,\n !!calls.helper_evcs_no_demand_since_at?.execute\n);\n\npushAction(\n 'helper_evcs_no_demand_filter_eval_at',\n calls.helper_evcs_no_demand_filter_eval_at,\n !!calls.helper_evcs_no_demand_filter_eval_at?.execute\n);\n\n// =====================================================\n// DEMANDE RÉSEAU EDF SI SOURCE GRID\n// =====================================================\npushAction(\n 'edf_general_ok_on',\n calls.edf_general_ok_on,\n true\n);\n\n// =====================================================\n// AUTO START OFF SI BESOIN\n// =====================================================\npushAction(\n 'evcs_auto_start_off',\n calls.evcs_auto_start_off,\n decision.auto_start_action === 'turn_off'\n);\n\n// =====================================================\n// SI ON ARRÊTE : CHARGING OFF D'ABORD\n// =====================================================\npushAction(\n 'evcs_charging',\n calls.evcs_charging,\n decision.charging_action === 'turn_off'\n);\n\n// =====================================================\n// PERMISSION / LATCH\n// =====================================================\npushAction(\n 'helper_permission',\n calls.helper_permission,\n decision.permission_action !== 'none'\n);\n\npushAction(\n 'helper_latch',\n calls.helper_latch,\n decision.latch_action !== 'none'\n);\n\n// =====================================================\n// COURANT SI NÉCESSAIRE\n// =====================================================\npushAction(\n 'evcs_set_current',\n calls.evcs_set_current,\n decision.current_action === 'set'\n);\n\npushAction(\n 'helper_last_current_change_at',\n calls.helper_last_current_change_at,\n !!decision.set_last_current_change_at\n);\n\n// =====================================================\n// SI ON DÉMARRE : CHARGING ON À LA FIN\n// =====================================================\npushAction(\n 'evcs_charging',\n calls.evcs_charging,\n decision.charging_action === 'turn_on'\n);\n\n// =====================================================\n// TIMESTAMPS START / STOP\n// =====================================================\npushAction(\n 'helper_last_start_at',\n calls.helper_last_start_at,\n !!decision.set_last_start_at\n);\n\npushAction(\n 'helper_last_stop_at',\n calls.helper_last_stop_at,\n !!decision.set_last_stop_at\n);\n\n// =====================================================\n// SIGNATURE + LOGBOOK SI CHANGEMENT\n// =====================================================\npushAction(\n 'helper_signature',\n calls.helper_signature,\n !!decision.signature_changed\n);\n\npushAction(\n 'logbook',\n calls.logbook,\n !!decision.signature_changed\n);\n\n// =====================================================\n// NOTIFICATIONS\n// =====================================================\npushAction(\n 'mobile_notification',\n calls.mobile_notification,\n !!decision.should_notify\n);\n\nreturn actions;\n"
+ },
+ "type": "n8n-nodes-base.code",
+ "typeVersion": 2,
+ "position": [
+ 944,
+ 32
+ ],
+ "id": "9c99f070-66ae-4e9e-aa82-340ae14f2d71",
+ "name": "Build EVCS Surplus Controller V2.0 Actions"
+ },
+ {
+ "parameters": {
+ "jsCode": "function clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nfunction to2(n) {\n return String(n).padStart(2, '0');\n}\n\nfunction getParisParts(ms = Date.now()) {\n const fmt = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris',\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n fmt.formatToParts(new Date(ms))\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour);\n const date = `${parts.year}-${parts.month}-${parts.day}`;\n\n return {\n date,\n hour,\n minute: Number(parts.minute),\n second: Number(parts.second),\n };\n}\n\nfunction getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') {\n const dtf = new Intl.DateTimeFormat('en-US', {\n timeZone,\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n dtf.formatToParts(new Date(utcTs))\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const asIfUtc = Date.UTC(\n Number(parts.year),\n Number(parts.month) - 1,\n Number(parts.day),\n Number(parts.hour) === 24 ? 0 : Number(parts.hour),\n Number(parts.minute),\n Number(parts.second)\n );\n\n const d = new Date(utcTs);\n const actualUtc = Date.UTC(\n d.getUTCFullYear(),\n d.getUTCMonth(),\n d.getUTCDate(),\n d.getUTCHours(),\n d.getUTCMinutes(),\n d.getUTCSeconds()\n );\n\n return asIfUtc - actualUtc;\n}\n\nfunction parseParisStampToMs(s) {\n const text = (s ?? '').toString().trim();\n const m = text.match(/^(\\d{4})-(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2})$/);\n if (!m) return null;\n\n const y = Number(m[1]);\n const mo = Number(m[2]);\n const d = Number(m[3]);\n const h = Number(m[4]);\n const mi = Number(m[5]);\n const se = Number(m[6]);\n\n const guessUtc = Date.UTC(y, mo - 1, d, h, mi, se);\n const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris');\n let ts = guessUtc - off1;\n const off2 = getTzOffsetMs(ts, 'Europe/Paris');\n\n if (off2 !== off1) ts = guessUtc - off2;\n return ts;\n}\n\nfunction hhmmssFromMinutes(minOfDay) {\n const mm = ((minOfDay % 1440) + 1440) % 1440;\n const hh = Math.floor(mm / 60);\n const mi = mm % 60;\n return `${to2(hh)}:${to2(mi)}:00`;\n}\n\nfunction nextDailyOccurrenceMs(startMin, refMs) {\n if (!Number.isFinite(startMin)) return null;\n\n const today = getParisParts(refMs).date;\n const todayStamp = `${today} ${hhmmssFromMinutes(startMin)}`;\n const todayMs = parseParisStampToMs(todayStamp);\n\n if (todayMs !== null && todayMs > refMs + 1000) return todayMs;\n\n const tomorrow = getParisParts(refMs + 36 * 3600 * 1000).date;\n const tomorrowStamp = `${tomorrow} ${hhmmssFromMinutes(startMin)}`;\n return parseParisStampToMs(tomorrowStamp);\n}\n\nconst prev = $input.first().json;\n\nconst {\n nowMs,\n cfg,\n parsed,\n params,\n history,\n batteryTune,\n strategy,\n derived,\n context,\n lifecycle,\n} = prev;\n\n// =====================================================\n// SESSION / SOURCE\n// =====================================================\nconst sessionObservedActive =\n parsed.evcsChargingSwitchOn ||\n parsed.controlLatch ||\n parsed.chargePermissionNow;\n\nconst isGridSource =\n context.sourceMode === 'grid_hc_allowed' ||\n context.sourceMode === 'grid_forced';\n\nconst isLocalSolarSource =\n context.sourceMode === 'solar_only' ||\n context.sourceMode === 'solar_potential';\n\n// =====================================================\n// SOC BAND / BATTERY DYNAMIC POLICY\n// =====================================================\nconst socBand =\n parsed.soc > 90 ? 'gt90' :\n parsed.soc > 80 ? '80_90' :\n parsed.soc > 60 ? '60_80' :\n parsed.soc > 40 ? '40_60' :\n parsed.soc > 25 ? '25_40' :\n 'lt25';\n\nconst band = batteryTune.bands[socBand];\n\nconst dischargeModeFactor =\n strategy.forceChargeRequested ? 1.2 :\n strategy.forceGridRequested ? 0.7 :\n strategy.userStrategy === 'auto_solar' ? 0.8 :\n 1.0;\n\nconst chargeReserveModeFactor =\n strategy.forceChargeRequested ? 0.8 :\n strategy.forceGridRequested ? 1.1 :\n strategy.userStrategy === 'auto_solar' ? 1.1 :\n 1.0;\n\nconst toleranceModeFactor =\n strategy.forceChargeRequested ? 1.25 :\n strategy.forceGridRequested ? 0.8 :\n strategy.userStrategy === 'auto_solar' ? 0.8 :\n 1.0;\n\nconst batteryDischargeSoftDynamicA = band.dischargeSoftA * dischargeModeFactor;\nconst batteryDischargeHardDynamicA = band.dischargeHardA * dischargeModeFactor;\nconst batteryChargeReserveDynamicA = Math.max(0, band.chargeReserveA * chargeReserveModeFactor);\nconst batterySoftToleranceMinutes = band.softToleranceMin * toleranceModeFactor;\nconst batteryHardToleranceSeconds = band.hardToleranceSec;\nconst batteryRecoveryMinutes = band.recoveryMin;\n\n// =====================================================\n// CONTEXT HELPERS\n// =====================================================\nconst houseSpikeThresholdW = 1500;\nconst houseSpikeDetected =\n (parsed.houseAcPowerW - parsed.houseAcPowerPrevW) >= houseSpikeThresholdW;\n\nconst solarPotentialContext =\n context.sourceMode === 'solar_potential' ||\n (\n parsed.soc >= 80 &&\n derived.forecastBufferKwh > 0 &&\n parsed.pvPowerW < context.effectivePvStartMinW &&\n derived.houseNonEvcsW < 1200\n );\n\n// =====================================================\n// OUTSIDE-WINDOW LOCAL BYPASS\n// IMPORTANT:\n// en auto_solar_plus ou force_charge_now, un contexte local solaire\n// ne doit plus rester bloqué juste parce qu'on est hors fenêtre.\n// =====================================================\nconst outsideWindowLocalBypass =\n context.profile === 'outside_window' &&\n !strategy.forceGridRequested &&\n (\n strategy.forceChargeRequested ||\n strategy.userStrategy === 'auto_solar_plus'\n ) &&\n isLocalSolarSource;\n\n// =====================================================\n// REASON-BASED LOCKS\n// =====================================================\nconst nextWindowCandidates = [\n nextDailyOccurrenceMs(params.morningStartMin, nowMs),\n nextDailyOccurrenceMs(params.hcMiddayStartMin, nowMs),\n nextDailyOccurrenceMs(params.eveningStartMin, nowMs),\n nextDailyOccurrenceMs(params.hcNightStartMin, nowMs),\n].filter(v => Number.isFinite(v));\n\nconst nextAllowedWindowStartMs =\n nextWindowCandidates.length > 0 ? Math.min(...nextWindowCandidates) : null;\n\nconst blockActive =\n history.blockedUntilMs !== null &&\n history.blockedUntilMs > nowMs + 1000;\n\nconst blockStillRelevant =\n history.lastBlockReason === 'outside_window'\n ? (context.profile === 'outside_window' && !outsideWindowLocalBypass)\n : history.lastBlockReason === 'probe_failed'\n ? context.sourceMode === 'hybrid_probe'\n : history.lastBlockReason === 'battery_protection_stop'\n ? (\n parsed.batteryCurrentA < -params.batteryNeutralBandA ||\n derived.anyVebusLowBattery ||\n derived.vebusDcVoltageSoftLow\n )\n : history.lastBlockReason === 'grid_battery_guard'\n ? isGridSource\n : history.lastBlockReason === 'vebus_overload_persistent'\n ? derived.anyVebusOverload\n : false;\n\nconst startBlockedByReason = blockActive && blockStillRelevant;\n\n// =====================================================\n// CLOUD DEBT\n// =====================================================\nconst batteryNetPowerW = parsed.batteryCurrentA * derived.batteryVoltageRef;\nlet cloudDebtNextKwh = params.cloudDebtPrevKwh;\n\nif (sessionObservedActive) {\n if (batteryNetPowerW < 0 && !isGridSource) {\n cloudDebtNextKwh += (Math.abs(batteryNetPowerW) * cfg.sampleSeconds) / 3600000;\n } else if (batteryNetPowerW > 0) {\n cloudDebtNextKwh -= (batteryNetPowerW * cfg.sampleSeconds) / 3600000;\n }\n} else if (batteryNetPowerW > 0) {\n cloudDebtNextKwh -= (batteryNetPowerW * cfg.sampleSeconds) / 3600000;\n}\n\ncloudDebtNextKwh = clamp(cloudDebtNextKwh, 0, params.cloudDebtMaxKwh * 4);\n\nconst cloudDebtMeaningful =\n cloudDebtNextKwh > cfg.cloudDebtMeaningfulThresholdKwh;\n\nconst cloudEventDischargingNow =\n sessionObservedActive &&\n !isGridSource &&\n parsed.batteryCurrentA < -Math.max(1, params.batteryNeutralBandA) &&\n cloudDebtMeaningful;\n\nconst continuingCloudEvent =\n cloudEventDischargingNow &&\n history.cloudDebtStartedMsPrev !== null &&\n params.cloudDebtPrevKwh > cfg.cloudDebtRecoveryThresholdKwh;\n\nconst enteringCloudDebt =\n cloudEventDischargingNow && !continuingCloudEvent;\n\nconst cloudDebtActive = cloudEventDischargingNow;\n\nconst cloudDebtStartedMs =\n cloudEventDischargingNow\n ? (continuingCloudEvent ? history.cloudDebtStartedMsPrev : nowMs)\n : null;\n\nconst cloudDebtDurationMinutes =\n cloudDebtActive && cloudDebtStartedMs !== null\n ? Math.max(0, (nowMs - cloudDebtStartedMs) / 60000)\n : 0;\n\nconst effectiveCloudToleranceMinutes =\n params.cloudToleranceMinutes +\n (solarPotentialContext ? batteryTune.global.solarPotentialGraceMinutes : 0) +\n (houseSpikeDetected ? batteryTune.global.houseSpikeGraceMinutes : 0);\n\nconst cloudToleranceOk =\n cloudDebtNextKwh <= params.cloudDebtMaxKwh &&\n (\n !cloudDebtActive ||\n cloudDebtDurationMinutes <= effectiveCloudToleranceMinutes\n );\n\n// =====================================================\n// LEGACY / FORECAST MODES\n// =====================================================\nconst batteryFullHarvestMode =\n parsed.forecastModeEnabled &&\n context.profile !== 'night_hc' &&\n context.profile !== 'outside_window' &&\n parsed.soc >= params.batteryFullSoc &&\n parsed.todayRemainingKwh >= params.batteryFullForecastMinKwh &&\n derived.projectedEndSocAfterCharge >= (derived.endDayGuardSoc - 2);\n\nconst forecastCommitMode =\n parsed.forecastModeEnabled &&\n context.profile !== 'night_hc' &&\n context.profile !== 'outside_window' &&\n parsed.soc >= params.forecastCommitSocFloor &&\n parsed.todayRemainingKwh >= params.forecastCommitMinKwh &&\n derived.projectedEndSocAfterCharge >= derived.endDayGuardSoc;\n\nconst batteryPriorityMode =\n !batteryFullHarvestMode && !forecastCommitMode;\n\n// =====================================================\n// EVCS NIGHT ENGINE\n// =====================================================\nconst nightSessionActive =\n context.profile === 'night_hc' &&\n parsed.evcsVehicleConnected;\n\nconst nightSessionStartSocPct =\n nightSessionActive\n ? (\n Number.isFinite(history.nightSessionStartSocPct)\n ? history.nightSessionStartSocPct\n : parsed.soc\n )\n : null;\n\nconst nightSessionStartAt =\n nightSessionActive\n ? (history.nightSessionStartAt || '')\n : '';\n\nconst nightSessionStartMs =\n nightSessionActive\n ? (history.nightSessionStartMs ?? null)\n : null;\n\nconst nightSessionNeedsCapture =\n nightSessionActive &&\n (\n !Number.isFinite(history.nightSessionStartSocPct) ||\n !nightSessionStartAt ||\n nightSessionStartMs === null\n );\n\nconst nightSessionNeedsClear =\n !nightSessionActive &&\n (\n Number.isFinite(history.nightSessionStartSocPct) ||\n !!history.nightSessionStartAt\n );\n\nconst batteryHardFloorPct =\n params.evcsNightBatteryHardFloorPct;\n\nconst batteryMorningTargetPct =\n parsed.todayRemainingKwh >= params.evcsNightStrongForecastKwh\n ? params.evcsNightTargetStrongForecastPct\n : parsed.todayRemainingKwh >= params.evcsNightMediumForecastKwh\n ? params.evcsNightTargetMediumForecastPct\n : params.evcsNightTargetWeakForecastPct;\n\nconst maxBatteryDischargePoints =\n nightSessionActive && Number.isFinite(nightSessionStartSocPct)\n ? Math.max(0, nightSessionStartSocPct - batteryMorningTargetPct)\n : 0;\n\nconst gridTakeoverTriggerPct =\n Math.max(batteryHardFloorPct, batteryMorningTargetPct);\n\nconst batteryKwhMissingToTarget =\n nightSessionActive\n ? Math.max(\n 0,\n ((batteryMorningTargetPct - parsed.soc) / 100) * cfg.batteryUsableCapacityKwh\n )\n : 0;\n\nconst batteryRecoveryMinutesNeeded =\n nightSessionActive && params.evcsNightBatteryRecoveryPowerKw > 0\n ? (batteryKwhMissingToTarget / params.evcsNightBatteryRecoveryPowerKw) * 60\n : 0;\n\nconst batteryRecoveryUrgent =\n nightSessionActive &&\n Number.isFinite(lifecycle.minutesUntilDeparture) &&\n lifecycle.minutesUntilDeparture >= 0 &&\n (\n batteryRecoveryMinutesNeeded + params.evcsNightDepartureSafetyMin\n >= lifecycle.minutesUntilDeparture\n );\n\nconst batteryBelowHardFloor =\n parsed.soc <= batteryHardFloorPct;\n\nconst batteryBelowMorningTarget =\n parsed.soc < batteryMorningTargetPct;\n\nconst batteryAtOrBelowGridTakeoverTrigger =\n parsed.soc <= gridTakeoverTriggerPct;\n\nconst batteryAssistAllowed =\n nightSessionActive &&\n !batteryBelowHardFloor &&\n !batteryRecoveryUrgent &&\n parsed.soc > gridTakeoverTriggerPct;\n\nconst nightGridTakeoverNeeded =\n nightSessionActive &&\n (\n batteryBelowHardFloor ||\n batteryAtOrBelowGridTakeoverTrigger ||\n batteryRecoveryUrgent\n );\n\nconst nightPreferredSourceMode =\n !nightSessionActive\n ? 'blocked'\n : batteryAssistAllowed\n ? 'battery_assist_night'\n : (\n batteryBelowHardFloor ||\n batteryBelowMorningTarget ||\n batteryRecoveryUrgent\n )\n ? 'grid_forced'\n : 'grid_hc_allowed';\n\nconst batteryRecoveryAllowedNow =\n nightSessionActive &&\n nightGridTakeoverNeeded &&\n (\n batteryBelowMorningTarget ||\n batteryRecoveryUrgent\n );\n\nconst batteryTargetReached =\n nightSessionActive &&\n parsed.soc >= batteryMorningTargetPct;\n\n// =====================================================\n// PROFILE-BASED THRESHOLDS\n// =====================================================\nconst profileSocStartFloor =\n context.profile === 'morning_solar' ? params.morningSocMin :\n context.profile === 'midday_hc_or_solar' ? params.middaySocMin :\n context.profile === 'evening_high_soc' ? params.eveningSocStartMin :\n params.socStartMin;\n\nconst profileSocHoldFloor =\n context.profile === 'morning_solar' ? Math.max(params.socStopMin, params.morningSocMin - 10) :\n context.profile === 'midday_hc_or_solar' ? Math.max(params.socStopMin, params.middaySocMin - 15) :\n context.profile === 'evening_high_soc' ? params.eveningSocStopMin :\n params.socStopMin;\n\nconst profileSpecificDischargeFloorA =\n context.profile === 'morning_solar' ? params.morningBatteryMaxDischargeA :\n context.profile === 'midday_hc_or_solar' ? params.middayBatteryMaxDischargeA :\n context.profile === 'evening_high_soc' ? params.eveningBatteryMaxDischargeA :\n params.batteryDischargeSoftA;\n\nconst profileBatteryDischargeFloorA =\n Math.max(profileSpecificDischargeFloorA, batteryDischargeSoftDynamicA);\n\n// =====================================================\n// GRID RELAX IN force_charge_now\n// =====================================================\n// Assouplissement uniquement pour la garde réseau,\n// sans toucher aux sécurités dures globales.\nconst gridForceChargeRelaxSoftA =\n strategy.forceChargeRequested && isGridSource\n ? (\n parsed.soc >= 90 ? 28 :\n parsed.soc >= 80 ? 16 :\n parsed.soc >= 60 ? 8 :\n parsed.soc >= 40 ? 4 :\n 0\n )\n : 0;\n\nconst gridForceChargeRelaxHardA =\n strategy.forceChargeRequested && isGridSource\n ? (\n parsed.soc >= 90 ? 39 :\n parsed.soc >= 80 ? 29 :\n parsed.soc >= 60 ? 18 :\n parsed.soc >= 40 ? 8 :\n 0\n )\n : 0;\n\nconst gridBatterySoftDynamicABase =\n Math.max(params.gridBatterySoftA, batteryDischargeSoftDynamicA);\n\nconst gridBatteryHardDynamicABase =\n Math.max(params.gridBatteryHardA, batteryDischargeHardDynamicA);\n\nlet gridBatterySoftDynamicA =\n gridBatterySoftDynamicABase - gridForceChargeRelaxSoftA;\n\nlet gridBatteryHardDynamicA =\n gridBatteryHardDynamicABase - gridForceChargeRelaxHardA;\n\n// Garantit que le hard reste plus permissif que le soft d'au moins 3 A\nif (gridBatteryHardDynamicA > (gridBatterySoftDynamicA - 3)) {\n gridBatteryHardDynamicA = gridBatterySoftDynamicA - 3;\n}\n\n// =====================================================\n// BASE SAFETY\n// =====================================================\nconst commonSafetyStartOk =\n derived.canRunAtMinCurrent &&\n !derived.anyVebusOverload &&\n !derived.anyVebusLowBattery &&\n !derived.vebusDcVoltageSoftLow &&\n !derived.vebusAcOutSoftReached &&\n parsed.evcsDeviceConnected &&\n parsed.evcsVehicleConnected &&\n !parsed.evcsModeIsAuto;\n\nconst commonSafetyHoldOk =\n derived.canRunAtMinCurrent &&\n !derived.anyVebusLowBattery &&\n !derived.vebusDcVoltageHardLow &&\n parsed.evcsDeviceConnected &&\n parsed.evcsVehicleConnected &&\n !parsed.evcsModeIsAuto;\n\n// =====================================================\n// BATTERY-ASSISTED SOLAR START/HOLD\n// IMPORTANT:\n// Installation sans injection:\n// le PV visible peut rester bas tant que l'EVCS ne démarre pas.\n// On autorise donc un démarrage/maintien local si la batterie est haute\n// et que la dette nuage reste faible.\n// =====================================================\nconst batteryAssistSolarPolicyEligible =\n !strategy.forceGridRequested &&\n (\n strategy.forceChargeRequested ||\n strategy.userStrategy === 'auto_solar_plus'\n ) &&\n isLocalSolarSource;\n\nconst batteryAssistSolarStartSocMin = 90;\nconst batteryAssistSolarHoldSocMin = 85;\n\nconst batteryAssistSolarDebtStartMaxKwh = Math.max(\n cfg.cloudDebtRecoveryThresholdKwh,\n Math.min(params.cloudDebtMaxKwh, 0.05)\n);\n\nconst batteryAssistSolarStartOk =\n batteryAssistSolarPolicyEligible &&\n commonSafetyStartOk &&\n !isGridSource &&\n parsed.soc >= Math.max(profileSocStartFloor, batteryAssistSolarStartSocMin) &&\n parsed.soc > batteryAssistSolarHoldSocMin &&\n parsed.batteryCurrentA >= profileBatteryDischargeFloorA &&\n cloudDebtNextKwh <= batteryAssistSolarDebtStartMaxKwh &&\n !cloudDebtActive;\n\nconst batteryAssistSolarHoldOk =\n batteryAssistSolarPolicyEligible &&\n commonSafetyHoldOk &&\n !isGridSource &&\n parsed.soc >= Math.max(profileSocHoldFloor, batteryAssistSolarHoldSocMin) &&\n parsed.batteryCurrentA >= profileBatteryDischargeFloorA &&\n cloudToleranceOk;\n\n// =====================================================\n// START / HOLD CONDITIONS\n// =====================================================\nconst rawSolarStartOk =\n (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') &&\n commonSafetyStartOk &&\n parsed.soc >= profileSocStartFloor &&\n parsed.batteryCurrentA >= profileBatteryDischargeFloorA &&\n (\n context.sourceMode === 'solar_potential' ||\n parsed.pvPowerW >= context.effectivePvStartMinW\n );\n\nconst solarStartOk =\n rawSolarStartOk || batteryAssistSolarStartOk;\n\nconst rawSolarHoldOk =\n (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') &&\n commonSafetyHoldOk &&\n parsed.soc >= profileSocHoldFloor &&\n parsed.batteryCurrentA >= profileBatteryDischargeFloorA &&\n (\n parsed.pvPowerW >= params.pvHoldMinW ||\n cloudToleranceOk\n );\n\nconst solarHoldOk =\n rawSolarHoldOk || batteryAssistSolarHoldOk;\n\nconst gridStartOk =\n isGridSource &&\n commonSafetyStartOk &&\n parsed.gridAvailable &&\n parsed.batteryCurrentA >= gridBatterySoftDynamicA;\n\nconst gridHoldOk =\n isGridSource &&\n commonSafetyHoldOk &&\n parsed.gridAvailable &&\n parsed.batteryCurrentA >= gridBatteryHardDynamicA;\n\nconst batteryAssistNightStartOk =\n context.sourceMode === 'battery_assist_night' &&\n commonSafetyStartOk &&\n nightSessionActive &&\n batteryAssistAllowed &&\n parsed.soc > batteryHardFloorPct &&\n parsed.soc > gridTakeoverTriggerPct &&\n parsed.batteryCurrentA >= profileBatteryDischargeFloorA;\n\nconst batteryAssistNightHoldOk =\n context.sourceMode === 'battery_assist_night' &&\n commonSafetyHoldOk &&\n nightSessionActive &&\n batteryAssistAllowed &&\n parsed.soc > batteryHardFloorPct &&\n parsed.soc > gridTakeoverTriggerPct &&\n parsed.batteryCurrentA >= batteryDischargeHardDynamicA;\n\n// =====================================================\n// PROBE LOGIC — STRICTEMENT LIMITÉE AU MODE PROBE\n// =====================================================\nconst probeModeActive = context.sourceMode === 'hybrid_probe';\n\nconst probeStartOk =\n probeModeActive &&\n commonSafetyStartOk &&\n (context.morningProbeEligible || context.middayProbeEligible);\n\nconst probeHoldGuardOk =\n probeModeActive &&\n commonSafetyHoldOk &&\n parsed.batteryCurrentA >= profileBatteryDischargeFloorA;\n\nconst probeWindowActive =\n probeModeActive &&\n sessionObservedActive &&\n history.probeStartedMs !== null &&\n lifecycle.minutesSinceProbeStart !== null &&\n lifecycle.minutesSinceProbeStart < params.probeMinutes;\n\nconst probeElapsedReached =\n probeModeActive &&\n sessionObservedActive &&\n history.probeStartedMs !== null &&\n lifecycle.minutesSinceProbeStart !== null &&\n lifecycle.minutesSinceProbeStart >= params.probeMinutes;\n\nconst probeSuccessNow =\n probeModeActive &&\n sessionObservedActive &&\n history.probeStartedMs !== null &&\n (\n parsed.pvPowerW >= params.pvHoldMinW &&\n parsed.batteryCurrentA >= Math.min(0, batteryDischargeSoftDynamicA)\n );\n\nconst probeFailedNow =\n probeModeActive &&\n probeElapsedReached &&\n !probeSuccessNow;\n\n// =====================================================\n// STEP-UP LOGIC\n// =====================================================\nconst stepUpByBatteryReserve =\n batteryPriorityMode &&\n parsed.batteryCurrentA >= batteryChargeReserveDynamicA;\n\nconst stepUpByForecastMode =\n !batteryPriorityMode &&\n (\n parsed.pvPowerW >= params.pvHoldMinW ||\n parsed.pvPowerAvg10mW >= params.pvHoldMinW\n ) &&\n derived.forecastBufferKwh >= -1 &&\n derived.projectedEndSocAfterCharge >= (derived.endDayGuardSoc - 5) &&\n cloudToleranceOk;\n\nconst solarShouldStepUp =\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA < derived.physicalCapA &&\n derived.physicalCapA >= params.currentMinA &&\n !derived.vebusAcOutSoftReached &&\n !derived.vebusDcVoltageSoftLow &&\n !derived.houseLoadHighVsHeadroom &&\n (\n stepUpByBatteryReserve ||\n stepUpByForecastMode ||\n parsed.pvPowerW >= (context.effectivePvStartMinW + 400)\n );\n\nconst gridShouldStepUp =\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA < derived.physicalCapA &&\n derived.physicalCapA >= params.currentMinA &&\n !derived.vebusAcOutSoftReached &&\n !derived.vebusDcVoltageSoftLow &&\n parsed.batteryCurrentA >= gridBatterySoftDynamicA &&\n !derived.houseLoadHighVsHeadroom;\n\nconst batteryAssistNightShouldStepUp =\n context.sourceMode === 'battery_assist_night' &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA < derived.physicalCapA &&\n derived.physicalCapA >= params.currentMinA &&\n !derived.vebusAcOutSoftReached &&\n !derived.vebusDcVoltageSoftLow &&\n !derived.houseLoadHighVsHeadroom &&\n parsed.batteryCurrentA >= batteryChargeReserveDynamicA &&\n batteryAssistAllowed;\n\n// =====================================================\n// STEP-DOWN LOGIC\n// =====================================================\nconst solarShouldStepDownBattery =\n parsed.batteryCurrentA <= batteryDischargeSoftDynamicA &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst solarShouldStepDownCloudDebt =\n !cloudToleranceOk &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst gridShouldStepDownBattery =\n parsed.batteryCurrentA < gridBatterySoftDynamicA &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst batteryAssistNightShouldStepDown =\n context.sourceMode === 'battery_assist_night' &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA &&\n (\n parsed.batteryCurrentA <= batteryDischargeSoftDynamicA ||\n parsed.soc <= gridTakeoverTriggerPct ||\n batteryRecoveryUrgent\n );\n\nconst shouldStepDownVebusPower =\n derived.vebusAcOutSoftReached &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst shouldStepDownVebusVoltage =\n derived.vebusDcVoltageSoftLow &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst shouldStepDownOverload =\n derived.anyVebusOverload &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst shouldStopOverloadAtMin =\n derived.anyVebusOverload &&\n derived.currentReferenceA <= params.currentMinA &&\n sessionObservedActive;\n\n// =====================================================\n// HARD STOPS / PROTECTIONS\n// =====================================================\nconst batteryCurrentHardExceeded =\n parsed.batteryCurrentA <= batteryDischargeHardDynamicA;\n\n// overload volontairement exclu du hard stop immédiat\nconst mustStopHard =\n derived.anyVebusLowBattery ||\n derived.vebusDcVoltageHardLow ||\n derived.vebusAcOutHardReached ||\n batteryCurrentHardExceeded;\n\nconst gridBatteryProtectionStop =\n sessionObservedActive &&\n isGridSource &&\n parsed.batteryCurrentA < gridBatteryHardDynamicA;\n\n// =====================================================\n// OUTPUT\n// =====================================================\nreturn [{\n json: {\n ...prev,\n\n batteryPolicy: {\n socBand,\n batteryDischargeSoftDynamicA,\n batteryDischargeHardDynamicA,\n batteryChargeReserveDynamicA,\n batterySoftToleranceMinutes,\n batteryHardToleranceSeconds,\n batteryRecoveryMinutes,\n\n gridBatterySoftDynamicA,\n gridBatteryHardDynamicA,\n gridForceChargeRelaxSoftA,\n gridForceChargeRelaxHardA,\n\n batteryAssistSolarStartSocMin,\n batteryAssistSolarHoldSocMin,\n batteryAssistSolarDebtStartMaxKwh,\n\n houseSpikeDetected,\n solarPotentialContext,\n outsideWindowLocalBypass,\n },\n\n locks: {\n nextAllowedWindowStartMs,\n blockActive,\n blockStillRelevant,\n startBlockedByReason,\n },\n\n cloud: {\n sessionObservedActive,\n batteryNetPowerW,\n cloudDebtNextKwh,\n cloudDebtMeaningful,\n cloudEventDischargingNow,\n continuingCloudEvent,\n enteringCloudDebt,\n cloudDebtActive,\n cloudDebtStartedMs,\n cloudDebtDurationMinutes,\n effectiveCloudToleranceMinutes,\n cloudToleranceOk,\n },\n\n modes: {\n batteryFullHarvestMode,\n forecastCommitMode,\n batteryPriorityMode,\n },\n\n nightEvcs: {\n nightSessionActive,\n nightSessionNeedsCapture,\n nightSessionNeedsClear,\n nightSessionStartSocPct,\n nightSessionStartAt,\n nightSessionStartMs,\n\n batteryHardFloorPct,\n batteryMorningTargetPct,\n maxBatteryDischargePoints,\n gridTakeoverTriggerPct,\n\n batteryBelowHardFloor,\n batteryBelowMorningTarget,\n batteryAtOrBelowGridTakeoverTrigger,\n\n batteryKwhMissingToTarget,\n batteryRecoveryMinutesNeeded,\n batteryRecoveryUrgent,\n batteryRecoveryAllowedNow,\n batteryTargetReached,\n\n batteryAssistAllowed,\n nightGridTakeoverNeeded,\n nightPreferredSourceMode,\n },\n\n safeguards: {\n isGridSource,\n commonSafetyStartOk,\n commonSafetyHoldOk,\n profileSocStartFloor,\n profileSocHoldFloor,\n profileBatteryDischargeFloorA,\n batteryCurrentHardExceeded,\n mustStopHard,\n gridBatteryProtectionStop,\n },\n\n conditions: {\n rawSolarStartOk,\n rawSolarHoldOk,\n batteryAssistSolarPolicyEligible,\n batteryAssistSolarStartOk,\n batteryAssistSolarHoldOk,\n\n solarStartOk,\n solarHoldOk,\n gridStartOk,\n gridHoldOk,\n\n batteryAssistNightStartOk,\n batteryAssistNightHoldOk,\n\n probeStartOk,\n probeHoldGuardOk,\n probeWindowActive,\n probeElapsedReached,\n probeSuccessNow,\n probeFailedNow,\n\n solarShouldStepUp,\n gridShouldStepUp,\n batteryAssistNightShouldStepUp,\n\n solarShouldStepDownBattery,\n solarShouldStepDownCloudDebt,\n gridShouldStepDownBattery,\n batteryAssistNightShouldStepDown,\n\n shouldStepDownVebusPower,\n shouldStepDownVebusVoltage,\n shouldStepDownOverload,\n shouldStopOverloadAtMin,\n }\n }\n}];\n"
+ },
+ "type": "n8n-nodes-base.code",
+ "typeVersion": 2,
+ "position": [
+ 272,
+ 32
+ ],
+ "id": "26501959-fa1a-498d-9e41-a2a5fa77b18d",
+ "name": "BATTERY_CLOUD_LOCKS"
+ },
+ {
+ "parameters": {
+ "jsCode": "function num(v, fallback = NaN) {\n const n = Number.parseFloat(v);\n return Number.isFinite(n) ? n : fallback;\n}\n\nfunction low(v) {\n return (v ?? '').toString().trim().toLowerCase();\n}\n\nfunction clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nfunction round1(v) {\n return Math.round(v * 10) / 10;\n}\n\nfunction round2(v) {\n return Math.round(v * 100) / 100;\n}\n\nfunction to2(n) {\n return String(n).padStart(2, '0');\n}\n\nfunction parseMaybeJson(v) {\n if (typeof v !== 'string') return v;\n try {\n return JSON.parse(v);\n } catch {\n return v;\n }\n}\n\nfunction extractSnapshot(input) {\n if (typeof input === 'string') return parseMaybeJson(input);\n if (input?.data && typeof input.data === 'string') return parseMaybeJson(input.data);\n if (input?.body && typeof input.body === 'string') return parseMaybeJson(input.body);\n if (input?.data && typeof input.data === 'object') return input.data;\n if (input?.body && typeof input.body === 'object') return input.body;\n return input;\n}\n\nfunction parseTimeToMinutes(v, fallback = null) {\n const s = (v ?? '').toString().trim();\n const m = s.match(/^(\\d{1,2}):(\\d{2})(?::(\\d{2}))?$/);\n if (!m) return fallback;\n\n const hh = Number(m[1]);\n const mm = Number(m[2]);\n\n if (!Number.isFinite(hh) || !Number.isFinite(mm)) return fallback;\n return (hh % 24) * 60 + mm;\n}\n\nfunction getParisParts(ms = Date.now()) {\n const fmt = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris',\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n fmt.formatToParts(new Date(ms))\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour);\n const date = `${parts.year}-${parts.month}-${parts.day}`;\n const stamp = `${date} ${to2(hour)}:${parts.minute}:${parts.second}`;\n\n return {\n year: Number(parts.year),\n month: Number(parts.month),\n day: Number(parts.day),\n hour,\n minute: Number(parts.minute),\n second: Number(parts.second),\n date,\n stamp,\n minNow: hour * 60 + Number(parts.minute),\n };\n}\n\nfunction getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') {\n const dtf = new Intl.DateTimeFormat('en-US', {\n timeZone,\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n dtf.formatToParts(new Date(utcTs))\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const asIfUtc = Date.UTC(\n Number(parts.year),\n Number(parts.month) - 1,\n Number(parts.day),\n Number(parts.hour) === 24 ? 0 : Number(parts.hour),\n Number(parts.minute),\n Number(parts.second)\n );\n\n const d = new Date(utcTs);\n const actualUtc = Date.UTC(\n d.getUTCFullYear(),\n d.getUTCMonth(),\n d.getUTCDate(),\n d.getUTCHours(),\n d.getUTCMinutes(),\n d.getUTCSeconds()\n );\n\n return asIfUtc - actualUtc;\n}\n\nfunction parseParisStampToMs(s) {\n const text = (s ?? '').toString().trim();\n const m = text.match(/^(\\d{4})-(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2})$/);\n if (!m) return null;\n\n const y = Number(m[1]);\n const mo = Number(m[2]);\n const d = Number(m[3]);\n const h = Number(m[4]);\n const mi = Number(m[5]);\n const se = Number(m[6]);\n\n if (![y, mo, d, h, mi, se].every(Number.isFinite)) return null;\n\n const guessUtc = Date.UTC(y, mo - 1, d, h, mi, se);\n const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris');\n let ts = guessUtc - off1;\n const off2 = getTzOffsetMs(ts, 'Europe/Paris');\n\n if (off2 !== off1) ts = guessUtc - off2;\n return ts;\n}\n\nfunction cleanText(v, fallback = '') {\n const s = (v ?? '').toString().trim();\n const x = s.toLowerCase();\n if (['unknown', 'unavailable', 'none', 'null', 'undefined'].includes(x)) return fallback;\n return s;\n}\n\nfunction finiteOrNull(v) {\n return Number.isFinite(v) ? v : null;\n}\n\nconst raw = $input.first().json;\nconst input = extractSnapshot(raw);\n\nconst nowMs = Date.now();\nconst now = getParisParts(nowMs);\n\nconst CFG = {\n mobileNotifyDomain: 'notify',\n mobileNotifyService: 'mobile_app_iphone_16_de_tt',\n\n batteryUsableCapacityKwh: 17.5,\n nominalAcVoltageV: 230,\n evcsDedicatedMaxW: 4900,\n systemReserveW: 300,\n\n vebusDcVoltageSoftFloorV: 49.0,\n vebusDcVoltageHardFloorV: 48.6,\n\n departurePriorityWindowMinutes: 240,\n sampleSeconds: 30,\n\n cloudDebtRecoveryThresholdKwh: 0.05,\n cloudDebtMeaningfulThresholdKwh: 0.01,\n};\n\n// =====================================================\n// PARSED SNAPSHOT\n// =====================================================\nconst parsed = {\n soc: clamp(num(input.soc_percent, 0), 0, 100),\n batteryCurrentA: num(input.battery_current_a, 0),\n\n pvPowerW: Math.max(0, num(input.pv_power_w, 0)),\n pvPowerAvg10mW: Math.max(0, num(input.pv_power_10m_avg_w, num(input.pv_power_w, 0))),\n pvTrendW: num(input.pv_trend_w, 0),\n\n houseAcPowerW: Math.max(0, num(input.house_ac_power_w, 0)),\n houseAcPowerPrevW: Math.max(0, num(input.house_ac_power_prev_w, num(input.house_ac_power_w, 0))),\n gridPowerW: num(input.grid_power_w, 0),\n\n gridIsOn: low(input.grid_state || 'off') === 'on',\n acInAvailable: num(input.victron_acin1_available ?? input.ac_in1_available, 0) === 1,\n gridAvailable:\n (low(input.grid_state || 'off') === 'on') ||\n (num(input.victron_acin1_available ?? input.ac_in1_available, 0) === 1),\n\n todayRemainingKwh: Math.max(0, num(input.today_remaining_kwh, 0)),\n\n evcsDeviceConnected: low(input.evcs_device_connected || 'off') === 'on',\n evcsVehicleConnected: low(input.evcs_vehicle_connected || 'off') === 'on',\n evcsStatusRaw: cleanText(input.evcs_status_raw),\n evcsModeRaw: [\n input.evcs_mode_sensor,\n input.evcs_mode_fallback,\n input.evcs_mode\n ]\n .map(v => cleanText(v))\n .find(v => v) || 'unknown',\n\n evcsSetCurrentA: num(input.evcs_set_current_a, NaN),\n evcsCommandCurrentA: num(input.evcs_command_current_a, NaN),\n\n // Mesure réelle côté borne\n evcsCurrentA: Math.max(0, num(input.evcs_current_a, 0)),\n evcsCurrentRealA: Math.max(0, num(input.evcs_current_a, 0)),\n\n evcsAcPowerW: Math.max(0, num(input.evcs_ac_power_w, 0)),\n evcsChargingSwitchOn: low(input.evcs_charging_switch || 'off') === 'on',\n evcsAutoStartSwitchOn: low(input.evcs_auto_start_switch || 'off') === 'on',\n\n controllerEnabled: low(input.controller_enabled || 'off') === 'on',\n notificationsEnabled: low(input.notifications_enabled || 'off') === 'on',\n hardStopEnabledLegacy: low(input.hard_stop_enabled || 'off') === 'on',\n chargePermissionNow: low(input.charge_permission_now || 'off') === 'on',\n controlLatch: low(input.control_latch || 'off') === 'on',\n departurePriorityEnabled: low(input.departure_priority_enabled || 'off') === 'on',\n forecastModeEnabled: low(input.forecast_mode_enabled || 'off') === 'on',\n\n evcsPriorityManualOverride: low(input.evcs_priority_manual_override || 'off') === 'on',\n evcsPriorityManualMode: cleanText(input.evcs_priority_manual_mode || 'normal', 'normal').toLowerCase(),\n evcsPriorityManualReason: cleanText(input.evcs_priority_manual_reason),\n\n vebusOverload: low(input.vebus_overload || 'off') === 'on',\n vebusL1Overload: low(input.vebus_l1_overload || 'off') === 'on',\n vebusLowBattery: low(input.vebus_low_battery || 'off') === 'on',\n vebusL1LowBattery: low(input.vebus_l1_low_battery || 'off') === 'on',\n\n vebusAcOutPowerW: Math.max(0, num(input.vebus_ac_out_l1_power_w, 0)),\n vebusAcOutCurrentA: Math.max(0, num(input.vebus_ac_out_l1_current_a, 0)),\n vebusAcOutVoltageV: num(input.vebus_ac_out_l1_voltage_v, CFG.nominalAcVoltageV),\n\n vebusDcVoltageV: num(input.vebus_dc_voltage_v, NaN),\n vebusDcCurrentA: num(input.vebus_dc_current_a, NaN),\n vebusDcPowerW: num(input.vebus_dc_power_w, NaN),\n\n vebusState: num(input.vebus_state, NaN),\n vebusMainState: num(input.vebus_main_state, NaN),\n};\n\nparsed.evcsModeIsAuto = low(parsed.evcsModeRaw).includes('auto');\n\n// =====================================================\n// PARAMETERS\n// =====================================================\nconst params = {\n userStrategyRaw: input.victron_evcs_strategy || 'auto_solar',\n\n targetCurrentAutoA: clamp(num(input.target_current_auto_a, 6), 0, 32),\n pvStartMinW: Math.max(0, num(input.pv_start_min_w, 1800)),\n pvHoldMinW: Math.max(0, num(input.pv_hold_min_w, 1200)),\n houseAcHeadroomW: Math.max(0, num(input.house_ac_headroom_w, 1200)),\n\n batteryDischargeSoftA: num(input.battery_discharge_soft_a, -5),\n batteryDischargeHardA: num(input.battery_discharge_hard_a, -15),\n batteryReserveUpA: Math.max(0, num(input.battery_reserve_up_a, 8)),\n batteryNeutralBandA: Math.max(0, num(input.battery_neutral_band_a, 3)),\n\n socStartMin: clamp(num(input.soc_start_min, 60), 0, 100),\n socStopMin: clamp(num(input.soc_stop_min, 35), 0, 100),\n\n currentMinA: clamp(num(input.current_min_a, 6), 6, 32),\n currentMaxAutoA: clamp(\n num(input.current_max_auto_a, 16),\n clamp(num(input.current_min_a, 6), 6, 32),\n 32\n ),\n currentStepUpA: clamp(num(input.current_step_up_a, 1), 1, 5),\n currentStepDownA: clamp(num(input.current_step_down_a, 2), 1, 8),\n\n currentChangeCooldownSeconds: Math.max(5, num(input.current_change_cooldown_seconds, 60)),\n minRunMinutes: Math.max(0, num(input.min_run_minutes, 3)),\n minStopMinutes: Math.max(0, num(input.min_stop_minutes, 10)),\n\n requiredEnergyKwh: Math.max(0, num(input.required_energy_kwh, 0)),\n socComfortTarget: clamp(num(input.soc_comfort_target, 85), 0, 100),\n endOfDaySocGuard: clamp(num(input.end_of_day_soc_guard, 85), 0, 100),\n baseLoadRemainingKwh: Math.max(0, num(input.base_load_remaining_kwh, 0)),\n flexibleSafetyMarginKwh: Math.max(0, num(input.flexible_safety_margin_kwh, 0)),\n endOfDaySocTarget: clamp(num(input.end_of_day_soc_target, 85), 0, 100),\n\n acLoadsMaxSafeW: Math.max(0, num(input.ac_loads_max_safe_w, 8500)),\n batteryFullSoc: clamp(num(input.battery_full_soc, 90), 0, 100),\n batteryFullForecastMinKwh: Math.max(0, num(input.battery_full_forecast_min_kwh, 8)),\n forecastCommitSocFloor: clamp(num(input.forecast_commit_soc_floor, 35), 0, 100),\n forecastCommitMinKwh: Math.max(0, num(input.forecast_commit_min_kwh, 25)),\n\n cloudDebtPrevKwh: Math.max(0, num(input.cloud_debt_kwh, 0)),\n cloudDebtMaxKwh: Math.max(0.05, num(input.cloud_debt_max_kwh, 1.2)),\n cloudToleranceMinutes: Math.max(1, num(input.cloud_tolerance_minutes, 20)),\n\n departureTimeMin: parseTimeToMinutes(input.departure_time, null),\n dayStartTimeMin: parseTimeToMinutes(input.day_start_time, 7 * 60 + 30),\n dayStopTimeMin: parseTimeToMinutes(input.day_stop_time, 20 * 60 + 30),\n\n hcNightStartMin: parseTimeToMinutes(input.hc_night_start_time, 23 * 60),\n hcNightStopMin: parseTimeToMinutes(input.hc_night_stop_time, 6 * 60),\n morningStartMin: parseTimeToMinutes(\n input.morning_start_time,\n parseTimeToMinutes(input.day_start_time, 7 * 60 + 30)\n ),\n morningStopMin: parseTimeToMinutes(input.morning_stop_time, 11 * 60),\n hcMiddayStartMin: parseTimeToMinutes(input.hc_midday_start_time, 12 * 60 + 21),\n hcMiddayStopMin: parseTimeToMinutes(input.hc_midday_stop_time, 14 * 60 + 21),\n eveningStartMin: parseTimeToMinutes(input.evening_start_time, 17 * 60),\n eveningStopMin: parseTimeToMinutes(\n input.evening_stop_time,\n parseTimeToMinutes(input.day_stop_time, 20 * 60 + 30)\n ),\n\n allowGridHcNight: low(input.allow_grid_hc_night || 'off') === 'on',\n allowGridHcMidday: low(input.allow_grid_hc_midday || 'off') === 'on',\n\n probeEnabled: low(input.probe_enabled || 'off') === 'on',\n probeMinutes: Math.max(1, num(input.probe_minutes, 3)),\n probeRetryMinutes: Math.max(5, num(input.probe_retry_minutes, 45)),\n probeCurrentA: clamp(\n num(input.probe_current_a, clamp(num(input.current_min_a, 6), 6, 32)),\n clamp(num(input.current_min_a, 6), 6, 32),\n clamp(num(input.current_max_auto_a, 16), clamp(num(input.current_min_a, 6), 6, 32), 32)\n ),\n\n gridBatterySoftA: num(input.grid_battery_soft_a, -3),\n gridBatteryHardA: num(input.grid_battery_hard_a, -6),\n\n morningProbePvAvgMinW: Math.max(0, num(input.morning_probe_pv_avg_min_w, 1500)),\n morningProbePvTrendMinW: num(input.morning_probe_pv_trend_min_w, 0),\n middayProbePvAvgMinW: Math.max(0, num(input.midday_probe_pv_avg_min_w, 1200)),\n\n morningSocMin: clamp(num(input.morning_soc_min, 35), 0, 100),\n middaySocMin: clamp(num(input.midday_soc_min, 75), 0, 100),\n eveningSocStartMin: clamp(num(input.evening_soc_start_min, 85), 0, 100),\n eveningSocStopMin: clamp(num(input.evening_soc_stop_min, 80), 0, 100),\n\n morningBatteryMaxDischargeA: num(input.morning_battery_max_discharge_a, -20),\n middayBatteryMaxDischargeA: num(input.midday_battery_max_discharge_a, -40),\n eveningBatteryMaxDischargeA: num(input.evening_battery_max_discharge_a, -30),\n\n // =====================================================\n // EVCS NIGHT ENGINE — NEW PARAMETERS\n // =====================================================\n evcsNightBatteryHardFloorPct: clamp(\n num(input.evcs_night_battery_hard_floor_pct, 22),\n 0,\n 100\n ),\n\n evcsNightStrongForecastKwh: Math.max(\n 0,\n num(input.evcs_night_strong_forecast_kwh, 18)\n ),\n\n evcsNightMediumForecastKwh: Math.max(\n 0,\n num(input.evcs_night_medium_forecast_kwh, 10)\n ),\n\n evcsNightTargetStrongForecastPct: clamp(\n num(input.evcs_night_target_strong_forecast_pct, 35),\n 0,\n 100\n ),\n\n evcsNightTargetMediumForecastPct: clamp(\n num(input.evcs_night_target_medium_forecast_pct, 55),\n 0,\n 100\n ),\n\n evcsNightTargetWeakForecastPct: clamp(\n num(input.evcs_night_target_weak_forecast_pct, 85),\n 0,\n 100\n ),\n\n evcsNightBatteryRecoveryPowerKw: Math.max(\n 0.1,\n num(input.evcs_night_battery_recovery_power_kw, 2.5)\n ),\n\n evcsNightDepartureSafetyMin: Math.max(\n 0,\n num(input.evcs_night_departure_safety_min, 30)\n ),\n};\n\n// =====================================================\n// HISTORY / PERSISTED HELPERS\n// =====================================================\nconst nightSessionStartSocRaw = num(\n input.evcs_night_session_start_soc ?? input.night_session_start_soc,\n NaN\n);\n\nconst nightSessionStartAtRaw = cleanText(\n input.evcs_night_session_start_at ?? input.night_session_start_at\n);\n\n// =====================================================\n// VEHICLE DEMAND FILTER HISTORY RAW INPUTS\n// =====================================================\nconst evcsNoDemandSinceAtRaw = cleanText(input.evcs_no_demand_since_at);\nconst evcsNoDemandFilterEvalAtRaw = cleanText(input.evcs_no_demand_filter_eval_at);\n\nconst history = {\n lastBlockReason: cleanText(input.last_block_reason, 'none') || 'none',\n blockedUntilAt: cleanText(input.blocked_until),\n probeStartedAt: cleanText(input.probe_started_at),\n lastStartAt: cleanText(input.last_start_at),\n lastStopAt: cleanText(input.last_stop_at),\n lastCurrentChangeAt: cleanText(input.last_current_change_at),\n lastSignature: cleanText(input.last_signature),\n cloudDebtStartedAt: cleanText(input.cloud_debt_started_at),\n evcsPriorityManualUntilAt: cleanText(input.evcs_priority_manual_until),\n\n blockedUntilMs: parseParisStampToMs(cleanText(input.blocked_until)),\n probeStartedMs: parseParisStampToMs(cleanText(input.probe_started_at)),\n lastStartMs: parseParisStampToMs(cleanText(input.last_start_at)),\n lastStopMs: parseParisStampToMs(cleanText(input.last_stop_at)),\n lastCurrentChangeMs: parseParisStampToMs(cleanText(input.last_current_change_at)),\n cloudDebtStartedMsPrev: parseParisStampToMs(cleanText(input.cloud_debt_started_at)),\n evcsPriorityManualUntilMs: parseParisStampToMs(cleanText(input.evcs_priority_manual_until)),\n\n // =====================================================\n // EVCS NIGHT SESSION MEMORY\n // =====================================================\n nightSessionStartSocPct: Number.isFinite(nightSessionStartSocRaw)\n ? clamp(nightSessionStartSocRaw, 0, 100)\n : null,\n\n nightSessionStartAt: nightSessionStartAtRaw || '',\n nightSessionStartMs: parseParisStampToMs(nightSessionStartAtRaw),\n\n // =====================================================\n // VEHICLE DEMAND FILTER PERSISTENCE\n // =====================================================\n evcsDeliveryRatioEma: num(input.evcs_delivery_ratio_ema, NaN),\n evcsDeliveryNoiseEma: num(input.evcs_delivery_noise_ema, NaN),\n evcsNoDemandScoreEma: num(input.evcs_no_demand_score_ema, NaN),\n evcsNoDemandSemanticActive: low(input.evcs_no_demand_semantic_active || 'off') === 'on',\n\n evcsNoDemandSinceAt: evcsNoDemandSinceAtRaw || '',\n evcsNoDemandSinceMs: parseParisStampToMs(evcsNoDemandSinceAtRaw),\n\n evcsNoDemandFilterEvalAt: evcsNoDemandFilterEvalAtRaw || '',\n evcsNoDemandFilterEvalMs: parseParisStampToMs(evcsNoDemandFilterEvalAtRaw),\n};\n\n// =====================================================\n// BATTERY TUNING\n// =====================================================\nconst batteryTune = {\n global: {\n houseSpikeGraceMinutes: Math.max(0, num(input.house_spike_grace_minutes, 10)),\n solarPotentialGraceMinutes: Math.max(0, num(input.solar_potential_grace_minutes, 12)),\n stepUpCooldownSeconds: Math.max(5, num(input.step_up_cooldown_seconds, params.currentChangeCooldownSeconds)),\n stepDownCooldownSeconds: Math.max(5, num(input.step_down_cooldown_seconds, params.currentChangeCooldownSeconds)),\n forceModeAutoRevertMinutes: Math.max(0, num(input.force_mode_auto_revert_minutes, 120)),\n overloadStepDownA: Math.max(1, num(input.overload_step_down_a, 2)),\n overloadHoldSeconds: Math.max(5, num(input.overload_hold_seconds, 30)),\n overloadStopAfterSeconds: Math.max(10, num(input.overload_stop_after_seconds, 120)),\n },\n\n bands: {\n gt90: {\n dischargeSoftA: num(input.batt_discharge_soft_gt90_a, -50),\n dischargeHardA: num(input.batt_discharge_hard_gt90_a, -100),\n chargeReserveA: Math.max(0, num(input.batt_charge_reserve_gt90_a, 5)),\n softToleranceMin: Math.max(0, num(input.batt_soft_tol_gt90_min, 12)),\n hardToleranceSec: Math.max(0, num(input.batt_hard_tol_gt90_sec, 20)),\n recoveryMin: Math.max(0, num(input.batt_recovery_gt90_min, 6)),\n },\n\n '80_90': {\n dischargeSoftA: num(input.batt_discharge_soft_80_90_a, -40),\n dischargeHardA: num(input.batt_discharge_hard_80_90_a, -80),\n chargeReserveA: Math.max(0, num(input.batt_charge_reserve_80_90_a, 10)),\n softToleranceMin: Math.max(0, num(input.batt_soft_tol_80_90_min, 10)),\n hardToleranceSec: Math.max(0, num(input.batt_hard_tol_80_90_sec, 20)),\n recoveryMin: Math.max(0, num(input.batt_recovery_80_90_min, 6)),\n },\n\n '60_80': {\n dischargeSoftA: num(input.batt_discharge_soft_60_80_a, -20),\n dischargeHardA: num(input.batt_discharge_hard_60_80_a, -50),\n chargeReserveA: Math.max(0, num(input.batt_charge_reserve_60_80_a, 40)),\n softToleranceMin: Math.max(0, num(input.batt_soft_tol_60_80_min, 6)),\n hardToleranceSec: Math.max(0, num(input.batt_hard_tol_60_80_sec, 15)),\n recoveryMin: Math.max(0, num(input.batt_recovery_60_80_min, 8)),\n },\n\n '40_60': {\n dischargeSoftA: num(input.batt_discharge_soft_40_60_a, -12),\n dischargeHardA: num(input.batt_discharge_hard_40_60_a, -30),\n chargeReserveA: Math.max(0, num(input.batt_charge_reserve_40_60_a, 70)),\n softToleranceMin: Math.max(0, num(input.batt_soft_tol_40_60_min, 4)),\n hardToleranceSec: Math.max(0, num(input.batt_hard_tol_40_60_sec, 10)),\n recoveryMin: Math.max(0, num(input.batt_recovery_40_60_min, 10)),\n },\n\n '25_40': {\n dischargeSoftA: num(input.batt_discharge_soft_25_40_a, -5),\n dischargeHardA: num(input.batt_discharge_hard_25_40_a, -15),\n chargeReserveA: Math.max(0, num(input.batt_charge_reserve_25_40_a, 110)),\n softToleranceMin: Math.max(0, num(input.batt_soft_tol_25_40_min, 2)),\n hardToleranceSec: Math.max(0, num(input.batt_hard_tol_25_40_sec, 5)),\n recoveryMin: Math.max(0, num(input.batt_recovery_25_40_min, 12)),\n },\n\n lt25: {\n dischargeSoftA: num(input.batt_discharge_soft_lt25_a, -3),\n dischargeHardA: num(input.batt_discharge_hard_lt25_a, -8),\n chargeReserveA: Math.max(0, num(input.batt_charge_reserve_lt25_a, 150)),\n softToleranceMin: Math.max(0, num(input.batt_soft_tol_lt25_min, 1)),\n hardToleranceSec: Math.max(0, num(input.batt_hard_tol_lt25_sec, 2)),\n recoveryMin: Math.max(0, num(input.batt_recovery_lt25_min, 15)),\n },\n },\n};\n\n// =====================================================\n// OPTIONAL DEBUG / SUMMARY\n// =====================================================\nconst evcsNightConfig = {\n batteryHardFloorPct: params.evcsNightBatteryHardFloorPct,\n strongForecastKwh: params.evcsNightStrongForecastKwh,\n mediumForecastKwh: params.evcsNightMediumForecastKwh,\n targetStrongForecastPct: params.evcsNightTargetStrongForecastPct,\n targetMediumForecastPct: params.evcsNightTargetMediumForecastPct,\n targetWeakForecastPct: params.evcsNightTargetWeakForecastPct,\n batteryRecoveryPowerKw: params.evcsNightBatteryRecoveryPowerKw,\n departureSafetyMin: params.evcsNightDepartureSafetyMin,\n sessionStartSocPct: history.nightSessionStartSocPct,\n sessionStartAt: history.nightSessionStartAt || null,\n};\n\nconst vehicleDemandHistoryDebug = {\n evcsDeliveryRatioEma: finiteOrNull(history.evcsDeliveryRatioEma),\n evcsDeliveryNoiseEma: finiteOrNull(history.evcsDeliveryNoiseEma),\n evcsNoDemandScoreEma: finiteOrNull(history.evcsNoDemandScoreEma),\n evcsNoDemandSemanticActive: history.evcsNoDemandSemanticActive,\n evcsNoDemandSinceAt: history.evcsNoDemandSinceAt || null,\n evcsNoDemandSinceMs: finiteOrNull(history.evcsNoDemandSinceMs),\n evcsNoDemandFilterEvalAt: history.evcsNoDemandFilterEvalAt || null,\n evcsNoDemandFilterEvalMs: finiteOrNull(history.evcsNoDemandFilterEvalMs),\n};\n\n// =====================================================\n// OUTPUT\n// =====================================================\nreturn [{\n json: {\n raw,\n input,\n nowMs,\n now,\n cfg: CFG,\n parsed,\n params,\n history,\n batteryTune,\n evcsNightConfig,\n vehicleDemandHistoryDebug,\n }\n}];\n"
+ },
+ "type": "n8n-nodes-base.code",
+ "typeVersion": 2,
+ "position": [
+ -176,
+ 32
+ ],
+ "id": "72f0e7a5-f86c-4451-96e4-b44dccd3fe90",
+ "name": "INPUT_PARSE"
+ },
+ {
+ "parameters": {
+ "jsCode": "function round1(v) {\n return Math.round(v * 10) / 10;\n}\n\nfunction round2(v) {\n return Math.round(v * 100) / 100;\n}\n\nfunction round3(v) {\n return Math.round(v * 1000) / 1000;\n}\n\nfunction clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nfunction to2(n) {\n return String(n).padStart(2, '0');\n}\n\nfunction formatParisStampFromMs(ms) {\n const fmt = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris',\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n fmt.formatToParts(new Date(ms))\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour);\n return `${parts.year}-${parts.month}-${parts.day} ${to2(hour)}:${parts.minute}:${parts.second}`;\n}\n\nconst prev = $input.first().json;\n\nconst {\n input,\n now,\n nowMs,\n cfg,\n parsed,\n params,\n history,\n strategy,\n windows,\n lifecycle,\n context,\n derived,\n batteryTune,\n batteryPolicy,\n locks,\n cloud,\n modes,\n safeguards,\n conditions,\n decision,\n nightEvcs,\n vehicleDemandFilter,\n} = prev;\n\n// =====================================================\n// SAFE ACCESS NIGHT EVCS\n// =====================================================\nconst night = nightEvcs ?? {\n nightSessionActive: false,\n nightSessionNeedsCapture: false,\n nightSessionNeedsClear: false,\n nightSessionStartSocPct: null,\n nightSessionStartAt: '',\n batteryHardFloorPct: null,\n batteryMorningTargetPct: null,\n maxBatteryDischargePoints: null,\n gridTakeoverTriggerPct: null,\n batteryRecoveryUrgent: false,\n nightGridTakeoverNeeded: false,\n nightPreferredSourceMode: 'blocked',\n};\n\n// =====================================================\n// SAFE ACCESS VEHICLE DEMAND FILTER\n// =====================================================\nconst vdf = vehicleDemandFilter ?? {\n dtSec: null,\n commandCurrentA: null,\n realCurrentA: null,\n expectedPowerW: null,\n realPowerW: null,\n currentDeliveryRatioRaw: null,\n powerDeliveryRatioRaw: null,\n deliveryRatioRaw: null,\n deliveryRatioEma: null,\n deliveryNoiseEma: null,\n adaptiveQuietBand: null,\n noDemandEvidenceRaw: null,\n sessionElapsedSec: null,\n sessionArmFactor: null,\n commandFactor: null,\n sourceDeliverableForNoDemand: false,\n evcsSessionArmedForNoDemand: false,\n noDemandScoreRaw: null,\n noDemandScoreEma: null,\n noDemandScoreOn: null,\n noDemandScoreOff: null,\n evcsVehicleNoDemandSemantic: false,\n evcsNoDemandSinceMs: null,\n evcsNoDemandSinceMinutes: null,\n nextHistory: {\n evcsDeliveryRatioEma: null,\n evcsDeliveryNoiseEma: null,\n evcsNoDemandScoreEma: null,\n evcsNoDemandSemanticActive: false,\n evcsNoDemandSinceMs: null,\n evcsNoDemandFilterEvalMs: null\n }\n};\n\n// =====================================================\n// FINAL ACTIONS\n// =====================================================\nlet desiredCurrentA = decision.desiredCurrentA;\n\ndesiredCurrentA = decision.desiredCharging\n ? clamp(\n desiredCurrentA,\n params.currentMinA,\n Math.max(params.currentMinA, derived.physicalCapA || params.currentMinA)\n )\n : params.currentMinA;\n\nconst chargingAction =\n decision.desiredCharging === parsed.evcsChargingSwitchOn\n ? 'none'\n : decision.desiredCharging\n ? 'turn_on'\n : 'turn_off';\n\nconst permissionAction =\n decision.desiredPermission === parsed.chargePermissionNow\n ? 'none'\n : decision.desiredPermission\n ? 'turn_on'\n : 'turn_off';\n\nconst latchAction =\n decision.desiredLatch === parsed.controlLatch\n ? 'none'\n : decision.desiredLatch\n ? 'turn_on'\n : 'turn_off';\n\nconst autoStartAction =\n parsed.controllerEnabled &&\n parsed.evcsVehicleConnected &&\n parsed.evcsAutoStartSwitchOn\n ? 'turn_off'\n : 'none';\n\nconst currentAction =\n decision.desiredCharging &&\n (\n !Number.isFinite(derived.currentReferenceA) ||\n Math.abs(desiredCurrentA - derived.currentReferenceA) >= 0.5\n )\n ? 'set'\n : 'none';\n\nconst setLastStartAt = chargingAction === 'turn_on';\nconst setLastStopAt = chargingAction === 'turn_off';\nconst setLastCurrentChangeAt = currentAction === 'set';\n\n// =====================================================\n// CLEAN BLOCKING LOGIC\n// =====================================================\nconst blockedUntilToWrite =\n decision.nextBlockReason === 'none'\n ? now.stamp\n : decision.nextBlockedUntilStamp;\n\nconst needsBlockReasonUpdate =\n decision.nextBlockReason !== history.lastBlockReason;\n\n// =====================================================\n// GLOBAL EVCS PRIORITY\n// AUTO + OVERRIDE MANUEL\n// =====================================================\nconst forceWindowMinutes = Math.max(\n 30,\n batteryTune?.global?.forceModeAutoRevertMinutes ?? 120\n);\n\nconst activeChargeIntent =\n decision.desiredCharging ||\n ['start', 'hold', 'step_up', 'step_down', 'step_down_fast'].includes(decision.currentDecision);\n\nconst evcsVehicleNotRequestingPowerState =\n decision.state === 'vehicle_full_or_paused' ||\n decision.currentDecision === 'stop_vehicle_full_or_paused';\n\n// -------------------------\n// PRIORITÉ AUTO\n// -------------------------\nlet autoEvcsPriorityActive = false;\nlet autoEnergyPriorityMode = 'normal';\nlet autoEnergyPriorityReason = 'Aucune priorité EVCS';\nlet autoEvcsPriorityUntil = now.stamp;\n\nif (\n parsed.evcsVehicleConnected &&\n !strategy.isStopMode &&\n !evcsVehicleNotRequestingPowerState\n) {\n if (strategy.forceChargeRequested || strategy.forceGridRequested) {\n autoEvcsPriorityActive = true;\n autoEnergyPriorityMode = 'evcs_priority';\n autoEnergyPriorityReason =\n strategy.forceGridRequested\n ? 'EVCS prioritaire : force_grid_now'\n : 'EVCS prioritaire : force_charge_now';\n autoEvcsPriorityUntil = formatParisStampFromMs(nowMs + forceWindowMinutes * 60000);\n }\n\n else if (night.nightSessionActive) {\n autoEvcsPriorityActive = true;\n autoEnergyPriorityMode = 'evcs_priority';\n autoEnergyPriorityReason =\n night.batteryRecoveryUrgent\n ? 'EVCS prioritaire : session nuit + urgence départ'\n : night.nightGridTakeoverNeeded\n ? 'EVCS prioritaire : session nuit + takeover réseau'\n : 'EVCS prioritaire : session nuit active';\n\n autoEvcsPriorityUntil =\n Number.isFinite(lifecycle.minutesUntilDeparture)\n ? formatParisStampFromMs(\n nowMs + Math.max(1, lifecycle.minutesUntilDeparture) * 60000\n )\n : formatParisStampFromMs(nowMs + forceWindowMinutes * 60000);\n }\n\n else if (context.departurePriorityActive && Number.isFinite(lifecycle.minutesUntilDeparture)) {\n autoEvcsPriorityActive = true;\n autoEnergyPriorityMode = 'evcs_priority';\n autoEnergyPriorityReason = `EVCS prioritaire : départ dans ${Math.round(lifecycle.minutesUntilDeparture)} min`;\n autoEvcsPriorityUntil = formatParisStampFromMs(\n nowMs + Math.max(1, lifecycle.minutesUntilDeparture) * 60000\n );\n }\n\n else if (activeChargeIntent && context.profile === 'midday_hc_or_solar') {\n autoEvcsPriorityActive = true;\n autoEnergyPriorityMode = 'evcs_priority';\n autoEnergyPriorityReason = 'EVCS prioritaire : recharge active fenêtre midi';\n autoEvcsPriorityUntil = formatParisStampFromMs(nowMs + 90 * 60000);\n }\n}\n\n// -------------------------\n// OVERRIDE MANUEL\n// -------------------------\nconst manualPriorityOverrideActive =\n parsed.evcsPriorityManualOverride &&\n parsed.evcsPriorityManualMode === 'evcs_priority' &&\n history.evcsPriorityManualUntilMs !== null &&\n history.evcsPriorityManualUntilMs > nowMs + 1000;\n\nlet evcsPriorityActive = autoEvcsPriorityActive;\nlet energyPriorityMode = autoEnergyPriorityMode;\nlet energyPriorityReason = autoEnergyPriorityReason;\nlet evcsPriorityUntil = autoEvcsPriorityUntil;\nlet evcsPrioritySource = 'auto';\n\nif (manualPriorityOverrideActive && !evcsVehicleNotRequestingPowerState) {\n evcsPriorityActive = true;\n energyPriorityMode = 'evcs_priority';\n energyPriorityReason =\n parsed.evcsPriorityManualReason || 'EVCS prioritaire : override manuel';\n evcsPriorityUntil =\n history.evcsPriorityManualUntilAt || autoEvcsPriorityUntil;\n evcsPrioritySource = 'manual';\n}\n\n// =====================================================\n// REASONS / LABELS / SIGNATURE\n// =====================================================\nconst reason = [...decision.reason];\n\nreason.push(`Stratégie=${strategy.userStrategy}`);\nreason.push(`Profil=${context.profile}`);\nreason.push(`Source=${context.sourceMode}`);\nreason.push(\n `Mode=${\n modes.batteryFullHarvestMode\n ? 'battery_full_harvest_mode'\n : modes.forecastCommitMode\n ? 'forecast_commit_mode'\n : 'battery_priority_mode'\n }`\n);\nreason.push(`SOC=${round1(parsed.soc)}%`);\nreason.push(`Batt=${round1(parsed.batteryCurrentA)}A`);\nreason.push(`PV=${Math.round(parsed.pvPowerW)}W`);\nreason.push(`PV10=${Math.round(parsed.pvPowerAvg10mW)}W`);\nreason.push(`Grid=${parsed.gridAvailable ? 'on' : 'off'} (${Math.round(parsed.gridPowerW)}W)`);\nreason.push(`Cap=${derived.physicalCapA}A`);\nreason.push(`Ref=${round1(derived.currentReferenceA)}A`);\nreason.push(`Target=${round1(desiredCurrentA)}A`);\nreason.push(`Debt=${round2(cloud.cloudDebtNextKwh)}kWh`);\nreason.push(`Vehicle=${parsed.evcsVehicleConnected ? 'on' : 'off'}`);\nreason.push(`EVCS mode=${parsed.evcsModeRaw}`);\nreason.push(`VE.Bus AC=${Math.round(parsed.vebusAcOutPowerW)}W`);\nreason.push(\n `VE.Bus DC=${\n Number.isFinite(parsed.vebusDcVoltageV)\n ? round1(parsed.vebusDcVoltageV)\n : 'n/a'\n }V`\n);\nreason.push(`PrioEVCS=${evcsPriorityActive ? 'on' : 'off'}(${evcsPrioritySource})`);\nreason.push(`VDF delivery=${Number.isFinite(vdf.deliveryRatioEma) ? round3(vdf.deliveryRatioEma) : 'na'}`);\nreason.push(`VDF noise=${Number.isFinite(vdf.deliveryNoiseEma) ? round3(vdf.deliveryNoiseEma) : 'na'}`);\nreason.push(`VDF score=${Number.isFinite(vdf.noDemandScoreEma) ? round3(vdf.noDemandScoreEma) : 'na'}`);\nreason.push(`VDF noDemand=${vdf.evcsVehicleNoDemandSemantic ? 'on' : 'off'}`);\n\nif (night.nightSessionActive) {\n reason.push(`NightTarget=${round1(night.batteryMorningTargetPct ?? 0)}%`);\n reason.push(`NightTrigger=${round1(night.gridTakeoverTriggerPct ?? 0)}%`);\n\n if (Number.isFinite(night.nightSessionStartSocPct)) {\n reason.push(`StartSOC=${round1(night.nightSessionStartSocPct)}%`);\n reason.push(`AllowDrop=${round1(night.maxBatteryDischargePoints ?? 0)}pts`);\n }\n\n reason.push(`NightPreferred=${night.nightPreferredSourceMode ?? 'blocked'}`);\n reason.push(`NightTakeover=${night.nightGridTakeoverNeeded ? 'on' : 'off'}`);\n reason.push(`NightUrgent=${night.batteryRecoveryUrgent ? 'on' : 'off'}`);\n}\n\nconst shortReason = reason.join(' | ').slice(0, 255);\n\nconst sourceModeLabel =\n context.sourceMode === 'blocked' ? 'Bloqué' :\n context.sourceMode === 'solar_only' ? 'Solaire' :\n context.sourceMode === 'solar_potential' ? 'Potentiel solaire' :\n context.sourceMode === 'hybrid_probe' ? 'Probe hybride' :\n context.sourceMode === 'grid_hc_allowed' ? 'Réseau HC autorisé' :\n context.sourceMode === 'grid_forced' ? 'Réseau forcé' :\n context.sourceMode === 'battery_assist_night' ? 'Assistance batterie nuit' :\n context.sourceMode;\n\nconst signature = [\n decision.state,\n `profile=${context.profile}`,\n `source=${context.sourceMode}`,\n `chg=${decision.desiredCharging ? '1' : '0'}`,\n `perm=${decision.desiredPermission ? '1' : '0'}`,\n `l=${decision.desiredLatch ? '1' : '0'}`,\n `cur=${round1(desiredCurrentA)}`,\n `block=${decision.nextBlockReason}`,\n `stop=${decision.mustStop ? '1' : '0'}`,\n `prio=${evcsPriorityActive ? '1' : '0'}`,\n `night=${night.nightSessionActive ? '1' : '0'}`,\n `ntrg=${Number.isFinite(night.gridTakeoverTriggerPct) ? round1(night.gridTakeoverTriggerPct) : 'na'}`,\n `nurg=${night.batteryRecoveryUrgent ? '1' : '0'}`,\n `vdf=${vdf.evcsVehicleNoDemandSemantic ? '1' : '0'}`,\n `vdfs=${Number.isFinite(vdf.noDemandScoreEma) ? round3(vdf.noDemandScoreEma) : 'na'}`\n].join('|');\n\nconst signatureChanged = signature !== history.lastSignature;\n\n// =====================================================\n// NOTIFICATIONS\n// =====================================================\nlet notifyWanted = false;\nlet notifyTitle = 'Victron EVCS Surplus';\nlet notifyMessage = shortReason;\n\nif (parsed.notificationsEnabled && signatureChanged) {\n if (decision.currentDecision === 'start') {\n notifyWanted = true;\n notifyTitle = '⚡ EVCS démarrage';\n } else if (decision.currentDecision === 'step_up') {\n notifyWanted = true;\n notifyTitle = '⬆️ EVCS hausse courant';\n } else if (\n decision.currentDecision === 'step_down' ||\n decision.currentDecision === 'step_down_fast'\n ) {\n notifyWanted = true;\n notifyTitle = '⬇️ EVCS baisse courant';\n } else if (\n decision.mustStop ||\n decision.currentDecision === 'stop' ||\n decision.currentDecision === 'stop_hard' ||\n decision.currentDecision === 'stop_power_cap'\n ) {\n notifyWanted = true;\n notifyTitle = '⛔ EVCS arrêt';\n } else if (decision.state === 'mode_auto_conflict') {\n notifyWanted = true;\n notifyTitle = '⚠️ EVCS mode Auto';\n } else if (decision.state === 'vehicle_full_or_paused') {\n notifyWanted = true;\n notifyTitle = '🚗 EVCS véhicule plein ou en pause';\n }\n}\n\n// =====================================================\n// TIMESTAMPS TO WRITE\n// =====================================================\nconst cloudDebtStartedAtToWrite =\n cloud.cloudDebtStartedMs\n ? formatParisStampFromMs(cloud.cloudDebtStartedMs)\n : now.stamp;\n\nconst probeStartedAtToWrite = now.stamp;\nconst lastStartAtToWrite = now.stamp;\nconst lastStopAtToWrite = now.stamp;\nconst lastCurrentChangeAtToWrite = now.stamp;\n\n// =====================================================\n// NIGHT SESSION MEMORY\n// =====================================================\nconst nightSessionStartSocToWrite =\n decision.clearNightSessionStart\n ? ''\n : decision.setNightSessionStart\n ? String(round1(parsed.soc))\n : (\n Number.isFinite(history.nightSessionStartSocPct)\n ? String(round1(history.nightSessionStartSocPct))\n : ''\n );\n\nconst nightSessionStartAtToWrite =\n decision.clearNightSessionStart\n ? ''\n : decision.setNightSessionStart\n ? now.stamp\n : (history.nightSessionStartAt || '');\n\n// =====================================================\n// OUTPUT\n// =====================================================\nreturn [{\n json: {\n timestamp_paris: now.stamp,\n inputs: input,\n\n computed: {\n operating_mode: context.operatingMode,\n profile: context.profile,\n source_mode: context.sourceMode,\n source_mode_label: sourceModeLabel,\n user_strategy: strategy.userStrategy,\n\n in_night_hc_window: windows.inNightHcWindow,\n in_morning_window: windows.inMorningWindow,\n in_midday_window: windows.inMiddayWindow,\n in_evening_window: windows.inEveningWindow,\n\n departure_priority_active: context.departurePriorityActive,\n minutes_until_departure: lifecycle.minutesUntilDeparture,\n\n battery_priority_mode: modes.batteryPriorityMode,\n battery_full_harvest_mode: modes.batteryFullHarvestMode,\n forecast_commit_mode: modes.forecastCommitMode,\n\n physical_cap_current_a: derived.physicalCapA,\n can_run_at_min_current: derived.canRunAtMinCurrent,\n\n forecast_buffer_kwh: round2(derived.forecastBufferKwh),\n projected_end_soc_after_charge: round1(derived.projectedEndSocAfterCharge),\n\n cloud_debt_next_kwh: round2(cloud.cloudDebtNextKwh),\n cloud_debt_duration_minutes: round1(cloud.cloudDebtDurationMinutes),\n cloud_debt_active: cloud.cloudDebtActive,\n cloud_tolerance_ok: cloud.cloudToleranceOk,\n\n start_blocked_by_reason: locks.startBlockedByReason,\n\n probe_window_active: conditions.probeWindowActive,\n probe_success_now: conditions.probeSuccessNow,\n probe_failed_now: conditions.probeFailedNow,\n\n any_vebus_overload: derived.anyVebusOverload,\n any_vebus_low_battery: derived.anyVebusLowBattery,\n vebus_ac_out_soft_reached: derived.vebusAcOutSoftReached,\n vebus_ac_out_hard_reached: derived.vebusAcOutHardReached,\n vebus_dc_voltage_soft_low: derived.vebusDcVoltageSoftLow,\n vebus_dc_voltage_hard_low: derived.vebusDcVoltageHardLow,\n\n soc_band: batteryPolicy.socBand,\n batt_discharge_soft_dynamic_a: round1(batteryPolicy.batteryDischargeSoftDynamicA),\n batt_discharge_hard_dynamic_a: round1(batteryPolicy.batteryDischargeHardDynamicA),\n batt_charge_reserve_dynamic_a: round1(batteryPolicy.batteryChargeReserveDynamicA),\n batt_soft_tolerance_min: round1(batteryPolicy.batterySoftToleranceMinutes),\n batt_hard_tolerance_sec: round1(batteryPolicy.batteryHardToleranceSeconds),\n batt_recovery_min: round1(batteryPolicy.batteryRecoveryMinutes),\n grid_battery_soft_dynamic_a: round1(batteryPolicy.gridBatterySoftDynamicA),\n grid_battery_hard_dynamic_a: round1(batteryPolicy.gridBatteryHardDynamicA),\n house_spike_detected: batteryPolicy.houseSpikeDetected,\n solar_potential_context: batteryPolicy.solarPotentialContext,\n\n evcs_priority_active: evcsPriorityActive,\n energy_priority_mode: energyPriorityMode,\n energy_priority_reason: energyPriorityReason,\n evcs_priority_until: evcsPriorityUntil,\n evcs_priority_source: evcsPrioritySource,\n evcs_priority_manual_override_active: manualPriorityOverrideActive,\n\n night_session_active: night.nightSessionActive,\n night_session_needs_capture: !!night.nightSessionNeedsCapture,\n night_session_needs_clear: !!night.nightSessionNeedsClear,\n night_session_start_soc_pct: Number.isFinite(night.nightSessionStartSocPct)\n ? round1(night.nightSessionStartSocPct)\n : null,\n night_session_start_at: night.nightSessionStartAt || null,\n\n battery_hard_floor_pct: Number.isFinite(night.batteryHardFloorPct)\n ? round1(night.batteryHardFloorPct)\n : null,\n battery_morning_target_pct: Number.isFinite(night.batteryMorningTargetPct)\n ? round1(night.batteryMorningTargetPct)\n : null,\n max_battery_discharge_points: Number.isFinite(night.maxBatteryDischargePoints)\n ? round1(night.maxBatteryDischargePoints)\n : null,\n grid_takeover_trigger_pct: Number.isFinite(night.gridTakeoverTriggerPct)\n ? round1(night.gridTakeoverTriggerPct)\n : null,\n\n battery_recovery_urgent: !!night.batteryRecoveryUrgent,\n night_grid_takeover_needed: !!night.nightGridTakeoverNeeded,\n night_preferred_source_mode: night.nightPreferredSourceMode || 'blocked',\n\n evcs_delivery_ratio_ema: Number.isFinite(vdf.deliveryRatioEma) ? round3(vdf.deliveryRatioEma) : null,\n evcs_delivery_noise_ema: Number.isFinite(vdf.deliveryNoiseEma) ? round3(vdf.deliveryNoiseEma) : null,\n evcs_no_demand_score_ema: Number.isFinite(vdf.noDemandScoreEma) ? round3(vdf.noDemandScoreEma) : null,\n evcs_vehicle_no_demand_semantic: !!vdf.evcsVehicleNoDemandSemantic,\n evcs_no_demand_since_minutes: Number.isFinite(vdf.evcsNoDemandSinceMinutes) ? round3(vdf.evcsNoDemandSinceMinutes) : null,\n evcs_source_deliverable_for_no_demand: !!vdf.sourceDeliverableForNoDemand,\n evcs_session_armed_for_no_demand: !!vdf.evcsSessionArmedForNoDemand,\n evcs_no_demand_score_on: Number.isFinite(vdf.noDemandScoreOn) ? round3(vdf.noDemandScoreOn) : null,\n evcs_no_demand_score_off: Number.isFinite(vdf.noDemandScoreOff) ? round3(vdf.noDemandScoreOff) : null\n },\n\n decision: {\n state: decision.state,\n current_decision: decision.currentDecision,\n desired_charging: decision.desiredCharging,\n desired_permission: decision.desiredPermission,\n desired_latch: decision.desiredLatch,\n desired_current_a: round1(desiredCurrentA),\n\n charging_action: chargingAction,\n permission_action: permissionAction,\n latch_action: latchAction,\n current_action: currentAction,\n auto_start_action: autoStartAction,\n\n set_last_start_at: setLastStartAt,\n set_last_stop_at: setLastStopAt,\n set_last_current_change_at: setLastCurrentChangeAt,\n set_cloud_debt_started_at: decision.setCloudDebtStartedAt,\n set_probe_started_at: decision.setProbeStartedAt,\n\n set_night_session_start: !!decision.setNightSessionStart,\n clear_night_session_start: !!decision.clearNightSessionStart,\n\n set_block_reason: needsBlockReasonUpdate || !parsed.controllerEnabled,\n block_reason: decision.nextBlockReason,\n\n set_blocked_until: true,\n blocked_until: blockedUntilToWrite,\n\n dynamic_ok: decision.dynamicOk,\n must_stop: decision.mustStop,\n\n short_reason: shortReason,\n reason,\n signature,\n signature_changed: signatureChanged,\n\n should_notify: notifyWanted,\n notify_title: notifyTitle,\n notify_message: notifyMessage,\n\n evcs_priority_active: evcsPriorityActive,\n energy_priority_mode: energyPriorityMode,\n energy_priority_reason: energyPriorityReason,\n evcs_priority_until: evcsPriorityUntil,\n evcs_priority_source: evcsPrioritySource\n },\n\n ha_service_calls: {\n helper_state: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_surplus_state',\n value: decision.state\n }\n },\n\n helper_current_decision: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_surplus_current_decision',\n value: decision.currentDecision\n }\n },\n\n helper_reason: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_surplus_reason',\n value: shortReason\n }\n },\n\n helper_operating_mode: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_operating_mode',\n value: context.operatingMode\n }\n },\n\n helper_source_mode: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_source_mode',\n value: sourceModeLabel\n }\n },\n\n helper_target_current: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_target_current_auto',\n value: round1(desiredCurrentA)\n }\n },\n\n helper_budget_remaining: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_budget_remaining_kwh',\n value: round2(derived.forecastBufferKwh)\n }\n },\n\n helper_projected_soc: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_projected_end_soc_after_charge',\n value: round1(derived.projectedEndSocAfterCharge)\n }\n },\n\n helper_cloud_debt: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_cloud_debt_kwh',\n value: round2(cloud.cloudDebtNextKwh)\n }\n },\n\n helper_dynamic_ok: {\n execute: true,\n domain: 'input_boolean',\n service: decision.dynamicOk ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_dynamic_ok'\n }\n },\n\n helper_must_stop: {\n execute: true,\n domain: 'input_boolean',\n service: decision.mustStop ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_must_stop'\n }\n },\n\n helper_cloud_debt_started_at: {\n execute: true,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_cloud_debt_started_at',\n datetime: cloudDebtStartedAtToWrite\n }\n },\n\n helper_last_current_change_at: {\n execute: setLastCurrentChangeAt,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_last_current_change_at',\n datetime: lastCurrentChangeAtToWrite\n }\n },\n\n helper_last_start_at: {\n execute: setLastStartAt,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_last_start_at',\n datetime: lastStartAtToWrite\n }\n },\n\n helper_last_stop_at: {\n execute: setLastStopAt,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_last_stop_at',\n datetime: lastStopAtToWrite\n }\n },\n\n helper_signature: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_control_last_signature',\n value: signature\n }\n },\n\n helper_block_reason: {\n execute: needsBlockReasonUpdate || !parsed.controllerEnabled,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_last_block_reason',\n value: decision.nextBlockReason\n }\n },\n\n helper_blocked_until: {\n execute: true,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_blocked_until',\n datetime: blockedUntilToWrite\n }\n },\n\n helper_probe_started_at: {\n execute: decision.setProbeStartedAt,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_probe_started_at',\n datetime: probeStartedAtToWrite\n }\n },\n\n helper_evcs_priority_active: {\n execute: true,\n domain: 'input_boolean',\n service: evcsPriorityActive ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_priority_active'\n }\n },\n\n helper_energy_priority_mode: {\n execute: true,\n domain: 'input_select',\n service: 'select_option',\n data: {\n entity_id: 'input_select.victron_energy_priority_mode',\n option: energyPriorityMode\n }\n },\n\n helper_energy_priority_reason: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_energy_priority_reason',\n value: energyPriorityReason\n }\n },\n\n helper_evcs_priority_until: {\n execute: true,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_priority_until',\n datetime: evcsPriorityUntil\n }\n },\n\n helper_night_target_soc: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_night_target_soc',\n value: round1(\n Number.isFinite(night.batteryMorningTargetPct)\n ? night.batteryMorningTargetPct\n : 0\n )\n }\n },\n\n helper_night_grid_trigger_soc: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_night_grid_trigger_soc',\n value: round1(\n Number.isFinite(night.gridTakeoverTriggerPct)\n ? night.gridTakeoverTriggerPct\n : 0\n )\n }\n },\n\n helper_night_preferred_source_mode: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_night_preferred_source_mode',\n value: night.nightPreferredSourceMode || 'blocked'\n }\n },\n\n helper_night_grid_takeover_needed: {\n execute: true,\n domain: 'input_boolean',\n service: night.nightGridTakeoverNeeded ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_night_grid_takeover_needed'\n }\n },\n\n helper_night_battery_recovery_urgent: {\n execute: true,\n domain: 'input_boolean',\n service: night.batteryRecoveryUrgent ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_night_battery_recovery_urgent'\n }\n },\n\n helper_night_session_active: {\n execute: true,\n domain: 'input_boolean',\n service: night.nightSessionActive ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_night_session_active'\n }\n },\n\n helper_night_session_start_soc: {\n execute: decision.setNightSessionStart || decision.clearNightSessionStart,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_night_session_start_soc',\n value: nightSessionStartSocToWrite\n }\n },\n\n helper_night_session_start_at: {\n execute: decision.setNightSessionStart || decision.clearNightSessionStart,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_night_session_start_at',\n value: nightSessionStartAtToWrite\n }\n },\n\n // =================================================\n // VEHICLE DEMAND FILTER HISTORY PERSISTENCE\n // =================================================\n helper_evcs_delivery_ratio_ema: {\n execute: Number.isFinite(vdf?.nextHistory?.evcsDeliveryRatioEma),\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_delivery_ratio_ema',\n value: round3(vdf.nextHistory.evcsDeliveryRatioEma)\n }\n },\n\n helper_evcs_delivery_noise_ema: {\n execute: Number.isFinite(vdf?.nextHistory?.evcsDeliveryNoiseEma),\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_delivery_noise_ema',\n value: round3(vdf.nextHistory.evcsDeliveryNoiseEma)\n }\n },\n\n helper_evcs_no_demand_score_ema: {\n execute: Number.isFinite(vdf?.nextHistory?.evcsNoDemandScoreEma),\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_no_demand_score_ema',\n value: round3(vdf.nextHistory.evcsNoDemandScoreEma)\n }\n },\n\n helper_evcs_no_demand_semantic_active: {\n execute: true,\n domain: 'input_boolean',\n service: vdf?.nextHistory?.evcsNoDemandSemanticActive ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_no_demand_semantic_active'\n }\n },\n\n helper_evcs_no_demand_since_at: {\n execute: Number.isFinite(vdf?.nextHistory?.evcsNoDemandSinceMs),\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_no_demand_since_at',\n datetime: formatParisStampFromMs(vdf.nextHistory.evcsNoDemandSinceMs)\n }\n },\n\n helper_evcs_no_demand_filter_eval_at: {\n execute: Number.isFinite(vdf?.nextHistory?.evcsNoDemandFilterEvalMs),\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_no_demand_filter_eval_at',\n datetime: formatParisStampFromMs(vdf.nextHistory.evcsNoDemandFilterEvalMs)\n }\n },\n\n // =================================================\n // PHYSICAL EDF GRID TAKEOVER\n // =================================================\n edf_general_ok_on: {\n execute:\n ['grid_forced', 'grid_hc_allowed'].includes(context.sourceMode) &&\n !parsed.gridAvailable,\n domain: 'switch',\n service: 'turn_on',\n data: {\n entity_id: 'switch.edf_general_ok_switch'\n }\n },\n\n evcs_auto_start_off: {\n execute: autoStartAction === 'turn_off',\n domain: 'switch',\n service: 'turn_off',\n data: {\n entity_id: 'switch.victron_evcs_auto_start'\n }\n },\n\n evcs_charging: {\n execute: chargingAction !== 'none',\n domain: 'switch',\n service: decision.desiredCharging ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'switch.victron_evcs_charging'\n }\n },\n\n helper_permission: {\n execute: permissionAction !== 'none',\n domain: 'input_boolean',\n service: decision.desiredPermission ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_charge_permission_now'\n }\n },\n\n helper_latch: {\n execute: latchAction !== 'none',\n domain: 'input_boolean',\n service: decision.desiredLatch ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_control_latch'\n }\n },\n\n evcs_set_current: {\n execute: currentAction === 'set',\n domain: 'number',\n service: 'set_value',\n data: {\n entity_id: 'number.victron_evcs_set_current_command',\n value: round1(desiredCurrentA)\n }\n },\n\n logbook: {\n execute: signatureChanged,\n domain: 'logbook',\n service: 'log',\n data: {\n name: 'Victron EVCS Surplus',\n message: shortReason\n }\n },\n\n mobile_notification: {\n execute: notifyWanted,\n domain: cfg.mobileNotifyDomain,\n service: cfg.mobileNotifyService,\n data: {\n title: notifyTitle,\n message: notifyMessage || shortReason\n }\n }\n }\n }\n}];\n"
+ },
+ "type": "n8n-nodes-base.code",
+ "typeVersion": 2,
+ "position": [
+ 720,
+ 32
+ ],
+ "id": "7395a494-03b8-40a3-870d-242fb1f0b381",
+ "name": "HA_SERVICE_CALLS_OUTPUT"
+ },
+ {
+ "parameters": {
+ "method": "POST",
+ "url": "=http://10.10.10.17:8123/api/template\n",
+ "authentication": "genericCredentialType",
+ "genericAuthType": "httpBearerAuth",
+ "sendHeaders": true,
+ "headerParameters": {
+ "parameters": [
+ {
+ "name": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "name": "Accept",
+ "value": "text/plain"
+ }
+ ]
+ },
+ "sendBody": true,
+ "bodyParameters": {
+ "parameters": [
+ {
+ "name": "template",
+ "value": "{{ {\n \"soc_percent\": states(\"sensor.victron_battery_soc\"),\n \"battery_current_a\": states(\"sensor.victron_battery_current\"),\n\n \"pv_power_w\": states(\"sensor.victron_pv_power_system\"),\n \"pv_power_10m_avg_w\": states(\"sensor.victron_pv_power_10m_avg\"),\n \"pv_trend_w\": states(\"sensor.victron_pv_power_trend_w\"),\n\n \"house_ac_power_w\": states(\"sensor.victron_consumption_power_l1\"),\n \"house_ac_power_prev_w\": states(\"sensor.victron_consumption_power_l1\"),\n\n \"grid_power_w\": states(\"sensor.victron_grid_power_l1\"),\n \"grid_state\": states(\"switch.edf_general_ok_switch\"),\n \"victron_acin1_available\": states(\"sensor.victron_ac_in1_available\"),\n\n \"today_remaining_kwh\": states(\"sensor.energy_production_today_remaining\"),\n\n \"evcs_device_connected\": states(\"binary_sensor.victron_evcs_device_connected\"),\n \"evcs_vehicle_connected\": states(\"binary_sensor.victron_evcs_vehicle_connected\"),\n \"evcs_status_raw\": states(\"sensor.victron_evcs_status_raw\"),\n \"evcs_mode_sensor\": states(\"sensor.victron_evcs_mode\"),\n \"evcs_mode_fallback\": states(\"input_text.sensor_victron_evcs_mode\"),\n \"evcs_set_current_a\": states(\"sensor.victron_evcs_set_current\"),\n \"evcs_command_current_a\": states(\"number.victron_evcs_set_current_command\"),\n \"evcs_current_a\": states(\"sensor.victron_evcs_current\"),\n \"evcs_ac_power_w\": states(\"sensor.victron_evcs_ac_power\"),\n \"evcs_charging_switch\": states(\"switch.victron_evcs_charging\"),\n \"evcs_auto_start_switch\": states(\"switch.victron_evcs_auto_start\"),\n\n \"controller_enabled\": states(\"input_boolean.victron_evcs_surplus_auto_enabled\"),\n \"notifications_enabled\": states(\"input_boolean.victron_evcs_surplus_notifications\"),\n \"hard_stop_enabled\": states(\"input_boolean.victron_evcs_hard_stop_enabled\"),\n \"charge_permission_now\": states(\"input_boolean.victron_evcs_charge_permission_now\"),\n \"control_latch\": states(\"input_boolean.victron_evcs_control_latch\"),\n \"departure_priority_enabled\": states(\"input_boolean.victron_evcs_departure_priority_enabled\"),\n \"forecast_mode_enabled\": states(\"input_boolean.victron_evcs_forecast_mode_enabled\"),\n\n \"target_current_auto_a\": states(\"input_number.victron_evcs_target_current_auto\"),\n \"budget_remaining_kwh\": states(\"input_number.victron_evcs_budget_remaining_kwh\"),\n \"projected_end_soc_after_charge\": states(\"input_number.victron_evcs_projected_end_soc_after_charge\"),\n\n \"pv_start_min_w\": states(\"input_number.victron_evcs_pv_start_min_w\"),\n \"pv_hold_min_w\": states(\"input_number.victron_evcs_pv_hold_min_w\"),\n \"house_ac_headroom_w\": states(\"input_number.victron_evcs_house_ac_headroom_w\"),\n \"battery_discharge_soft_a\": states(\"input_number.victron_evcs_battery_discharge_soft_a\"),\n \"battery_discharge_hard_a\": states(\"input_number.victron_evcs_battery_discharge_hard_a\"),\n \"battery_reserve_up_a\": states(\"input_number.victron_evcs_battery_reserve_up_a\"),\n \"battery_neutral_band_a\": states(\"input_number.victron_evcs_battery_neutral_band_a\"),\n \"soc_start_min\": states(\"input_number.victron_evcs_soc_start_min\"),\n \"soc_stop_min\": states(\"input_number.victron_evcs_soc_stop_min\"),\n \"current_min_a\": states(\"input_number.victron_evcs_current_min_a\"),\n \"current_max_auto_a\": states(\"input_number.victron_evcs_current_max_auto_a\"),\n \"current_step_up_a\": states(\"input_number.victron_evcs_current_step_up_a\"),\n \"current_step_down_a\": states(\"input_number.victron_evcs_current_step_down_a\"),\n \"current_change_cooldown_seconds\": states(\"input_number.victron_evcs_current_change_cooldown_seconds\"),\n \"min_run_minutes\": states(\"input_number.victron_evcs_min_run_minutes\"),\n \"min_stop_minutes\": states(\"input_number.victron_evcs_min_stop_minutes\"),\n\n \"required_energy_kwh\": states(\"input_number.victron_evcs_required_energy_kwh\"),\n \"soc_comfort_target\": states(\"input_number.victron_evcs_soc_comfort_target\"),\n \"end_of_day_soc_guard\": states(\"input_number.victron_evcs_end_of_day_soc_guard\"),\n\n \"base_load_remaining_kwh\": states(\"input_number.victron_base_load_remaining_kwh\"),\n \"flexible_safety_margin_kwh\": states(\"input_number.victron_flexible_safety_margin_kwh\"),\n \"end_of_day_soc_target\": states(\"input_number.victron_end_of_day_soc_target\"),\n \"ac_loads_max_safe_w\": states(\"input_number.victron_ac_loads_max_safe_w\"),\n\n \"battery_full_soc\": states(\"input_number.victron_evcs_battery_full_soc\"),\n \"battery_full_forecast_min_kwh\": states(\"input_number.victron_evcs_battery_full_forecast_min_kwh\"),\n \"forecast_commit_soc_floor\": states(\"input_number.victron_evcs_forecast_commit_soc_floor\"),\n \"forecast_commit_min_kwh\": states(\"input_number.victron_evcs_forecast_commit_min_kwh\"),\n\n \"cloud_debt_kwh\": states(\"input_number.victron_evcs_cloud_debt_kwh\"),\n \"cloud_debt_max_kwh\": states(\"input_number.victron_evcs_cloud_debt_max_kwh\"),\n \"cloud_tolerance_minutes\": states(\"input_number.victron_evcs_cloud_tolerance_minutes\"),\n \"cloud_debt_started_at\": states(\"input_datetime.victron_evcs_cloud_debt_started_at\"),\n \"operating_mode_helper\": states(\"input_text.victron_evcs_operating_mode\"),\n\n \"departure_time\": states(\"input_datetime.victron_evcs_departure_time\"),\n \"last_start_at\": states(\"input_datetime.victron_evcs_last_start_at\"),\n \"last_stop_at\": states(\"input_datetime.victron_evcs_last_stop_at\"),\n \"last_current_change_at\": states(\"input_datetime.victron_evcs_last_current_change_at\"),\n\n \"day_start_time\": states(\"input_datetime.victron_flexible_day_start_time\"),\n \"day_stop_time\": states(\"input_datetime.victron_flexible_day_stop_time\"),\n\n \"vebus_overload\": states(\"binary_sensor.victron_ve_bus_overload\"),\n \"vebus_l1_overload\": states(\"binary_sensor.victron_ve_bus_l1_overload\"),\n \"vebus_low_battery\": states(\"binary_sensor.victron_ve_bus_low_battery\"),\n \"vebus_l1_low_battery\": states(\"binary_sensor.victron_ve_bus_l1_low_battery\"),\n\n \"vebus_ac_out_l1_power_w\": states(\"sensor.victron_ve_bus_ac_out_l1_power\"),\n \"vebus_ac_out_l1_current_a\": states(\"sensor.victron_ve_bus_ac_out_l1_current\"),\n \"vebus_ac_out_l1_voltage_v\": states(\"sensor.victron_ve_bus_ac_out_l1_voltage\"),\n\n \"vebus_dc_voltage_v\": states(\"sensor.victron_ve_bus_dc_voltage\"),\n \"vebus_dc_current_a\": states(\"sensor.victron_ve_bus_dc_current\"),\n \"vebus_dc_power_w\": states(\"sensor.victron_ve_bus_dc_power_system\"),\n\n \"vebus_state\": states(\"sensor.victron_ve_bus_state\"),\n \"vebus_main_state\": states(\"sensor.victron_ve_bus_main_state\"),\n\n \"last_signature\": states(\"input_text.victron_evcs_control_last_signature\"),\n\n \"hc_night_start_time\": states(\"input_datetime.victron_evcs_hc_night_start\"),\n \"hc_night_stop_time\": states(\"input_datetime.victron_evcs_hc_night_stop\"),\n \"morning_start_time\": states(\"input_datetime.victron_evcs_morning_start\"),\n \"morning_stop_time\": states(\"input_datetime.victron_evcs_morning_stop\"),\n \"hc_midday_start_time\": states(\"input_datetime.victron_evcs_hc_midday_start\"),\n \"hc_midday_stop_time\": states(\"input_datetime.victron_evcs_hc_midday_stop\"),\n \"evening_start_time\": states(\"input_datetime.victron_evcs_evening_start\"),\n \"evening_stop_time\": states(\"input_datetime.victron_evcs_evening_stop\"),\n\n \"allow_grid_hc_night\": states(\"input_boolean.victron_evcs_allow_grid_hc_night\"),\n \"allow_grid_hc_midday\": states(\"input_boolean.victron_evcs_allow_grid_hc_midday\"),\n \"probe_enabled\": states(\"input_boolean.victron_evcs_probe_enabled\"),\n\n \"probe_minutes\": states(\"input_number.victron_evcs_probe_minutes\"),\n \"probe_retry_minutes\": states(\"input_number.victron_evcs_probe_retry_minutes\"),\n \"probe_current_a\": states(\"input_number.victron_evcs_probe_current_a\"),\n\n \"grid_battery_soft_a\": states(\"input_number.victron_evcs_grid_battery_soft_a\"),\n \"grid_battery_hard_a\": states(\"input_number.victron_evcs_grid_battery_hard_a\"),\n\n \"morning_probe_pv_avg_min_w\": states(\"input_number.victron_evcs_morning_probe_pv_avg_min_w\"),\n \"morning_probe_pv_trend_min_w\": states(\"input_number.victron_evcs_morning_probe_pv_trend_min_w\"),\n \"midday_probe_pv_avg_min_w\": states(\"input_number.victron_evcs_midday_probe_pv_avg_min_w\"),\n\n \"morning_soc_min\": states(\"input_number.victron_evcs_morning_soc_min\"),\n \"midday_soc_min\": states(\"input_number.victron_evcs_midday_soc_min\"),\n \"evening_soc_start_min\": states(\"input_number.victron_evcs_evening_soc_start_min\"),\n \"evening_soc_stop_min\": states(\"input_number.victron_evcs_evening_soc_stop_min\"),\n\n \"morning_battery_max_discharge_a\": states(\"input_number.victron_evcs_morning_battery_max_discharge_a\"),\n \"midday_battery_max_discharge_a\": states(\"input_number.victron_evcs_midday_battery_max_discharge_a\"),\n \"evening_battery_max_discharge_a\": states(\"input_number.victron_evcs_evening_battery_max_discharge_a\"),\n\n \"last_block_reason\": states(\"input_text.victron_evcs_last_block_reason\"),\n \"blocked_until\": states(\"input_datetime.victron_evcs_blocked_until\"),\n \"probe_started_at\": states(\"input_datetime.victron_evcs_probe_started_at\"),\n\n \"victron_evcs_strategy\": states(\"input_select.victron_evcs_strategy\"),\n \"evcs_priority_manual_override\": states(\"input_boolean.victron_evcs_priority_manual_override\"),\n \"evcs_priority_manual_mode\": states(\"input_select.victron_evcs_priority_manual_mode\"),\n \"evcs_priority_manual_reason\": states(\"input_text.victron_evcs_priority_manual_reason\"),\n \"evcs_priority_manual_until\": states(\"input_datetime.victron_evcs_priority_manual_until\"),\n\n \"house_spike_grace_minutes\": states(\"input_number.victron_evcs_house_spike_grace_minutes\"),\n \"solar_potential_grace_minutes\": states(\"input_number.victron_evcs_solar_potential_grace_minutes\"),\n \"step_up_cooldown_seconds\": states(\"input_number.victron_evcs_step_up_cooldown_seconds\"),\n \"step_down_cooldown_seconds\": states(\"input_number.victron_evcs_step_down_cooldown_seconds\"),\n \"force_mode_auto_revert_minutes\": states(\"input_number.victron_evcs_force_mode_auto_revert_minutes\"),\n\n \"overload_step_down_a\": states(\"input_number.victron_evcs_overload_step_down_a\"),\n \"overload_hold_seconds\": states(\"input_number.victron_evcs_overload_hold_seconds\"),\n \"overload_stop_after_seconds\": states(\"input_number.victron_evcs_overload_stop_after_seconds\"),\n\n \"batt_discharge_soft_gt90_a\": states(\"input_number.victron_evcs_batt_discharge_soft_gt90_a\"),\n \"batt_discharge_hard_gt90_a\": states(\"input_number.victron_evcs_batt_discharge_hard_gt90_a\"),\n \"batt_charge_reserve_gt90_a\": states(\"input_number.victron_evcs_batt_charge_reserve_gt90_a\"),\n \"batt_soft_tol_gt90_min\": states(\"input_number.victron_evcs_batt_soft_tol_gt90_min\"),\n \"batt_hard_tol_gt90_sec\": states(\"input_number.victron_evcs_batt_hard_tol_gt90_sec\"),\n \"batt_recovery_gt90_min\": states(\"input_number.victron_evcs_batt_recovery_gt90_min\"),\n\n \"batt_discharge_soft_80_90_a\": states(\"input_number.victron_evcs_batt_discharge_soft_80_90_a\"),\n \"batt_discharge_hard_80_90_a\": states(\"input_number.victron_evcs_batt_discharge_hard_80_90_a\"),\n \"batt_charge_reserve_80_90_a\": states(\"input_number.victron_evcs_batt_charge_reserve_80_90_a\"),\n \"batt_soft_tol_80_90_min\": states(\"input_number.victron_evcs_batt_soft_tol_80_90_min\"),\n \"batt_hard_tol_80_90_sec\": states(\"input_number.victron_evcs_batt_hard_tol_80_90_sec\"),\n \"batt_recovery_80_90_min\": states(\"input_number.victron_evcs_batt_recovery_80_90_min\"),\n\n \"batt_discharge_soft_60_80_a\": states(\"input_number.victron_evcs_batt_discharge_soft_60_80_a\"),\n \"batt_discharge_hard_60_80_a\": states(\"input_number.victron_evcs_batt_discharge_hard_60_80_a\"),\n \"batt_charge_reserve_60_80_a\": states(\"input_number.victron_evcs_batt_charge_reserve_60_80_a\"),\n \"batt_soft_tol_60_80_min\": states(\"input_number.victron_evcs_batt_soft_tol_60_80_min\"),\n \"batt_hard_tol_60_80_sec\": states(\"input_number.victron_evcs_batt_hard_tol_60_80_sec\"),\n \"batt_recovery_60_80_min\": states(\"input_number.victron_evcs_batt_recovery_60_80_min\"),\n\n \"batt_discharge_soft_40_60_a\": states(\"input_number.victron_evcs_batt_discharge_soft_40_60_a\"),\n \"batt_discharge_hard_40_60_a\": states(\"input_number.victron_evcs_batt_discharge_hard_40_60_a\"),\n \"batt_charge_reserve_40_60_a\": states(\"input_number.victron_evcs_batt_charge_reserve_40_60_a\"),\n \"batt_soft_tol_40_60_min\": states(\"input_number.victron_evcs_batt_soft_tol_40_60_min\"),\n \"batt_hard_tol_40_60_sec\": states(\"input_number.victron_evcs_batt_hard_tol_40_60_sec\"),\n \"batt_recovery_40_60_min\": states(\"input_number.victron_evcs_batt_recovery_40_60_min\"),\n\n \"batt_discharge_soft_25_40_a\": states(\"input_number.victron_evcs_batt_discharge_soft_25_40_a\"),\n \"batt_discharge_hard_25_40_a\": states(\"input_number.victron_evcs_batt_discharge_hard_25_40_a\"),\n \"batt_charge_reserve_25_40_a\": states(\"input_number.victron_evcs_batt_charge_reserve_25_40_a\"),\n \"batt_soft_tol_25_40_min\": states(\"input_number.victron_evcs_batt_soft_tol_25_40_min\"),\n \"batt_hard_tol_25_40_sec\": states(\"input_number.victron_evcs_batt_hard_tol_25_40_sec\"),\n \"batt_recovery_25_40_min\": states(\"input_number.victron_evcs_batt_recovery_25_40_min\"),\n\n \"batt_discharge_soft_lt25_a\": states(\"input_number.victron_evcs_batt_discharge_soft_lt25_a\"),\n \"batt_discharge_hard_lt25_a\": states(\"input_number.victron_evcs_batt_discharge_hard_lt25_a\"),\n \"batt_charge_reserve_lt25_a\": states(\"input_number.victron_evcs_batt_charge_reserve_lt25_a\"),\n \"batt_soft_tol_lt25_min\": states(\"input_number.victron_evcs_batt_soft_tol_lt25_min\"),\n \"batt_hard_tol_lt25_sec\": states(\"input_number.victron_evcs_batt_hard_tol_lt25_sec\"),\n \"batt_recovery_lt25_min\": states(\"input_number.victron_evcs_batt_recovery_lt25_min\"),\n\n \"evcs_delivery_ratio_ema\": states(\"input_number.victron_evcs_delivery_ratio_ema\"),\n \"evcs_delivery_noise_ema\": states(\"input_number.victron_evcs_delivery_noise_ema\"),\n \"evcs_no_demand_score_ema\": states(\"input_number.victron_evcs_no_demand_score_ema\"),\n \"evcs_no_demand_semantic_active\": states(\"input_boolean.victron_evcs_no_demand_semantic_active\"),\n \"evcs_no_demand_since_at\": states(\"input_datetime.victron_evcs_no_demand_since_at\"),\n \"evcs_no_demand_filter_eval_at\": states(\"input_datetime.victron_evcs_no_demand_filter_eval_at\")\n} | tojson }}\n"
+ }
+ ]
+ },
+ "options": {}
+ },
+ "id": "3492fd88-8eb4-4cb2-85f2-68299d133630",
+ "name": "HA Snapshot EVCS Surplus Controller V2.2",
+ "type": "n8n-nodes-base.httpRequest",
+ "typeVersion": 4.2,
+ "position": [
+ -400,
+ 32
+ ],
+ "credentials": {
+ "httpBearerAuth": {
+ "id": "uv3sknYYigxBkaVN",
+ "name": "Bearer Auth account 2"
+ }
+ }
+ },
+ {
+ "parameters": {
+ "jsCode": "function minutesSince(ts, nowMs) {\n if (ts === null || nowMs === null) return null;\n return Math.max(0, (nowMs - ts) / 60000);\n}\n\nfunction secondsSince(ts, nowMs) {\n if (ts === null || nowMs === null) return null;\n return Math.max(0, (nowMs - ts) / 1000);\n}\n\nfunction isWithinWindow(nowMin, startMin, stopMin) {\n if (!Number.isFinite(startMin) || !Number.isFinite(stopMin)) return false;\n if (startMin === stopMin) return false;\n if (startMin < stopMin) return nowMin >= startMin && nowMin < stopMin;\n return nowMin >= startMin || nowMin < stopMin;\n}\n\nfunction clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nconst prev = $input.first().json;\nconst {\n input,\n nowMs,\n now,\n cfg,\n parsed,\n params,\n history,\n batteryTune\n} = prev;\n\n// =====================================================\n// STRATEGY\n// =====================================================\nconst userStrategy = (params.userStrategyRaw ?? 'auto_solar').toString().trim().toLowerCase();\n\nconst isStopMode = userStrategy === 'stop';\nconst forceGridRequested = userStrategy === 'force_grid_now';\nconst forceChargeRequested = userStrategy === 'force_charge_now';\nconst isForceMode = forceGridRequested || forceChargeRequested;\n\n// =====================================================\n// LIFECYCLE / TIMING\n// =====================================================\nconst minutesSinceStart = minutesSince(history.lastStartMs, nowMs);\nconst minutesSinceStop = minutesSince(history.lastStopMs, nowMs);\nconst secondsSinceCurrentChange = secondsSince(history.lastCurrentChangeMs, nowMs);\nconst minutesSinceProbeStart = minutesSince(history.probeStartedMs, nowMs);\n\nconst minRunElapsed =\n minutesSinceStart === null ? true : minutesSinceStart >= params.minRunMinutes;\n\nconst minStopElapsed =\n minutesSinceStop === null ? true : minutesSinceStop >= params.minStopMinutes;\n\nconst currentCooldownBaseSec = parsed.evcsChargingSwitchOn\n ? batteryTune.global.stepDownCooldownSeconds\n : batteryTune.global.stepUpCooldownSeconds;\n\nconst currentCooldownElapsed =\n secondsSinceCurrentChange === null\n ? true\n : secondsSinceCurrentChange >= Math.max(\n params.currentChangeCooldownSeconds,\n currentCooldownBaseSec\n );\n\n// =====================================================\n// DEPARTURE PRIORITY\n// =====================================================\nconst minutesUntilDeparture =\n params.departureTimeMin === null\n ? null\n : (() => {\n const rawDiff = params.departureTimeMin - now.minNow;\n return rawDiff >= 0 ? rawDiff : (1440 - now.minNow + params.departureTimeMin);\n })();\n\nconst departurePriorityActive =\n parsed.departurePriorityEnabled &&\n Number.isFinite(minutesUntilDeparture) &&\n minutesUntilDeparture >= 0 &&\n minutesUntilDeparture <= cfg.departurePriorityWindowMinutes &&\n parsed.evcsVehicleConnected;\n\n// =====================================================\n// MANUAL CHARGE SEMANTICS\n// IMPORTANT:\n// force_charge_now = bypass fenêtres pour la charge,\n// MAIS PAS demande implicite de réseau.\n// =====================================================\nconst manualChargeBypassWindows = forceChargeRequested;\nconst manualChargeLocalRequested = forceChargeRequested && !forceGridRequested;\n\n// Compatibilité ascendante : on garde le champ dans context,\n// mais on le neutralise volontairement.\nconst forceChargeDepartureGridOverride = false;\n\n// =====================================================\n// SYSTEM DERIVED VALUES\n// =====================================================\nconst acVoltageRef =\n Number.isFinite(parsed.vebusAcOutVoltageV) && parsed.vebusAcOutVoltageV > 180\n ? parsed.vebusAcOutVoltageV\n : cfg.nominalAcVoltageV;\n\nconst batteryVoltageRef =\n Number.isFinite(parsed.vebusDcVoltageV) && parsed.vebusDcVoltageV > 0\n ? parsed.vebusDcVoltageV\n : 51.2;\n\nconst houseNonEvcsW = Math.max(0, parsed.houseAcPowerW - parsed.evcsAcPowerW);\nconst vebusNonEvcsW = Math.max(0, parsed.vebusAcOutPowerW - parsed.evcsAcPowerW);\n\nconst totalVebusSoftCeilingW = Math.max(6500, params.acLoadsMaxSafeW || 8500);\nconst totalVebusHardCeilingW = totalVebusSoftCeilingW + 400;\n\nconst safeEvcsByDedicatedCapW = Math.max(0, cfg.evcsDedicatedMaxW - cfg.systemReserveW);\nconst safeEvcsByVebusW = Math.max(0, totalVebusSoftCeilingW - vebusNonEvcsW - cfg.systemReserveW);\nconst safeEvcsCombinedW = Math.max(0, Math.min(safeEvcsByDedicatedCapW, safeEvcsByVebusW));\n\nconst systemCapCurrentA = Math.max(0, Math.floor(safeEvcsCombinedW / acVoltageRef));\nconst physicalCapA = clamp(systemCapCurrentA, 0, params.currentMaxAutoA);\nconst canRunAtMinCurrent = physicalCapA >= params.currentMinA;\n\nconst endDayGuardSoc = Math.max(params.endOfDaySocGuard, params.endOfDaySocTarget);\n\nconst energyNeededForEndDayGuardKwh = Math.max(\n 0,\n ((endDayGuardSoc - parsed.soc) / 100) * cfg.batteryUsableCapacityKwh\n);\n\nconst forecastBufferKwh =\n parsed.todayRemainingKwh -\n params.baseLoadRemainingKwh -\n params.flexibleSafetyMarginKwh -\n params.requiredEnergyKwh -\n energyNeededForEndDayGuardKwh;\n\nconst projectedEndSocAfterCharge = clamp(\n parsed.soc + (\n (\n parsed.todayRemainingKwh -\n params.baseLoadRemainingKwh -\n params.flexibleSafetyMarginKwh -\n params.requiredEnergyKwh\n ) / cfg.batteryUsableCapacityKwh\n ) * 100,\n 0,\n 100\n);\n\nconst currentReferenceA = clamp(\n Number.isFinite(parsed.evcsCommandCurrentA)\n ? parsed.evcsCommandCurrentA\n : Number.isFinite(parsed.evcsSetCurrentA)\n ? parsed.evcsSetCurrentA\n : params.targetCurrentAutoA,\n 0,\n 32\n);\n\nconst anyVebusOverload = parsed.vebusOverload || parsed.vebusL1Overload;\nconst anyVebusLowBattery = parsed.vebusLowBattery || parsed.vebusL1LowBattery;\n\nconst vebusAcOutSoftReached = parsed.vebusAcOutPowerW >= totalVebusSoftCeilingW;\nconst vebusAcOutHardReached = parsed.vebusAcOutPowerW >= totalVebusHardCeilingW;\n\nconst vebusDcVoltageSoftLow =\n Number.isFinite(parsed.vebusDcVoltageV) &&\n parsed.vebusDcVoltageV > 0 &&\n parsed.vebusDcVoltageV <= cfg.vebusDcVoltageSoftFloorV;\n\nconst vebusDcVoltageHardLow =\n Number.isFinite(parsed.vebusDcVoltageV) &&\n parsed.vebusDcVoltageV > 0 &&\n parsed.vebusDcVoltageV <= cfg.vebusDcVoltageHardFloorV;\n\nconst houseLoadHighVsHeadroom = houseNonEvcsW > params.houseAcHeadroomW;\n\n// =====================================================\n// WINDOWS / PROFILE\n// =====================================================\nconst inNightHcWindow = isWithinWindow(now.minNow, params.hcNightStartMin, params.hcNightStopMin);\nconst inMorningWindow = isWithinWindow(now.minNow, params.morningStartMin, params.morningStopMin);\nconst inMiddayWindow = isWithinWindow(now.minNow, params.hcMiddayStartMin, params.hcMiddayStopMin);\nconst inEveningWindow = isWithinWindow(now.minNow, params.eveningStartMin, params.eveningStopMin);\n\nconst profile =\n inNightHcWindow ? 'night_hc' :\n inMorningWindow ? 'morning_solar' :\n inMiddayWindow ? 'midday_hc_or_solar' :\n inEveningWindow ? 'evening_high_soc' :\n 'outside_window';\n\n// =====================================================\n// EVCS NIGHT PREVIEW ENGINE\n// =====================================================\nconst nightSessionActive =\n profile === 'night_hc' &&\n parsed.evcsVehicleConnected;\n\nconst nightSessionStartSocPct =\n nightSessionActive\n ? (\n Number.isFinite(history.nightSessionStartSocPct)\n ? history.nightSessionStartSocPct\n : parsed.soc\n )\n : null;\n\nconst nightSessionStartAt =\n nightSessionActive\n ? (history.nightSessionStartAt || '')\n : '';\n\nconst nightSessionNeedsCapture =\n nightSessionActive &&\n (\n !Number.isFinite(history.nightSessionStartSocPct) ||\n !history.nightSessionStartAt\n );\n\nconst nightSessionNeedsClear =\n !nightSessionActive &&\n (\n Number.isFinite(history.nightSessionStartSocPct) ||\n !!history.nightSessionStartAt\n );\n\nconst batteryHardFloorPct =\n params.evcsNightBatteryHardFloorPct;\n\nconst batteryMorningTargetPct =\n parsed.todayRemainingKwh >= params.evcsNightStrongForecastKwh\n ? params.evcsNightTargetStrongForecastPct\n : parsed.todayRemainingKwh >= params.evcsNightMediumForecastKwh\n ? params.evcsNightTargetMediumForecastPct\n : params.evcsNightTargetWeakForecastPct;\n\nconst maxBatteryDischargePoints =\n nightSessionActive && Number.isFinite(nightSessionStartSocPct)\n ? Math.max(0, nightSessionStartSocPct - batteryMorningTargetPct)\n : 0;\n\nconst gridTakeoverTriggerPct =\n Math.max(batteryHardFloorPct, batteryMorningTargetPct);\n\nconst batteryBelowHardFloor =\n parsed.soc <= batteryHardFloorPct;\n\nconst batteryBelowMorningTarget =\n parsed.soc < batteryMorningTargetPct;\n\nconst batteryAtOrBelowGridTakeoverTrigger =\n parsed.soc <= gridTakeoverTriggerPct;\n\nconst batteryKwhMissingToTarget =\n nightSessionActive\n ? Math.max(\n 0,\n ((batteryMorningTargetPct - parsed.soc) / 100) * cfg.batteryUsableCapacityKwh\n )\n : 0;\n\nconst batteryRecoveryMinutesNeeded =\n nightSessionActive && params.evcsNightBatteryRecoveryPowerKw > 0\n ? (batteryKwhMissingToTarget / params.evcsNightBatteryRecoveryPowerKw) * 60\n : 0;\n\nconst batteryRecoveryUrgent =\n nightSessionActive &&\n Number.isFinite(minutesUntilDeparture) &&\n minutesUntilDeparture >= 0 &&\n (\n batteryRecoveryMinutesNeeded + params.evcsNightDepartureSafetyMin\n >= minutesUntilDeparture\n );\n\nconst batteryAssistAllowed =\n nightSessionActive &&\n parsed.soc > batteryHardFloorPct &&\n parsed.soc > gridTakeoverTriggerPct &&\n !batteryRecoveryUrgent;\n\nconst nightGridTakeoverNeeded =\n nightSessionActive &&\n (\n batteryBelowHardFloor ||\n batteryAtOrBelowGridTakeoverTrigger ||\n batteryRecoveryUrgent\n );\n\nconst nightPreferredSourceMode =\n !nightSessionActive\n ? 'blocked'\n : batteryAssistAllowed\n ? 'battery_assist_night'\n : (\n batteryBelowHardFloor ||\n batteryBelowMorningTarget ||\n batteryRecoveryUrgent\n )\n ? 'grid_forced'\n : 'grid_hc_allowed';\n\n// =====================================================\n// PROBES / SOLAR POTENTIAL\n// =====================================================\n// IMPORTANT:\n// On ne met plus effectivePvStartMinW à 0 en force_charge_now,\n// sinon la nuit 0W >= 0W ferait croire à un faux signal solaire.\nconst effectivePvStartMinW = params.pvStartMinW;\n\nconst morningProbeEligible =\n profile === 'morning_solar' &&\n params.probeEnabled &&\n parsed.pvPowerAvg10mW >= params.morningProbePvAvgMinW &&\n parsed.pvTrendW >= params.morningProbePvTrendMinW &&\n parsed.soc >= params.morningSocMin &&\n parsed.batteryCurrentA >= params.morningBatteryMaxDischargeA;\n\nconst middayProbeEligible =\n profile === 'midday_hc_or_solar' &&\n params.probeEnabled &&\n parsed.pvPowerAvg10mW >= params.middayProbePvAvgMinW &&\n parsed.soc >= params.middaySocMin &&\n parsed.batteryCurrentA >= params.middayBatteryMaxDischargeA;\n\nconst cloudDebtOkForPotential = params.cloudDebtPrevKwh <= params.cloudDebtMaxKwh;\nconst batteryOkForPotential = parsed.batteryCurrentA >= -Math.max(1, params.batteryNeutralBandA);\n\nconst isHighBatteryForPotential =\n parsed.soc >= 85 &&\n forecastBufferKwh > 0 &&\n cloudDebtOkForPotential &&\n batteryOkForPotential;\n\nconst potentialAllowed = userStrategy !== 'auto_solar';\n\n// IMPORTANT:\n// On impose un vrai PV > 0 pour le test sur pvPowerW.\n// Ça évite qu'en force_charge_now la nuit, 0W soit pris pour un signal solaire.\nconst strongSolarSignal =\n (\n parsed.pvPowerW > 0 &&\n parsed.pvPowerW >= effectivePvStartMinW\n ) ||\n parsed.pvPowerAvg10mW >= params.pvHoldMinW ||\n (departurePriorityActive && parsed.pvPowerAvg10mW >= params.pvHoldMinW);\n\n// =====================================================\n// SOURCE MODE\n// SÉMANTIQUE CORRIGÉE:\n// - force_charge_now bypass les fenêtres\n// - mais ne devient jamais grid_forced à lui seul\n// - outside_window = pas de réseau programmé\n// - la charge locale peut rester autorisée\n// =====================================================\nlet sourceMode = 'blocked';\n\nif (isStopMode) {\n sourceMode = 'blocked';\n}\n\nelse if (forceGridRequested) {\n // Demande explicite de réseau.\n sourceMode = 'grid_forced';\n}\n\nelse if (forceChargeRequested) {\n // IMPORTANT:\n // force_charge_now = intention de charger maintenant,\n // sans exiger le réseau.\n\n if (strongSolarSignal) {\n sourceMode = 'solar_only';\n }\n\n else if (nightSessionActive && batteryAssistAllowed) {\n sourceMode = 'battery_assist_night';\n }\n\n else if (isHighBatteryForPotential && potentialAllowed) {\n sourceMode = 'solar_potential';\n }\n\n else if (profile === 'midday_hc_or_solar' && middayProbeEligible) {\n sourceMode = 'hybrid_probe';\n }\n\n else if (profile === 'morning_solar' && morningProbeEligible) {\n sourceMode = 'hybrid_probe';\n }\n\n else {\n // Fallback manuel local :\n // on n'impose pas le grid, on laisse l'étage aval décider\n // selon les garde-fous physiques / batterie.\n sourceMode = 'solar_potential';\n }\n}\n\nelse if (profile === 'night_hc') {\n if (nightSessionActive) {\n sourceMode = nightPreferredSourceMode;\n } else {\n sourceMode = 'blocked';\n }\n}\n\nelse if (profile === 'midday_hc_or_solar' || profile === 'morning_solar') {\n if (strongSolarSignal) {\n sourceMode = 'solar_only';\n }\n\n else if (isHighBatteryForPotential && potentialAllowed) {\n sourceMode = 'solar_potential';\n }\n\n else if (profile === 'midday_hc_or_solar' && middayProbeEligible) {\n sourceMode = 'hybrid_probe';\n }\n\n else if (profile === 'morning_solar' && morningProbeEligible) {\n sourceMode = 'hybrid_probe';\n }\n\n else if (profile === 'midday_hc_or_solar' && params.allowGridHcMidday && parsed.gridAvailable) {\n sourceMode = 'grid_hc_allowed';\n }\n\n else {\n sourceMode = 'blocked';\n }\n}\n\nelse if (profile === 'evening_high_soc') {\n if (strongSolarSignal) {\n sourceMode = 'solar_only';\n }\n\n else if (isHighBatteryForPotential && potentialAllowed) {\n sourceMode = 'solar_potential';\n }\n\n else {\n sourceMode = 'blocked';\n }\n}\n\nelse if (profile === 'outside_window') {\n if (strongSolarSignal) {\n sourceMode = 'solar_only';\n }\n\n else if (isHighBatteryForPotential && potentialAllowed) {\n sourceMode = 'solar_potential';\n }\n\n else {\n // IMPORTANT:\n // hors fenêtre = pas de réseau programmé.\n // Donc on ne bascule plus vers grid_forced juste parce que le réseau existe.\n sourceMode = 'blocked';\n }\n}\n\nconst operatingMode = profile;\n\n// =====================================================\n// OUTPUT\n// =====================================================\nreturn [{\n json: {\n ...prev,\n\n strategy: {\n userStrategy,\n isStopMode,\n isForceMode,\n forceGridRequested,\n forceChargeRequested,\n },\n\n lifecycle: {\n minutesSinceStart,\n minutesSinceStop,\n secondsSinceCurrentChange,\n minutesSinceProbeStart,\n minRunElapsed,\n minStopElapsed,\n currentCooldownElapsed,\n minutesUntilDeparture,\n },\n\n windows: {\n inNightHcWindow,\n inMorningWindow,\n inMiddayWindow,\n inEveningWindow,\n },\n\n derived: {\n acVoltageRef,\n batteryVoltageRef,\n houseNonEvcsW,\n vebusNonEvcsW,\n totalVebusSoftCeilingW,\n totalVebusHardCeilingW,\n safeEvcsByDedicatedCapW,\n safeEvcsByVebusW,\n safeEvcsCombinedW,\n systemCapCurrentA,\n physicalCapA,\n canRunAtMinCurrent,\n endDayGuardSoc,\n energyNeededForEndDayGuardKwh,\n forecastBufferKwh,\n projectedEndSocAfterCharge,\n currentReferenceA,\n anyVebusOverload,\n anyVebusLowBattery,\n vebusAcOutSoftReached,\n vebusAcOutHardReached,\n vebusDcVoltageSoftLow,\n vebusDcVoltageHardLow,\n houseLoadHighVsHeadroom,\n },\n\n context: {\n departurePriorityActive,\n forceChargeDepartureGridOverride,\n manualChargeBypassWindows,\n manualChargeLocalRequested,\n morningProbeEligible,\n middayProbeEligible,\n isHighBatteryForPotential,\n strongSolarSignal,\n effectivePvStartMinW,\n profile,\n operatingMode,\n sourceMode,\n },\n\n nightEvcsPreview: {\n nightSessionActive,\n nightSessionNeedsCapture,\n nightSessionNeedsClear,\n nightSessionStartSocPct,\n nightSessionStartAt,\n\n batteryHardFloorPct,\n batteryMorningTargetPct,\n maxBatteryDischargePoints,\n gridTakeoverTriggerPct,\n\n batteryBelowHardFloor,\n batteryBelowMorningTarget,\n batteryAtOrBelowGridTakeoverTrigger,\n\n batteryKwhMissingToTarget,\n batteryRecoveryMinutesNeeded,\n batteryRecoveryUrgent,\n\n batteryAssistAllowed,\n nightGridTakeoverNeeded,\n nightPreferredSourceMode,\n }\n }\n}];\n"
+ },
+ "type": "n8n-nodes-base.code",
+ "typeVersion": 2,
+ "position": [
+ 48,
+ 32
+ ],
+ "id": "3306e8aa-5838-43af-a9af-4e346b28aac5",
+ "name": "WINDOWS_SOURCEV2"
+ },
+ {
+ "parameters": {
+ "jsCode": "function clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nfunction round1(v) {\n return Math.round(v * 10) / 10;\n}\n\nfunction round3(v) {\n return Math.round(v * 1000) / 1000;\n}\n\nfunction to2(n) {\n return String(n).padStart(2, '0');\n}\n\nfunction finiteOr(v, fallback) {\n return Number.isFinite(v) ? v : fallback;\n}\n\nfunction sigmoid(x) {\n return 1 / (1 + Math.exp(-x));\n}\n\nfunction alphaFromTau(dtSec, tauSec) {\n const dt = Math.max(1, finiteOr(dtSec, 60));\n const tau = Math.max(1, finiteOr(tauSec, 60));\n return Math.min(1, Math.max(0.001, 1 - Math.exp(-dt / tau)));\n}\n\nfunction ema(prev, sample, alpha) {\n if (!Number.isFinite(sample)) return Number.isFinite(prev) ? prev : 0;\n if (!Number.isFinite(prev)) return sample;\n return prev + alpha * (sample - prev);\n}\n\nfunction formatParisStampFromMs(ms) {\n const fmt = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris',\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n fmt.formatToParts(new Date(ms))\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour);\n return `${parts.year}-${parts.month}-${parts.day} ${to2(hour)}:${parts.minute}:${parts.second}`;\n}\n\nconst prev = $input.first().json;\n\nconst {\n now,\n nowMs,\n parsed,\n params,\n history,\n strategy,\n lifecycle,\n context,\n derived,\n locks,\n cloud,\n modes,\n safeguards,\n conditions,\n batteryTune,\n nightEvcs,\n} = prev;\n\n// =====================================================\n// DECISION INIT\n// =====================================================\nlet desiredCharging = false;\nlet desiredPermission = false;\nlet desiredLatch = false;\nlet desiredCurrentA = params.currentMinA;\n\nlet state = 'idle';\nlet currentDecision = 'hold';\nlet dynamicOk = false;\nlet mustStop = false;\n\nlet reason = [];\n\nlet setCloudDebtStartedAt = false;\nlet setProbeStartedAt = false;\n\n// EVCS night session memory\nlet setNightSessionStart = false;\nlet clearNightSessionStart = false;\n\nlet notifyWanted = false;\nlet notifyTitle = 'Victron EVCS Surplus';\nlet notifyMessage = '';\n\nlet nextBlockReason = 'none';\nlet nextBlockedUntilStamp = now.stamp;\n\n// =====================================================\n// HELPERS\n// =====================================================\nfunction setOff({\n stateName,\n decisionName = 'stop',\n reasonText,\n dynamic = false,\n mustStopFlag = false,\n blockReason = 'none',\n blockedUntilStamp = now.stamp,\n}) {\n desiredCharging = false;\n desiredPermission = false;\n desiredLatch = false;\n desiredCurrentA = params.currentMinA;\n\n state = stateName;\n currentDecision = decisionName;\n dynamicOk = dynamic;\n mustStop = mustStopFlag;\n nextBlockReason = blockReason;\n nextBlockedUntilStamp = blockedUntilStamp;\n\n if (reasonText) reason.push(reasonText);\n}\n\nfunction setOn({\n stateName,\n decisionName = 'hold',\n currentA = params.currentMinA,\n reasonText,\n dynamic = true,\n mustStopFlag = false,\n}) {\n desiredCharging = true;\n desiredPermission = true;\n desiredLatch = true;\n desiredCurrentA = clamp(\n currentA,\n params.currentMinA,\n Math.max(params.currentMinA, derived.physicalCapA || params.currentMinA)\n );\n\n state = stateName;\n currentDecision = decisionName;\n dynamicOk = dynamic;\n mustStop = mustStopFlag;\n\n if (reasonText) reason.push(reasonText);\n}\n\n// =====================================================\n// SESSION MEMORY SIDE EFFECTS\n// =====================================================\nif (nightEvcs?.nightSessionNeedsCapture) {\n setNightSessionStart = true;\n}\n\nif (nightEvcs?.nightSessionNeedsClear) {\n clearNightSessionStart = true;\n}\n\n// =====================================================\n// MANUAL LOCAL CHARGE SEMANTICS\n// IMPORTANT:\n// force_charge_now = bypass fenêtres pour la charge locale,\n// sans imposer le réseau.\n// =====================================================\nconst manualChargeBypassWindows = !!context.manualChargeBypassWindows;\nconst manualChargeLocalRequested = !!context.manualChargeLocalRequested;\n\nconst manualLocalChargeAllowed =\n !!strategy.forceChargeRequested &&\n manualChargeBypassWindows &&\n manualChargeLocalRequested &&\n !safeguards.mustStopHard &&\n !!derived.canRunAtMinCurrent;\n\n// =====================================================\n// EVCS NUMERIC NO-DEMAND FILTER\n// Objectif:\n// détecter \"voiture branchée + session armée + commande claire\n// mais plus de demande réelle\" sans seuils fixes bruts.\n// IMPORTANT:\n// on n'utilise PAS le PV comme critère.\n// =====================================================\nconst prevDeliveryRatioEma = finiteOr(history.evcsDeliveryRatioEma, NaN);\nconst prevDeliveryNoiseEma = finiteOr(history.evcsDeliveryNoiseEma, NaN);\nconst prevNoDemandScoreEma = finiteOr(history.evcsNoDemandScoreEma, NaN);\nconst prevNoDemandSemanticActive = !!history.evcsNoDemandSemanticActive;\nconst prevNoDemandSinceMs = finiteOr(history.evcsNoDemandSinceMs, NaN);\nconst prevNoDemandFilterEvalMs = finiteOr(history.evcsNoDemandFilterEvalMs, NaN);\n\nconst noDemandDtSec =\n Number.isFinite(prevNoDemandFilterEvalMs) && Number.isFinite(nowMs)\n ? clamp((nowMs - prevNoDemandFilterEvalMs) / 1000, 1, 600)\n : 60;\n\n// Constantes dynamiques du filtre = temps de réponse du filtre,\n// pas des seuils métiers bruts.\nconst deliveryTauSec = Math.max(15, finiteOr(params.evcsDeliveryTauSec, 45));\nconst noiseTauSec = Math.max(deliveryTauSec, finiteOr(params.evcsDeliveryNoiseTauSec, 180));\nconst scoreTauSec = Math.max(20, finiteOr(params.evcsNoDemandScoreTauSec, 150));\nconst armTauSec = Math.max(20, finiteOr(params.evcsNoDemandArmTauSec, 180));\n\nconst alphaDelivery = alphaFromTau(noDemandDtSec, deliveryTauSec);\nconst alphaNoise = alphaFromTau(noDemandDtSec, noiseTauSec);\nconst alphaScore = alphaFromTau(noDemandDtSec, scoreTauSec);\n\nconst acVoltageRef = finiteOr(derived.acVoltageRef, 230);\n\nconst commandCurrentA = clamp(\n finiteOr(\n derived.currentReferenceA,\n finiteOr(\n parsed.evcsCommandCurrentA,\n finiteOr(parsed.evcsSetCurrentA, finiteOr(params.currentMinA, 6))\n )\n ),\n 0,\n 32\n);\n\nconst expectedPowerW = Math.max(1, commandCurrentA * acVoltageRef);\n\nconst realCurrentA = Math.abs(\n finiteOr(\n parsed.evcsCurrentRealA,\n acVoltageRef > 180\n ? finiteOr(parsed.evcsAcPowerW, 0) / acVoltageRef\n : 0\n )\n);\n\nconst realPowerW = Math.max(0, finiteOr(parsed.evcsAcPowerW, 0));\n\nconst currentDeliveryRatioRaw = clamp(\n realCurrentA / Math.max(commandCurrentA, 0.1),\n 0,\n 1.25\n);\n\nconst powerDeliveryRatioRaw = clamp(\n realPowerW / expectedPowerW,\n 0,\n 1.25\n);\n\nconst deliveryRatioRaw = clamp(\n Math.max(currentDeliveryRatioRaw, powerDeliveryRatioRaw),\n 0,\n 1\n);\n\nconst deliveryRatioEma = ema(prevDeliveryRatioEma, deliveryRatioRaw, alphaDelivery);\n\nconst deliveryResidualAbs = Math.abs(deliveryRatioRaw - deliveryRatioEma);\nconst deliveryNoiseEma = ema(prevDeliveryNoiseEma, deliveryResidualAbs, alphaNoise);\n\nconst adaptiveQuietBand = clamp(\n 0.02 + 3.0 * deliveryNoiseEma,\n 0.02,\n 0.35\n);\n\nconst noDemandEvidenceRaw = clamp(\n (adaptiveQuietBand - deliveryRatioEma) / adaptiveQuietBand,\n 0,\n 1\n);\n\nconst sessionElapsedSec = Math.max(\n 0,\n finiteOr(lifecycle.minutesSinceStart, 0) * 60\n);\n\nconst sessionArmFactor = 1 - Math.exp(-sessionElapsedSec / armTauSec);\n\nconst currentMinRefA = Math.max(1, finiteOr(params.currentMinA, 6));\nconst commandStrength = commandCurrentA / currentMinRefA;\n\nconst commandFactor = clamp(\n sigmoid((commandStrength - 1.0) * 2.2),\n 0,\n 1\n);\n\n// Source effectivement livrable.\n// IMPORTANT:\n// on évite de conclure \"voiture pleine\" si en réalité la source n'est\n// pas disponible (ex: attente grid takeover).\nconst manualLocalNoDemandEligible =\n manualLocalChargeAllowed &&\n (\n context.sourceMode === 'solar_only' ||\n context.sourceMode === 'solar_potential' ||\n context.sourceMode === 'blocked'\n );\n\nconst sourceDeliverableForNoDemand =\n manualLocalNoDemandEligible\n ? true\n : (context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced')\n ? !!parsed.gridAvailable\n : (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential')\n ? !!conditions.solarHoldOk || !!conditions.solarStartOk\n : context.sourceMode === 'hybrid_probe'\n ? !!conditions.probeHoldGuardOk || !!conditions.probeSuccessNow || !!conditions.probeStartOk\n : context.sourceMode === 'battery_assist_night'\n ? !!conditions.batteryAssistNightHoldOk || !!conditions.batteryAssistNightStartOk\n : false;\n\nconst evcsSessionArmedForNoDemand =\n !!cloud.sessionObservedActive &&\n !!parsed.evcsVehicleConnected &&\n !!parsed.evcsChargingSwitchOn &&\n !!parsed.chargePermissionNow &&\n !!parsed.controlLatch &&\n commandCurrentA > 0 &&\n sourceDeliverableForNoDemand;\n\nconst noDemandScoreRaw = evcsSessionArmedForNoDemand\n ? clamp(\n noDemandEvidenceRaw * sessionArmFactor * commandFactor,\n 0,\n 1\n )\n : 0;\n\nconst noDemandScoreEma = ema(prevNoDemandScoreEma, noDemandScoreRaw, alphaScore);\n\nconst adaptiveDecisionCenter = clamp(\n 0.72 - 0.20 * deliveryNoiseEma,\n 0.55,\n 0.82\n);\n\nconst adaptiveDecisionBand = clamp(\n 0.16 + 0.80 * deliveryNoiseEma,\n 0.12,\n 0.34\n);\n\nconst noDemandScoreOn = clamp(\n adaptiveDecisionCenter + adaptiveDecisionBand / 2,\n 0.60,\n 0.95\n);\n\nconst noDemandScoreOff = clamp(\n adaptiveDecisionCenter - adaptiveDecisionBand / 2,\n 0.15,\n noDemandScoreOn - 0.05\n);\n\nlet evcsVehicleNoDemandSemantic = prevNoDemandSemanticActive;\n\nif (!evcsSessionArmedForNoDemand) {\n evcsVehicleNoDemandSemantic = false;\n} else if (prevNoDemandSemanticActive) {\n evcsVehicleNoDemandSemantic = noDemandScoreEma >= noDemandScoreOff;\n} else {\n evcsVehicleNoDemandSemantic = noDemandScoreEma >= noDemandScoreOn;\n}\n\nconst evcsNoDemandSinceMs =\n evcsVehicleNoDemandSemantic\n ? (\n prevNoDemandSemanticActive && Number.isFinite(prevNoDemandSinceMs)\n ? prevNoDemandSinceMs\n : nowMs\n )\n : null;\n\nconst evcsNoDemandSinceMinutes =\n Number.isFinite(evcsNoDemandSinceMs) && Number.isFinite(nowMs)\n ? Math.max(0, (nowMs - evcsNoDemandSinceMs) / 60000)\n : null;\n\nconst vehicleDemandFilter = {\n dtSec: round3(noDemandDtSec),\n\n commandCurrentA: round3(commandCurrentA),\n realCurrentA: round3(realCurrentA),\n expectedPowerW: round3(expectedPowerW),\n realPowerW: round3(realPowerW),\n\n currentDeliveryRatioRaw: round3(currentDeliveryRatioRaw),\n powerDeliveryRatioRaw: round3(powerDeliveryRatioRaw),\n deliveryRatioRaw: round3(deliveryRatioRaw),\n deliveryRatioEma: round3(deliveryRatioEma),\n deliveryNoiseEma: round3(deliveryNoiseEma),\n\n adaptiveQuietBand: round3(adaptiveQuietBand),\n noDemandEvidenceRaw: round3(noDemandEvidenceRaw),\n\n sessionElapsedSec: round3(sessionElapsedSec),\n sessionArmFactor: round3(sessionArmFactor),\n commandFactor: round3(commandFactor),\n sourceDeliverableForNoDemand,\n evcsSessionArmedForNoDemand,\n\n noDemandScoreRaw: round3(noDemandScoreRaw),\n noDemandScoreEma: round3(noDemandScoreEma),\n\n noDemandScoreOn: round3(noDemandScoreOn),\n noDemandScoreOff: round3(noDemandScoreOff),\n\n evcsVehicleNoDemandSemantic,\n evcsNoDemandSinceMs: Number.isFinite(evcsNoDemandSinceMs) ? evcsNoDemandSinceMs : null,\n evcsNoDemandSinceMinutes: Number.isFinite(evcsNoDemandSinceMinutes)\n ? round3(evcsNoDemandSinceMinutes)\n : null,\n\n nextHistory: {\n evcsDeliveryRatioEma: round3(deliveryRatioEma),\n evcsDeliveryNoiseEma: round3(deliveryNoiseEma),\n evcsNoDemandScoreEma: round3(noDemandScoreEma),\n evcsNoDemandSemanticActive: evcsVehicleNoDemandSemantic,\n evcsNoDemandSinceMs: Number.isFinite(evcsNoDemandSinceMs) ? evcsNoDemandSinceMs : null,\n evcsNoDemandFilterEvalMs: nowMs\n }\n};\n\n// =====================================================\n// DECISION TREE\n// =====================================================\nif (!parsed.controllerEnabled) {\n desiredCharging = parsed.evcsChargingSwitchOn;\n desiredPermission = parsed.chargePermissionNow;\n desiredLatch = parsed.controlLatch;\n desiredCurrentA = clamp(\n derived.currentReferenceA || params.currentMinA,\n params.currentMinA,\n params.currentMaxAutoA\n );\n\n state = 'controller_disabled';\n currentDecision = 'hold';\n dynamicOk = false;\n mustStop = false;\n reason.push('Contrôleur désactivé');\n}\n\nelse if (strategy.isStopMode) {\n setOff({\n stateName: 'stopped_user',\n decisionName: 'stop_stop_mode',\n reasonText: 'Arrêt manuel via Stratégie STOP',\n dynamic: false,\n mustStopFlag: false,\n blockReason: 'manual_stop',\n blockedUntilStamp: now.stamp,\n });\n}\n\nelse if (parsed.evcsModeIsAuto) {\n setOff({\n stateName: 'mode_auto_conflict',\n decisionName: 'stop_auto_mode_conflict',\n reasonText: 'Borne en mode Auto interne',\n dynamic: false,\n mustStopFlag: true,\n blockReason: 'mode_auto_conflict',\n blockedUntilStamp: now.stamp,\n });\n}\n\nelse if (!parsed.evcsDeviceConnected || !parsed.evcsVehicleConnected) {\n setOff({\n stateName: 'vehicle_not_connected',\n decisionName: 'stop_not_connected',\n reasonText: 'Véhicule ou borne non connecté',\n dynamic: false,\n mustStopFlag: false,\n blockReason: 'vehicle_not_connected',\n blockedUntilStamp: now.stamp,\n });\n}\n\nelse if (safeguards.mustStopHard) {\n setOff({\n stateName:\n derived.anyVebusLowBattery ? 'vebus_low_battery_hard_stop' :\n derived.vebusDcVoltageHardLow ? 'vebus_dc_voltage_hard_stop' :\n derived.vebusAcOutHardReached ? 'vebus_ac_hard_stop' :\n safeguards.batteryCurrentHardExceeded ? 'battery_current_hard_stop' :\n 'hard_stop',\n decisionName: 'stop_hard',\n reasonText: 'Hard stop de protection',\n dynamic: false,\n mustStopFlag: true,\n blockReason:\n safeguards.batteryCurrentHardExceeded ? 'battery_current_hard_stop' :\n derived.vebusDcVoltageHardLow ? 'vebus_dc_voltage_stop' :\n derived.anyVebusLowBattery ? 'vebus_low_battery_stop' :\n derived.vebusAcOutHardReached ? 'vebus_ac_hard_stop' :\n 'battery_protection_stop',\n blockedUntilStamp: formatParisStampFromMs(nowMs + 15 * 60000),\n });\n}\n\n// =====================================================\n// ACTIVE SESSION BRANCH\n// =====================================================\nelse if (cloud.sessionObservedActive) {\n if (cloud.enteringCloudDebt) {\n setCloudDebtStartedAt = true;\n }\n\n if (conditions.shouldStopOverloadAtMin) {\n setOff({\n stateName: 'vebus_overload_stop_min_current',\n decisionName: 'stop',\n reasonText: 'Arrêt: surcharge convertisseur persistante au courant mini',\n dynamic: false,\n mustStopFlag: true,\n blockReason: 'vebus_overload_persistent',\n blockedUntilStamp: formatParisStampFromMs(\n nowMs + batteryTune.global.overloadStopAfterSeconds * 1000\n ),\n });\n }\n\n else if (safeguards.gridBatteryProtectionStop) {\n setOff({\n stateName: 'stop_grid_battery_guard',\n decisionName: 'stop',\n reasonText: `Arrêt charge réseau: batterie trop sollicitée (${round1(parsed.batteryCurrentA)}A)`,\n dynamic: false,\n mustStopFlag: true,\n blockReason: 'grid_battery_guard',\n blockedUntilStamp: formatParisStampFromMs(nowMs + 20 * 60000),\n });\n }\n\n else if (conditions.probeFailedNow) {\n setOff({\n stateName: 'probe_failed_lock',\n decisionName: 'stop',\n reasonText: 'Mode sonde échoué',\n dynamic: false,\n mustStopFlag: true,\n blockReason: 'probe_failed',\n blockedUntilStamp: formatParisStampFromMs(nowMs + params.probeRetryMinutes * 60000),\n });\n }\n\n else if (derived.physicalCapA < params.currentMinA) {\n if (lifecycle.minRunElapsed) {\n setOff({\n stateName: 'stop_power_cap',\n decisionName: 'stop_power_cap',\n reasonText: 'Capacité système insuffisante',\n dynamic: false,\n mustStopFlag: true,\n blockReason: 'system_capacity_stop',\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\n });\n } else {\n setOn({\n stateName: 'hold_min_run_power_cap',\n decisionName: 'hold',\n currentA: params.currentMinA,\n reasonText: \"Maintien jusqu'au temps mini ON\",\n dynamic: false,\n mustStopFlag: false,\n });\n }\n }\n\n else if (conditions.probeWindowActive && !conditions.probeSuccessNow) {\n if (!conditions.probeHoldGuardOk) {\n setOff({\n stateName: 'probe_stop_guard',\n decisionName: 'stop',\n reasonText: 'Sonde interrompue: garde-fous non respectés',\n dynamic: false,\n mustStopFlag: true,\n blockReason: 'probe_failed',\n blockedUntilStamp: formatParisStampFromMs(nowMs + params.probeRetryMinutes * 60000),\n });\n } else {\n setOn({\n stateName: 'probe_hold',\n decisionName: 'hold',\n currentA: Math.min(params.probeCurrentA, derived.physicalCapA),\n reasonText: `Mode sonde actif à ${round1(Math.min(params.probeCurrentA, derived.physicalCapA))}A`,\n dynamic: true,\n mustStopFlag: false,\n });\n }\n }\n\n else {\n const activeSourceMode =\n conditions.probeSuccessNow\n ? 'solar_only'\n : context.sourceMode;\n\n const holdConditionsOk =\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced')\n ? conditions.gridHoldOk\n : (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential')\n ? conditions.solarHoldOk\n : activeSourceMode === 'hybrid_probe'\n ? conditions.probeHoldGuardOk\n : activeSourceMode === 'battery_assist_night'\n ? conditions.batteryAssistNightHoldOk\n : false;\n\n const manualLocalHoldOverride =\n manualLocalChargeAllowed &&\n (\n activeSourceMode === 'solar_only' ||\n activeSourceMode === 'solar_potential' ||\n activeSourceMode === 'blocked'\n );\n\n const effectiveHoldConditionsOk =\n holdConditionsOk || manualLocalHoldOverride;\n\n // =================================================\n // VEHICLE FULL / PAUSED - NUMERIC FILTER DECISION\n // IMPORTANT:\n // - uniquement en session active\n // - uniquement si la source est réellement livrable\n // - sans utiliser le PV comme critère\n // =================================================\n if (\n vehicleDemandFilter.evcsVehicleNoDemandSemantic &&\n vehicleDemandFilter.sourceDeliverableForNoDemand\n ) {\n setOff({\n stateName: 'vehicle_full_or_paused',\n decisionName: 'stop_vehicle_full_or_paused',\n reasonText: 'Véhicule branché mais ne demande plus réellement de puissance',\n dynamic: false,\n mustStopFlag: false,\n blockReason: 'none',\n blockedUntilStamp: now.stamp,\n });\n\n reason.push(`DeliveryRatioEma=${vehicleDemandFilter.deliveryRatioEma}`);\n reason.push(`NoiseEma=${vehicleDemandFilter.deliveryNoiseEma}`);\n reason.push(`NoDemandScore=${vehicleDemandFilter.noDemandScoreEma}`);\n reason.push(`ScoreOn=${vehicleDemandFilter.noDemandScoreOn}`);\n reason.push(`ScoreOff=${vehicleDemandFilter.noDemandScoreOff}`);\n\n if (vehicleDemandFilter.evcsNoDemandSinceMinutes !== null) {\n reason.push(`NoDemand depuis ${round1(vehicleDemandFilter.evcsNoDemandSinceMinutes)} min`);\n }\n }\n\n else if (manualLocalHoldOverride && activeSourceMode === 'blocked') {\n setOn({\n stateName: 'charging_hold_manual_local',\n decisionName: 'hold',\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\n reasonText: 'Maintien manuel local hors fenêtre sans réseau',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (activeSourceMode === 'blocked') {\n if (lifecycle.minRunElapsed) {\n setOff({\n stateName: 'stop_source_lost',\n decisionName: 'stop',\n reasonText: 'Source autorisée perdue',\n dynamic: false,\n mustStopFlag: true,\n blockReason: context.profile === 'outside_window' ? 'outside_window' : 'source_unavailable',\n blockedUntilStamp:\n context.profile === 'outside_window' && Number.isFinite(locks.nextAllowedWindowStartMs)\n ? formatParisStampFromMs(locks.nextAllowedWindowStartMs)\n : formatParisStampFromMs(nowMs + params.minStopMinutes * 60000),\n });\n } else {\n setOn({\n stateName: 'hold_min_run_source_lost',\n decisionName: 'hold',\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\n reasonText: \"Source perdue mais maintien jusqu'au mini ON\",\n dynamic: false,\n mustStopFlag: false,\n });\n }\n }\n\n else if (\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&\n !parsed.gridAvailable\n ) {\n if (lifecycle.minRunElapsed) {\n setOff({\n stateName:\n context.profile === 'night_hc'\n ? 'night_wait_grid_takeover_runtime'\n : 'wait_grid_source_runtime',\n decisionName: 'hold',\n reasonText:\n context.profile === 'night_hc'\n ? 'Relais réseau demandé pendant la session nuit EVCS'\n : 'Source réseau demandée mais pas encore disponible',\n dynamic: false,\n mustStopFlag: false,\n blockReason:\n context.profile === 'night_hc' ? 'grid_hc_unavailable' : 'grid_unavailable',\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\n });\n } else {\n setOn({\n stateName: 'hold_min_run_wait_grid_takeover',\n decisionName: 'hold',\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\n reasonText: \"Maintien jusqu'au mini ON en attente du réseau\",\n dynamic: false,\n mustStopFlag: false,\n });\n }\n }\n\n else if (!effectiveHoldConditionsOk) {\n if (lifecycle.minRunElapsed) {\n setOff({\n stateName: 'stop_conditions_lost',\n decisionName: 'stop',\n reasonText: 'Conditions de maintien perdues',\n dynamic: false,\n mustStopFlag: true,\n blockReason: 'hold_conditions_lost',\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\n });\n } else {\n setOn({\n stateName: 'hold_min_run',\n decisionName: 'hold',\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\n reasonText: \"Maintien jusqu'au temps mini ON\",\n dynamic: true,\n mustStopFlag: false,\n });\n }\n }\n\n else if ((derived.currentReferenceA > derived.physicalCapA) && lifecycle.currentCooldownElapsed) {\n setOn({\n stateName: 'charging_step_down_system_cap',\n decisionName: 'step_down',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - Math.max(params.currentStepDownA, 2),\n derived.physicalCapA\n )\n ),\n reasonText: 'Réduction pour respecter la capacité système',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (conditions.shouldStepDownOverload) {\n setOn({\n stateName: 'charging_step_down_overload',\n decisionName: 'step_down_fast',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - Math.max(batteryTune.global.overloadStepDownA, params.currentStepDownA),\n derived.physicalCapA\n )\n ),\n reasonText: 'Baisse rapide: surcharge convertisseur',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (conditions.shouldStepDownVebusPower) {\n setOn({\n stateName: 'charging_step_down_vebus_power',\n decisionName: 'step_down_fast',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - Math.max(params.currentStepDownA + 1, 3),\n derived.physicalCapA\n )\n ),\n reasonText: 'Baisse rapide: puissance VE.Bus élevée',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (conditions.shouldStepDownVebusVoltage) {\n setOn({\n stateName: 'charging_step_down_dc_voltage',\n decisionName: 'step_down_fast',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - Math.max(params.currentStepDownA + 1, 3),\n derived.physicalCapA\n )\n ),\n reasonText: 'Baisse rapide: tension DC basse',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (\n activeSourceMode === 'battery_assist_night' &&\n conditions.batteryAssistNightShouldStepDown\n ) {\n setOn({\n stateName: 'charging_step_down_battery_assist_night',\n decisionName: 'step_down',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - params.currentStepDownA,\n derived.physicalCapA\n )\n ),\n reasonText:\n nightEvcs?.batteryRecoveryUrgent\n ? 'Réduction: approche départ, préparation takeover réseau'\n : 'Réduction: assistance batterie nuit devient trop coûteuse',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&\n conditions.gridShouldStepDownBattery\n ) {\n setOn({\n stateName:\n activeSourceMode === 'grid_forced'\n ? 'charging_step_down_grid_forced_guard'\n : 'charging_step_down_grid_guard',\n decisionName: 'step_down',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - params.currentStepDownA,\n derived.physicalCapA\n )\n ),\n reasonText:\n activeSourceMode === 'grid_forced'\n ? 'Réduction réseau forcé: la batterie commence à participer'\n : 'Réduction réseau: la batterie commence à participer',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (\n (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&\n conditions.solarShouldStepDownCloudDebt\n ) {\n setOn({\n stateName: 'charging_step_down_cloud_debt',\n decisionName: 'step_down',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - Math.max(params.currentStepDownA, 2),\n derived.physicalCapA\n )\n ),\n reasonText: 'Réduction: dette nuage trop élevée',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (\n (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&\n conditions.solarShouldStepDownBattery\n ) {\n setOn({\n stateName: 'charging_step_down_battery',\n decisionName: 'step_down',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - params.currentStepDownA,\n derived.physicalCapA\n )\n ),\n reasonText: 'Réduction: batterie trop sollicitée',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&\n conditions.gridShouldStepUp\n ) {\n setOn({\n stateName:\n activeSourceMode === 'grid_forced'\n ? (\n nightEvcs?.batteryRecoveryUrgent\n ? 'night_departure_deadline_grid_forced'\n : 'charging_step_up_grid_forced'\n )\n : 'charging_step_up_grid_hc',\n decisionName: 'step_up',\n currentA: clamp(\n derived.currentReferenceA + params.currentStepUpA,\n params.currentMinA,\n derived.physicalCapA\n ),\n reasonText:\n activeSourceMode === 'grid_forced'\n ? (\n nightEvcs?.batteryRecoveryUrgent\n ? 'Hausse prudente: priorité départ, réseau forcé'\n : 'Hausse prudente du courant en charge réseau forcée'\n )\n : 'Hausse prudente du courant en HC réseau',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (\n activeSourceMode === 'battery_assist_night' &&\n conditions.batteryAssistNightShouldStepUp\n ) {\n setOn({\n stateName: 'charging_step_up_battery_assist_night',\n decisionName: 'step_up',\n currentA: clamp(\n derived.currentReferenceA + params.currentStepUpA,\n params.currentMinA,\n derived.physicalCapA\n ),\n reasonText: 'Hausse prudente du courant en assistance batterie nuit',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (\n (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&\n conditions.solarShouldStepUp\n ) {\n setOn({\n stateName:\n conditions.probeSuccessNow\n ? 'probe_success_step_up'\n : (modes.batteryPriorityMode\n ? 'charging_step_up_battery'\n : 'charging_step_up_forecast'),\n decisionName: 'step_up',\n currentA: clamp(\n derived.currentReferenceA + params.currentStepUpA,\n params.currentMinA,\n derived.physicalCapA\n ),\n reasonText: 'Hausse prudente du courant',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else {\n setOn({\n stateName:\n manualLocalHoldOverride ? 'charging_hold_manual_local' :\n conditions.probeSuccessNow ? 'probe_success_hold' :\n activeSourceMode === 'battery_assist_night' ? 'charging_hold_battery_assist_night' :\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') ? 'charging_hold_grid' :\n cloud.cloudDebtActive ? 'charging_hold_cloud_tolerance' :\n 'charging_hold',\n decisionName: 'hold',\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\n reasonText:\n manualLocalHoldOverride\n ? 'Maintien manuel local du courant'\n : activeSourceMode === 'battery_assist_night'\n ? 'Maintien du courant en assistance batterie nuit'\n : 'Maintien du courant',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n }\n}\n\n// =====================================================\n// START BRANCH\n// =====================================================\nelse {\n const startConditionsOk =\n (context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced')\n ? conditions.gridStartOk\n : (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential')\n ? conditions.solarStartOk\n : context.sourceMode === 'hybrid_probe'\n ? conditions.probeStartOk\n : context.sourceMode === 'battery_assist_night'\n ? conditions.batteryAssistNightStartOk\n : false;\n\n const manualLocalStartOverride =\n manualLocalChargeAllowed &&\n (\n context.sourceMode === 'solar_only' ||\n context.sourceMode === 'solar_potential' ||\n context.sourceMode === 'blocked'\n );\n\n if (!derived.canRunAtMinCurrent) {\n setOff({\n stateName: 'blocked_by_capacity_min_current',\n decisionName: 'hold',\n reasonText: 'Capacité insuffisante pour courant mini',\n dynamic: false,\n mustStopFlag: false,\n blockReason: 'system_capacity_stop',\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\n });\n }\n\n else if (manualLocalStartOverride) {\n setOn({\n stateName: 'start_manual_local_min_current',\n decisionName: 'start',\n currentA: params.currentMinA,\n reasonText:\n context.profile === 'outside_window'\n ? 'Démarrage manuel local hors fenêtre sans réseau'\n : 'Démarrage manuel local sans réseau',\n dynamic: true,\n mustStopFlag: false,\n });\n\n nextBlockReason = 'none';\n nextBlockedUntilStamp = now.stamp;\n setProbeStartedAt = false;\n }\n\n else if (context.sourceMode === 'blocked') {\n setOff({\n stateName:\n context.profile === 'outside_window'\n ? 'blocked_outside_window'\n : (context.profile === 'night_hc'\n ? 'night_no_grid_hc_lock'\n : 'idle_wait_source'),\n decisionName: 'hold',\n reasonText:\n context.profile === 'outside_window'\n ? 'Hors fenêtre préférée sans source suffisante'\n : context.profile === 'night_hc'\n ? 'Nuit sans autorisation HC réseau'\n : 'Source non autorisée ou insuffisante',\n dynamic: false,\n mustStopFlag: false,\n blockReason:\n context.profile === 'outside_window'\n ? 'outside_window'\n : context.profile === 'night_hc'\n ? 'grid_hc_unavailable'\n : 'source_unavailable',\n blockedUntilStamp:\n Number.isFinite(locks.nextAllowedWindowStartMs)\n ? formatParisStampFromMs(locks.nextAllowedWindowStartMs)\n : formatParisStampFromMs(nowMs + 15 * 60000),\n });\n }\n\n else if (\n (context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced') &&\n !parsed.gridAvailable\n ) {\n setOff({\n stateName:\n context.profile === 'night_hc'\n ? 'night_wait_grid_takeover'\n : 'wait_grid_source',\n decisionName: 'hold',\n reasonText:\n context.profile === 'night_hc'\n ? (\n nightEvcs?.batteryRecoveryUrgent\n ? 'Réseau demandé: urgence recharge batterie avant départ'\n : 'Prise de relais réseau demandée pour session nuit EVCS'\n )\n : 'Source réseau demandée mais pas encore disponible',\n dynamic: false,\n mustStopFlag: false,\n blockReason:\n context.profile === 'night_hc'\n ? 'grid_hc_unavailable'\n : 'grid_unavailable',\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\n });\n }\n\n else if (locks.startBlockedByReason) {\n setOff({\n stateName: 'wait_reason_lock',\n decisionName: 'hold',\n reasonText: `Blocage anti-cliquetis: ${history.lastBlockReason}`,\n dynamic: false,\n mustStopFlag: false,\n blockReason: history.lastBlockReason,\n blockedUntilStamp: history.blockedUntilAt || now.stamp,\n });\n }\n\n else if (!startConditionsOk) {\n setOff({\n stateName: 'idle_wait_conditions',\n decisionName: 'hold',\n reasonText:\n context.sourceMode === 'battery_assist_night'\n ? 'Conditions de démarrage nuit batterie non réunies'\n : 'Conditions de démarrage non réunies',\n dynamic: false,\n mustStopFlag: false,\n blockReason: 'none',\n blockedUntilStamp: now.stamp,\n });\n }\n\n else if (!lifecycle.minStopElapsed) {\n setOff({\n stateName: 'wait_min_stop',\n decisionName: 'hold',\n reasonText: 'Temps mini OFF non écoulé',\n dynamic: false,\n mustStopFlag: false,\n blockReason: 'none',\n blockedUntilStamp: now.stamp,\n });\n }\n\n else {\n const startCurrentA =\n context.sourceMode === 'hybrid_probe'\n ? clamp(params.probeCurrentA, params.currentMinA, derived.physicalCapA)\n : params.currentMinA;\n\n setOn({\n stateName:\n context.sourceMode === 'grid_forced' ? (\n nightEvcs?.batteryRecoveryUrgent\n ? 'night_departure_deadline_grid_forced'\n : 'start_grid_forced_min_current'\n ) :\n context.sourceMode === 'grid_hc_allowed' ? 'start_grid_hc_min_current' :\n context.sourceMode === 'battery_assist_night' ? 'start_battery_assist_night_min_current' :\n context.sourceMode === 'hybrid_probe' ? 'probe_start_6a' :\n context.sourceMode === 'solar_potential' ? 'start_forecast_solar_potential' :\n modes.batteryFullHarvestMode ? 'start_min_current_battery_full_harvest' :\n modes.forecastCommitMode ? 'start_min_current_forecast_commit' :\n 'start_min_current',\n decisionName: 'start',\n currentA: startCurrentA,\n reasonText:\n context.sourceMode === 'grid_forced'\n ? (\n nightEvcs?.batteryRecoveryUrgent\n ? 'Démarrage réseau forcé: priorité départ / recharge batterie'\n : 'Démarrage en charge réseau forcée'\n )\n : context.sourceMode === 'grid_hc_allowed'\n ? 'Démarrage en fenêtre HC réseau'\n : context.sourceMode === 'battery_assist_night'\n ? 'Démarrage nuit en assistance batterie contrôlée'\n : context.sourceMode === 'hybrid_probe'\n ? 'Démarrage en mode sonde'\n : context.sourceMode === 'solar_potential'\n ? 'Démarrage par potentiel solaire confirmé'\n : modes.batteryFullHarvestMode\n ? 'Démarrage harvest solaire'\n : modes.forecastCommitMode\n ? 'Démarrage forecast commit'\n : 'Démarrage prudent à courant mini',\n dynamic: true,\n mustStopFlag: false,\n });\n\n nextBlockReason = 'none';\n nextBlockedUntilStamp = now.stamp;\n setProbeStartedAt = context.sourceMode === 'hybrid_probe';\n }\n}\n\n// =====================================================\n// CONTEXT REASONS\n// =====================================================\nif (nightEvcs?.nightSessionActive) {\n reason.push(\n `Nuit EVCS: target=${round1(nightEvcs.batteryMorningTargetPct)}% | trigger réseau=${round1(nightEvcs.gridTakeoverTriggerPct)}%`\n );\n\n if (Number.isFinite(nightEvcs.nightSessionStartSocPct)) {\n reason.push(\n `SOC départ session nuit=${round1(nightEvcs.nightSessionStartSocPct)}% | décharge autorisée=${round1(nightEvcs.maxBatteryDischargePoints)} pts`\n );\n }\n\n if (nightEvcs.batteryRecoveryUrgent) {\n reason.push(\"Urgence départ: il faut anticiper la reprise réseau\");\n }\n}\n\nif (manualLocalChargeAllowed) {\n reason.push(`ManualLocalCharge=on`);\n reason.push(`ManualLocalProfile=${context.profile}`);\n reason.push(`ManualLocalSource=${context.sourceMode}`);\n}\n\nreason.push(`VehicleDemandFilter delivery=${vehicleDemandFilter.deliveryRatioEma}`);\nreason.push(`VehicleDemandFilter noise=${vehicleDemandFilter.deliveryNoiseEma}`);\nreason.push(`VehicleDemandFilter score=${vehicleDemandFilter.noDemandScoreEma}`);\nreason.push(`VehicleDemandFilter noDemand=${vehicleDemandFilter.evcsVehicleNoDemandSemantic ? 'on' : 'off'}`);\n\n// =====================================================\n// OUTPUT\n// =====================================================\nreturn [{\n json: {\n ...prev,\n vehicleDemandFilter,\n decision: {\n desiredCharging,\n desiredPermission,\n desiredLatch,\n desiredCurrentA,\n\n state,\n currentDecision,\n dynamicOk,\n mustStop,\n reason,\n\n nextBlockReason,\n nextBlockedUntilStamp,\n\n setCloudDebtStartedAt,\n setProbeStartedAt,\n\n setNightSessionStart,\n clearNightSessionStart,\n\n notifyWanted,\n notifyTitle,\n notifyMessage,\n }\n }\n}];\n"
+ },
+ "type": "n8n-nodes-base.code",
+ "typeVersion": 2,
+ "position": [
+ 496,
+ 32
+ ],
+ "id": "1a8271fd-112a-45c6-8bc6-664d1215f72f",
+ "name": "DECISION_ENGINEV2"
+ }
+ ],
+ "connections": {
+ "Schedule Trigger": {
+ "main": [
+ [
+ {
+ "node": "HA Snapshot EVCS Surplus Controller V2.2",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Build EVCS Surplus Controller V2.0 Actions": {
+ "main": [
+ [
+ {
+ "node": "Execute HA Service EVCS Surplus",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "BATTERY_CLOUD_LOCKS": {
+ "main": [
+ [
+ {
+ "node": "DECISION_ENGINEV2",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "INPUT_PARSE": {
+ "main": [
+ [
+ {
+ "node": "WINDOWS_SOURCEV2",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "HA_SERVICE_CALLS_OUTPUT": {
+ "main": [
+ [
+ {
+ "node": "Build EVCS Surplus Controller V2.0 Actions",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "HA Snapshot EVCS Surplus Controller V2.2": {
+ "main": [
+ [
+ {
+ "node": "INPUT_PARSE",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "WINDOWS_SOURCEV2": {
+ "main": [
+ [
+ {
+ "node": "BATTERY_CLOUD_LOCKS",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "DECISION_ENGINEV2": {
+ "main": [
+ [
+ {
+ "node": "HA_SERVICE_CALLS_OUTPUT",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ }
+ },
+ "authors": "thierry tallagrand",
+ "name": "Version 03b032a1",
+ "description": "",
+ "autosaved": true,
+ "workflowPublishHistory": [
+ {
+ "createdAt": "2026-05-06T13:48:38.358Z",
+ "id": 82,
+ "workflowId": "ATswvGfG4KJUls0K",
+ "versionId": "03b032a1-b7c9-45e7-8ba0-3813f6843b40",
+ "event": "activated",
+ "userId": "8b9e9552-cd74-4863-82a3-174b3b3befa2"
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/modified/GRID_COMPUTE_patched.js b/modified/GRID_COMPUTE_patched.js
new file mode 100644
index 0000000..2dc42e6
--- /dev/null
+++ b/modified/GRID_COMPUTE_patched.js
@@ -0,0 +1,645 @@
+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 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 round1(v) {
+ return Math.round(v * 10) / 10;
+}
+
+function clamp(v, min, max) {
+ return Math.min(Math.max(v, min), max);
+}
+
+function pickDefined(...values) {
+ for (const v of values) {
+ if (v === undefined || v === null) continue;
+ if (typeof v === 'string' && v.trim() === '') continue;
+ return v;
+ }
+ return undefined;
+}
+
+function isOnLike(v) {
+ const s = low(v);
+ return s === 'on' || s === 'true' || s === '1' || s === 'yes';
+}
+
+function getParisNowParts() {
+ 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())
+ .filter(p => p.type !== 'literal')
+ .map(p => [p.type, p.value])
+ );
+
+ const date = `${parts.year}-${parts.month}-${parts.day}`;
+ const stamp = `${date} ${parts.hour}:${parts.minute}:${parts.second}`;
+
+ return {
+ year: Number(parts.year),
+ month: Number(parts.month),
+ day: Number(parts.day),
+ hour: Number(parts.hour),
+ minute: Number(parts.minute),
+ second: Number(parts.second),
+ date,
+ stamp,
+ };
+}
+
+function parseLocalStampToMs(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;
+
+ return new Date(y, mo - 1, d, h, mi, se).getTime();
+}
+
+const cfg = {
+ mobileNotifyDomain: 'notify',
+ mobileNotifyService: 'mobile_app_iphone_16_de_tt',
+};
+
+const raw = $input.first().json;
+const input = extractSnapshot(raw);
+const now = getParisNowParts();
+const nowMs = parseLocalStampToMs(now.stamp);
+
+// ======================================================
+// INPUTS PRINCIPAUX
+// ======================================================
+const soc = clamp(num(input.soc_percent, 0), 0, 100);
+const gridIsOn = low(input.grid_state || 'off') === 'on';
+const pvPowerW = Math.max(0, num(input.pv_power_w, 0));
+
+const houseAcPowerW = Math.max(0, num(input.house_ac_power_w, 0));
+const gridPowerW = num(input.grid_power_w, 0);
+
+const evcsAcPowerW = Math.max(0, num(input.evcs_ac_power_w, 0));
+const evcsCharging = low(input.evcs_charging || 'off') === 'on';
+const evcsStatusRaw = num(input.evcs_status_raw, NaN);
+
+const hysteresisEnabled = low(input.hysteresis_enabled || 'off') === 'on';
+const notificationsEnabled = low(input.notifications_enabled || 'off') === 'on';
+const latchIsOn = low(input.grid_latch || 'off') === 'on';
+
+const gridSocOn = clamp(num(input.grid_soc_on, 22), 0, 100);
+const gridSocOff = clamp(num(input.grid_soc_off, 35), 0, 100);
+const gridMinOnMinutes = Math.max(0, num(input.grid_min_on_minutes, 45));
+const gridCooldownMinutes = Math.max(0, num(input.grid_cooldown_minutes, 15));
+const acLoadsMaxSafeW = Math.max(0, num(input.ac_loads_max_safe_w, 7800));
+
+const lastOnAt = (input.last_on_at ?? '').toString().trim();
+const lastOffAt = (input.last_off_at ?? '').toString().trim();
+const lastSignature = (input.last_signature ?? '').toString().trim();
+
+const lastOnMs = parseLocalStampToMs(lastOnAt);
+const lastOffMs = parseLocalStampToMs(lastOffAt);
+
+const minutesSinceLastOn =
+ lastOnMs !== null && nowMs !== null ? Math.max(0, (nowMs - lastOnMs) / 60000) : null;
+
+const minutesSinceLastOff =
+ lastOffMs !== null && nowMs !== null ? Math.max(0, (nowMs - lastOffMs) / 60000) : null;
+
+const minOnElapsed =
+ minutesSinceLastOn === null ? true : minutesSinceLastOn >= gridMinOnMinutes;
+
+const cooldownElapsed =
+ minutesSinceLastOff === null ? true : minutesSinceLastOff >= gridCooldownMinutes;
+
+const socBand =
+ soc <= gridSocOn ? 'low' :
+ soc < gridSocOff ? 'mid' :
+ 'high';
+
+const evcsSeemsActive = evcsCharging || evcsAcPowerW > 300;
+const acOverSafeLimit = houseAcPowerW >= acLoadsMaxSafeW;
+
+// ======================================================
+// EVCS OVERRIDE INPUTS
+// ======================================================
+const evcsPriorityActive = isOnLike(
+ pickDefined(
+ input.evcs_priority_active,
+ input.victron_evcs_priority_active,
+ input['input_boolean.victron_evcs_priority_active']
+ )
+);
+
+const evcsNightGridTakeoverNeeded = isOnLike(
+ pickDefined(
+ input.night_grid_takeover_needed,
+ input.victron_evcs_night_grid_takeover_needed,
+ input['input_boolean.victron_evcs_night_grid_takeover_needed']
+ )
+);
+
+const evcsSourceModeRaw = (
+ pickDefined(
+ input.evcs_source_mode,
+ input.victron_evcs_source_mode,
+ input['input_text.victron_evcs_source_mode']
+ ) ?? ''
+).toString().trim();
+
+const evcsSourceMode = low(evcsSourceModeRaw);
+
+const userStrategyRaw = (
+ pickDefined(
+ input.user_strategy,
+ input.victron_evcs_user_strategy,
+ input['input_select.victron_evcs_user_strategy']
+ ) ?? ''
+).toString().trim();
+
+const userStrategy = low(userStrategyRaw);
+
+// ======================================================
+// EVCS SEMANTIC STATE
+// Ici on ne détecte PAS la voiture pleine par seuils.
+// On consomme seulement l'état sémantique déjà produit
+// par le workflow EVCS.
+// ======================================================
+const evcsSurplusStateRaw = (
+ pickDefined(
+ input.evcs_surplus_state,
+ input.victron_evcs_surplus_state,
+ input['input_text.victron_evcs_surplus_state']
+ ) ?? ''
+).toString().trim();
+
+const evcsSurplusState = low(evcsSurplusStateRaw);
+
+const evcsVehicleNoDemandSemantic =
+ evcsSurplusState === 'vehicle_full_or_paused' ||
+ evcsSurplusState === 'vehicle_not_requesting_power' ||
+ evcsSurplusState === 'vehicle_full' ||
+ evcsSurplusState === 'charge_complete' ||
+ evcsSurplusState.includes('full_or_paused') ||
+ evcsSurplusState.includes('not_requesting_power') ||
+ evcsSurplusState.includes('charge_complete') ||
+ evcsSurplusState.includes('no_demand');
+
+// ======================================================
+// OVERRIDE EVCS VERS GRID
+// CORRECTION:
+// - evcsPriorityActive seul ne doit PLUS suffire à allumer EDF
+// - seuls les cas explicitement réseau peuvent forcer le grid
+// - ainsi "Réseau HC midi = OFF" redevient réellement respecté
+// ======================================================
+const evcsExplicitGridRequest =
+ userStrategy === 'force_grid_now' ||
+ userStrategy === 'force_charge_now' ||
+ evcsNightGridTakeoverNeeded ||
+ evcsSourceMode === 'réseau forcé' ||
+ evcsSourceMode === 'réseau hc autorisé' ||
+ evcsSourceMode === 'grid_forced' ||
+ evcsSourceMode === 'grid_hc_allowed';
+
+const evcsGridOverride =
+ !evcsVehicleNoDemandSemantic &&
+ evcsExplicitGridRequest;
+
+// ======================================================
+// DÉCISION
+// ======================================================
+let state = 'observe_only';
+let reason = [];
+let desiredGrid = gridIsOn ? 'on' : 'off';
+let desiredLatch = latchIsOn ? 'on' : 'off';
+
+// ======================================================
+// FORCE_GRID + VEHICLE FULL IMMEDIATE CUT
+// FIX V2.5: Si force_grid_now actif et voiture pleine,
+// couper EDF immédiatement sans attendre minOnRuntime.
+// ======================================================
+if (
+ userStrategy === 'force_grid_now' &&
+ evcsVehicleNoDemandSemantic &&
+ latchIsOn
+) {
+ desiredGrid = 'off';
+ desiredLatch = 'off';
+ state = 'turn_off_force_grid_vehicle_full';
+ reason.push('Force grid + voiture pleine : coupure immediate du reseau');
+}
+
+
+// ======================================================
+// CORE HYSTERESIS LOGIC
+// ======================================================
+if (evcsGridOverride) {
+ desiredGrid = 'on';
+ desiredLatch = 'on';
+
+ if (!gridIsOn) {
+ state = 'turn_on_evcs_override';
+ reason.push('Override EVCS actif : activation EDF forcée');
+ } else if (!latchIsOn) {
+ state = 'adopt_existing_grid_on_evcs_override';
+ reason.push('Override EVCS actif : EDF déjà ON, adoption par latch');
+ } else {
+ state = 'hold_on_evcs_override';
+ reason.push('Override EVCS actif : maintien EDF ON');
+ }
+
+ if (userStrategy === 'force_grid_now') {
+ reason.push('Cause override : force_grid_now');
+ } else if (userStrategy === 'force_charge_now') {
+ reason.push('Cause override : force_charge_now');
+ } else if (evcsNightGridTakeoverNeeded) {
+ reason.push('Cause override : night_grid_takeover_needed');
+ } else {
+ reason.push(`Cause override : source_mode=${evcsSourceModeRaw || 'n/a'}`);
+ }
+
+} else if (!hysteresisEnabled) {
+ state = 'hysteresis_disabled';
+ desiredGrid = gridIsOn ? 'on' : 'off';
+ desiredLatch = latchIsOn ? 'on' : 'off';
+ reason.push('Automatisme hystérésis réseau désactivé');
+
+} else if (soc <= gridSocOn) {
+ desiredGrid = 'on';
+ desiredLatch = 'on';
+
+ if (!cooldownElapsed && !gridIsOn) {
+ state = 'turn_on_low_soc_cooldown_bypassed';
+ reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : activation EDF malgré cooldown`);
+ } else if (!gridIsOn) {
+ state = 'turn_on_low_soc';
+ reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : activation EDF`);
+ } else if (!latchIsOn) {
+ state = 'adopt_existing_grid_on_low_soc';
+ reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : EDF déjà ON, prise de contrôle par latch`);
+ } else {
+ state = 'hold_on_low_soc';
+ reason.push(`SOC encore bas ${round1(soc)}% : maintien EDF ON`);
+ }
+
+} else if (gridIsOn && !latchIsOn) {
+ if (soc < gridSocOff) {
+ desiredGrid = 'on';
+ desiredLatch = 'on';
+ state = 'adopt_existing_grid_on';
+ reason.push(`EDF déjà ON avec latch OFF et SOC ${round1(soc)}% < seuil OFF ${gridSocOff}% : adoption de l'état ON`);
+ } else {
+ desiredGrid = 'off';
+ desiredLatch = 'off';
+ state = 'turn_off_stray_grid_on';
+ reason.push(`EDF ON avec latch OFF et SOC ${round1(soc)}% >= seuil OFF ${gridSocOff}% : coupure de reprise de contrôle`);
+ }
+
+} else if (latchIsOn) {
+ if (!minOnElapsed) {
+ desiredGrid = 'on';
+ desiredLatch = 'on';
+ state = 'hold_on_min_runtime';
+ reason.push(`EDF maintenu ON : durée mini ${gridMinOnMinutes} min non atteinte (${round1(minutesSinceLastOn || 0)} min)`);
+ } else if (soc < gridSocOff) {
+ desiredGrid = 'on';
+ desiredLatch = 'on';
+ state = 'hold_on_until_soc_off';
+ reason.push(`EDF maintenu ON : SOC ${round1(soc)}% < seuil OFF ${gridSocOff}%`);
+ } else {
+ desiredGrid = 'off';
+ desiredLatch = 'off';
+ state = 'turn_off_recovered';
+ reason.push(`SOC ${round1(soc)}% >= seuil OFF ${gridSocOff}% : coupure EDF`);
+ }
+
+} else {
+ desiredGrid = 'off';
+ desiredLatch = 'off';
+ state = 'observe_only';
+ reason.push('Aucune action : hors zone d’hystérésis');
+}
+
+// ======================================================
+// COMPARE DESIRED VS CURRENT
+// ======================================================
+const gridAction =
+ desiredGrid === (gridIsOn ? 'on' : 'off')
+ ? 'none'
+ : desiredGrid === 'on'
+ ? 'turn_on'
+ : 'turn_off';
+
+const latchAction =
+ desiredLatch === (latchIsOn ? 'on' : 'off')
+ ? 'none'
+ : desiredLatch === 'on'
+ ? 'turn_on'
+ : 'turn_off';
+
+// ======================================================
+// TIMESTAMPS
+// ======================================================
+const setLastOnAt =
+ gridAction === 'turn_on' ||
+ state === 'adopt_existing_grid_on' ||
+ state === 'adopt_existing_grid_on_low_soc' ||
+ state === 'adopt_existing_grid_on_evcs_override';
+
+const setLastOffAt =
+ gridAction === 'turn_off';
+
+// ======================================================
+// CONTEXT / ALERTS
+// ======================================================
+reason.push(`Grid actuel ${gridIsOn ? 'ON' : 'OFF'}`);
+reason.push(`Latch actuel ${latchIsOn ? 'ON' : 'OFF'}`);
+reason.push(`SOC ${round1(soc)}% | seuils ${gridSocOn}%/${gridSocOff}%`);
+reason.push(`PV ${Math.round(pvPowerW)}W`);
+reason.push(`Charge AC maison ${Math.round(houseAcPowerW)}W`);
+reason.push(`Puissance réseau ${Math.round(gridPowerW)}W`);
+reason.push(`EVCS ${evcsSeemsActive ? 'actif' : 'inactif'} | ${Math.round(evcsAcPowerW)}W | status_raw=${Number.isFinite(evcsStatusRaw) ? evcsStatusRaw : 'n/a'}`);
+
+if (minutesSinceLastOn !== null) {
+ reason.push(`Dernier ON il y a ${round1(minutesSinceLastOn)} min`);
+}
+if (minutesSinceLastOff !== null) {
+ reason.push(`Dernier OFF il y a ${round1(minutesSinceLastOff)} min`);
+}
+
+if (acOverSafeLimit) {
+ reason.push(`⚠️ Charge AC maison ${Math.round(houseAcPowerW)}W >= limite sûre ${Math.round(acLoadsMaxSafeW)}W`);
+}
+
+reason.push(`EVCS override ${evcsGridOverride ? 'ON' : 'OFF'}`);
+reason.push(`EVCS explicit grid request ${evcsExplicitGridRequest ? 'ON' : 'OFF'}`);
+reason.push(`EVCS priority ${evcsPriorityActive ? 'ON' : 'OFF'}`);
+reason.push(`EVCS night takeover ${evcsNightGridTakeoverNeeded ? 'ON' : 'OFF'}`);
+reason.push(`EVCS source ${evcsSourceModeRaw || 'n/a'}`);
+reason.push(`EVCS strategy ${userStrategyRaw || 'n/a'}`);
+reason.push(`EVCS surplus state ${evcsSurplusStateRaw || 'n/a'}`);
+reason.push(`EVCS no-demand semantic ${evcsVehicleNoDemandSemantic ? 'ON' : 'OFF'}`);
+
+// ======================================================
+// NOTIFICATIONS
+// ======================================================
+let notifyTitle = 'Victron Grid Hysteresis';
+let notifyMessage = '';
+let notifyWanted = false;
+
+if (
+ state === 'turn_on_evcs_override' ||
+ state === 'adopt_existing_grid_on_evcs_override' ||
+ state === 'hold_on_evcs_override'
+) {
+ notifyTitle = '🚗 EDF forcé par EVCS';
+ notifyMessage = `Priorité EVCS active. EDF maintenu ON pour la recharge véhicule. Cause: ${userStrategyRaw || evcsSourceModeRaw || 'override EVCS'}.`;
+ notifyWanted = true;
+
+} else if (state === 'turn_on_low_soc' || state === 'turn_on_low_soc_cooldown_bypassed') {
+ notifyTitle = '⚡ EDF activé sur SOC bas';
+ notifyMessage = `SOC ${round1(soc)}% <= ${gridSocOn}%. EDF activé et maintenu jusqu'à au moins ${gridSocOff}% (min ${gridMinOnMinutes} min).`;
+ notifyWanted = true;
+
+} else if (state === 'adopt_existing_grid_on_low_soc' || state === 'adopt_existing_grid_on') {
+ notifyTitle = '🔁 EDF ON repris par hystérésis';
+ notifyMessage = `EDF était déjà ON. Le workflow reprend la main et le maintient jusqu'à ${gridSocOff}% (min ${gridMinOnMinutes} min).`;
+ notifyWanted = true;
+
+} else if (state === 'turn_off_recovered') {
+ notifyTitle = '✅ EDF coupé après recharge';
+ notifyMessage = `SOC ${round1(soc)}% >= ${gridSocOff}%. Retour hors réseau autorisé.`;
+ notifyWanted = true;
+
+} else if (state === 'turn_off_stray_grid_on') {
+ notifyTitle = '✅ EDF coupé (reprise de contrôle)';
+ notifyMessage = `EDF était ON sans latch. SOC ${round1(soc)}% >= ${gridSocOff}%, coupure réseau appliquée.`;
+ notifyWanted = true;
+
+} else if (acOverSafeLimit && !gridIsOn) {
+ notifyTitle = '🚨 Charge AC élevée hors réseau';
+ notifyMessage = `Charge AC maison ${Math.round(houseAcPowerW)}W au-dessus de la limite sûre ${Math.round(acLoadsMaxSafeW)}W alors que EDF est OFF.`;
+ notifyWanted = true;
+
+} else if (acOverSafeLimit && gridIsOn) {
+ notifyTitle = '⚠️ Charge AC élevée';
+ notifyMessage = `Charge AC maison ${Math.round(houseAcPowerW)}W proche/au-dessus de la limite sûre ${Math.round(acLoadsMaxSafeW)}W.`;
+ notifyWanted = true;
+}
+
+const shortReason = reason.join(' | ').slice(0, 255);
+
+const signature = [
+ now.date,
+ state,
+ `dg=${desiredGrid}`,
+ `dl=${desiredLatch}`,
+ `sb=${socBand}`,
+ `ac=${acOverSafeLimit ? '1' : '0'}`,
+ `ov=${evcsGridOverride ? '1' : '0'}`,
+ `eg=${evcsExplicitGridRequest ? '1' : '0'}`,
+ `ep=${evcsPriorityActive ? '1' : '0'}`,
+ `es=${evcsSurplusState || 'na'}`
+].join('|');
+
+const signatureChanged = signature !== lastSignature;
+
+// ======================================================
+// OUTPUT
+// ======================================================
+const result = {
+ timestamp_paris: now.stamp,
+
+ inputs: {
+ soc_percent: soc,
+ grid_state: gridIsOn ? 'on' : 'off',
+ pv_power_w: pvPowerW,
+ house_ac_power_w: houseAcPowerW,
+ grid_power_w: gridPowerW,
+ evcs_ac_power_w: evcsAcPowerW,
+ evcs_charging: evcsCharging,
+ evcs_status_raw: Number.isFinite(evcsStatusRaw) ? evcsStatusRaw : null,
+
+ hysteresis_enabled: hysteresisEnabled,
+ notifications_enabled: notificationsEnabled,
+ grid_latch: latchIsOn,
+ grid_soc_on: gridSocOn,
+ grid_soc_off: gridSocOff,
+ grid_min_on_minutes: gridMinOnMinutes,
+ grid_cooldown_minutes: gridCooldownMinutes,
+ ac_loads_max_safe_w: acLoadsMaxSafeW,
+
+ last_on_at: lastOnAt || null,
+ last_off_at: lastOffAt || null,
+ last_signature: lastSignature || null,
+
+ evcs_priority_active: evcsPriorityActive,
+ night_grid_takeover_needed: evcsNightGridTakeoverNeeded,
+ evcs_source_mode: evcsSourceModeRaw || null,
+ user_strategy: userStrategyRaw || null,
+ evcs_surplus_state: evcsSurplusStateRaw || null
+ },
+
+ computed: {
+ desired_grid: desiredGrid,
+ desired_latch: desiredLatch,
+ minutes_since_last_on: minutesSinceLastOn !== null ? round1(minutesSinceLastOn) : null,
+ minutes_since_last_off: minutesSinceLastOff !== null ? round1(minutesSinceLastOff) : null,
+ min_on_elapsed: minOnElapsed,
+ cooldown_elapsed: cooldownElapsed,
+ ac_over_safe_limit: acOverSafeLimit,
+ evcs_seems_active: evcsSeemsActive,
+
+ evcs_grid_override: evcsGridOverride,
+ evcs_explicit_grid_request: evcsExplicitGridRequest,
+ evcs_priority_active: evcsPriorityActive,
+ evcs_night_grid_takeover_needed: evcsNightGridTakeoverNeeded,
+ evcs_source_mode: evcsSourceModeRaw || null,
+ evcs_user_strategy: userStrategyRaw || null,
+ evcs_surplus_state: evcsSurplusStateRaw || null,
+ evcs_vehicle_no_demand_semantic: evcsVehicleNoDemandSemantic
+ },
+
+ decision: {
+ state,
+ grid_action: gridAction,
+ latch_action: latchAction,
+ set_last_on_at: setLastOnAt,
+ set_last_off_at: setLastOffAt,
+ should_notify: notificationsEnabled && signatureChanged && notifyWanted,
+ signature,
+ signature_changed: signatureChanged,
+ reason,
+ short_reason: shortReason
+ },
+
+ ha_service_calls: {
+ helper_state: {
+ domain: 'input_text',
+ service: 'set_value',
+ data: {
+ entity_id: 'input_text.victron_grid_control_state',
+ value: state
+ }
+ },
+
+ helper_reason: {
+ domain: 'input_text',
+ service: 'set_value',
+ data: {
+ entity_id: 'input_text.victron_grid_control_reason',
+ value: shortReason || 'Aucune raison'
+ }
+ },
+
+ helper_signature: {
+ domain: 'input_text',
+ service: 'set_value',
+ data: {
+ entity_id: 'input_text.victron_grid_control_last_signature',
+ value: signature
+ }
+ },
+
+ helper_latch: {
+ domain: 'input_boolean',
+ service: desiredLatch === 'on' ? 'turn_on' : 'turn_off',
+ data: {
+ entity_id: 'input_boolean.victron_grid_hysteresis_latch'
+ }
+ },
+
+ helper_last_on_at: {
+ domain: 'input_datetime',
+ service: 'set_datetime',
+ data: {
+ entity_id: 'input_datetime.victron_grid_last_on_at',
+ datetime: now.stamp
+ }
+ },
+
+ helper_last_off_at: {
+ domain: 'input_datetime',
+ service: 'set_datetime',
+ data: {
+ entity_id: 'input_datetime.victron_grid_last_off_at',
+ datetime: now.stamp
+ }
+ },
+
+ grid: {
+ domain: 'switch',
+ service: desiredGrid === 'on' ? 'turn_on' : 'turn_off',
+ data: {
+ entity_id: 'switch.edf_general_ok_switch'
+ }
+ },
+
+ logbook: {
+ domain: 'logbook',
+ service: 'log',
+ data: {
+ name: 'Victron Grid Hysteresis',
+ message: `${state} | Grid=${desiredGrid} | Latch=${desiredLatch} | SOC=${round1(soc)}% | AC=${Math.round(houseAcPowerW)}W | ${shortReason}`,
+ entity_id: 'switch.edf_general_ok_switch'
+ }
+ },
+
+ mobile_notification: {
+ domain: cfg.mobileNotifyDomain,
+ service: cfg.mobileNotifyService,
+ data: {
+ title: notifyTitle,
+ message: notifyMessage,
+ data: {
+ push: {
+ 'interruption-level': 'time-sensitive',
+ sound: { name: 'default' }
+ },
+ ttl: 0,
+ priority: 'high'
+ }
+ }
+ }
+ }
+};
+
+return [{ json: result }];
diff --git a/modified/GRID_HYSTERESIS_PATCHED.json b/modified/GRID_HYSTERESIS_PATCHED.json
new file mode 100644
index 0000000..8c301e8
--- /dev/null
+++ b/modified/GRID_HYSTERESIS_PATCHED.json
@@ -0,0 +1,425 @@
+{
+ "updatedAt": "2026-04-24T22:31:48.117Z",
+ "createdAt": "2026-03-17T01:43:29.018Z",
+ "id": "7iIlu7LLTW7TZA8o",
+ "name": "Victron - Battery / Grid Hysteresis Fix V1",
+ "description": null,
+ "active": true,
+ "isArchived": false,
+ "nodes": [
+ {
+ "parameters": {
+ "rule": {
+ "interval": [
+ {
+ "field": "minutes",
+ "minutesInterval": 1
+ }
+ ]
+ }
+ },
+ "type": "n8n-nodes-base.scheduleTrigger",
+ "typeVersion": 1.3,
+ "position": [
+ 0,
+ 0
+ ],
+ "id": "0b9ad256-b51b-4921-b81b-5ba8a2113412",
+ "name": "Schedule Trigger"
+ },
+ {
+ "parameters": {
+ "method": "POST",
+ "url": "=http://10.10.10.17:8123/api/template\n",
+ "authentication": "genericCredentialType",
+ "genericAuthType": "httpBearerAuth",
+ "sendHeaders": true,
+ "headerParameters": {
+ "parameters": [
+ {
+ "name": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "name": "Accept",
+ "value": "text/plain"
+ }
+ ]
+ },
+ "sendBody": true,
+ "bodyParameters": {
+ "parameters": [
+ {
+ "name": "template",
+ "value": "{{ {\n \"soc_percent\": states(\"sensor.victron_battery_soc\"),\n \"grid_state\": states(\"switch.edf_general_ok_switch\"),\n \"pv_power_w\": states(\"sensor.victron_pv_power_system\"),\n\n \"house_ac_power_w\": states(\"sensor.victron_consumption_power_l1\"),\n \"grid_power_w\": states(\"sensor.victron_grid_power_l1\"),\n\n \"evcs_ac_power_w\": states(\"sensor.victron_evcs_ac_power\"),\n \"evcs_charging\": states(\"switch.victron_evcs_charging\"),\n \"evcs_status_raw\": states(\"sensor.victron_evcs_status_raw\"),\n\n \"hysteresis_enabled\": states(\"input_boolean.victron_grid_hysteresis_enabled\"),\n \"notifications_enabled\": states(\"input_boolean.victron_grid_hysteresis_notifications\"),\n \"grid_latch\": states(\"input_boolean.victron_grid_hysteresis_latch\"),\n\n \"grid_soc_on\": states(\"input_number.victron_grid_soc_on\"),\n \"grid_soc_off\": states(\"input_number.victron_grid_soc_off\"),\n \"grid_min_on_minutes\": states(\"input_number.victron_grid_min_on_minutes\"),\n \"grid_cooldown_minutes\": states(\"input_number.victron_grid_cooldown_minutes\"),\n \"ac_loads_max_safe_w\": states(\"input_number.victron_ac_loads_max_safe_w\"),\n\n \"last_on_at\": states(\"input_datetime.victron_grid_last_on_at\"),\n \"last_off_at\": states(\"input_datetime.victron_grid_last_off_at\"),\n \"last_signature\": states(\"input_text.victron_grid_control_last_signature\"),\n\n \"evcs_priority_active\": states(\"input_boolean.victron_evcs_priority_active\"),\n \"night_grid_takeover_needed\": states(\"input_boolean.victron_evcs_night_grid_takeover_needed\"),\n \"evcs_source_mode\": states(\"input_text.victron_evcs_source_mode\"),\n \"user_strategy\": states(\"input_select.victron_evcs_user_strategy\"),\n \"evcs_surplus_state\": states(\"input_text.victron_evcs_surplus_state\")\n} | tojson }}\n"
+ }
+ ]
+ },
+ "options": {}
+ },
+ "id": "577c8308-a790-44f8-a9dc-098811bd5e0b",
+ "name": "HA Snapshot Grid Hysteresis",
+ "type": "n8n-nodes-base.httpRequest",
+ "typeVersion": 4.2,
+ "position": [
+ 224,
+ 0
+ ],
+ "credentials": {
+ "httpBearerAuth": {
+ "id": "uv3sknYYigxBkaVN",
+ "name": "Bearer Auth account 2"
+ }
+ }
+ },
+ {
+ "parameters": {
+ "jsCode": "function num(v, fallback = NaN) {\n const n = Number.parseFloat(v);\n return Number.isFinite(n) ? n : fallback;\n}\n\nfunction low(v) {\n return (v ?? '').toString().trim().toLowerCase();\n}\n\nfunction parseMaybeJson(v) {\n if (typeof v !== 'string') return v;\n try {\n return JSON.parse(v);\n } catch {\n return v;\n }\n}\n\nfunction extractSnapshot(input) {\n if (typeof input === 'string') return parseMaybeJson(input);\n if (input?.data && typeof input.data === 'string') return parseMaybeJson(input.data);\n if (input?.body && typeof input.body === 'string') return parseMaybeJson(input.body);\n if (input?.data && typeof input.data === 'object') return input.data;\n if (input?.body && typeof input.body === 'object') return input.body;\n return input;\n}\n\nfunction round1(v) {\n return Math.round(v * 10) / 10;\n}\n\nfunction clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nfunction pickDefined(...values) {\n for (const v of values) {\n if (v === undefined || v === null) continue;\n if (typeof v === 'string' && v.trim() === '') continue;\n return v;\n }\n return undefined;\n}\n\nfunction isOnLike(v) {\n const s = low(v);\n return s === 'on' || s === 'true' || s === '1' || s === 'yes';\n}\n\nfunction getParisNowParts() {\n const fmt = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris',\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n fmt.formatToParts(new Date())\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const date = `${parts.year}-${parts.month}-${parts.day}`;\n const stamp = `${date} ${parts.hour}:${parts.minute}:${parts.second}`;\n\n return {\n year: Number(parts.year),\n month: Number(parts.month),\n day: Number(parts.day),\n hour: Number(parts.hour),\n minute: Number(parts.minute),\n second: Number(parts.second),\n date,\n stamp,\n };\n}\n\nfunction parseLocalStampToMs(s) {\n const text = (s ?? '').toString().trim();\n const m = text.match(/^(\\d{4})-(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2})$/);\n if (!m) return null;\n\n const y = Number(m[1]);\n const mo = Number(m[2]);\n const d = Number(m[3]);\n const h = Number(m[4]);\n const mi = Number(m[5]);\n const se = Number(m[6]);\n\n if (![y, mo, d, h, mi, se].every(Number.isFinite)) return null;\n\n return new Date(y, mo - 1, d, h, mi, se).getTime();\n}\n\nconst cfg = {\n mobileNotifyDomain: 'notify',\n mobileNotifyService: 'mobile_app_iphone_16_de_tt',\n};\n\nconst raw = $input.first().json;\nconst input = extractSnapshot(raw);\nconst now = getParisNowParts();\nconst nowMs = parseLocalStampToMs(now.stamp);\n\n// ======================================================\n// INPUTS PRINCIPAUX\n// ======================================================\nconst soc = clamp(num(input.soc_percent, 0), 0, 100);\nconst gridIsOn = low(input.grid_state || 'off') === 'on';\nconst pvPowerW = Math.max(0, num(input.pv_power_w, 0));\n\nconst houseAcPowerW = Math.max(0, num(input.house_ac_power_w, 0));\nconst gridPowerW = num(input.grid_power_w, 0);\n\nconst evcsAcPowerW = Math.max(0, num(input.evcs_ac_power_w, 0));\nconst evcsCharging = low(input.evcs_charging || 'off') === 'on';\nconst evcsStatusRaw = num(input.evcs_status_raw, NaN);\n\nconst hysteresisEnabled = low(input.hysteresis_enabled || 'off') === 'on';\nconst notificationsEnabled = low(input.notifications_enabled || 'off') === 'on';\nconst latchIsOn = low(input.grid_latch || 'off') === 'on';\n\nconst gridSocOn = clamp(num(input.grid_soc_on, 22), 0, 100);\nconst gridSocOff = clamp(num(input.grid_soc_off, 35), 0, 100);\nconst gridMinOnMinutes = Math.max(0, num(input.grid_min_on_minutes, 45));\nconst gridCooldownMinutes = Math.max(0, num(input.grid_cooldown_minutes, 15));\nconst acLoadsMaxSafeW = Math.max(0, num(input.ac_loads_max_safe_w, 7800));\n\nconst lastOnAt = (input.last_on_at ?? '').toString().trim();\nconst lastOffAt = (input.last_off_at ?? '').toString().trim();\nconst lastSignature = (input.last_signature ?? '').toString().trim();\n\nconst lastOnMs = parseLocalStampToMs(lastOnAt);\nconst lastOffMs = parseLocalStampToMs(lastOffAt);\n\nconst minutesSinceLastOn =\n lastOnMs !== null && nowMs !== null ? Math.max(0, (nowMs - lastOnMs) / 60000) : null;\n\nconst minutesSinceLastOff =\n lastOffMs !== null && nowMs !== null ? Math.max(0, (nowMs - lastOffMs) / 60000) : null;\n\nconst minOnElapsed =\n minutesSinceLastOn === null ? true : minutesSinceLastOn >= gridMinOnMinutes;\n\nconst cooldownElapsed =\n minutesSinceLastOff === null ? true : minutesSinceLastOff >= gridCooldownMinutes;\n\nconst socBand =\n soc <= gridSocOn ? 'low' :\n soc < gridSocOff ? 'mid' :\n 'high';\n\nconst evcsSeemsActive = evcsCharging || evcsAcPowerW > 300;\nconst acOverSafeLimit = houseAcPowerW >= acLoadsMaxSafeW;\n\n// ======================================================\n// EVCS OVERRIDE INPUTS\n// ======================================================\nconst evcsPriorityActive = isOnLike(\n pickDefined(\n input.evcs_priority_active,\n input.victron_evcs_priority_active,\n input['input_boolean.victron_evcs_priority_active']\n )\n);\n\nconst evcsNightGridTakeoverNeeded = isOnLike(\n pickDefined(\n input.night_grid_takeover_needed,\n input.victron_evcs_night_grid_takeover_needed,\n input['input_boolean.victron_evcs_night_grid_takeover_needed']\n )\n);\n\nconst evcsSourceModeRaw = (\n pickDefined(\n input.evcs_source_mode,\n input.victron_evcs_source_mode,\n input['input_text.victron_evcs_source_mode']\n ) ?? ''\n).toString().trim();\n\nconst evcsSourceMode = low(evcsSourceModeRaw);\n\nconst userStrategyRaw = (\n pickDefined(\n input.user_strategy,\n input.victron_evcs_user_strategy,\n input['input_select.victron_evcs_user_strategy']\n ) ?? ''\n).toString().trim();\n\nconst userStrategy = low(userStrategyRaw);\n\n// ======================================================\n// EVCS SEMANTIC STATE\n// Ici on ne détecte PAS la voiture pleine par seuils.\n// On consomme seulement l'état sémantique déjà produit\n// par le workflow EVCS.\n// ======================================================\nconst evcsSurplusStateRaw = (\n pickDefined(\n input.evcs_surplus_state,\n input.victron_evcs_surplus_state,\n input['input_text.victron_evcs_surplus_state']\n ) ?? ''\n).toString().trim();\n\nconst evcsSurplusState = low(evcsSurplusStateRaw);\n\nconst evcsVehicleNoDemandSemantic =\n evcsSurplusState === 'vehicle_full_or_paused' ||\n evcsSurplusState === 'vehicle_not_requesting_power' ||\n evcsSurplusState === 'vehicle_full' ||\n evcsSurplusState === 'charge_complete' ||\n evcsSurplusState.includes('full_or_paused') ||\n evcsSurplusState.includes('not_requesting_power') ||\n evcsSurplusState.includes('charge_complete') ||\n evcsSurplusState.includes('no_demand');\n\n// ======================================================\n// OVERRIDE EVCS VERS GRID\n// CORRECTION:\n// - evcsPriorityActive seul ne doit PLUS suffire à allumer EDF\n// - seuls les cas explicitement réseau peuvent forcer le grid\n// - ainsi \"Réseau HC midi = OFF\" redevient réellement respecté\n// ======================================================\nconst evcsExplicitGridRequest =\n userStrategy === 'force_grid_now' ||\n userStrategy === 'force_charge_now' ||\n evcsNightGridTakeoverNeeded ||\n evcsSourceMode === 'réseau forcé' ||\n evcsSourceMode === 'réseau hc autorisé' ||\n evcsSourceMode === 'grid_forced' ||\n evcsSourceMode === 'grid_hc_allowed';\n\nconst evcsGridOverride =\n !evcsVehicleNoDemandSemantic &&\n evcsExplicitGridRequest;\n\n// ======================================================\n// DÉCISION\n// ======================================================\nlet state = 'observe_only';\nlet reason = [];\nlet desiredGrid = gridIsOn ? 'on' : 'off';\nlet desiredLatch = latchIsOn ? 'on' : 'off';\n\n// ======================================================\n// FORCE_GRID + VEHICLE FULL IMMEDIATE CUT\n// FIX V2.5: Si force_grid_now actif et voiture pleine,\n// couper EDF immédiatement sans attendre minOnRuntime.\n// ======================================================\nif (\n userStrategy === 'force_grid_now' &&\n evcsVehicleNoDemandSemantic &&\n latchIsOn\n) {\n desiredGrid = 'off';\n desiredLatch = 'off';\n state = 'turn_off_force_grid_vehicle_full';\n reason.push('Force grid + voiture pleine : coupure immediate du reseau');\n}\n\n\n// ======================================================\n// CORE HYSTERESIS LOGIC\n// ======================================================\nif (evcsGridOverride) {\n desiredGrid = 'on';\n desiredLatch = 'on';\n\n if (!gridIsOn) {\n state = 'turn_on_evcs_override';\n reason.push('Override EVCS actif : activation EDF forcée');\n } else if (!latchIsOn) {\n state = 'adopt_existing_grid_on_evcs_override';\n reason.push('Override EVCS actif : EDF déjà ON, adoption par latch');\n } else {\n state = 'hold_on_evcs_override';\n reason.push('Override EVCS actif : maintien EDF ON');\n }\n\n if (userStrategy === 'force_grid_now') {\n reason.push('Cause override : force_grid_now');\n } else if (userStrategy === 'force_charge_now') {\n reason.push('Cause override : force_charge_now');\n } else if (evcsNightGridTakeoverNeeded) {\n reason.push('Cause override : night_grid_takeover_needed');\n } else {\n reason.push(`Cause override : source_mode=${evcsSourceModeRaw || 'n/a'}`);\n }\n\n} else if (!hysteresisEnabled) {\n state = 'hysteresis_disabled';\n desiredGrid = gridIsOn ? 'on' : 'off';\n desiredLatch = latchIsOn ? 'on' : 'off';\n reason.push('Automatisme hystérésis réseau désactivé');\n\n} else if (soc <= gridSocOn) {\n desiredGrid = 'on';\n desiredLatch = 'on';\n\n if (!cooldownElapsed && !gridIsOn) {\n state = 'turn_on_low_soc_cooldown_bypassed';\n reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : activation EDF malgré cooldown`);\n } else if (!gridIsOn) {\n state = 'turn_on_low_soc';\n reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : activation EDF`);\n } else if (!latchIsOn) {\n state = 'adopt_existing_grid_on_low_soc';\n reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : EDF déjà ON, prise de contrôle par latch`);\n } else {\n state = 'hold_on_low_soc';\n reason.push(`SOC encore bas ${round1(soc)}% : maintien EDF ON`);\n }\n\n} else if (gridIsOn && !latchIsOn) {\n if (soc < gridSocOff) {\n desiredGrid = 'on';\n desiredLatch = 'on';\n state = 'adopt_existing_grid_on';\n reason.push(`EDF déjà ON avec latch OFF et SOC ${round1(soc)}% < seuil OFF ${gridSocOff}% : adoption de l'état ON`);\n } else {\n desiredGrid = 'off';\n desiredLatch = 'off';\n state = 'turn_off_stray_grid_on';\n reason.push(`EDF ON avec latch OFF et SOC ${round1(soc)}% >= seuil OFF ${gridSocOff}% : coupure de reprise de contrôle`);\n }\n\n} else if (latchIsOn) {\n if (!minOnElapsed) {\n desiredGrid = 'on';\n desiredLatch = 'on';\n state = 'hold_on_min_runtime';\n reason.push(`EDF maintenu ON : durée mini ${gridMinOnMinutes} min non atteinte (${round1(minutesSinceLastOn || 0)} min)`);\n } else if (soc < gridSocOff) {\n desiredGrid = 'on';\n desiredLatch = 'on';\n state = 'hold_on_until_soc_off';\n reason.push(`EDF maintenu ON : SOC ${round1(soc)}% < seuil OFF ${gridSocOff}%`);\n } else {\n desiredGrid = 'off';\n desiredLatch = 'off';\n state = 'turn_off_recovered';\n reason.push(`SOC ${round1(soc)}% >= seuil OFF ${gridSocOff}% : coupure EDF`);\n }\n\n} else {\n desiredGrid = 'off';\n desiredLatch = 'off';\n state = 'observe_only';\n reason.push('Aucune action : hors zone d’hystérésis');\n}\n\n// ======================================================\n// COMPARE DESIRED VS CURRENT\n// ======================================================\nconst gridAction =\n desiredGrid === (gridIsOn ? 'on' : 'off')\n ? 'none'\n : desiredGrid === 'on'\n ? 'turn_on'\n : 'turn_off';\n\nconst latchAction =\n desiredLatch === (latchIsOn ? 'on' : 'off')\n ? 'none'\n : desiredLatch === 'on'\n ? 'turn_on'\n : 'turn_off';\n\n// ======================================================\n// TIMESTAMPS\n// ======================================================\nconst setLastOnAt =\n gridAction === 'turn_on' ||\n state === 'adopt_existing_grid_on' ||\n state === 'adopt_existing_grid_on_low_soc' ||\n state === 'adopt_existing_grid_on_evcs_override';\n\nconst setLastOffAt =\n gridAction === 'turn_off';\n\n// ======================================================\n// CONTEXT / ALERTS\n// ======================================================\nreason.push(`Grid actuel ${gridIsOn ? 'ON' : 'OFF'}`);\nreason.push(`Latch actuel ${latchIsOn ? 'ON' : 'OFF'}`);\nreason.push(`SOC ${round1(soc)}% | seuils ${gridSocOn}%/${gridSocOff}%`);\nreason.push(`PV ${Math.round(pvPowerW)}W`);\nreason.push(`Charge AC maison ${Math.round(houseAcPowerW)}W`);\nreason.push(`Puissance réseau ${Math.round(gridPowerW)}W`);\nreason.push(`EVCS ${evcsSeemsActive ? 'actif' : 'inactif'} | ${Math.round(evcsAcPowerW)}W | status_raw=${Number.isFinite(evcsStatusRaw) ? evcsStatusRaw : 'n/a'}`);\n\nif (minutesSinceLastOn !== null) {\n reason.push(`Dernier ON il y a ${round1(minutesSinceLastOn)} min`);\n}\nif (minutesSinceLastOff !== null) {\n reason.push(`Dernier OFF il y a ${round1(minutesSinceLastOff)} min`);\n}\n\nif (acOverSafeLimit) {\n reason.push(`⚠️ Charge AC maison ${Math.round(houseAcPowerW)}W >= limite sûre ${Math.round(acLoadsMaxSafeW)}W`);\n}\n\nreason.push(`EVCS override ${evcsGridOverride ? 'ON' : 'OFF'}`);\nreason.push(`EVCS explicit grid request ${evcsExplicitGridRequest ? 'ON' : 'OFF'}`);\nreason.push(`EVCS priority ${evcsPriorityActive ? 'ON' : 'OFF'}`);\nreason.push(`EVCS night takeover ${evcsNightGridTakeoverNeeded ? 'ON' : 'OFF'}`);\nreason.push(`EVCS source ${evcsSourceModeRaw || 'n/a'}`);\nreason.push(`EVCS strategy ${userStrategyRaw || 'n/a'}`);\nreason.push(`EVCS surplus state ${evcsSurplusStateRaw || 'n/a'}`);\nreason.push(`EVCS no-demand semantic ${evcsVehicleNoDemandSemantic ? 'ON' : 'OFF'}`);\n\n// ======================================================\n// NOTIFICATIONS\n// ======================================================\nlet notifyTitle = 'Victron Grid Hysteresis';\nlet notifyMessage = '';\nlet notifyWanted = false;\n\nif (\n state === 'turn_on_evcs_override' ||\n state === 'adopt_existing_grid_on_evcs_override' ||\n state === 'hold_on_evcs_override'\n) {\n notifyTitle = '🚗 EDF forcé par EVCS';\n notifyMessage = `Priorité EVCS active. EDF maintenu ON pour la recharge véhicule. Cause: ${userStrategyRaw || evcsSourceModeRaw || 'override EVCS'}.`;\n notifyWanted = true;\n\n} else if (state === 'turn_on_low_soc' || state === 'turn_on_low_soc_cooldown_bypassed') {\n notifyTitle = '⚡ EDF activé sur SOC bas';\n notifyMessage = `SOC ${round1(soc)}% <= ${gridSocOn}%. EDF activé et maintenu jusqu'à au moins ${gridSocOff}% (min ${gridMinOnMinutes} min).`;\n notifyWanted = true;\n\n} else if (state === 'adopt_existing_grid_on_low_soc' || state === 'adopt_existing_grid_on') {\n notifyTitle = '🔁 EDF ON repris par hystérésis';\n notifyMessage = `EDF était déjà ON. Le workflow reprend la main et le maintient jusqu'à ${gridSocOff}% (min ${gridMinOnMinutes} min).`;\n notifyWanted = true;\n\n} else if (state === 'turn_off_recovered') {\n notifyTitle = '✅ EDF coupé après recharge';\n notifyMessage = `SOC ${round1(soc)}% >= ${gridSocOff}%. Retour hors réseau autorisé.`;\n notifyWanted = true;\n\n} else if (state === 'turn_off_stray_grid_on') {\n notifyTitle = '✅ EDF coupé (reprise de contrôle)';\n notifyMessage = `EDF était ON sans latch. SOC ${round1(soc)}% >= ${gridSocOff}%, coupure réseau appliquée.`;\n notifyWanted = true;\n\n} else if (acOverSafeLimit && !gridIsOn) {\n notifyTitle = '🚨 Charge AC élevée hors réseau';\n notifyMessage = `Charge AC maison ${Math.round(houseAcPowerW)}W au-dessus de la limite sûre ${Math.round(acLoadsMaxSafeW)}W alors que EDF est OFF.`;\n notifyWanted = true;\n\n} else if (acOverSafeLimit && gridIsOn) {\n notifyTitle = '⚠️ Charge AC élevée';\n notifyMessage = `Charge AC maison ${Math.round(houseAcPowerW)}W proche/au-dessus de la limite sûre ${Math.round(acLoadsMaxSafeW)}W.`;\n notifyWanted = true;\n}\n\nconst shortReason = reason.join(' | ').slice(0, 255);\n\nconst signature = [\n now.date,\n state,\n `dg=${desiredGrid}`,\n `dl=${desiredLatch}`,\n `sb=${socBand}`,\n `ac=${acOverSafeLimit ? '1' : '0'}`,\n `ov=${evcsGridOverride ? '1' : '0'}`,\n `eg=${evcsExplicitGridRequest ? '1' : '0'}`,\n `ep=${evcsPriorityActive ? '1' : '0'}`,\n `es=${evcsSurplusState || 'na'}`\n].join('|');\n\nconst signatureChanged = signature !== lastSignature;\n\n// ======================================================\n// OUTPUT\n// ======================================================\nconst result = {\n timestamp_paris: now.stamp,\n\n inputs: {\n soc_percent: soc,\n grid_state: gridIsOn ? 'on' : 'off',\n pv_power_w: pvPowerW,\n house_ac_power_w: houseAcPowerW,\n grid_power_w: gridPowerW,\n evcs_ac_power_w: evcsAcPowerW,\n evcs_charging: evcsCharging,\n evcs_status_raw: Number.isFinite(evcsStatusRaw) ? evcsStatusRaw : null,\n\n hysteresis_enabled: hysteresisEnabled,\n notifications_enabled: notificationsEnabled,\n grid_latch: latchIsOn,\n grid_soc_on: gridSocOn,\n grid_soc_off: gridSocOff,\n grid_min_on_minutes: gridMinOnMinutes,\n grid_cooldown_minutes: gridCooldownMinutes,\n ac_loads_max_safe_w: acLoadsMaxSafeW,\n\n last_on_at: lastOnAt || null,\n last_off_at: lastOffAt || null,\n last_signature: lastSignature || null,\n\n evcs_priority_active: evcsPriorityActive,\n night_grid_takeover_needed: evcsNightGridTakeoverNeeded,\n evcs_source_mode: evcsSourceModeRaw || null,\n user_strategy: userStrategyRaw || null,\n evcs_surplus_state: evcsSurplusStateRaw || null\n },\n\n computed: {\n desired_grid: desiredGrid,\n desired_latch: desiredLatch,\n minutes_since_last_on: minutesSinceLastOn !== null ? round1(minutesSinceLastOn) : null,\n minutes_since_last_off: minutesSinceLastOff !== null ? round1(minutesSinceLastOff) : null,\n min_on_elapsed: minOnElapsed,\n cooldown_elapsed: cooldownElapsed,\n ac_over_safe_limit: acOverSafeLimit,\n evcs_seems_active: evcsSeemsActive,\n\n evcs_grid_override: evcsGridOverride,\n evcs_explicit_grid_request: evcsExplicitGridRequest,\n evcs_priority_active: evcsPriorityActive,\n evcs_night_grid_takeover_needed: evcsNightGridTakeoverNeeded,\n evcs_source_mode: evcsSourceModeRaw || null,\n evcs_user_strategy: userStrategyRaw || null,\n evcs_surplus_state: evcsSurplusStateRaw || null,\n evcs_vehicle_no_demand_semantic: evcsVehicleNoDemandSemantic\n },\n\n decision: {\n state,\n grid_action: gridAction,\n latch_action: latchAction,\n set_last_on_at: setLastOnAt,\n set_last_off_at: setLastOffAt,\n should_notify: notificationsEnabled && signatureChanged && notifyWanted,\n signature,\n signature_changed: signatureChanged,\n reason,\n short_reason: shortReason\n },\n\n ha_service_calls: {\n helper_state: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_grid_control_state',\n value: state\n }\n },\n\n helper_reason: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_grid_control_reason',\n value: shortReason || 'Aucune raison'\n }\n },\n\n helper_signature: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_grid_control_last_signature',\n value: signature\n }\n },\n\n helper_latch: {\n domain: 'input_boolean',\n service: desiredLatch === 'on' ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_grid_hysteresis_latch'\n }\n },\n\n helper_last_on_at: {\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_grid_last_on_at',\n datetime: now.stamp\n }\n },\n\n helper_last_off_at: {\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_grid_last_off_at',\n datetime: now.stamp\n }\n },\n\n grid: {\n domain: 'switch',\n service: desiredGrid === 'on' ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'switch.edf_general_ok_switch'\n }\n },\n\n logbook: {\n domain: 'logbook',\n service: 'log',\n data: {\n name: 'Victron Grid Hysteresis',\n message: `${state} | Grid=${desiredGrid} | Latch=${desiredLatch} | SOC=${round1(soc)}% | AC=${Math.round(houseAcPowerW)}W | ${shortReason}`,\n entity_id: 'switch.edf_general_ok_switch'\n }\n },\n\n mobile_notification: {\n domain: cfg.mobileNotifyDomain,\n service: cfg.mobileNotifyService,\n data: {\n title: notifyTitle,\n message: notifyMessage,\n data: {\n push: {\n 'interruption-level': 'time-sensitive',\n sound: { name: 'default' }\n },\n ttl: 0,\n priority: 'high'\n }\n }\n }\n }\n};\n\nreturn [{ json: result }];\n"
+ },
+ "type": "n8n-nodes-base.code",
+ "typeVersion": 2,
+ "position": [
+ 448,
+ 0
+ ],
+ "id": "dde6d41d-3212-4371-83b3-b58d0d70c21e",
+ "name": "Compute Grid Hysteresis"
+ },
+ {
+ "parameters": {
+ "jsCode": "const root = $input.first().json || {};\nconst decision = root.decision || {};\nconst calls = root.ha_service_calls || {};\nconst actions = [];\n\nfunction pushAction(action, call, enabled = true) {\n if (!enabled) return;\n if (!call) return;\n\n actions.push({\n json: {\n action,\n ...call\n }\n });\n}\n\n// ======================================================\n// HELPERS TOUJOURS MIS À JOUR\n// ======================================================\npushAction('helper_state', calls.helper_state, true);\npushAction('helper_reason', calls.helper_reason, true);\n\n// ======================================================\n// SIGNATURE SI CHANGEMENT\n// ======================================================\nif (decision.signature_changed) {\n pushAction('helper_signature', calls.helper_signature, true);\n}\n\n// ======================================================\n// SÉQUENCEMENT ACTIONS RÉSEAU / LATCH\n//\n// IMPORTANT :\n// - pour ALLUMER : latch ON d'abord, puis grid ON\n// - pour ÉTEINDRE : grid OFF d'abord, puis latch OFF\n// ======================================================\n\n// ---------- CAS 1 : LATCH ON avant GRID ON ----------\nif (decision.latch_action === 'turn_on') {\n pushAction('helper_latch', calls.helper_latch, true);\n}\n\nif (decision.grid_action === 'turn_on') {\n pushAction('grid', calls.grid, true);\n}\n\n// ---------- CAS 2 : GRID OFF avant LATCH OFF ----------\nif (decision.grid_action === 'turn_off') {\n pushAction('grid', calls.grid, true);\n}\n\nif (decision.latch_action === 'turn_off') {\n pushAction('helper_latch', calls.helper_latch, true);\n}\n\n// ======================================================\n// TIMESTAMPS SI NÉCESSAIRE\n// ======================================================\nif (decision.set_last_on_at) {\n pushAction('helper_last_on_at', calls.helper_last_on_at, true);\n}\n\nif (decision.set_last_off_at) {\n pushAction('helper_last_off_at', calls.helper_last_off_at, true);\n}\n\n// ======================================================\n// LOGBOOK SI CHANGEMENT DE SIGNATURE\n// ======================================================\nif (decision.signature_changed) {\n pushAction('logbook', calls.logbook, true);\n}\n\n// ======================================================\n// NOTIFICATION SI UTILE\n// ======================================================\nif (decision.should_notify) {\n pushAction('mobile_notification', calls.mobile_notification, true);\n}\n\nreturn actions;\n"
+ },
+ "type": "n8n-nodes-base.code",
+ "typeVersion": 2,
+ "position": [
+ 672,
+ 0
+ ],
+ "id": "d3728055-9f5b-4362-a275-247cc91c5f41",
+ "name": "Build Grid Hysteresis Actions"
+ },
+ {
+ "parameters": {
+ "method": "POST",
+ "url": "={{ 'http://10.10.10.17:8123/api/services/' + $json.domain + '/' + $json.service }}\n",
+ "authentication": "genericCredentialType",
+ "genericAuthType": "httpBearerAuth",
+ "sendHeaders": true,
+ "headerParameters": {
+ "parameters": [
+ {
+ "name": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "name": "accept",
+ "value": "application/json"
+ }
+ ]
+ },
+ "sendBody": true,
+ "specifyBody": "json",
+ "jsonBody": "={{ $json.data }}",
+ "options": {}
+ },
+ "id": "7d38f7cf-ad84-4481-a201-d21dd0dce321",
+ "name": "Execute HA Service",
+ "type": "n8n-nodes-base.httpRequest",
+ "typeVersion": 4.2,
+ "position": [
+ 896,
+ 0
+ ],
+ "alwaysOutputData": true,
+ "credentials": {
+ "httpBearerAuth": {
+ "id": "uv3sknYYigxBkaVN",
+ "name": "Bearer Auth account 2"
+ }
+ }
+ }
+ ],
+ "connections": {
+ "Schedule Trigger": {
+ "main": [
+ [
+ {
+ "node": "HA Snapshot Grid Hysteresis",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "HA Snapshot Grid Hysteresis": {
+ "main": [
+ [
+ {
+ "node": "Compute Grid Hysteresis",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Compute Grid Hysteresis": {
+ "main": [
+ [
+ {
+ "node": "Build Grid Hysteresis Actions",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Build Grid Hysteresis Actions": {
+ "main": [
+ [
+ {
+ "node": "Execute HA Service",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ }
+ },
+ "settings": {
+ "executionOrder": "v1",
+ "binaryMode": "separate",
+ "availableInMCP": false,
+ "timeSavedMode": "fixed",
+ "callerPolicy": "workflowsFromSameOwner"
+ },
+ "staticData": {
+ "node:Schedule Trigger": {
+ "recurrenceRules": []
+ }
+ },
+ "meta": null,
+ "pinData": {},
+ "versionId": "3c48c0fa-b573-4f58-b0c3-4fb1730dae2a",
+ "activeVersionId": "3c48c0fa-b573-4f58-b0c3-4fb1730dae2a",
+ "versionCounter": 68,
+ "triggerCount": 1,
+ "shared": [
+ {
+ "updatedAt": "2026-03-17T01:43:29.021Z",
+ "createdAt": "2026-03-17T01:43:29.021Z",
+ "role": "workflow:owner",
+ "workflowId": "7iIlu7LLTW7TZA8o",
+ "projectId": "1q2yd8IyTc8fb25V",
+ "project": {
+ "updatedAt": "2026-02-06T16:27:24.469Z",
+ "createdAt": "2026-02-06T16:23:40.444Z",
+ "id": "1q2yd8IyTc8fb25V",
+ "name": "thierry tallagrand ",
+ "type": "personal",
+ "icon": null,
+ "description": null,
+ "creatorId": "8b9e9552-cd74-4863-82a3-174b3b3befa2"
+ }
+ }
+ ],
+ "tags": [],
+ "activeVersion": {
+ "updatedAt": "2026-04-24T22:31:56.000Z",
+ "createdAt": "2026-04-24T22:31:48.121Z",
+ "versionId": "3c48c0fa-b573-4f58-b0c3-4fb1730dae2a",
+ "workflowId": "7iIlu7LLTW7TZA8o",
+ "nodes": [
+ {
+ "parameters": {
+ "rule": {
+ "interval": [
+ {
+ "field": "minutes",
+ "minutesInterval": 1
+ }
+ ]
+ }
+ },
+ "type": "n8n-nodes-base.scheduleTrigger",
+ "typeVersion": 1.3,
+ "position": [
+ 0,
+ 0
+ ],
+ "id": "0b9ad256-b51b-4921-b81b-5ba8a2113412",
+ "name": "Schedule Trigger"
+ },
+ {
+ "parameters": {
+ "method": "POST",
+ "url": "=http://10.10.10.17:8123/api/template\n",
+ "authentication": "genericCredentialType",
+ "genericAuthType": "httpBearerAuth",
+ "sendHeaders": true,
+ "headerParameters": {
+ "parameters": [
+ {
+ "name": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "name": "Accept",
+ "value": "text/plain"
+ }
+ ]
+ },
+ "sendBody": true,
+ "bodyParameters": {
+ "parameters": [
+ {
+ "name": "template",
+ "value": "{{ {\n \"soc_percent\": states(\"sensor.victron_battery_soc\"),\n \"grid_state\": states(\"switch.edf_general_ok_switch\"),\n \"pv_power_w\": states(\"sensor.victron_pv_power_system\"),\n\n \"house_ac_power_w\": states(\"sensor.victron_consumption_power_l1\"),\n \"grid_power_w\": states(\"sensor.victron_grid_power_l1\"),\n\n \"evcs_ac_power_w\": states(\"sensor.victron_evcs_ac_power\"),\n \"evcs_charging\": states(\"switch.victron_evcs_charging\"),\n \"evcs_status_raw\": states(\"sensor.victron_evcs_status_raw\"),\n\n \"hysteresis_enabled\": states(\"input_boolean.victron_grid_hysteresis_enabled\"),\n \"notifications_enabled\": states(\"input_boolean.victron_grid_hysteresis_notifications\"),\n \"grid_latch\": states(\"input_boolean.victron_grid_hysteresis_latch\"),\n\n \"grid_soc_on\": states(\"input_number.victron_grid_soc_on\"),\n \"grid_soc_off\": states(\"input_number.victron_grid_soc_off\"),\n \"grid_min_on_minutes\": states(\"input_number.victron_grid_min_on_minutes\"),\n \"grid_cooldown_minutes\": states(\"input_number.victron_grid_cooldown_minutes\"),\n \"ac_loads_max_safe_w\": states(\"input_number.victron_ac_loads_max_safe_w\"),\n\n \"last_on_at\": states(\"input_datetime.victron_grid_last_on_at\"),\n \"last_off_at\": states(\"input_datetime.victron_grid_last_off_at\"),\n \"last_signature\": states(\"input_text.victron_grid_control_last_signature\"),\n\n \"evcs_priority_active\": states(\"input_boolean.victron_evcs_priority_active\"),\n \"night_grid_takeover_needed\": states(\"input_boolean.victron_evcs_night_grid_takeover_needed\"),\n \"evcs_source_mode\": states(\"input_text.victron_evcs_source_mode\"),\n \"user_strategy\": states(\"input_select.victron_evcs_user_strategy\")\n} | tojson }}\n"
+ }
+ ]
+ },
+ "options": {}
+ },
+ "id": "577c8308-a790-44f8-a9dc-098811bd5e0b",
+ "name": "HA Snapshot Grid Hysteresis",
+ "type": "n8n-nodes-base.httpRequest",
+ "typeVersion": 4.2,
+ "position": [
+ 224,
+ 0
+ ],
+ "credentials": {
+ "httpBearerAuth": {
+ "id": "uv3sknYYigxBkaVN",
+ "name": "Bearer Auth account 2"
+ }
+ }
+ },
+ {
+ "parameters": {
+ "jsCode": "function num(v, fallback = NaN) {\n const n = Number.parseFloat(v);\n return Number.isFinite(n) ? n : fallback;\n}\n\nfunction low(v) {\n return (v ?? '').toString().trim().toLowerCase();\n}\n\nfunction parseMaybeJson(v) {\n if (typeof v !== 'string') return v;\n try {\n return JSON.parse(v);\n } catch {\n return v;\n }\n}\n\nfunction extractSnapshot(input) {\n if (typeof input === 'string') return parseMaybeJson(input);\n if (input?.data && typeof input.data === 'string') return parseMaybeJson(input.data);\n if (input?.body && typeof input.body === 'string') return parseMaybeJson(input.body);\n if (input?.data && typeof input.data === 'object') return input.data;\n if (input?.body && typeof input.body === 'object') return input.body;\n return input;\n}\n\nfunction round1(v) {\n return Math.round(v * 10) / 10;\n}\n\nfunction clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nfunction pickDefined(...values) {\n for (const v of values) {\n if (v === undefined || v === null) continue;\n if (typeof v === 'string' && v.trim() === '') continue;\n return v;\n }\n return undefined;\n}\n\nfunction isOnLike(v) {\n const s = low(v);\n return s === 'on' || s === 'true' || s === '1' || s === 'yes';\n}\n\nfunction getParisNowParts() {\n const fmt = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris',\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n fmt.formatToParts(new Date())\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const date = `${parts.year}-${parts.month}-${parts.day}`;\n const stamp = `${date} ${parts.hour}:${parts.minute}:${parts.second}`;\n\n return {\n year: Number(parts.year),\n month: Number(parts.month),\n day: Number(parts.day),\n hour: Number(parts.hour),\n minute: Number(parts.minute),\n second: Number(parts.second),\n date,\n stamp,\n };\n}\n\nfunction parseLocalStampToMs(s) {\n const text = (s ?? '').toString().trim();\n const m = text.match(/^(\\d{4})-(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2})$/);\n if (!m) return null;\n\n const y = Number(m[1]);\n const mo = Number(m[2]);\n const d = Number(m[3]);\n const h = Number(m[4]);\n const mi = Number(m[5]);\n const se = Number(m[6]);\n\n if (![y, mo, d, h, mi, se].every(Number.isFinite)) return null;\n\n return new Date(y, mo - 1, d, h, mi, se).getTime();\n}\n\nconst cfg = {\n mobileNotifyDomain: 'notify',\n mobileNotifyService: 'mobile_app_iphone_16_de_tt',\n};\n\nconst raw = $input.first().json;\nconst input = extractSnapshot(raw);\nconst now = getParisNowParts();\nconst nowMs = parseLocalStampToMs(now.stamp);\n\n// ======================================================\n// INPUTS PRINCIPAUX\n// ======================================================\nconst soc = clamp(num(input.soc_percent, 0), 0, 100);\nconst gridIsOn = low(input.grid_state || 'off') === 'on';\nconst pvPowerW = Math.max(0, num(input.pv_power_w, 0));\n\nconst houseAcPowerW = Math.max(0, num(input.house_ac_power_w, 0));\nconst gridPowerW = num(input.grid_power_w, 0);\n\nconst evcsAcPowerW = Math.max(0, num(input.evcs_ac_power_w, 0));\nconst evcsCharging = low(input.evcs_charging || 'off') === 'on';\nconst evcsStatusRaw = num(input.evcs_status_raw, NaN);\n\nconst hysteresisEnabled = low(input.hysteresis_enabled || 'off') === 'on';\nconst notificationsEnabled = low(input.notifications_enabled || 'off') === 'on';\nconst latchIsOn = low(input.grid_latch || 'off') === 'on';\n\nconst gridSocOn = clamp(num(input.grid_soc_on, 22), 0, 100);\nconst gridSocOff = clamp(num(input.grid_soc_off, 35), 0, 100);\nconst gridMinOnMinutes = Math.max(0, num(input.grid_min_on_minutes, 45));\nconst gridCooldownMinutes = Math.max(0, num(input.grid_cooldown_minutes, 15));\nconst acLoadsMaxSafeW = Math.max(0, num(input.ac_loads_max_safe_w, 7800));\n\nconst lastOnAt = (input.last_on_at ?? '').toString().trim();\nconst lastOffAt = (input.last_off_at ?? '').toString().trim();\nconst lastSignature = (input.last_signature ?? '').toString().trim();\n\nconst lastOnMs = parseLocalStampToMs(lastOnAt);\nconst lastOffMs = parseLocalStampToMs(lastOffAt);\n\nconst minutesSinceLastOn =\n lastOnMs !== null && nowMs !== null ? Math.max(0, (nowMs - lastOnMs) / 60000) : null;\n\nconst minutesSinceLastOff =\n lastOffMs !== null && nowMs !== null ? Math.max(0, (nowMs - lastOffMs) / 60000) : null;\n\nconst minOnElapsed =\n minutesSinceLastOn === null ? true : minutesSinceLastOn >= gridMinOnMinutes;\n\nconst cooldownElapsed =\n minutesSinceLastOff === null ? true : minutesSinceLastOff >= gridCooldownMinutes;\n\nconst socBand =\n soc <= gridSocOn ? 'low' :\n soc < gridSocOff ? 'mid' :\n 'high';\n\nconst evcsSeemsActive = evcsCharging || evcsAcPowerW > 300;\nconst acOverSafeLimit = houseAcPowerW >= acLoadsMaxSafeW;\n\n// ======================================================\n// EVCS OVERRIDE INPUTS\n// ======================================================\nconst evcsPriorityActive = isOnLike(\n pickDefined(\n input.evcs_priority_active,\n input.victron_evcs_priority_active,\n input['input_boolean.victron_evcs_priority_active']\n )\n);\n\nconst evcsNightGridTakeoverNeeded = isOnLike(\n pickDefined(\n input.night_grid_takeover_needed,\n input.victron_evcs_night_grid_takeover_needed,\n input['input_boolean.victron_evcs_night_grid_takeover_needed']\n )\n);\n\nconst evcsSourceModeRaw = (\n pickDefined(\n input.evcs_source_mode,\n input.victron_evcs_source_mode,\n input['input_text.victron_evcs_source_mode']\n ) ?? ''\n).toString().trim();\n\nconst evcsSourceMode = low(evcsSourceModeRaw);\n\nconst userStrategyRaw = (\n pickDefined(\n input.user_strategy,\n input.victron_evcs_user_strategy,\n input['input_select.victron_evcs_user_strategy']\n ) ?? ''\n).toString().trim();\n\nconst userStrategy = low(userStrategyRaw);\n\n// ======================================================\n// EVCS SEMANTIC STATE\n// Ici on ne détecte PAS la voiture pleine par seuils.\n// On consomme seulement l'état sémantique déjà produit\n// par le workflow EVCS.\n// ======================================================\nconst evcsSurplusStateRaw = (\n pickDefined(\n input.evcs_surplus_state,\n input.victron_evcs_surplus_state,\n input['input_text.victron_evcs_surplus_state']\n ) ?? ''\n).toString().trim();\n\nconst evcsSurplusState = low(evcsSurplusStateRaw);\n\nconst evcsVehicleNoDemandSemantic =\n evcsSurplusState === 'vehicle_full_or_paused' ||\n evcsSurplusState === 'vehicle_not_requesting_power' ||\n evcsSurplusState === 'vehicle_full' ||\n evcsSurplusState === 'charge_complete' ||\n evcsSurplusState.includes('full_or_paused') ||\n evcsSurplusState.includes('not_requesting_power') ||\n evcsSurplusState.includes('charge_complete') ||\n evcsSurplusState.includes('no_demand');\n\n// ======================================================\n// OVERRIDE EVCS VERS GRID\n// CORRECTION:\n// - evcsPriorityActive seul ne doit PLUS suffire à allumer EDF\n// - seuls les cas explicitement réseau peuvent forcer le grid\n// - ainsi \"Réseau HC midi = OFF\" redevient réellement respecté\n// ======================================================\nconst evcsExplicitGridRequest =\n userStrategy === 'force_grid_now' ||\n userStrategy === 'force_charge_now' ||\n evcsNightGridTakeoverNeeded ||\n evcsSourceMode === 'réseau forcé' ||\n evcsSourceMode === 'réseau hc autorisé' ||\n evcsSourceMode === 'grid_forced' ||\n evcsSourceMode === 'grid_hc_allowed';\n\nconst evcsGridOverride =\n !evcsVehicleNoDemandSemantic &&\n evcsExplicitGridRequest;\n\n// ======================================================\n// DÉCISION\n// ======================================================\nlet state = 'observe_only';\nlet reason = [];\nlet desiredGrid = gridIsOn ? 'on' : 'off';\nlet desiredLatch = latchIsOn ? 'on' : 'off';\n\n// ======================================================\n// CORE HYSTERESIS LOGIC\n// ======================================================\nif (evcsGridOverride) {\n desiredGrid = 'on';\n desiredLatch = 'on';\n\n if (!gridIsOn) {\n state = 'turn_on_evcs_override';\n reason.push('Override EVCS actif : activation EDF forcée');\n } else if (!latchIsOn) {\n state = 'adopt_existing_grid_on_evcs_override';\n reason.push('Override EVCS actif : EDF déjà ON, adoption par latch');\n } else {\n state = 'hold_on_evcs_override';\n reason.push('Override EVCS actif : maintien EDF ON');\n }\n\n if (userStrategy === 'force_grid_now') {\n reason.push('Cause override : force_grid_now');\n } else if (userStrategy === 'force_charge_now') {\n reason.push('Cause override : force_charge_now');\n } else if (evcsNightGridTakeoverNeeded) {\n reason.push('Cause override : night_grid_takeover_needed');\n } else {\n reason.push(`Cause override : source_mode=${evcsSourceModeRaw || 'n/a'}`);\n }\n\n} else if (!hysteresisEnabled) {\n state = 'hysteresis_disabled';\n desiredGrid = gridIsOn ? 'on' : 'off';\n desiredLatch = latchIsOn ? 'on' : 'off';\n reason.push('Automatisme hystérésis réseau désactivé');\n\n} else if (soc <= gridSocOn) {\n desiredGrid = 'on';\n desiredLatch = 'on';\n\n if (!cooldownElapsed && !gridIsOn) {\n state = 'turn_on_low_soc_cooldown_bypassed';\n reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : activation EDF malgré cooldown`);\n } else if (!gridIsOn) {\n state = 'turn_on_low_soc';\n reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : activation EDF`);\n } else if (!latchIsOn) {\n state = 'adopt_existing_grid_on_low_soc';\n reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : EDF déjà ON, prise de contrôle par latch`);\n } else {\n state = 'hold_on_low_soc';\n reason.push(`SOC encore bas ${round1(soc)}% : maintien EDF ON`);\n }\n\n} else if (gridIsOn && !latchIsOn) {\n if (soc < gridSocOff) {\n desiredGrid = 'on';\n desiredLatch = 'on';\n state = 'adopt_existing_grid_on';\n reason.push(`EDF déjà ON avec latch OFF et SOC ${round1(soc)}% < seuil OFF ${gridSocOff}% : adoption de l'état ON`);\n } else {\n desiredGrid = 'off';\n desiredLatch = 'off';\n state = 'turn_off_stray_grid_on';\n reason.push(`EDF ON avec latch OFF et SOC ${round1(soc)}% >= seuil OFF ${gridSocOff}% : coupure de reprise de contrôle`);\n }\n\n} else if (latchIsOn) {\n if (!minOnElapsed) {\n desiredGrid = 'on';\n desiredLatch = 'on';\n state = 'hold_on_min_runtime';\n reason.push(`EDF maintenu ON : durée mini ${gridMinOnMinutes} min non atteinte (${round1(minutesSinceLastOn || 0)} min)`);\n } else if (soc < gridSocOff) {\n desiredGrid = 'on';\n desiredLatch = 'on';\n state = 'hold_on_until_soc_off';\n reason.push(`EDF maintenu ON : SOC ${round1(soc)}% < seuil OFF ${gridSocOff}%`);\n } else {\n desiredGrid = 'off';\n desiredLatch = 'off';\n state = 'turn_off_recovered';\n reason.push(`SOC ${round1(soc)}% >= seuil OFF ${gridSocOff}% : coupure EDF`);\n }\n\n} else {\n desiredGrid = 'off';\n desiredLatch = 'off';\n state = 'observe_only';\n reason.push('Aucune action : hors zone d’hystérésis');\n}\n\n// ======================================================\n// COMPARE DESIRED VS CURRENT\n// ======================================================\nconst gridAction =\n desiredGrid === (gridIsOn ? 'on' : 'off')\n ? 'none'\n : desiredGrid === 'on'\n ? 'turn_on'\n : 'turn_off';\n\nconst latchAction =\n desiredLatch === (latchIsOn ? 'on' : 'off')\n ? 'none'\n : desiredLatch === 'on'\n ? 'turn_on'\n : 'turn_off';\n\n// ======================================================\n// TIMESTAMPS\n// ======================================================\nconst setLastOnAt =\n gridAction === 'turn_on' ||\n state === 'adopt_existing_grid_on' ||\n state === 'adopt_existing_grid_on_low_soc' ||\n state === 'adopt_existing_grid_on_evcs_override';\n\nconst setLastOffAt =\n gridAction === 'turn_off';\n\n// ======================================================\n// CONTEXT / ALERTS\n// ======================================================\nreason.push(`Grid actuel ${gridIsOn ? 'ON' : 'OFF'}`);\nreason.push(`Latch actuel ${latchIsOn ? 'ON' : 'OFF'}`);\nreason.push(`SOC ${round1(soc)}% | seuils ${gridSocOn}%/${gridSocOff}%`);\nreason.push(`PV ${Math.round(pvPowerW)}W`);\nreason.push(`Charge AC maison ${Math.round(houseAcPowerW)}W`);\nreason.push(`Puissance réseau ${Math.round(gridPowerW)}W`);\nreason.push(`EVCS ${evcsSeemsActive ? 'actif' : 'inactif'} | ${Math.round(evcsAcPowerW)}W | status_raw=${Number.isFinite(evcsStatusRaw) ? evcsStatusRaw : 'n/a'}`);\n\nif (minutesSinceLastOn !== null) {\n reason.push(`Dernier ON il y a ${round1(minutesSinceLastOn)} min`);\n}\nif (minutesSinceLastOff !== null) {\n reason.push(`Dernier OFF il y a ${round1(minutesSinceLastOff)} min`);\n}\n\nif (acOverSafeLimit) {\n reason.push(`⚠️ Charge AC maison ${Math.round(houseAcPowerW)}W >= limite sûre ${Math.round(acLoadsMaxSafeW)}W`);\n}\n\nreason.push(`EVCS override ${evcsGridOverride ? 'ON' : 'OFF'}`);\nreason.push(`EVCS explicit grid request ${evcsExplicitGridRequest ? 'ON' : 'OFF'}`);\nreason.push(`EVCS priority ${evcsPriorityActive ? 'ON' : 'OFF'}`);\nreason.push(`EVCS night takeover ${evcsNightGridTakeoverNeeded ? 'ON' : 'OFF'}`);\nreason.push(`EVCS source ${evcsSourceModeRaw || 'n/a'}`);\nreason.push(`EVCS strategy ${userStrategyRaw || 'n/a'}`);\nreason.push(`EVCS surplus state ${evcsSurplusStateRaw || 'n/a'}`);\nreason.push(`EVCS no-demand semantic ${evcsVehicleNoDemandSemantic ? 'ON' : 'OFF'}`);\n\n// ======================================================\n// NOTIFICATIONS\n// ======================================================\nlet notifyTitle = 'Victron Grid Hysteresis';\nlet notifyMessage = '';\nlet notifyWanted = false;\n\nif (\n state === 'turn_on_evcs_override' ||\n state === 'adopt_existing_grid_on_evcs_override' ||\n state === 'hold_on_evcs_override'\n) {\n notifyTitle = '🚗 EDF forcé par EVCS';\n notifyMessage = `Priorité EVCS active. EDF maintenu ON pour la recharge véhicule. Cause: ${userStrategyRaw || evcsSourceModeRaw || 'override EVCS'}.`;\n notifyWanted = true;\n\n} else if (state === 'turn_on_low_soc' || state === 'turn_on_low_soc_cooldown_bypassed') {\n notifyTitle = '⚡ EDF activé sur SOC bas';\n notifyMessage = `SOC ${round1(soc)}% <= ${gridSocOn}%. EDF activé et maintenu jusqu'à au moins ${gridSocOff}% (min ${gridMinOnMinutes} min).`;\n notifyWanted = true;\n\n} else if (state === 'adopt_existing_grid_on_low_soc' || state === 'adopt_existing_grid_on') {\n notifyTitle = '🔁 EDF ON repris par hystérésis';\n notifyMessage = `EDF était déjà ON. Le workflow reprend la main et le maintient jusqu'à ${gridSocOff}% (min ${gridMinOnMinutes} min).`;\n notifyWanted = true;\n\n} else if (state === 'turn_off_recovered') {\n notifyTitle = '✅ EDF coupé après recharge';\n notifyMessage = `SOC ${round1(soc)}% >= ${gridSocOff}%. Retour hors réseau autorisé.`;\n notifyWanted = true;\n\n} else if (state === 'turn_off_stray_grid_on') {\n notifyTitle = '✅ EDF coupé (reprise de contrôle)';\n notifyMessage = `EDF était ON sans latch. SOC ${round1(soc)}% >= ${gridSocOff}%, coupure réseau appliquée.`;\n notifyWanted = true;\n\n} else if (acOverSafeLimit && !gridIsOn) {\n notifyTitle = '🚨 Charge AC élevée hors réseau';\n notifyMessage = `Charge AC maison ${Math.round(houseAcPowerW)}W au-dessus de la limite sûre ${Math.round(acLoadsMaxSafeW)}W alors que EDF est OFF.`;\n notifyWanted = true;\n\n} else if (acOverSafeLimit && gridIsOn) {\n notifyTitle = '⚠️ Charge AC élevée';\n notifyMessage = `Charge AC maison ${Math.round(houseAcPowerW)}W proche/au-dessus de la limite sûre ${Math.round(acLoadsMaxSafeW)}W.`;\n notifyWanted = true;\n}\n\nconst shortReason = reason.join(' | ').slice(0, 255);\n\nconst signature = [\n now.date,\n state,\n `dg=${desiredGrid}`,\n `dl=${desiredLatch}`,\n `sb=${socBand}`,\n `ac=${acOverSafeLimit ? '1' : '0'}`,\n `ov=${evcsGridOverride ? '1' : '0'}`,\n `eg=${evcsExplicitGridRequest ? '1' : '0'}`,\n `ep=${evcsPriorityActive ? '1' : '0'}`,\n `es=${evcsSurplusState || 'na'}`\n].join('|');\n\nconst signatureChanged = signature !== lastSignature;\n\n// ======================================================\n// OUTPUT\n// ======================================================\nconst result = {\n timestamp_paris: now.stamp,\n\n inputs: {\n soc_percent: soc,\n grid_state: gridIsOn ? 'on' : 'off',\n pv_power_w: pvPowerW,\n house_ac_power_w: houseAcPowerW,\n grid_power_w: gridPowerW,\n evcs_ac_power_w: evcsAcPowerW,\n evcs_charging: evcsCharging,\n evcs_status_raw: Number.isFinite(evcsStatusRaw) ? evcsStatusRaw : null,\n\n hysteresis_enabled: hysteresisEnabled,\n notifications_enabled: notificationsEnabled,\n grid_latch: latchIsOn,\n grid_soc_on: gridSocOn,\n grid_soc_off: gridSocOff,\n grid_min_on_minutes: gridMinOnMinutes,\n grid_cooldown_minutes: gridCooldownMinutes,\n ac_loads_max_safe_w: acLoadsMaxSafeW,\n\n last_on_at: lastOnAt || null,\n last_off_at: lastOffAt || null,\n last_signature: lastSignature || null,\n\n evcs_priority_active: evcsPriorityActive,\n night_grid_takeover_needed: evcsNightGridTakeoverNeeded,\n evcs_source_mode: evcsSourceModeRaw || null,\n user_strategy: userStrategyRaw || null,\n evcs_surplus_state: evcsSurplusStateRaw || null\n },\n\n computed: {\n desired_grid: desiredGrid,\n desired_latch: desiredLatch,\n minutes_since_last_on: minutesSinceLastOn !== null ? round1(minutesSinceLastOn) : null,\n minutes_since_last_off: minutesSinceLastOff !== null ? round1(minutesSinceLastOff) : null,\n min_on_elapsed: minOnElapsed,\n cooldown_elapsed: cooldownElapsed,\n ac_over_safe_limit: acOverSafeLimit,\n evcs_seems_active: evcsSeemsActive,\n\n evcs_grid_override: evcsGridOverride,\n evcs_explicit_grid_request: evcsExplicitGridRequest,\n evcs_priority_active: evcsPriorityActive,\n evcs_night_grid_takeover_needed: evcsNightGridTakeoverNeeded,\n evcs_source_mode: evcsSourceModeRaw || null,\n evcs_user_strategy: userStrategyRaw || null,\n evcs_surplus_state: evcsSurplusStateRaw || null,\n evcs_vehicle_no_demand_semantic: evcsVehicleNoDemandSemantic\n },\n\n decision: {\n state,\n grid_action: gridAction,\n latch_action: latchAction,\n set_last_on_at: setLastOnAt,\n set_last_off_at: setLastOffAt,\n should_notify: notificationsEnabled && signatureChanged && notifyWanted,\n signature,\n signature_changed: signatureChanged,\n reason,\n short_reason: shortReason\n },\n\n ha_service_calls: {\n helper_state: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_grid_control_state',\n value: state\n }\n },\n\n helper_reason: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_grid_control_reason',\n value: shortReason || 'Aucune raison'\n }\n },\n\n helper_signature: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_grid_control_last_signature',\n value: signature\n }\n },\n\n helper_latch: {\n domain: 'input_boolean',\n service: desiredLatch === 'on' ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_grid_hysteresis_latch'\n }\n },\n\n helper_last_on_at: {\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_grid_last_on_at',\n datetime: now.stamp\n }\n },\n\n helper_last_off_at: {\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_grid_last_off_at',\n datetime: now.stamp\n }\n },\n\n grid: {\n domain: 'switch',\n service: desiredGrid === 'on' ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'switch.edf_general_ok_switch'\n }\n },\n\n logbook: {\n domain: 'logbook',\n service: 'log',\n data: {\n name: 'Victron Grid Hysteresis',\n message: `${state} | Grid=${desiredGrid} | Latch=${desiredLatch} | SOC=${round1(soc)}% | AC=${Math.round(houseAcPowerW)}W | ${shortReason}`,\n entity_id: 'switch.edf_general_ok_switch'\n }\n },\n\n mobile_notification: {\n domain: cfg.mobileNotifyDomain,\n service: cfg.mobileNotifyService,\n data: {\n title: notifyTitle,\n message: notifyMessage,\n data: {\n push: {\n 'interruption-level': 'time-sensitive',\n sound: { name: 'default' }\n },\n ttl: 0,\n priority: 'high'\n }\n }\n }\n }\n};\n\nreturn [{ json: result }];\n"
+ },
+ "type": "n8n-nodes-base.code",
+ "typeVersion": 2,
+ "position": [
+ 448,
+ 0
+ ],
+ "id": "dde6d41d-3212-4371-83b3-b58d0d70c21e",
+ "name": "Compute Grid Hysteresis"
+ },
+ {
+ "parameters": {
+ "jsCode": "const root = $input.first().json || {};\nconst decision = root.decision || {};\nconst calls = root.ha_service_calls || {};\nconst actions = [];\n\nfunction pushAction(action, call, enabled = true) {\n if (!enabled) return;\n if (!call) return;\n\n actions.push({\n json: {\n action,\n ...call\n }\n });\n}\n\n// ======================================================\n// HELPERS TOUJOURS MIS À JOUR\n// ======================================================\npushAction('helper_state', calls.helper_state, true);\npushAction('helper_reason', calls.helper_reason, true);\n\n// ======================================================\n// SIGNATURE SI CHANGEMENT\n// ======================================================\nif (decision.signature_changed) {\n pushAction('helper_signature', calls.helper_signature, true);\n}\n\n// ======================================================\n// SÉQUENCEMENT ACTIONS RÉSEAU / LATCH\n//\n// IMPORTANT :\n// - pour ALLUMER : latch ON d'abord, puis grid ON\n// - pour ÉTEINDRE : grid OFF d'abord, puis latch OFF\n// ======================================================\n\n// ---------- CAS 1 : LATCH ON avant GRID ON ----------\nif (decision.latch_action === 'turn_on') {\n pushAction('helper_latch', calls.helper_latch, true);\n}\n\nif (decision.grid_action === 'turn_on') {\n pushAction('grid', calls.grid, true);\n}\n\n// ---------- CAS 2 : GRID OFF avant LATCH OFF ----------\nif (decision.grid_action === 'turn_off') {\n pushAction('grid', calls.grid, true);\n}\n\nif (decision.latch_action === 'turn_off') {\n pushAction('helper_latch', calls.helper_latch, true);\n}\n\n// ======================================================\n// TIMESTAMPS SI NÉCESSAIRE\n// ======================================================\nif (decision.set_last_on_at) {\n pushAction('helper_last_on_at', calls.helper_last_on_at, true);\n}\n\nif (decision.set_last_off_at) {\n pushAction('helper_last_off_at', calls.helper_last_off_at, true);\n}\n\n// ======================================================\n// LOGBOOK SI CHANGEMENT DE SIGNATURE\n// ======================================================\nif (decision.signature_changed) {\n pushAction('logbook', calls.logbook, true);\n}\n\n// ======================================================\n// NOTIFICATION SI UTILE\n// ======================================================\nif (decision.should_notify) {\n pushAction('mobile_notification', calls.mobile_notification, true);\n}\n\nreturn actions;\n"
+ },
+ "type": "n8n-nodes-base.code",
+ "typeVersion": 2,
+ "position": [
+ 672,
+ 0
+ ],
+ "id": "d3728055-9f5b-4362-a275-247cc91c5f41",
+ "name": "Build Grid Hysteresis Actions"
+ },
+ {
+ "parameters": {
+ "method": "POST",
+ "url": "={{ 'http://10.10.10.17:8123/api/services/' + $json.domain + '/' + $json.service }}\n",
+ "authentication": "genericCredentialType",
+ "genericAuthType": "httpBearerAuth",
+ "sendHeaders": true,
+ "headerParameters": {
+ "parameters": [
+ {
+ "name": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "name": "accept",
+ "value": "application/json"
+ }
+ ]
+ },
+ "sendBody": true,
+ "specifyBody": "json",
+ "jsonBody": "={{ $json.data }}",
+ "options": {}
+ },
+ "id": "7d38f7cf-ad84-4481-a201-d21dd0dce321",
+ "name": "Execute HA Service",
+ "type": "n8n-nodes-base.httpRequest",
+ "typeVersion": 4.2,
+ "position": [
+ 896,
+ 0
+ ],
+ "alwaysOutputData": true,
+ "credentials": {
+ "httpBearerAuth": {
+ "id": "uv3sknYYigxBkaVN",
+ "name": "Bearer Auth account 2"
+ }
+ }
+ }
+ ],
+ "connections": {
+ "Schedule Trigger": {
+ "main": [
+ [
+ {
+ "node": "HA Snapshot Grid Hysteresis",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "HA Snapshot Grid Hysteresis": {
+ "main": [
+ [
+ {
+ "node": "Compute Grid Hysteresis",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Compute Grid Hysteresis": {
+ "main": [
+ [
+ {
+ "node": "Build Grid Hysteresis Actions",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Build Grid Hysteresis Actions": {
+ "main": [
+ [
+ {
+ "node": "Execute HA Service",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ }
+ },
+ "authors": "thierry tallagrand",
+ "name": "Version 3c48c0fa",
+ "description": "",
+ "autosaved": true,
+ "workflowPublishHistory": [
+ {
+ "createdAt": "2026-04-24T22:31:56.784Z",
+ "id": 76,
+ "workflowId": "7iIlu7LLTW7TZA8o",
+ "versionId": "3c48c0fa-b573-4f58-b0c3-4fb1730dae2a",
+ "event": "activated",
+ "userId": "8b9e9552-cd74-4863-82a3-174b3b3befa2"
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/modified/GRID_SNAPSHOT_patched.js b/modified/GRID_SNAPSHOT_patched.js
new file mode 100644
index 0000000..0ead770
--- /dev/null
+++ b/modified/GRID_SNAPSHOT_patched.js
@@ -0,0 +1,32 @@
+{{ {
+ "soc_percent": states("sensor.victron_battery_soc"),
+ "grid_state": states("switch.edf_general_ok_switch"),
+ "pv_power_w": states("sensor.victron_pv_power_system"),
+
+ "house_ac_power_w": states("sensor.victron_consumption_power_l1"),
+ "grid_power_w": states("sensor.victron_grid_power_l1"),
+
+ "evcs_ac_power_w": states("sensor.victron_evcs_ac_power"),
+ "evcs_charging": states("switch.victron_evcs_charging"),
+ "evcs_status_raw": states("sensor.victron_evcs_status_raw"),
+
+ "hysteresis_enabled": states("input_boolean.victron_grid_hysteresis_enabled"),
+ "notifications_enabled": states("input_boolean.victron_grid_hysteresis_notifications"),
+ "grid_latch": states("input_boolean.victron_grid_hysteresis_latch"),
+
+ "grid_soc_on": states("input_number.victron_grid_soc_on"),
+ "grid_soc_off": states("input_number.victron_grid_soc_off"),
+ "grid_min_on_minutes": states("input_number.victron_grid_min_on_minutes"),
+ "grid_cooldown_minutes": states("input_number.victron_grid_cooldown_minutes"),
+ "ac_loads_max_safe_w": states("input_number.victron_ac_loads_max_safe_w"),
+
+ "last_on_at": states("input_datetime.victron_grid_last_on_at"),
+ "last_off_at": states("input_datetime.victron_grid_last_off_at"),
+ "last_signature": states("input_text.victron_grid_control_last_signature"),
+
+ "evcs_priority_active": states("input_boolean.victron_evcs_priority_active"),
+ "night_grid_takeover_needed": states("input_boolean.victron_evcs_night_grid_takeover_needed"),
+ "evcs_source_mode": states("input_text.victron_evcs_source_mode"),
+ "user_strategy": states("input_select.victron_evcs_user_strategy"),
+ "evcs_surplus_state": states("input_text.victron_evcs_surplus_state")
+} | tojson }}
diff --git a/modified/WINDOWS_SOURCEV2_patched.js b/modified/WINDOWS_SOURCEV2_patched.js
new file mode 100644
index 0000000..1b7ee5e
--- /dev/null
+++ b/modified/WINDOWS_SOURCEV2_patched.js
@@ -0,0 +1,579 @@
+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;
+
+const safeEvcsByDedicatedCapW = Math.max(0, cfg.evcsDedicatedMaxW - cfg.systemReserveW);
+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, 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 nightSessionNeedsCapture =
+ nightSessionActive &&
+ (
+ !Number.isFinite(history.nightSessionStartSocPct) ||
+ !history.nightSessionStartAt
+ );
+
+const nightSessionNeedsClear =
+ !nightSessionActive &&
+ (
+ Number.isFinite(history.nightSessionStartSocPct) ||
+ !!history.nightSessionStartAt
+ );
+
+const batteryHardFloorPct =
+ params.evcsNightBatteryHardFloorPct;
+
+const batteryMorningTargetPct =
+ parsed.todayRemainingKwh >= params.evcsNightStrongForecastKwh
+ ? params.evcsNightTargetStrongForecastPct
+ : parsed.todayRemainingKwh >= 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
+ );
+
+const batteryAssistAllowed =
+ nightSessionActive &&
+ parsed.soc > batteryHardFloorPct &&
+ parsed.soc > gridTakeoverTriggerPct &&
+ !batteryRecoveryUrgent;
+
+const nightGridTakeoverNeeded =
+ nightSessionActive &&
+ (
+ batteryBelowHardFloor ||
+ batteryAtOrBelowGridTakeoverTrigger ||
+ batteryRecoveryUrgent
+ );
+
+const nightPreferredSourceMode =
+ !nightSessionActive
+ ? 'blocked'
+ : batteryAssistAllowed
+ ? 'battery_assist_night'
+ : (
+ batteryBelowHardFloor ||
+ batteryBelowMorningTarget ||
+ batteryRecoveryUrgent
+ )
+ ? 'grid_forced'
+ : 'grid_hc_allowed';
+
+// =====================================================
+// 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 cloudDebtOkForPotential = params.cloudDebtPrevKwh <= params.cloudDebtMaxKwh;
+const batteryOkForPotential = parsed.batteryCurrentA >= -Math.max(1, params.batteryNeutralBandA);
+
+const isHighBatteryForPotential =
+ 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';
+ }
+
+ // FIX V2.5: En auto_solar_plus, hors fenêtre, batteries hautes (>=85%),
+ // et peu de charge maison : permettre solar_potential pour provoquer le PV.
+ else if (userStrategy === 'auto_solar_plus' && parsed.soc >= 85 && houseNonEvcsW < 1500) {
+ sourceMode = 'solar_potential';
+ }
+
+ 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,
+ isHighBatteryForPotential,
+ strongSolarSignal,
+ effectivePvStartMinW,
+ profile,
+ operatingMode,
+ sourceMode,
+ },
+
+ nightEvcsPreview: {
+ nightSessionActive,
+ nightSessionNeedsCapture,
+ nightSessionNeedsClear,
+ nightSessionStartSocPct,
+ nightSessionStartAt,
+
+ batteryHardFloorPct,
+ batteryMorningTargetPct,
+ maxBatteryDischargePoints,
+ gridTakeoverTriggerPct,
+
+ batteryBelowHardFloor,
+ batteryBelowMorningTarget,
+ batteryAtOrBelowGridTakeoverTrigger,
+
+ batteryKwhMissingToTarget,
+ batteryRecoveryMinutesNeeded,
+ batteryRecoveryUrgent,
+
+ batteryAssistAllowed,
+ nightGridTakeoverNeeded,
+ nightPreferredSourceMode,
+ }
+ }
+}];