La regle du 05/09 ferme quand la maison depasse 80 % du plafond de decharge du parc — elle a mordu au bon instant hier soir, a -97,4 A pour 100 A autorises. Mais elle relachait sur un seul echantillon sous 50 %, et la soiree offrait quatorze occasions de le faire. Le troisieme pic est tombe deux minutes avant l'ouverture reelle : decale de cinq minutes, elle refermait, et chaque aller-retour du contacteur coute 50 a 150 Wh. Les accalmies mesurees sont franchement bimodales — treize de 5 min au plus pendant la cuisson, une de 201 min apres. Le relachement se mesure donc en duree, et la duree vient du bilan energetique : 20 min tant que la batterie est condamnee a toucher son plancher avant les heures creuses (l'energie reseau du soir est due de toute facon, la tenir ne change que son heure), 6 min des qu'elle passerait la nuit seule (chaque kWh du soir deplacerait alors un kWh d'heures creuses vers des heures pleines). Premiere lecture du bilan par le workflow d'hysteresis. Le dwell se compte sur la maison, jamais sur le contacteur : l'horodatage est ecrit qu'il soit ouvert ou ferme, sans quoi la decision detruirait sa propre cause. Et parce que la tension batterie s'est tue 27 minutes hier soir en pleine cuisson, le plafond retombe sur la tension nominale du parc, et a defaut l'horodatage — un fait persiste — tient la protection jusqu'au bout du dwell. Golden-hysteresis 11 -> 20. Six mutations, chacune tombant sur un jeu different. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
460 lines
75 KiB
JSON
460 lines
75 KiB
JSON
{
|
||
"updatedAt": "2026-07-09T21:45:52.945Z",
|
||
"createdAt": "2026-03-17T01:43:29.018Z",
|
||
"id": "7iIlu7LLTW7TZA8o",
|
||
"name": "Victron - Battery / Grid Hysteresis Fix V1",
|
||
"description": null,
|
||
"active": true,
|
||
"isArchived": false,
|
||
"nodes": [
|
||
{
|
||
"parameters": {
|
||
"rule": {
|
||
"interval": [
|
||
{
|
||
"field": "minutes",
|
||
"minutesInterval": 1
|
||
}
|
||
]
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.scheduleTrigger",
|
||
"typeVersion": 1.3,
|
||
"position": [
|
||
0,
|
||
0
|
||
],
|
||
"id": "0b9ad256-b51b-4921-b81b-5ba8a2113412",
|
||
"name": "Schedule Trigger"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "POST",
|
||
"url": "=http://10.10.10.17:8123/api/template\n",
|
||
"authentication": "genericCredentialType",
|
||
"genericAuthType": "httpBearerAuth",
|
||
"sendHeaders": true,
|
||
"headerParameters": {
|
||
"parameters": [
|
||
{
|
||
"name": "Content-Type",
|
||
"value": "application/json"
|
||
},
|
||
{
|
||
"name": "Accept",
|
||
"value": "text/plain"
|
||
}
|
||
]
|
||
},
|
||
"sendBody": true,
|
||
"bodyParameters": {
|
||
"parameters": [
|
||
{
|
||
"name": "template",
|
||
"value": "{{ {\n \"soc_percent\": states(\"sensor.victron_battery_soc\"),\n \"grid_state\": states(\"switch.edf_general_ok_switch\"),\n \"pv_power_w\": states(\"sensor.victron_pv_power_system\"),\n\n \"house_ac_power_w\": states(\"sensor.victron_consumption_power_l1\"),\n \"battery_max_discharge_a\": states(\"sensor.airtria_8_us2000c_maximum_allowed_discharge_current\"),\n \"battery_voltage_v\": states(\"sensor.victron_battery_voltage\"),\n \"charge_high_last_at\": states(\"input_datetime.victron_grid_charge_high_last_at\"),\n \"charge_dwell_long_min\": states(\"input_number.victron_grid_charge_dwell_long_min\"),\n \"charge_dwell_court_min\": states(\"input_number.victron_grid_charge_dwell_court_min\"),\n \"balance_required_soc\": states(\"input_number.evcs_balance_required_soc\"),\n \"balance_status\": states(\"input_text.evcs_balance_status\"),\n \"balance_last_run\": states(\"input_text.evcs_balance_last_run\"),\n \"grid_power_w\": states(\"sensor.victron_grid_power_l1\"),\n\n \"evcs_ac_power_w\": states(\"sensor.victron_evcs_ac_power\"),\n \"evcs_charging\": states(\"switch.victron_evcs_charging\"),\n \"evcs_status_raw\": states(\"sensor.victron_evcs_status_raw\"),\n\n \"hysteresis_enabled\": states(\"input_boolean.victron_grid_hysteresis_enabled\"),\n \"notifications_enabled\": states(\"input_boolean.victron_grid_hysteresis_notifications\"),\n \"grid_latch\": states(\"input_boolean.victron_grid_hysteresis_latch\"),\n\n \"grid_soc_on\": states(\"input_number.victron_grid_soc_on\"),\n \"grid_soc_off\": states(\"input_number.victron_grid_soc_off\"),\n \"grid_min_on_minutes\": states(\"input_number.victron_grid_min_on_minutes\"),\n \"grid_cooldown_minutes\": states(\"input_number.victron_grid_cooldown_minutes\"),\n \"ac_loads_max_safe_w\": states(\"input_number.victron_ac_loads_max_safe_w\"),\n\n \"last_on_at\": states(\"input_datetime.victron_grid_last_on_at\"),\n \"last_off_at\": states(\"input_datetime.victron_grid_last_off_at\"),\n \"last_signature\": states(\"input_text.victron_grid_control_last_signature\"),\n\n \"evcs_priority_active\": states(\"input_boolean.victron_evcs_priority_active\"),\n \"night_grid_takeover_needed\": states(\"input_boolean.victron_evcs_night_grid_takeover_needed\"),\n \"evcs_source_mode\": states(\"input_text.victron_evcs_source_mode\"),\n \"user_strategy\": states(\"input_select.victron_evcs_strategy\"),\n \"evcs_surplus_state\": states(\"input_text.victron_evcs_surplus_state\")\n} | tojson }}\n"
|
||
}
|
||
]
|
||
},
|
||
"options": {}
|
||
},
|
||
"id": "577c8308-a790-44f8-a9dc-098811bd5e0b",
|
||
"name": "HA Snapshot Grid Hysteresis",
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.2,
|
||
"position": [
|
||
224,
|
||
0
|
||
],
|
||
"credentials": {
|
||
"httpBearerAuth": {
|
||
"id": "uv3sknYYigxBkaVN",
|
||
"name": "Bearer Auth account 2"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "function num(v, fallback = NaN) {\n const n = Number.parseFloat(v);\n return Number.isFinite(n) ? n : fallback;\n}\n\nfunction low(v) {\n return (v ?? '').toString().trim().toLowerCase();\n}\n\nfunction parseMaybeJson(v) {\n if (typeof v !== 'string') return v;\n try {\n return JSON.parse(v);\n } catch {\n return v;\n }\n}\n\nfunction extractSnapshot(input) {\n if (typeof input === 'string') return parseMaybeJson(input);\n if (input?.data && typeof input.data === 'string') return parseMaybeJson(input.data);\n if (input?.body && typeof input.body === 'string') return parseMaybeJson(input.body);\n if (input?.data && typeof input.data === 'object') return input.data;\n if (input?.body && typeof input.body === 'object') return input.body;\n return input;\n}\n\nfunction round1(v) {\n return Math.round(v * 10) / 10;\n}\n\nfunction clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nfunction pickDefined(...values) {\n for (const v of values) {\n if (v === undefined || v === null) continue;\n if (typeof v === 'string' && v.trim() === '') continue;\n return v;\n }\n return undefined;\n}\n\nfunction isOnLike(v) {\n const s = low(v);\n return s === 'on' || s === 'true' || s === '1' || s === 'yes';\n}\n\nfunction getParisNowParts() {\n const fmt = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris',\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n fmt.formatToParts(new Date())\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const date = `${parts.year}-${parts.month}-${parts.day}`;\n const stamp = `${date} ${parts.hour}:${parts.minute}:${parts.second}`;\n\n return {\n year: Number(parts.year),\n month: Number(parts.month),\n day: Number(parts.day),\n hour: Number(parts.hour),\n minute: Number(parts.minute),\n second: Number(parts.second),\n date,\n stamp,\n };\n}\n\nfunction parseLocalStampToMs(s) {\n const text = (s ?? '').toString().trim();\n const m = text.match(/^(\\d{4})-(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2})$/);\n if (!m) return null;\n\n const y = Number(m[1]);\n const mo = Number(m[2]);\n const d = Number(m[3]);\n const h = Number(m[4]);\n const mi = Number(m[5]);\n const se = Number(m[6]);\n\n if (![y, mo, d, h, mi, se].every(Number.isFinite)) return null;\n\n return new Date(y, mo - 1, d, h, mi, se).getTime();\n}\n\n// Le bilan energetique publie son horodatage au format \"DD/MM HH:mm\" (le meme\n// que l'advisor). On le ramene au format de parseLocalStampToMs plutot que\n// d'ajouter un second systeme de dates : toute l'arithmetique de ce node se\n// fait sur une heure murale de Paris, la conversion se compense d'elle-meme.\nfunction parseRunStampToMs(s, year, nowMsRef) {\n const m = (s ?? '').toString().trim().match(/^(\\d{2})\\/(\\d{2}) (\\d{2}):(\\d{2})$/);\n if (!m) return null;\n\n const build = (y) => parseLocalStampToMs(`${y}-${m[2]}-${m[1]} ${m[3]}:${m[4]}:00`);\n let ts = build(year);\n // Passage d'annee : un horodatage qui tomberait plus d'un jour dans le futur\n // vient forcement de l'annee precedente.\n if (ts !== null && nowMsRef !== null && ts - nowMsRef > 24 * 3600 * 1000) ts = build(year - 1);\n return ts;\n}\n\nconst cfg = {\n mobileNotifyDomain: 'notify',\n mobileNotifyService: 'mobile_app_iphone_16_de_tt',\n};\n\nconst raw = $input.first().json;\nconst input = extractSnapshot(raw);\nconst now = getParisNowParts();\nconst nowMs = parseLocalStampToMs(now.stamp);\n\n// ======================================================\n// INPUTS PRINCIPAUX\n// ======================================================\nconst soc = clamp(num(input.soc_percent, 0), 0, 100);\nconst gridStateRaw = low(input.grid_state || 'off');\nconst gridIsOn = gridStateRaw === 'on';\nconst gridSwitchUnavailable = gridStateRaw === 'unavailable' || gridStateRaw === 'unknown' || gridStateRaw === 'none';\nconst pvPowerW = Math.max(0, num(input.pv_power_w, 0));\n\nconst houseAcPowerW = Math.max(0, num(input.house_ac_power_w, 0));\nconst gridPowerW = num(input.grid_power_w, 0);\n\nconst evcsAcPowerW = Math.max(0, num(input.evcs_ac_power_w, 0));\nconst evcsCharging = low(input.evcs_charging || 'off') === 'on';\nconst evcsStatusRaw = num(input.evcs_status_raw, NaN);\n\nconst hysteresisEnabled = low(input.hysteresis_enabled || 'off') === 'on';\nconst notificationsEnabled = low(input.notifications_enabled || 'off') === 'on';\nconst latchIsOn = low(input.grid_latch || 'off') === 'on';\n\nconst gridSocOn = clamp(num(input.grid_soc_on, 22), 0, 100);\nconst gridSocOff = clamp(num(input.grid_soc_off, 35), 0, 100);\nconst gridMinOnMinutes = Math.max(0, num(input.grid_min_on_minutes, 45));\nconst gridCooldownMinutes = Math.max(0, num(input.grid_cooldown_minutes, 15));\nconst acLoadsMaxSafeW = Math.max(0, num(input.ac_loads_max_safe_w, 7800));\n\nconst lastOnAt = (input.last_on_at ?? '').toString().trim();\nconst lastOffAt = (input.last_off_at ?? '').toString().trim();\nconst lastSignature = (input.last_signature ?? '').toString().trim();\n\nconst lastOnMs = parseLocalStampToMs(lastOnAt);\nconst lastOffMs = parseLocalStampToMs(lastOffAt);\n\nconst minutesSinceLastOn =\n lastOnMs !== null && nowMs !== null ? Math.max(0, (nowMs - lastOnMs) / 60000) : null;\n\nconst minutesSinceLastOff =\n lastOffMs !== null && nowMs !== null ? Math.max(0, (nowMs - lastOffMs) / 60000) : null;\n\nconst minOnElapsed =\n minutesSinceLastOn === null ? true : minutesSinceLastOn >= gridMinOnMinutes;\n\nconst cooldownElapsed =\n minutesSinceLastOff === null ? true : minutesSinceLastOff >= gridCooldownMinutes;\n\nconst socBand =\n soc <= gridSocOn ? 'low' :\n soc < gridSocOff ? 'mid' :\n 'high';\n\nconst evcsSeemsActive = evcsCharging || evcsAcPowerW > 300;\nconst acOverSafeLimit = houseAcPowerW >= acLoadsMaxSafeW;\n\n// ======================================================\n// CHARGE MAISON AU-DELA DE CE QUE LA BATTERIE SAIT DONNER\n// ======================================================\n// Le plafond vient du BMS lui-meme et suit le nombre de modules : rien n'est\n// code en dur, retirer ou ajouter un module deplace le seuil tout seul.\nconst plafondDechargeA = num(input.battery_max_discharge_a, 0);\nconst tensionParcV = num(input.battery_voltage_v, 0);\n// La tension a disparu 27 min d'affilee le 05/09 alors que le plafond en\n// AMPERES restait lisible. Sur toute la soiree la tension mesuree n'est pas\n// sortie de 48,85-51,5 V : un nominal a 51 V vaut au pire 5 % d'erreur sur le\n// seuil, contre 100 % si la regle s'eteint.\nconst TENSION_PARC_NOMINALE_V = 51;\nconst plafondDechargeW = plafondDechargeA > 0\n ? plafondDechargeA * (tensionParcV > 0 ? tensionParcV : TENSION_PARC_NOMINALE_V)\n : 0;\n// Bande large et volontairement dissymetrique : on ferme a 80 % du plafond, on\n// ne relache qu'en dessous de 50 %. Les deux seuils portent sur la charge\n// maison, grandeur que le contacteur ne modifie pas.\nconst seuilFermetureW = plafondDechargeW * 0.80;\nconst seuilRelachementW = plafondDechargeW * 0.50;\nconst chargeAuDessusDuParc = plafondDechargeW > 0 && houseAcPowerW >= seuilFermetureW;\nconst chargeSousSeuilBas = plafondDechargeW > 0 && houseAcPowerW < seuilRelachementW;\n\n// ======================================================\n// LE RELACHEMENT SE MESURE EN DUREE (06/09/2026)\n// ======================================================\n// Soiree du 05/09, seuil de relachement 2550 W, pas d'une minute :\n// pendant la cuisson 20:33->21:37 : 13 accalmies, la plus longue 5 min\n// apres la cuisson 21:40->01:00 : 1 accalmie, 201 min\n// Un creux de 20 minutes n'existe pas tant que ca cuisine. Le relachement\n// instantane offrait donc 13 occasions de rendre le reseau au milieu du\n// service, chacune facturee 50 a 150 Wh de transitoire.\n//\n// L'horodatage porte la derniere charge maison HAUTE et il est ecrit que le\n// contacteur soit ouvert ou ferme : il suit une grandeur que la decision ne\n// modifie pas. Absent ou illisible => dwell repute ecoule => comportement\n// d'avant ce correctif, jamais une supposition.\nconst chargeHighLastAt = (input.charge_high_last_at ?? '').toString().trim();\nconst chargeHighLastMs = parseLocalStampToMs(chargeHighLastAt);\nconst minutesDepuisChargeHaute =\n chargeHighLastMs !== null && nowMs !== null\n ? Math.max(0, (nowMs - chargeHighLastMs) / 60000)\n : null;\n\n// La duree vient du BILAN. Tant que la batterie touchera son plancher avant\n// l'ouverture des heures creuses, l'energie reseau du soir est due de toute\n// facon : la tenir plus longtemps ne change que l'heure d'achat. Des qu'elle\n// passerait la nuit seule, chaque kWh du soir deplace un kWh d'heures creuses\n// vers des heures pleines, et il faut relacher vite.\n//\n// ⚠️ required_soc vise le CROISEMENT PV, plus lointain que 01:24 : le test est\n// necessaire mais pas suffisant. L'erreur est unilaterale (long dwell une nuit\n// ou le court aurait suffi, ~0,4 kWh deplaces, parc menage) — direction voulue.\nconst BALANCE_MAX_AGE_MIN = 30; // six cycles du bilan, qui tourne aux 5 min\nconst balanceStatusOk = low(input.balance_status) === 'ok';\nconst balanceRequiredSocPct = num(input.balance_required_soc, NaN);\nconst balanceRunMs = parseRunStampToMs(input.balance_last_run, now.year, nowMs);\nconst balanceAgeMin =\n balanceRunMs !== null && nowMs !== null ? (nowMs - balanceRunMs) / 60000 : null;\nconst balanceUsable =\n balanceStatusOk &&\n Number.isFinite(balanceRequiredSocPct) &&\n balanceAgeMin !== null &&\n balanceAgeMin <= BALANCE_MAX_AGE_MIN &&\n balanceAgeMin >= -5; // horodatage dans le futur : rejete\nconst reserveDejaCondamnee = balanceUsable && soc < balanceRequiredSocPct;\n\nconst dwellMinutes = reserveDejaCondamnee\n ? Math.max(0, num(input.charge_dwell_long_min, 20))\n : Math.max(0, num(input.charge_dwell_court_min, 6));\n\nconst dwellEcoule =\n minutesDepuisChargeHaute === null ? true : minutesDepuisChargeHaute >= dwellMinutes;\n\n// Le reseau reste du tant que la charge n'est pas retombee ET restee basse.\n// Si le plafond devient illisible EN COURS de protection, l'horodatage de\n// charge haute reste, lui, un fait PERSISTE : on tient jusqu'a la fin du dwell\n// plutot que de rendre le reseau sur un capteur muet. La memoire ne survit pas\n// au dwell — l'horodatage n'etant ecrit que quand le plafond est lisible, un\n// capteur mort depuis des heures rend bien la regle inerte.\nconst protectionRecente = !dwellEcoule;\nconst chargeEncoreElevee = plafondDechargeW > 0\n ? !(chargeSousSeuilBas && dwellEcoule)\n : protectionRecente;\n\n// ======================================================\n// EVCS OVERRIDE INPUTS\n// ======================================================\nconst evcsPriorityActive = isOnLike(\n pickDefined(\n input.evcs_priority_active,\n input.victron_evcs_priority_active,\n input['input_boolean.victron_evcs_priority_active']\n )\n);\n\nconst evcsNightGridTakeoverNeeded = isOnLike(\n pickDefined(\n input.night_grid_takeover_needed,\n input.victron_evcs_night_grid_takeover_needed,\n input['input_boolean.victron_evcs_night_grid_takeover_needed']\n )\n);\n\nconst evcsSourceModeRaw = (\n pickDefined(\n input.evcs_source_mode,\n input.victron_evcs_source_mode,\n input['input_text.victron_evcs_source_mode']\n ) ?? ''\n).toString().trim();\n\nconst evcsSourceMode = low(evcsSourceModeRaw);\n\nconst userStrategyRaw = (\n pickDefined(\n input.user_strategy,\n input.victron_evcs_user_strategy,\n input['input_select.victron_evcs_user_strategy']\n ) ?? ''\n).toString().trim();\n\nconst userStrategy = low(userStrategyRaw);\n\n// ======================================================\n// EVCS SEMANTIC STATE\n// Ici on ne détecte PAS la voiture pleine par seuils.\n// On consomme seulement l'état sémantique déjà produit\n// par le workflow EVCS.\n// ======================================================\nconst evcsSurplusStateRaw = (\n pickDefined(\n input.evcs_surplus_state,\n input.victron_evcs_surplus_state,\n input['input_text.victron_evcs_surplus_state']\n ) ?? ''\n).toString().trim();\n\nconst evcsSurplusState = low(evcsSurplusStateRaw);\n\nconst evcsVehicleNoDemandSemantic =\n evcsSurplusState === 'vehicle_full_or_paused' ||\n evcsSurplusState === 'vehicle_not_requesting_power' ||\n evcsSurplusState === 'vehicle_full' ||\n evcsSurplusState === 'charge_complete' ||\n evcsSurplusState.includes('full_or_paused') ||\n evcsSurplusState.includes('not_requesting_power') ||\n evcsSurplusState.includes('charge_complete') ||\n evcsSurplusState.includes('no_demand');\n\n// ======================================================\n// OVERRIDE EVCS VERS GRID\n// CORRECTION:\n// - evcsPriorityActive seul ne doit PLUS suffire à allumer EDF\n// - seuls les cas explicitement réseau peuvent forcer le grid\n// - ainsi \"Réseau HC midi = OFF\" redevient réellement respecté\n// ======================================================\nconst evcsExplicitGridRequest =\n userStrategy === 'force_grid_now' ||\n userStrategy === 'force_charge_now' ||\n evcsNightGridTakeoverNeeded ||\n evcsSourceMode === 'réseau forcé' ||\n evcsSourceMode === 'réseau hc autorisé' ||\n evcsSourceMode === 'grid_forced' ||\n evcsSourceMode === 'grid_hc_allowed';\n\nconst evcsGridOverride =\n !evcsVehicleNoDemandSemantic &&\n evcsExplicitGridRequest;\n\n// ======================================================\n// DÉCISION\n// ======================================================\nlet state = 'observe_only';\nlet reason = [];\nlet desiredGrid = gridIsOn ? 'on' : 'off';\nlet desiredLatch = latchIsOn ? 'on' : 'off';\n\n// ======================================================\n// FORCE_GRID + VEHICLE FULL IMMEDIATE CUT\n// FIX V2.5: Si force_grid_now actif et voiture pleine,\n// couper EDF immédiatement sans attendre minOnRuntime.\n// ======================================================\nlet forcedCut = false;\nif (\n userStrategy === 'force_grid_now' &&\n evcsVehicleNoDemandSemantic &&\n latchIsOn &&\n !chargeEncoreElevee // la maison prime\n) {\n desiredGrid = 'off';\n desiredLatch = 'off';\n state = 'turn_off_force_grid_vehicle_full';\n reason.push('Force grid + voiture pleine : coupure immediate du reseau');\n forcedCut = true;\n}\n\n// ======================================================\n// FIX C (08/08/2026): VEHICULE DEBRANCHE -> COUPURE IMMEDIATE\n// Incident nuit 07-08/08 : apres debranchement du vehicule, EDF restait\n// maintenu ON 25 min (hold_on_min_runtime) alors que plus rien ne le\n// justifiait (SOC >= seuil OFF). Ici : coupure immediate, bypass min_on.\n// ======================================================\nif (\n !forcedCut &&\n latchIsOn &&\n gridIsOn &&\n !evcsGridOverride &&\n evcsSurplusState === 'vehicle_not_connected' &&\n soc >= gridSocOff &&\n !chargeEncoreElevee // la maison prime\n) {\n desiredGrid = 'off';\n desiredLatch = 'off';\n state = 'turn_off_vehicle_unplugged';\n reason.push('Vehicule debranche + SOC >= seuil OFF : coupure immediate du reseau (bypass min_on)');\n forcedCut = true;\n}\n\n// ======================================================\n// FIX E2 (09/08/2026): RECOVERY DONE -> COUPURE IMMEDIATE\n// 'night_recovery_done_idle' est un etat COLLANT du controleur EVCS :\n// charge terminee ET batterie a la cible, le takeover ne reviendra pas\n// (sauf SOC <= cible-2, qui repasserait par l'override). Attendre le\n// min_on 25 min ne protege ici de rien : coupure immediate.\n// ======================================================\nif (\n !forcedCut &&\n latchIsOn &&\n gridIsOn &&\n !evcsGridOverride &&\n (\n evcsSurplusState === 'night_recovery_done_idle' ||\n evcsSurplusState === 'departure_done_idle'\n ) &&\n soc >= gridSocOff &&\n !chargeEncoreElevee // la maison prime\n) {\n desiredGrid = 'off';\n desiredLatch = 'off';\n state = 'turn_off_recovery_done';\n reason.push('Charge terminee + batterie a la cible : coupure immediate du reseau (bypass min_on)');\n forcedCut = true;\n}\n\n\n// ======================================================\n// CORE HYSTERESIS LOGIC\n// ======================================================\nif (gridSwitchUnavailable) {\n // Switch EDF hors ligne : mode sûr. Aucune action, aucun timestamp,\n // on garde le latch tel quel et on réévaluera au retour du switch.\n state = 'switch_unavailable';\n desiredGrid = gridIsOn ? 'on' : 'off';\n desiredLatch = latchIsOn ? 'on' : 'off';\n reason.push('Switch EDF indisponible : aucune action, attente du retour');\n\n} else if (forcedCut) {\n // Décision déjà prise plus haut (force_grid + voiture pleine) :\n // on ne laisse plus la chaîne principale l'écraser.\n\n} else if (evcsGridOverride) {\n desiredGrid = 'on';\n desiredLatch = 'on';\n\n if (!gridIsOn) {\n state = 'turn_on_evcs_override';\n reason.push('Override EVCS actif : activation EDF forcée');\n } else if (!latchIsOn) {\n state = 'adopt_existing_grid_on_evcs_override';\n reason.push('Override EVCS actif : EDF déjà ON, adoption par latch');\n } else {\n state = 'hold_on_evcs_override';\n reason.push('Override EVCS actif : maintien EDF ON');\n }\n\n if (userStrategy === 'force_grid_now') {\n reason.push('Cause override : force_grid_now');\n } else if (userStrategy === 'force_charge_now') {\n reason.push('Cause override : force_charge_now');\n } else if (evcsNightGridTakeoverNeeded) {\n reason.push('Cause override : night_grid_takeover_needed');\n } else {\n reason.push(`Cause override : source_mode=${evcsSourceModeRaw || 'n/a'}`);\n }\n\n} else if (!hysteresisEnabled) {\n state = 'hysteresis_disabled';\n desiredGrid = gridIsOn ? 'on' : 'off';\n desiredLatch = latchIsOn ? 'on' : 'off';\n reason.push('Automatisme hystérésis réseau désactivé');\n\n} else if (chargeAuDessusDuParc) {\n // Priorite sur la bande SOC : c'est une contrainte de PUISSANCE, elle ne se\n // resout pas en attendant que le SOC descende.\n desiredGrid = 'on';\n desiredLatch = 'on';\n state = gridIsOn ? 'hold_on_charge_au_dessus_du_parc' : 'turn_on_charge_au_dessus_du_parc';\n reason.push(\n `Charge maison ${Math.round(houseAcPowerW)}W au-dessus de ${Math.round(seuilFermetureW)}W `\n + `(80% du plafond de decharge BMS ${Math.round(plafondDechargeW)}W) : le reseau prend la difference`);\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 && chargeEncoreElevee) {\n // Tant que la maison consomme plus de la moitie de ce que la batterie sait\n // donner, on garde le reseau — meme si le SOC autoriserait de le lacher.\n desiredGrid = 'on';\n desiredLatch = 'on';\n state = 'hold_on_charge_encore_elevee';\n reason.push(chargeSousSeuilBas\n ? `Charge maison retombee a ${Math.round(houseAcPowerW)}W depuis ${round1(minutesDepuisChargeHaute || 0)} min : `\n + `on garde le reseau jusqu'a ${dwellMinutes} min `\n + `(${reserveDejaCondamnee ? 'reserve deja condamnee, le reseau du soir est du de toute facon' : 'reserve suffisante, relachement rapide'})`\n : `Charge maison ${Math.round(houseAcPowerW)}W encore au-dessus de ${Math.round(seuilRelachementW)}W : on garde le reseau`);\n\n} else if (latchIsOn) {\n if (!minOnElapsed) {\n desiredGrid = 'on';\n desiredLatch = 'on';\n state = 'hold_on_min_runtime';\n reason.push(`EDF maintenu ON : durée mini ${gridMinOnMinutes} min non atteinte (${round1(minutesSinceLastOn || 0)} min)`);\n } else if (soc < gridSocOff) {\n desiredGrid = 'on';\n desiredLatch = 'on';\n state = 'hold_on_until_soc_off';\n reason.push(`EDF maintenu ON : SOC ${round1(soc)}% < seuil OFF ${gridSocOff}%`);\n } else {\n desiredGrid = 'off';\n desiredLatch = 'off';\n state = 'turn_off_recovered';\n reason.push(`SOC ${round1(soc)}% >= seuil OFF ${gridSocOff}% : coupure EDF`);\n }\n\n} else {\n desiredGrid = 'off';\n desiredLatch = 'off';\n state = 'observe_only';\n reason.push('Aucune action : hors zone d’hystérésis');\n}\n\n// ======================================================\n// COMPARE DESIRED VS CURRENT\n// ======================================================\nconst gridAction =\n desiredGrid === (gridIsOn ? 'on' : 'off')\n ? 'none'\n : desiredGrid === 'on'\n ? 'turn_on'\n : 'turn_off';\n\nconst latchAction =\n desiredLatch === (latchIsOn ? 'on' : 'off')\n ? 'none'\n : desiredLatch === 'on'\n ? 'turn_on'\n : 'turn_off';\n\n// ======================================================\n// TIMESTAMPS\n// ======================================================\nconst setLastOnAt =\n (gridAction === 'turn_on' && latchAction === 'turn_on') ||\n state === 'adopt_existing_grid_on' ||\n state === 'adopt_existing_grid_on_low_soc' ||\n state === 'adopt_existing_grid_on_evcs_override';\n\nconst setLastOffAt =\n gridAction === 'turn_off';\n\n// Horodatage de la derniere charge maison HAUTE. Ecrit quel que soit l'etat du\n// contacteur : c'est ce qui garantit que le compteur de dwell ne depend pas de\n// la decision qu'il alimente.\nconst setChargeHighLastAt = plafondDechargeW > 0 && !chargeSousSeuilBas;\n\n// ======================================================\n// CONTEXT / ALERTS\n// ======================================================\nreason.push(`Grid actuel ${gridIsOn ? 'ON' : 'OFF'}`);\nreason.push(`Latch actuel ${latchIsOn ? 'ON' : 'OFF'}`);\nreason.push(`SOC ${round1(soc)}% | seuils ${gridSocOn}%/${gridSocOff}%`);\nreason.push(`PV ${Math.round(pvPowerW)}W`);\nreason.push(`Charge AC maison ${Math.round(houseAcPowerW)}W`);\nreason.push(`Puissance réseau ${Math.round(gridPowerW)}W`);\nreason.push(`EVCS ${evcsSeemsActive ? 'actif' : 'inactif'} | ${Math.round(evcsAcPowerW)}W | status_raw=${Number.isFinite(evcsStatusRaw) ? evcsStatusRaw : 'n/a'}`);\n\nif (minutesSinceLastOn !== null) {\n reason.push(`Dernier ON il y a ${round1(minutesSinceLastOn)} min`);\n}\nif (minutesSinceLastOff !== null) {\n reason.push(`Dernier OFF il y a ${round1(minutesSinceLastOff)} min`);\n}\n\nif (acOverSafeLimit) {\n reason.push(`⚠️ Charge AC maison ${Math.round(houseAcPowerW)}W >= limite sûre ${Math.round(acLoadsMaxSafeW)}W`);\n}\n\nreason.push(`EVCS override ${evcsGridOverride ? 'ON' : 'OFF'}`);\nreason.push(`EVCS explicit grid request ${evcsExplicitGridRequest ? 'ON' : 'OFF'}`);\nreason.push(`EVCS priority ${evcsPriorityActive ? 'ON' : 'OFF'}`);\nreason.push(`EVCS night takeover ${evcsNightGridTakeoverNeeded ? 'ON' : 'OFF'}`);\nreason.push(`EVCS source ${evcsSourceModeRaw || 'n/a'}`);\nreason.push(`EVCS strategy ${userStrategyRaw || 'n/a'}`);\nreason.push(`EVCS surplus state ${evcsSurplusStateRaw || 'n/a'}`);\nreason.push(`EVCS no-demand semantic ${evcsVehicleNoDemandSemantic ? 'ON' : 'OFF'}`);\n\n// ======================================================\n// NOTIFICATIONS\n// ======================================================\nlet notifyTitle = 'Victron Grid Hysteresis';\nlet notifyMessage = '';\nlet notifyWanted = false;\n\nif (\n state === 'turn_on_evcs_override' ||\n state === 'adopt_existing_grid_on_evcs_override' ||\n state === 'hold_on_evcs_override'\n) {\n notifyTitle = '🚗 EDF forcé par EVCS';\n notifyMessage = `Priorité EVCS active. EDF maintenu ON pour la recharge véhicule. Cause: ${userStrategyRaw || evcsSourceModeRaw || 'override EVCS'}.`;\n notifyWanted = true;\n\n} else if (state === 'turn_on_low_soc' || state === 'turn_on_low_soc_cooldown_bypassed') {\n notifyTitle = '⚡ EDF activé sur SOC bas';\n notifyMessage = `SOC ${round1(soc)}% <= ${gridSocOn}%. EDF activé et maintenu jusqu'à au moins ${gridSocOff}% (min ${gridMinOnMinutes} min).`;\n notifyWanted = true;\n\n} else if (state === 'adopt_existing_grid_on_low_soc' || state === 'adopt_existing_grid_on') {\n notifyTitle = '🔁 EDF ON repris par hystérésis';\n notifyMessage = `EDF était déjà ON. Le workflow reprend la main et le maintient jusqu'à ${gridSocOff}% (min ${gridMinOnMinutes} min).`;\n notifyWanted = true;\n\n} else if (state === 'turn_off_vehicle_unplugged') {\n notifyTitle = '✅ EDF coupé (véhicule débranché)';\n notifyMessage = `Véhicule débranché et SOC ${round1(soc)}% >= ${gridSocOff}%. Coupure immédiate du réseau.`;\n notifyWanted = true;\n\n} else if (state === 'turn_off_recovery_done') {\n notifyTitle = '✅ EDF coupé (charge terminée)';\n notifyMessage = `Charge VE terminée et batterie à la cible (SOC ${round1(soc)}%). Coupure immédiate du réseau.`;\n notifyWanted = true;\n\n} else if (state === 'turn_off_recovered') {\n notifyTitle = '✅ EDF coupé après recharge';\n notifyMessage = `SOC ${round1(soc)}% >= ${gridSocOff}%. Retour hors réseau autorisé.`;\n notifyWanted = true;\n\n} else if (state === 'turn_off_stray_grid_on') {\n notifyTitle = '✅ EDF coupé (reprise de contrôle)';\n notifyMessage = `EDF était ON sans latch. SOC ${round1(soc)}% >= ${gridSocOff}%, coupure réseau appliquée.`;\n notifyWanted = true;\n\n} else if (acOverSafeLimit && !gridIsOn) {\n notifyTitle = '🚨 Charge AC élevée hors réseau';\n notifyMessage = `Charge AC maison ${Math.round(houseAcPowerW)}W au-dessus de la limite sûre ${Math.round(acLoadsMaxSafeW)}W alors que EDF est OFF.`;\n notifyWanted = true;\n\n} else if (acOverSafeLimit && gridIsOn) {\n notifyTitle = '⚠️ Charge AC élevée';\n notifyMessage = `Charge AC maison ${Math.round(houseAcPowerW)}W proche/au-dessus de la limite sûre ${Math.round(acLoadsMaxSafeW)}W.`;\n notifyWanted = true;\n}\n\nconst shortReason = reason.join(' | ').slice(0, 255);\n\nconst signature = [\n now.date,\n state,\n `dg=${desiredGrid}`,\n `dl=${desiredLatch}`,\n `sb=${socBand}`,\n `ac=${acOverSafeLimit ? '1' : '0'}`,\n `ov=${evcsGridOverride ? '1' : '0'}`,\n `eg=${evcsExplicitGridRequest ? '1' : '0'}`,\n `ep=${evcsPriorityActive ? '1' : '0'}`,\n `es=${evcsSurplusState || 'na'}`\n].join('|');\n\nconst signatureChanged = signature !== lastSignature;\n\n// ======================================================\n// OUTPUT\n// ======================================================\nconst result = {\n timestamp_paris: now.stamp,\n\n inputs: {\n soc_percent: soc,\n grid_state: gridIsOn ? 'on' : 'off',\n pv_power_w: pvPowerW,\n house_ac_power_w: houseAcPowerW,\n grid_power_w: gridPowerW,\n evcs_ac_power_w: evcsAcPowerW,\n evcs_charging: evcsCharging,\n evcs_status_raw: Number.isFinite(evcsStatusRaw) ? evcsStatusRaw : null,\n\n hysteresis_enabled: hysteresisEnabled,\n notifications_enabled: notificationsEnabled,\n grid_latch: latchIsOn,\n grid_soc_on: gridSocOn,\n grid_soc_off: gridSocOff,\n grid_min_on_minutes: gridMinOnMinutes,\n grid_cooldown_minutes: gridCooldownMinutes,\n ac_loads_max_safe_w: acLoadsMaxSafeW,\n\n last_on_at: lastOnAt || null,\n last_off_at: lastOffAt || null,\n last_signature: lastSignature || null,\n\n evcs_priority_active: evcsPriorityActive,\n night_grid_takeover_needed: evcsNightGridTakeoverNeeded,\n evcs_source_mode: evcsSourceModeRaw || null,\n user_strategy: userStrategyRaw || null,\n evcs_surplus_state: evcsSurplusStateRaw || null\n },\n\n computed: {\n desired_grid: desiredGrid,\n desired_latch: desiredLatch,\n minutes_since_last_on: minutesSinceLastOn !== null ? round1(minutesSinceLastOn) : null,\n minutes_since_last_off: minutesSinceLastOff !== null ? round1(minutesSinceLastOff) : null,\n min_on_elapsed: minOnElapsed,\n cooldown_elapsed: cooldownElapsed,\n ac_over_safe_limit: acOverSafeLimit,\n plafond_decharge_w: Math.round(plafondDechargeW),\n charge_au_dessus_du_parc: chargeAuDessusDuParc,\n charge_sous_seuil_bas: chargeSousSeuilBas,\n minutes_depuis_charge_haute:\n minutesDepuisChargeHaute !== null ? round1(minutesDepuisChargeHaute) : null,\n dwell_minutes: dwellMinutes,\n dwell_ecoule: dwellEcoule,\n balance_usable: balanceUsable,\n balance_required_soc_pct:\n Number.isFinite(balanceRequiredSocPct) ? round1(balanceRequiredSocPct) : null,\n balance_age_min: balanceAgeMin !== null ? round1(balanceAgeMin) : null,\n reserve_deja_condamnee: reserveDejaCondamnee,\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 set_charge_high_last_at: setChargeHighLastAt,\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_charge_high_last_at: {\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_grid_charge_high_last_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_charge_high_last_at) {\n pushAction('helper_charge_high_last_at', calls.helper_charge_high_last_at, true);\n}\n\nif (decision.set_last_off_at) {\n pushAction('helper_last_off_at', calls.helper_last_off_at, true);\n}\n\n// ======================================================\n// LOGBOOK SI CHANGEMENT DE SIGNATURE\n// ======================================================\nif (decision.signature_changed) {\n pushAction('logbook', calls.logbook, true);\n}\n\n// ======================================================\n// NOTIFICATION SI UTILE\n// ======================================================\nif (decision.should_notify) {\n pushAction('mobile_notification', calls.mobile_notification, true);\n}\n\nreturn actions;\n"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
672,
|
||
0
|
||
],
|
||
"id": "d3728055-9f5b-4362-a275-247cc91c5f41",
|
||
"name": "Build Grid Hysteresis Actions"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "POST",
|
||
"url": "={{ 'http://10.10.10.17:8123/api/services/' + $json.domain + '/' + $json.service }}\n",
|
||
"authentication": "genericCredentialType",
|
||
"genericAuthType": "httpBearerAuth",
|
||
"sendHeaders": true,
|
||
"headerParameters": {
|
||
"parameters": [
|
||
{
|
||
"name": "Content-Type",
|
||
"value": "application/json"
|
||
},
|
||
{
|
||
"name": "accept",
|
||
"value": "application/json"
|
||
}
|
||
]
|
||
},
|
||
"sendBody": true,
|
||
"specifyBody": "json",
|
||
"jsonBody": "={{ $json.data }}",
|
||
"options": {}
|
||
},
|
||
"id": "7d38f7cf-ad84-4481-a201-d21dd0dce321",
|
||
"name": "Execute HA Service",
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.2,
|
||
"position": [
|
||
896,
|
||
0
|
||
],
|
||
"alwaysOutputData": true,
|
||
"credentials": {
|
||
"httpBearerAuth": {
|
||
"id": "uv3sknYYigxBkaVN",
|
||
"name": "Bearer Auth account 2"
|
||
}
|
||
}
|
||
}
|
||
],
|
||
"connections": {
|
||
"Schedule Trigger": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HA Snapshot Grid Hysteresis",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HA Snapshot Grid Hysteresis": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Compute Grid Hysteresis",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Compute Grid Hysteresis": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Build Grid Hysteresis Actions",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Build Grid Hysteresis Actions": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Execute HA Service",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
}
|
||
},
|
||
"settings": {
|
||
"executionOrder": "v1",
|
||
"binaryMode": "separate",
|
||
"availableInMCP": true,
|
||
"timeSavedMode": "fixed",
|
||
"callerPolicy": "workflowsFromSameOwner",
|
||
"saveDataErrorExecution": "all"
|
||
},
|
||
"staticData": {
|
||
"node:Schedule Trigger": {
|
||
"recurrenceRules": [
|
||
null
|
||
],
|
||
"recurrenceRuleSignatures": [
|
||
null
|
||
]
|
||
}
|
||
},
|
||
"meta": null,
|
||
"nodeGroups": [],
|
||
"pinData": {},
|
||
"versionId": "ecba2d4a-5f6f-4024-951f-3bd01905cd03",
|
||
"activeVersionId": "ecba2d4a-5f6f-4024-951f-3bd01905cd03",
|
||
"versionCounter": 87,
|
||
"triggerCount": 1,
|
||
"sourceWorkflowId": null,
|
||
"shared": [
|
||
{
|
||
"updatedAt": "2026-03-17T01:43:29.021Z",
|
||
"createdAt": "2026-03-17T01:43:29.021Z",
|
||
"role": "workflow:owner",
|
||
"workflowId": "7iIlu7LLTW7TZA8o",
|
||
"projectId": "1q2yd8IyTc8fb25V",
|
||
"project": {
|
||
"updatedAt": "2026-02-06T16:27:24.469Z",
|
||
"createdAt": "2026-02-06T16:23:40.444Z",
|
||
"id": "1q2yd8IyTc8fb25V",
|
||
"name": "thierry tallagrand <tallagrand.thierry@gmail.com>",
|
||
"type": "personal",
|
||
"icon": null,
|
||
"description": null,
|
||
"customTelemetryTags": [],
|
||
"creatorId": "8b9e9552-cd74-4863-82a3-174b3b3befa2"
|
||
}
|
||
}
|
||
],
|
||
"tags": [],
|
||
"activeVersion": {
|
||
"updatedAt": "2026-07-09T01:00:10.890Z",
|
||
"createdAt": "2026-07-09T01:00:10.890Z",
|
||
"versionId": "ecba2d4a-5f6f-4024-951f-3bd01905cd03",
|
||
"workflowId": "7iIlu7LLTW7TZA8o",
|
||
"nodes": [
|
||
{
|
||
"parameters": {
|
||
"rule": {
|
||
"interval": [
|
||
{
|
||
"field": "minutes",
|
||
"minutesInterval": 1
|
||
}
|
||
]
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.scheduleTrigger",
|
||
"typeVersion": 1.3,
|
||
"position": [
|
||
0,
|
||
0
|
||
],
|
||
"id": "0b9ad256-b51b-4921-b81b-5ba8a2113412",
|
||
"name": "Schedule Trigger"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "POST",
|
||
"url": "=http://10.10.10.17:8123/api/template\n",
|
||
"authentication": "genericCredentialType",
|
||
"genericAuthType": "httpBearerAuth",
|
||
"sendHeaders": true,
|
||
"headerParameters": {
|
||
"parameters": [
|
||
{
|
||
"name": "Content-Type",
|
||
"value": "application/json"
|
||
},
|
||
{
|
||
"name": "Accept",
|
||
"value": "text/plain"
|
||
}
|
||
]
|
||
},
|
||
"sendBody": true,
|
||
"bodyParameters": {
|
||
"parameters": [
|
||
{
|
||
"name": "template",
|
||
"value": "{{ {\n \"soc_percent\": states(\"sensor.victron_battery_soc\"),\n \"grid_state\": states(\"switch.edf_general_ok_switch\"),\n \"pv_power_w\": states(\"sensor.victron_pv_power_system\"),\n\n \"house_ac_power_w\": states(\"sensor.victron_consumption_power_l1\"),\n \"grid_power_w\": states(\"sensor.victron_grid_power_l1\"),\n\n \"evcs_ac_power_w\": states(\"sensor.victron_evcs_ac_power\"),\n \"evcs_charging\": states(\"switch.victron_evcs_charging\"),\n \"evcs_status_raw\": states(\"sensor.victron_evcs_status_raw\"),\n\n \"hysteresis_enabled\": states(\"input_boolean.victron_grid_hysteresis_enabled\"),\n \"notifications_enabled\": states(\"input_boolean.victron_grid_hysteresis_notifications\"),\n \"grid_latch\": states(\"input_boolean.victron_grid_hysteresis_latch\"),\n\n \"grid_soc_on\": states(\"input_number.victron_grid_soc_on\"),\n \"grid_soc_off\": states(\"input_number.victron_grid_soc_off\"),\n \"grid_min_on_minutes\": states(\"input_number.victron_grid_min_on_minutes\"),\n \"grid_cooldown_minutes\": states(\"input_number.victron_grid_cooldown_minutes\"),\n \"ac_loads_max_safe_w\": states(\"input_number.victron_ac_loads_max_safe_w\"),\n\n \"last_on_at\": states(\"input_datetime.victron_grid_last_on_at\"),\n \"last_off_at\": states(\"input_datetime.victron_grid_last_off_at\"),\n \"last_signature\": states(\"input_text.victron_grid_control_last_signature\"),\n\n \"evcs_priority_active\": states(\"input_boolean.victron_evcs_priority_active\"),\n \"night_grid_takeover_needed\": states(\"input_boolean.victron_evcs_night_grid_takeover_needed\"),\n \"evcs_source_mode\": states(\"input_text.victron_evcs_source_mode\"),\n \"user_strategy\": states(\"input_select.victron_evcs_strategy\"),\n \"evcs_surplus_state\": states(\"input_text.victron_evcs_surplus_state\")\n} | tojson }}\n"
|
||
}
|
||
]
|
||
},
|
||
"options": {}
|
||
},
|
||
"id": "577c8308-a790-44f8-a9dc-098811bd5e0b",
|
||
"name": "HA Snapshot Grid Hysteresis",
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.2,
|
||
"position": [
|
||
224,
|
||
0
|
||
],
|
||
"credentials": {
|
||
"httpBearerAuth": {
|
||
"id": "uv3sknYYigxBkaVN",
|
||
"name": "Bearer Auth account 2"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "function num(v, fallback = NaN) {\n const n = Number.parseFloat(v);\n return Number.isFinite(n) ? n : fallback;\n}\n\nfunction low(v) {\n return (v ?? '').toString().trim().toLowerCase();\n}\n\nfunction parseMaybeJson(v) {\n if (typeof v !== 'string') return v;\n try {\n return JSON.parse(v);\n } catch {\n return v;\n }\n}\n\nfunction extractSnapshot(input) {\n if (typeof input === 'string') return parseMaybeJson(input);\n if (input?.data && typeof input.data === 'string') return parseMaybeJson(input.data);\n if (input?.body && typeof input.body === 'string') return parseMaybeJson(input.body);\n if (input?.data && typeof input.data === 'object') return input.data;\n if (input?.body && typeof input.body === 'object') return input.body;\n return input;\n}\n\nfunction round1(v) {\n return Math.round(v * 10) / 10;\n}\n\nfunction clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nfunction pickDefined(...values) {\n for (const v of values) {\n if (v === undefined || v === null) continue;\n if (typeof v === 'string' && v.trim() === '') continue;\n return v;\n }\n return undefined;\n}\n\nfunction isOnLike(v) {\n const s = low(v);\n return s === 'on' || s === 'true' || s === '1' || s === 'yes';\n}\n\nfunction getParisNowParts() {\n const fmt = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris',\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n fmt.formatToParts(new Date())\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const date = `${parts.year}-${parts.month}-${parts.day}`;\n const stamp = `${date} ${parts.hour}:${parts.minute}:${parts.second}`;\n\n return {\n year: Number(parts.year),\n month: Number(parts.month),\n day: Number(parts.day),\n hour: Number(parts.hour),\n minute: Number(parts.minute),\n second: Number(parts.second),\n date,\n stamp,\n };\n}\n\nfunction parseLocalStampToMs(s) {\n const text = (s ?? '').toString().trim();\n const m = text.match(/^(\\d{4})-(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2})$/);\n if (!m) return null;\n\n const y = Number(m[1]);\n const mo = Number(m[2]);\n const d = Number(m[3]);\n const h = Number(m[4]);\n const mi = Number(m[5]);\n const se = Number(m[6]);\n\n if (![y, mo, d, h, mi, se].every(Number.isFinite)) return null;\n\n return new Date(y, mo - 1, d, h, mi, se).getTime();\n}\n\nconst cfg = {\n mobileNotifyDomain: 'notify',\n mobileNotifyService: 'mobile_app_iphone_16_de_tt',\n};\n\nconst raw = $input.first().json;\nconst input = extractSnapshot(raw);\nconst now = getParisNowParts();\nconst nowMs = parseLocalStampToMs(now.stamp);\n\n// ======================================================\n// INPUTS PRINCIPAUX\n// ======================================================\nconst soc = clamp(num(input.soc_percent, 0), 0, 100);\nconst gridStateRaw = low(input.grid_state || 'off');\nconst gridIsOn = gridStateRaw === 'on';\nconst gridSwitchUnavailable = gridStateRaw === 'unavailable' || gridStateRaw === 'unknown' || gridStateRaw === 'none';\nconst pvPowerW = Math.max(0, num(input.pv_power_w, 0));\n\nconst houseAcPowerW = Math.max(0, num(input.house_ac_power_w, 0));\nconst gridPowerW = num(input.grid_power_w, 0);\n\nconst evcsAcPowerW = Math.max(0, num(input.evcs_ac_power_w, 0));\nconst evcsCharging = low(input.evcs_charging || 'off') === 'on';\nconst evcsStatusRaw = num(input.evcs_status_raw, NaN);\n\nconst hysteresisEnabled = low(input.hysteresis_enabled || 'off') === 'on';\nconst notificationsEnabled = low(input.notifications_enabled || 'off') === 'on';\nconst latchIsOn = low(input.grid_latch || 'off') === 'on';\n\nconst gridSocOn = clamp(num(input.grid_soc_on, 22), 0, 100);\nconst gridSocOff = clamp(num(input.grid_soc_off, 35), 0, 100);\nconst gridMinOnMinutes = Math.max(0, num(input.grid_min_on_minutes, 45));\nconst gridCooldownMinutes = Math.max(0, num(input.grid_cooldown_minutes, 15));\nconst acLoadsMaxSafeW = Math.max(0, num(input.ac_loads_max_safe_w, 7800));\n\nconst lastOnAt = (input.last_on_at ?? '').toString().trim();\nconst lastOffAt = (input.last_off_at ?? '').toString().trim();\nconst lastSignature = (input.last_signature ?? '').toString().trim();\n\nconst lastOnMs = parseLocalStampToMs(lastOnAt);\nconst lastOffMs = parseLocalStampToMs(lastOffAt);\n\nconst minutesSinceLastOn =\n lastOnMs !== null && nowMs !== null ? Math.max(0, (nowMs - lastOnMs) / 60000) : null;\n\nconst minutesSinceLastOff =\n lastOffMs !== null && nowMs !== null ? Math.max(0, (nowMs - lastOffMs) / 60000) : null;\n\nconst minOnElapsed =\n minutesSinceLastOn === null ? true : minutesSinceLastOn >= gridMinOnMinutes;\n\nconst cooldownElapsed =\n minutesSinceLastOff === null ? true : minutesSinceLastOff >= gridCooldownMinutes;\n\nconst socBand =\n soc <= gridSocOn ? 'low' :\n soc < gridSocOff ? 'mid' :\n 'high';\n\nconst evcsSeemsActive = evcsCharging || evcsAcPowerW > 300;\nconst acOverSafeLimit = houseAcPowerW >= acLoadsMaxSafeW;\n\n// ======================================================\n// EVCS OVERRIDE INPUTS\n// ======================================================\nconst evcsPriorityActive = isOnLike(\n pickDefined(\n input.evcs_priority_active,\n input.victron_evcs_priority_active,\n input['input_boolean.victron_evcs_priority_active']\n )\n);\n\nconst evcsNightGridTakeoverNeeded = isOnLike(\n pickDefined(\n input.night_grid_takeover_needed,\n input.victron_evcs_night_grid_takeover_needed,\n input['input_boolean.victron_evcs_night_grid_takeover_needed']\n )\n);\n\nconst evcsSourceModeRaw = (\n pickDefined(\n input.evcs_source_mode,\n input.victron_evcs_source_mode,\n input['input_text.victron_evcs_source_mode']\n ) ?? ''\n).toString().trim();\n\nconst evcsSourceMode = low(evcsSourceModeRaw);\n\nconst userStrategyRaw = (\n pickDefined(\n input.user_strategy,\n input.victron_evcs_user_strategy,\n input['input_select.victron_evcs_user_strategy']\n ) ?? ''\n).toString().trim();\n\nconst userStrategy = low(userStrategyRaw);\n\n// ======================================================\n// EVCS SEMANTIC STATE\n// Ici on ne détecte PAS la voiture pleine par seuils.\n// On consomme seulement l'état sémantique déjà produit\n// par le workflow EVCS.\n// ======================================================\nconst evcsSurplusStateRaw = (\n pickDefined(\n input.evcs_surplus_state,\n input.victron_evcs_surplus_state,\n input['input_text.victron_evcs_surplus_state']\n ) ?? ''\n).toString().trim();\n\nconst evcsSurplusState = low(evcsSurplusStateRaw);\n\nconst evcsVehicleNoDemandSemantic =\n evcsSurplusState === 'vehicle_full_or_paused' ||\n evcsSurplusState === 'vehicle_not_requesting_power' ||\n evcsSurplusState === 'vehicle_full' ||\n evcsSurplusState === 'charge_complete' ||\n evcsSurplusState.includes('full_or_paused') ||\n evcsSurplusState.includes('not_requesting_power') ||\n evcsSurplusState.includes('charge_complete') ||\n evcsSurplusState.includes('no_demand');\n\n// ======================================================\n// OVERRIDE EVCS VERS GRID\n// CORRECTION:\n// - evcsPriorityActive seul ne doit PLUS suffire à allumer EDF\n// - seuls les cas explicitement réseau peuvent forcer le grid\n// - ainsi \"Réseau HC midi = OFF\" redevient réellement respecté\n// ======================================================\nconst evcsExplicitGridRequest =\n userStrategy === 'force_grid_now' ||\n userStrategy === 'force_charge_now' ||\n evcsNightGridTakeoverNeeded ||\n evcsSourceMode === 'réseau forcé' ||\n evcsSourceMode === 'réseau hc autorisé' ||\n evcsSourceMode === 'grid_forced' ||\n evcsSourceMode === 'grid_hc_allowed';\n\nconst evcsGridOverride =\n !evcsVehicleNoDemandSemantic &&\n evcsExplicitGridRequest;\n\n// ======================================================\n// DÉCISION\n// ======================================================\nlet state = 'observe_only';\nlet reason = [];\nlet desiredGrid = gridIsOn ? 'on' : 'off';\nlet desiredLatch = latchIsOn ? 'on' : 'off';\n\n// ======================================================\n// FORCE_GRID + VEHICLE FULL IMMEDIATE CUT\n// FIX V2.5: Si force_grid_now actif et voiture pleine,\n// couper EDF immédiatement sans attendre minOnRuntime.\n// ======================================================\nlet forcedCut = false;\nif (\n userStrategy === 'force_grid_now' &&\n evcsVehicleNoDemandSemantic &&\n latchIsOn\n) {\n desiredGrid = 'off';\n desiredLatch = 'off';\n state = 'turn_off_force_grid_vehicle_full';\n reason.push('Force grid + voiture pleine : coupure immediate du reseau');\n forcedCut = true;\n}\n\n\n// ======================================================\n// CORE HYSTERESIS LOGIC\n// ======================================================\nif (gridSwitchUnavailable) {\n // Switch EDF hors ligne : mode sûr. Aucune action, aucun timestamp,\n // on garde le latch tel quel et on réévaluera au retour du switch.\n state = 'switch_unavailable';\n desiredGrid = gridIsOn ? 'on' : 'off';\n desiredLatch = latchIsOn ? 'on' : 'off';\n reason.push('Switch EDF indisponible : aucune action, attente du retour');\n\n} else if (forcedCut) {\n // Décision déjà prise plus haut (force_grid + voiture pleine) :\n // on ne laisse plus la chaîne principale l'écraser.\n\n} else if (evcsGridOverride) {\n desiredGrid = 'on';\n desiredLatch = 'on';\n\n if (!gridIsOn) {\n state = 'turn_on_evcs_override';\n reason.push('Override EVCS actif : activation EDF forcée');\n } else if (!latchIsOn) {\n state = 'adopt_existing_grid_on_evcs_override';\n reason.push('Override EVCS actif : EDF déjà ON, adoption par latch');\n } else {\n state = 'hold_on_evcs_override';\n reason.push('Override EVCS actif : maintien EDF ON');\n }\n\n if (userStrategy === 'force_grid_now') {\n reason.push('Cause override : force_grid_now');\n } else if (userStrategy === 'force_charge_now') {\n reason.push('Cause override : force_charge_now');\n } else if (evcsNightGridTakeoverNeeded) {\n reason.push('Cause override : night_grid_takeover_needed');\n } else {\n reason.push(`Cause override : source_mode=${evcsSourceModeRaw || 'n/a'}`);\n }\n\n} else if (!hysteresisEnabled) {\n state = 'hysteresis_disabled';\n desiredGrid = gridIsOn ? 'on' : 'off';\n desiredLatch = latchIsOn ? 'on' : 'off';\n reason.push('Automatisme hystérésis réseau désactivé');\n\n} else if (soc <= gridSocOn) {\n desiredGrid = 'on';\n desiredLatch = 'on';\n\n if (!cooldownElapsed && !gridIsOn) {\n state = 'turn_on_low_soc_cooldown_bypassed';\n reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : activation EDF malgré cooldown`);\n } else if (!gridIsOn) {\n state = 'turn_on_low_soc';\n reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : activation EDF`);\n } else if (!latchIsOn) {\n state = 'adopt_existing_grid_on_low_soc';\n reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : EDF déjà ON, prise de contrôle par latch`);\n } else {\n state = 'hold_on_low_soc';\n reason.push(`SOC encore bas ${round1(soc)}% : maintien EDF ON`);\n }\n\n} else if (gridIsOn && !latchIsOn) {\n if (soc < gridSocOff) {\n desiredGrid = 'on';\n desiredLatch = 'on';\n state = 'adopt_existing_grid_on';\n reason.push(`EDF déjà ON avec latch OFF et SOC ${round1(soc)}% < seuil OFF ${gridSocOff}% : adoption de l'état ON`);\n } else {\n desiredGrid = 'off';\n desiredLatch = 'off';\n state = 'turn_off_stray_grid_on';\n reason.push(`EDF ON avec latch OFF et SOC ${round1(soc)}% >= seuil OFF ${gridSocOff}% : coupure de reprise de contrôle`);\n }\n\n} else if (latchIsOn) {\n if (!minOnElapsed) {\n desiredGrid = 'on';\n desiredLatch = 'on';\n state = 'hold_on_min_runtime';\n reason.push(`EDF maintenu ON : durée mini ${gridMinOnMinutes} min non atteinte (${round1(minutesSinceLastOn || 0)} min)`);\n } else if (soc < gridSocOff) {\n desiredGrid = 'on';\n desiredLatch = 'on';\n state = 'hold_on_until_soc_off';\n reason.push(`EDF maintenu ON : SOC ${round1(soc)}% < seuil OFF ${gridSocOff}%`);\n } else {\n desiredGrid = 'off';\n desiredLatch = 'off';\n state = 'turn_off_recovered';\n reason.push(`SOC ${round1(soc)}% >= seuil OFF ${gridSocOff}% : coupure EDF`);\n }\n\n} else {\n desiredGrid = 'off';\n desiredLatch = 'off';\n state = 'observe_only';\n reason.push('Aucune action : hors zone d’hystérésis');\n}\n\n// ======================================================\n// COMPARE DESIRED VS CURRENT\n// ======================================================\nconst gridAction =\n desiredGrid === (gridIsOn ? 'on' : 'off')\n ? 'none'\n : desiredGrid === 'on'\n ? 'turn_on'\n : 'turn_off';\n\nconst latchAction =\n desiredLatch === (latchIsOn ? 'on' : 'off')\n ? 'none'\n : desiredLatch === 'on'\n ? 'turn_on'\n : 'turn_off';\n\n// ======================================================\n// TIMESTAMPS\n// ======================================================\nconst setLastOnAt =\n (gridAction === 'turn_on' && latchAction === 'turn_on') ||\n state === 'adopt_existing_grid_on' ||\n state === 'adopt_existing_grid_on_low_soc' ||\n state === 'adopt_existing_grid_on_evcs_override';\n\nconst setLastOffAt =\n gridAction === 'turn_off';\n\n// ======================================================\n// CONTEXT / ALERTS\n// ======================================================\nreason.push(`Grid actuel ${gridIsOn ? 'ON' : 'OFF'}`);\nreason.push(`Latch actuel ${latchIsOn ? 'ON' : 'OFF'}`);\nreason.push(`SOC ${round1(soc)}% | seuils ${gridSocOn}%/${gridSocOff}%`);\nreason.push(`PV ${Math.round(pvPowerW)}W`);\nreason.push(`Charge AC maison ${Math.round(houseAcPowerW)}W`);\nreason.push(`Puissance réseau ${Math.round(gridPowerW)}W`);\nreason.push(`EVCS ${evcsSeemsActive ? 'actif' : 'inactif'} | ${Math.round(evcsAcPowerW)}W | status_raw=${Number.isFinite(evcsStatusRaw) ? evcsStatusRaw : 'n/a'}`);\n\nif (minutesSinceLastOn !== null) {\n reason.push(`Dernier ON il y a ${round1(minutesSinceLastOn)} min`);\n}\nif (minutesSinceLastOff !== null) {\n reason.push(`Dernier OFF il y a ${round1(minutesSinceLastOff)} min`);\n}\n\nif (acOverSafeLimit) {\n reason.push(`⚠️ Charge AC maison ${Math.round(houseAcPowerW)}W >= limite sûre ${Math.round(acLoadsMaxSafeW)}W`);\n}\n\nreason.push(`EVCS override ${evcsGridOverride ? 'ON' : 'OFF'}`);\nreason.push(`EVCS explicit grid request ${evcsExplicitGridRequest ? 'ON' : 'OFF'}`);\nreason.push(`EVCS priority ${evcsPriorityActive ? 'ON' : 'OFF'}`);\nreason.push(`EVCS night takeover ${evcsNightGridTakeoverNeeded ? 'ON' : 'OFF'}`);\nreason.push(`EVCS source ${evcsSourceModeRaw || 'n/a'}`);\nreason.push(`EVCS strategy ${userStrategyRaw || 'n/a'}`);\nreason.push(`EVCS surplus state ${evcsSurplusStateRaw || 'n/a'}`);\nreason.push(`EVCS no-demand semantic ${evcsVehicleNoDemandSemantic ? 'ON' : 'OFF'}`);\n\n// ======================================================\n// NOTIFICATIONS\n// ======================================================\nlet notifyTitle = 'Victron Grid Hysteresis';\nlet notifyMessage = '';\nlet notifyWanted = false;\n\nif (\n state === 'turn_on_evcs_override' ||\n state === 'adopt_existing_grid_on_evcs_override' ||\n state === 'hold_on_evcs_override'\n) {\n notifyTitle = '🚗 EDF forcé par EVCS';\n notifyMessage = `Priorité EVCS active. EDF maintenu ON pour la recharge véhicule. Cause: ${userStrategyRaw || evcsSourceModeRaw || 'override EVCS'}.`;\n notifyWanted = true;\n\n} else if (state === 'turn_on_low_soc' || state === 'turn_on_low_soc_cooldown_bypassed') {\n notifyTitle = '⚡ EDF activé sur SOC bas';\n notifyMessage = `SOC ${round1(soc)}% <= ${gridSocOn}%. EDF activé et maintenu jusqu'à au moins ${gridSocOff}% (min ${gridMinOnMinutes} min).`;\n notifyWanted = true;\n\n} else if (state === 'adopt_existing_grid_on_low_soc' || state === 'adopt_existing_grid_on') {\n notifyTitle = '🔁 EDF ON repris par hystérésis';\n notifyMessage = `EDF était déjà ON. Le workflow reprend la main et le maintient jusqu'à ${gridSocOff}% (min ${gridMinOnMinutes} min).`;\n notifyWanted = true;\n\n} else if (state === 'turn_off_recovered') {\n notifyTitle = '✅ EDF coupé après recharge';\n notifyMessage = `SOC ${round1(soc)}% >= ${gridSocOff}%. Retour hors réseau autorisé.`;\n notifyWanted = true;\n\n} else if (state === 'turn_off_stray_grid_on') {\n notifyTitle = '✅ EDF coupé (reprise de contrôle)';\n notifyMessage = `EDF était ON sans latch. SOC ${round1(soc)}% >= ${gridSocOff}%, coupure réseau appliquée.`;\n notifyWanted = true;\n\n} else if (acOverSafeLimit && !gridIsOn) {\n notifyTitle = '🚨 Charge AC élevée hors réseau';\n notifyMessage = `Charge AC maison ${Math.round(houseAcPowerW)}W au-dessus de la limite sûre ${Math.round(acLoadsMaxSafeW)}W alors que EDF est OFF.`;\n notifyWanted = true;\n\n} else if (acOverSafeLimit && gridIsOn) {\n notifyTitle = '⚠️ Charge AC élevée';\n notifyMessage = `Charge AC maison ${Math.round(houseAcPowerW)}W proche/au-dessus de la limite sûre ${Math.round(acLoadsMaxSafeW)}W.`;\n notifyWanted = true;\n}\n\nconst shortReason = reason.join(' | ').slice(0, 255);\n\nconst signature = [\n now.date,\n state,\n `dg=${desiredGrid}`,\n `dl=${desiredLatch}`,\n `sb=${socBand}`,\n `ac=${acOverSafeLimit ? '1' : '0'}`,\n `ov=${evcsGridOverride ? '1' : '0'}`,\n `eg=${evcsExplicitGridRequest ? '1' : '0'}`,\n `ep=${evcsPriorityActive ? '1' : '0'}`,\n `es=${evcsSurplusState || 'na'}`\n].join('|');\n\nconst signatureChanged = signature !== lastSignature;\n\n// ======================================================\n// OUTPUT\n// ======================================================\nconst result = {\n timestamp_paris: now.stamp,\n\n inputs: {\n soc_percent: soc,\n grid_state: gridIsOn ? 'on' : 'off',\n pv_power_w: pvPowerW,\n house_ac_power_w: houseAcPowerW,\n grid_power_w: gridPowerW,\n evcs_ac_power_w: evcsAcPowerW,\n evcs_charging: evcsCharging,\n evcs_status_raw: Number.isFinite(evcsStatusRaw) ? evcsStatusRaw : null,\n\n hysteresis_enabled: hysteresisEnabled,\n notifications_enabled: notificationsEnabled,\n grid_latch: latchIsOn,\n grid_soc_on: gridSocOn,\n grid_soc_off: gridSocOff,\n grid_min_on_minutes: gridMinOnMinutes,\n grid_cooldown_minutes: gridCooldownMinutes,\n ac_loads_max_safe_w: acLoadsMaxSafeW,\n\n last_on_at: lastOnAt || null,\n last_off_at: lastOffAt || null,\n last_signature: lastSignature || null,\n\n evcs_priority_active: evcsPriorityActive,\n night_grid_takeover_needed: evcsNightGridTakeoverNeeded,\n evcs_source_mode: evcsSourceModeRaw || null,\n user_strategy: userStrategyRaw || null,\n evcs_surplus_state: evcsSurplusStateRaw || null\n },\n\n computed: {\n desired_grid: desiredGrid,\n desired_latch: desiredLatch,\n minutes_since_last_on: minutesSinceLastOn !== null ? round1(minutesSinceLastOn) : null,\n minutes_since_last_off: minutesSinceLastOff !== null ? round1(minutesSinceLastOff) : null,\n min_on_elapsed: minOnElapsed,\n cooldown_elapsed: cooldownElapsed,\n ac_over_safe_limit: acOverSafeLimit,\n evcs_seems_active: evcsSeemsActive,\n\n evcs_grid_override: evcsGridOverride,\n evcs_explicit_grid_request: evcsExplicitGridRequest,\n evcs_priority_active: evcsPriorityActive,\n evcs_night_grid_takeover_needed: evcsNightGridTakeoverNeeded,\n evcs_source_mode: evcsSourceModeRaw || null,\n evcs_user_strategy: userStrategyRaw || null,\n evcs_surplus_state: evcsSurplusStateRaw || null,\n evcs_vehicle_no_demand_semantic: evcsVehicleNoDemandSemantic\n },\n\n decision: {\n state,\n grid_action: gridAction,\n latch_action: latchAction,\n set_last_on_at: setLastOnAt,\n set_last_off_at: setLastOffAt,\n should_notify: notificationsEnabled && signatureChanged && notifyWanted,\n signature,\n signature_changed: signatureChanged,\n reason,\n short_reason: shortReason\n },\n\n ha_service_calls: {\n helper_state: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_grid_control_state',\n value: state\n }\n },\n\n helper_reason: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_grid_control_reason',\n value: shortReason || 'Aucune raison'\n }\n },\n\n helper_signature: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_grid_control_last_signature',\n value: signature\n }\n },\n\n helper_latch: {\n domain: 'input_boolean',\n service: desiredLatch === 'on' ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_grid_hysteresis_latch'\n }\n },\n\n helper_last_on_at: {\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_grid_last_on_at',\n datetime: now.stamp\n }\n },\n\n helper_last_off_at: {\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_grid_last_off_at',\n datetime: now.stamp\n }\n },\n\n grid: {\n domain: 'switch',\n service: desiredGrid === 'on' ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'switch.edf_general_ok_switch'\n }\n },\n\n logbook: {\n domain: 'logbook',\n service: 'log',\n data: {\n name: 'Victron Grid Hysteresis',\n message: `${state} | Grid=${desiredGrid} | Latch=${desiredLatch} | SOC=${round1(soc)}% | AC=${Math.round(houseAcPowerW)}W | ${shortReason}`,\n entity_id: 'switch.edf_general_ok_switch'\n }\n },\n\n mobile_notification: {\n domain: cfg.mobileNotifyDomain,\n service: cfg.mobileNotifyService,\n data: {\n title: notifyTitle,\n message: notifyMessage,\n data: {\n push: {\n 'interruption-level': 'time-sensitive',\n sound: { name: 'default' }\n },\n ttl: 0,\n priority: 'high'\n }\n }\n }\n }\n};\n\nreturn [{ json: result }];\n"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
448,
|
||
0
|
||
],
|
||
"id": "dde6d41d-3212-4371-83b3-b58d0d70c21e",
|
||
"name": "Compute Grid Hysteresis"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const root = $input.first().json || {};\nconst decision = root.decision || {};\nconst calls = root.ha_service_calls || {};\nconst actions = [];\n\nfunction pushAction(action, call, enabled = true) {\n if (!enabled) return;\n if (!call) return;\n\n actions.push({\n json: {\n action,\n ...call\n }\n });\n}\n\n// ======================================================\n// HELPERS TOUJOURS MIS À JOUR\n// ======================================================\npushAction('helper_state', calls.helper_state, true);\npushAction('helper_reason', calls.helper_reason, true);\n\n// ======================================================\n// SIGNATURE SI CHANGEMENT\n// ======================================================\nif (decision.signature_changed) {\n pushAction('helper_signature', calls.helper_signature, true);\n}\n\n// ======================================================\n// SÉQUENCEMENT ACTIONS RÉSEAU / LATCH\n//\n// IMPORTANT :\n// - pour ALLUMER : latch ON d'abord, puis grid ON\n// - pour ÉTEINDRE : grid OFF d'abord, puis latch OFF\n// ======================================================\n\n// ---------- CAS 1 : LATCH ON avant GRID ON ----------\nif (decision.latch_action === 'turn_on') {\n pushAction('helper_latch', calls.helper_latch, true);\n}\n\nif (decision.grid_action === 'turn_on') {\n pushAction('grid', calls.grid, true);\n}\n\n// ---------- CAS 2 : GRID OFF avant LATCH OFF ----------\nif (decision.grid_action === 'turn_off') {\n pushAction('grid', calls.grid, true);\n}\n\nif (decision.latch_action === 'turn_off') {\n pushAction('helper_latch', calls.helper_latch, true);\n}\n\n// ======================================================\n// TIMESTAMPS SI NÉCESSAIRE\n// ======================================================\nif (decision.set_last_on_at) {\n pushAction('helper_last_on_at', calls.helper_last_on_at, true);\n}\n\nif (decision.set_last_off_at) {\n pushAction('helper_last_off_at', calls.helper_last_off_at, true);\n}\n\n// ======================================================\n// LOGBOOK SI CHANGEMENT DE SIGNATURE\n// ======================================================\nif (decision.signature_changed) {\n pushAction('logbook', calls.logbook, true);\n}\n\n// ======================================================\n// NOTIFICATION SI UTILE\n// ======================================================\nif (decision.should_notify) {\n pushAction('mobile_notification', calls.mobile_notification, true);\n}\n\nreturn actions;\n"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
672,
|
||
0
|
||
],
|
||
"id": "d3728055-9f5b-4362-a275-247cc91c5f41",
|
||
"name": "Build Grid Hysteresis Actions"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "POST",
|
||
"url": "={{ 'http://10.10.10.17:8123/api/services/' + $json.domain + '/' + $json.service }}\n",
|
||
"authentication": "genericCredentialType",
|
||
"genericAuthType": "httpBearerAuth",
|
||
"sendHeaders": true,
|
||
"headerParameters": {
|
||
"parameters": [
|
||
{
|
||
"name": "Content-Type",
|
||
"value": "application/json"
|
||
},
|
||
{
|
||
"name": "accept",
|
||
"value": "application/json"
|
||
}
|
||
]
|
||
},
|
||
"sendBody": true,
|
||
"specifyBody": "json",
|
||
"jsonBody": "={{ $json.data }}",
|
||
"options": {}
|
||
},
|
||
"id": "7d38f7cf-ad84-4481-a201-d21dd0dce321",
|
||
"name": "Execute HA Service",
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.2,
|
||
"position": [
|
||
896,
|
||
0
|
||
],
|
||
"alwaysOutputData": true,
|
||
"credentials": {
|
||
"httpBearerAuth": {
|
||
"id": "uv3sknYYigxBkaVN",
|
||
"name": "Bearer Auth account 2"
|
||
}
|
||
}
|
||
}
|
||
],
|
||
"connections": {
|
||
"Schedule Trigger": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HA Snapshot Grid Hysteresis",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HA Snapshot Grid Hysteresis": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Compute Grid Hysteresis",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Compute Grid Hysteresis": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Build Grid Hysteresis Actions",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Build Grid Hysteresis Actions": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Execute HA Service",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
}
|
||
},
|
||
"nodeGroups": [],
|
||
"authors": "thierry tallagrand",
|
||
"name": null,
|
||
"description": null,
|
||
"autosaved": false,
|
||
"workflowPublishHistory": [
|
||
{
|
||
"createdAt": "2026-07-09T01:00:10.991Z",
|
||
"id": 162,
|
||
"workflowId": "7iIlu7LLTW7TZA8o",
|
||
"versionId": "ecba2d4a-5f6f-4024-951f-3bd01905cd03",
|
||
"event": "activated",
|
||
"userId": "8b9e9552-cd74-4863-82a3-174b3b3befa2"
|
||
},
|
||
{
|
||
"createdAt": "2026-07-09T21:45:52.998Z",
|
||
"id": 188,
|
||
"workflowId": "7iIlu7LLTW7TZA8o",
|
||
"versionId": "ecba2d4a-5f6f-4024-951f-3bd01905cd03",
|
||
"event": "activated",
|
||
"userId": "8b9e9552-cd74-4863-82a3-174b3b3befa2"
|
||
},
|
||
{
|
||
"createdAt": "2026-07-09T01:00:10.961Z",
|
||
"id": 161,
|
||
"workflowId": "7iIlu7LLTW7TZA8o",
|
||
"versionId": "ecba2d4a-5f6f-4024-951f-3bd01905cd03",
|
||
"event": "deactivated",
|
||
"userId": "8b9e9552-cd74-4863-82a3-174b3b3befa2"
|
||
},
|
||
{
|
||
"createdAt": "2026-07-09T21:45:52.981Z",
|
||
"id": 187,
|
||
"workflowId": "7iIlu7LLTW7TZA8o",
|
||
"versionId": "ecba2d4a-5f6f-4024-951f-3bd01905cd03",
|
||
"event": "deactivated",
|
||
"userId": "8b9e9552-cd74-4863-82a3-174b3b3befa2"
|
||
}
|
||
]
|
||
}
|
||
}
|