Files
EVCS/workflows/victron-auto-charge-manager-v1.2.json
Tallagrand Thierry 283ef42203 pv_mid passe aussi a 130 A
Le patch precedent n'avait releve que pv_boost : au-dessus de 80 % de SOC
c'est pv_mid qui commande, donc son plafond de 110 A restait le plus bas et
rien n'etait observable cet apres-midi. Table finale : 150 / 130 / 80 / 30,
toujours decroissante.

Applique a 13:31. Le courant est passe de 90 a 116 A dans la minute — mais
la charge de la maison a chute de 2,6 kW au meme moment, et le courant n'a
repris que 1,3 kW des 2,6 liberes tandis qu'un chargeur de plus se bridait.
Ce n'est donc PAS une preuve propre, et je ne la presente pas comme telle.

Ce que le releve montre vraiment : dans la tranche 80-90, le courant a
plafonne vers 116 A sans jamais approcher les 130, pendant que la tension
montait de 50,82 a 51,07 V vers la consigne. A ce niveau de SOC c'est la
TENSION qui contraint, pas le courant.

La ou le plafond mordait : a 12:36-12:42, SOC 78-80, le courant etait a
129 A pour une DVCC de 130 — colle dessus. C'est la tranche pv_boost, donc
c'est la charge du matin qui dira ce que le 150 A libere.

Cellules a 2 mV et aucune alarme sur toute la sequence. Golden ACM 20/20,
attendu de acm-jour-mid mis a jour dans le meme commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GwpFpy2aQ2rJok6tNQSbVj
2026-09-13 13:41:19 +02:00

458 lines
52 KiB
JSON

{
"updatedAt": "2026-07-09T21:45:53.243Z",
"createdAt": "2026-03-13T01:18:19.435Z",
"id": "cTeeHy4ePiVeELLh",
"name": "Victron - Auto Charge Manager V1.2",
"description": null,
"active": true,
"isArchived": false,
"nodes": [
{
"parameters": {
"rule": {
"interval": [
{
"field": "minutes"
}
]
}
},
"id": "3bc36d27-0e92-4207-bbde-7f23efbc1881",
"name": "Schedule Trigger",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [
0,
0
]
},
{
"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_voltage_v\": states(\"sensor.victron_battery_voltage\"),\n \"battery_current_a\": states(\"sensor.victron_battery_current\"),\n \"pv_power_w\": states(\"sensor.victron_pv_power_system\"),\n \"edf_switch\": states(\"switch.edf_general_ok_switch\"),\n \"today_remaining_kwh\": states(\"sensor.energy_production_today_remaining\"),\n \"tomorrow_kwh\": states(\"sensor.energy_production_tomorrow\"),\n \"current_max_charge_current_a\": states(\"sensor.victron_max_charge_current_setting\"),\n \"current_max_charge_voltage_v\": states(\"sensor.victron_max_charge_voltage_setting\"),\n \"auto_enabled\": states(\"input_boolean.victron_auto_charge_enabled\"),\n \"night_takeover\": states(\"input_boolean.victron_evcs_night_grid_takeover_needed\"),\n \"night_target_soc\": states(\"input_number.victron_evcs_night_target_soc\"),\n \"notifications_enabled\": states(\"input_boolean.victron_auto_charge_notifications\"),\n \"last_signature\": states(\"input_text.victron_charge_last_signature\"),\n\n \"override_enabled\": states(\"input_boolean.victron_charge_override_enabled\"),\n \"override_profile\": states(\"input_select.victron_charge_override_profile\"),\n \"override_current_a\": states(\"input_number.victron_override_charge_current\"),\n \"override_voltage_v\": states(\"input_number.victron_override_charge_voltage\"),\n \"override_until\": states(\"input_datetime.victron_charge_override_until\"),\n \"override_reason\": states(\"input_text.victron_charge_override_reason\")\n} | tojson }}\n"
}
]
},
"options": {}
},
"id": "3aa1ccc7-0831-4c25-b511-e4fc76c0cc83",
"name": "HA Snapshot via Template",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
240,
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 timeToMinutes(hh, mm) {\n return hh * 60 + mm;\n}\n\nfunction inRange(minNow, startMin, endMin) {\n return minNow >= startMin && minNow < endMin;\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 localStamp = `${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 localStamp,\n minNow: Number(parts.hour) * 60 + Number(parts.minute),\n };\n}\n\nfunction forecastBand(kwh) {\n if (!Number.isFinite(kwh)) return 'unknown';\n if (kwh >= 25) return 'sunny';\n if (kwh >= 12) return 'mixed';\n return 'dark';\n}\n\nconst cfg = {\n winterMonths: [11, 12, 1, 2],\n\n windows: {\n nightHC_start: timeToMinutes(1, 24),\n nightHC_end: timeToMinutes(7, 24),\n\n middayHC_start: timeToMinutes(12, 24),\n middayHC_end: timeToMinutes(14, 24),\n\n winterDay_start: timeToMinutes(7, 15),\n winterDay_end: timeToMinutes(16, 45),\n\n summerDay_start: timeToMinutes(7, 30),\n summerDay_end: timeToMinutes(19, 30),\n },\n\n profiles: {\n night_slow: { current_a: 30, voltage_v: 50.1 },\n takeover_hold: { current_a: 2, voltage_v: 50.1 },\n pv_boost: { current_a: 150, voltage_v: 51.5 },\n pv_mid: { current_a: 130, voltage_v: 51.5 },\n pv_topoff: { current_a: 80, voltage_v: 52.4 },\n pv_hold: { current_a: 30, voltage_v: 50.6 },\n midday_grid_catchup: { current_a: 30, voltage_v: 50.8 },\n emergency_rescue: { current_a: 60, voltage_v: 50.4 },\n },\n\n voltagePublishDelta: 0.05,\n currentPublishDelta: 0.5,\n\n mobileNotifyDomain: 'notify',\n mobileNotifyService: 'mobile_app_iphone_16_de_tt'\n};\n\nconst MQTT_TOPIC_CHARGE_CURRENT = 'W/c0619ab34ac5/settings/0/Settings/SystemSetup/MaxChargeCurrent';\nconst MQTT_TOPIC_CHARGE_VOLTAGE = 'W/c0619ab34ac5/settings/0/Settings/SystemSetup/MaxChargeVoltage';\n\nconst raw = $input.first().json;\nconst input = extractSnapshot(raw);\nconst now = getParisNowParts();\n\nconst soc = num(input.soc_percent, 0);\nconst batteryVoltageV = num(input.battery_voltage_v, NaN);\nconst batteryCurrentA = num(input.battery_current_a, NaN);\nconst pvPowerW = num(input.pv_power_w, 0);\nconst edfSwitch = low(input.edf_switch || 'off') === 'on' ? 'on' : 'off';\n\nconst todayRemainingKwh = num(input.today_remaining_kwh, 0);\nconst tomorrowKwh = num(input.tomorrow_kwh, 0);\n\nconst currentMaxChargeCurrentA = num(input.current_max_charge_current_a, 0);\nconst currentMaxChargeVoltageV = num(input.current_max_charge_voltage_v, 0);\n\nconst autoEnabled = low(input.auto_enabled || 'off') === 'on';\nconst nightTakeover = low(input.night_takeover || 'off') === 'on';\nconst nightTargetSoc = num(input.night_target_soc, 35);\nconst notificationsEnabled = low(input.notifications_enabled || 'off') === 'on';\nconst lastSignature = (input.last_signature ?? '').toString().trim();\n\nconst overrideEnabled = low(input.override_enabled || 'off') === 'on';\nconst overrideProfile = low(input.override_profile || 'auto');\nconst overrideCurrentA = num(input.override_current_a, NaN);\nconst overrideVoltageV = num(input.override_voltage_v, NaN);\nconst overrideUntil = (input.override_until ?? '').toString().trim();\nconst overrideReason = (input.override_reason ?? '').toString().trim();\n\nconst isWinter = cfg.winterMonths.includes(now.month);\nconst inNightHC = inRange(now.minNow, cfg.windows.nightHC_start, cfg.windows.nightHC_end);\nconst inMiddayHC = inRange(now.minNow, cfg.windows.middayHC_start, cfg.windows.middayHC_end);\nconst inDayWindow = isWinter\n ? inRange(now.minNow, cfg.windows.winterDay_start, cfg.windows.winterDay_end)\n : inRange(now.minNow, cfg.windows.summerDay_start, cfg.windows.summerDay_end);\n\nconst forecastRefKwh = now.hour < 8 ? Math.max(todayRemainingKwh, tomorrowKwh, 0) : todayRemainingKwh;\nconst forecastState = forecastBand(forecastRefKwh);\n\nfunction profileValues(profileName) {\n if (profileName === 'manual') {\n return {\n current_a: Number.isFinite(overrideCurrentA) ? overrideCurrentA : cfg.profiles.pv_boost.current_a,\n voltage_v: Number.isFinite(overrideVoltageV) ? overrideVoltageV : cfg.profiles.pv_boost.voltage_v,\n };\n }\n return cfg.profiles[profileName] || null;\n}\n\nconst overrideActive =\n overrideEnabled &&\n overrideProfile !== 'auto' &&\n /^\\d{4}-\\d{2}-\\d{2}/.test(overrideUntil) &&\n now.localStamp < overrideUntil;\n\nlet mode = 'idle';\nlet effectiveProfile = 'auto_idle';\nlet targetCurrentA = currentMaxChargeCurrentA || 30;\nlet targetVoltageV = currentMaxChargeVoltageV || 50.5;\nlet criticalAlert = false;\nlet reason = [];\n\nif (!autoEnabled) {\n mode = 'automation_disabled';\n effectiveProfile = 'manual_keep_current';\n targetCurrentA = currentMaxChargeCurrentA || 30;\n targetVoltageV = currentMaxChargeVoltageV || 50.5;\n reason.push(\"Automatisation désactivée, conservation des réglages actuels\");\n} else {\n if (soc < 20 && !inNightHC && !inMiddayHC) {\n mode = 'emergency_rescue';\n effectiveProfile = 'emergency_rescue';\n targetCurrentA = cfg.profiles.emergency_rescue.current_a;\n targetVoltageV = cfg.profiles.emergency_rescue.voltage_v;\n criticalAlert = true;\n reason.push(`SOC critique ${soc}%`);\n }\n\n // P4 : takeover EVCS nocturne. EDF est ON pour la maison + le VE (HC),\n // la batterie doit seulement être RAMENÉE à la cible matin puis MAINTENUE,\n // pas rechargée à fond (cible adaptée au forecast : été ~35%, hiver ~85%).\n else if (nightTakeover && edfSwitch === 'on') {\n const holdTargetSoc = Math.min(95, Math.max(20, nightTargetSoc));\n if (soc < holdTargetSoc - 1) {\n mode = 'evcs_takeover_recover';\n effectiveProfile = 'night_slow';\n targetCurrentA = cfg.profiles.night_slow.current_a;\n targetVoltageV = cfg.profiles.night_slow.voltage_v;\n reason.push(`Takeover EVCS : recharge vers cible matin ${holdTargetSoc}% (SOC ${soc}%)`);\n } else {\n mode = 'evcs_takeover_hold';\n effectiveProfile = 'takeover_hold';\n targetCurrentA = cfg.profiles.takeover_hold.current_a;\n targetVoltageV = cfg.profiles.takeover_hold.voltage_v;\n reason.push(`Takeover EVCS : maintien SOC ~${holdTargetSoc}% (maison+VE sur HC, batterie en pause)`);\n }\n }\n\n else if (isWinter && inNightHC) {\n let targetSoc;\n if (forecastState === 'sunny') targetSoc = 40;\n else if (forecastState === 'mixed') targetSoc = 60;\n else targetSoc = 80;\n\n if (soc < targetSoc - 1) {\n mode = 'winter_night_charge_window';\n effectiveProfile = 'night_slow';\n targetCurrentA = cfg.profiles.night_slow.current_a;\n targetVoltageV = cfg.profiles.night_slow.voltage_v;\n reason.push(`Hiver HC nuit, cible SOC ${targetSoc}% selon forecast ${forecastState}`);\n } else {\n mode = 'winter_night_idle';\n effectiveProfile = 'night_slow';\n targetCurrentA = cfg.profiles.night_slow.current_a;\n targetVoltageV = cfg.profiles.night_slow.voltage_v;\n reason.push(`Hiver HC nuit, SOC ${soc}% déjà suffisant`);\n }\n }\n\n else if (!isWinter && inNightHC) {\n if (soc < 22) {\n mode = 'summer_night_rescue_window';\n effectiveProfile = 'night_slow';\n targetCurrentA = cfg.profiles.night_slow.current_a;\n targetVoltageV = cfg.profiles.night_slow.voltage_v;\n reason.push(\"Été HC nuit, secours SOC < 22%\");\n } else {\n mode = 'summer_night_idle';\n effectiveProfile = 'night_slow';\n targetCurrentA = cfg.profiles.night_slow.current_a;\n targetVoltageV = cfg.profiles.night_slow.voltage_v;\n reason.push(\"Été HC nuit, pas de besoin réseau\");\n }\n }\n\n else if (\n inMiddayHC &&\n soc < 85 &&\n forecastRefKwh < 8 &&\n pvPowerW < 1500\n ) {\n mode = 'midday_catchup_window';\n effectiveProfile = 'midday_grid_catchup';\n targetCurrentA = cfg.profiles.midday_grid_catchup.current_a;\n targetVoltageV = cfg.profiles.midday_grid_catchup.voltage_v;\n reason.push(`HC midi rattrapage : SOC ${soc}% | forecast ${forecastRefKwh} kWh | PV ${pvPowerW} W`);\n }\n\n else if (inDayWindow) {\n if (soc < 80) {\n mode = 'day_pv_boost';\n effectiveProfile = 'pv_boost';\n targetCurrentA = cfg.profiles.pv_boost.current_a;\n targetVoltageV = cfg.profiles.pv_boost.voltage_v;\n reason.push(`Jour SOC bas ${soc}% : capture PV max`);\n } else if (soc < 90) {\n mode = 'day_pv_mid';\n effectiveProfile = 'pv_mid';\n targetCurrentA = cfg.profiles.pv_mid.current_a;\n targetVoltageV = cfg.profiles.pv_mid.voltage_v;\n reason.push(`Jour SOC intermédiaire ${soc}%`);\n } else if (soc < 95) {\n mode = 'day_pv_topoff';\n effectiveProfile = 'pv_topoff';\n targetCurrentA = cfg.profiles.pv_topoff.current_a;\n targetVoltageV = cfg.profiles.pv_topoff.voltage_v;\n reason.push(`Jour top-off vers 95% | SOC ${soc}%`);\n } else {\n mode = 'day_pv_hold';\n effectiveProfile = 'pv_hold';\n targetCurrentA = cfg.profiles.pv_hold.current_a;\n targetVoltageV = cfg.profiles.pv_hold.voltage_v;\n reason.push(`Jour maintien haut SOC ${soc}%`);\n }\n\n if (\n Number.isFinite(batteryVoltageV) &&\n soc < 95 &&\n pvPowerW > 2000 &&\n batteryVoltageV >= (targetVoltageV - 0.15)\n ) {\n targetVoltageV = Math.max(targetVoltageV, 51.3);\n targetCurrentA = Math.max(targetCurrentA, 80);\n if (soc >= 90) {\n effectiveProfile = 'pv_topoff';\n mode = 'day_pv_topoff';\n } else {\n effectiveProfile = 'pv_boost';\n }\n reason.push(`Batterie proche consigne (${round1(batteryVoltageV)}V), relèvement anti-bridage`);\n }\n\n if (\n Number.isFinite(batteryCurrentA) &&\n soc < 95 &&\n pvPowerW > 6000 &&\n batteryCurrentA >= (targetCurrentA - 3)\n ) {\n targetCurrentA = Math.max(targetCurrentA, soc >= 90 ? 80 : 130);\n reason.push(`Courant batterie proche limite (${round1(batteryCurrentA)}A), relèvement du plafond`);\n }\n }\n\n\n else {\n mode = 'off_window_idle';\n effectiveProfile = 'pv_hold';\n targetCurrentA = cfg.profiles.pv_hold.current_a;\n targetVoltageV = cfg.profiles.pv_hold.voltage_v;\n reason.push(\"Hors fenêtre utile\");\n }\n}\n\nif (overrideActive) {\n const ov = profileValues(overrideProfile);\n if (ov) {\n targetCurrentA = ov.current_a;\n targetVoltageV = ov.voltage_v;\n effectiveProfile = `override_${overrideProfile}`;\n mode = `${mode}_override`;\n reason.unshift(\n `Override actif jusqu'à ${overrideUntil} | profil=${overrideProfile}` +\n (overrideReason ? ` | ${overrideReason}` : '')\n );\n }\n} else if (overrideEnabled && overrideProfile !== 'auto') {\n reason.push(\"Override configuré mais inactif ou expiré\");\n}\n\ntargetCurrentA = round1(targetCurrentA);\ntargetVoltageV = round1(targetVoltageV);\n\nconst shouldPublishCurrent =\n !Number.isFinite(currentMaxChargeCurrentA) ||\n Math.abs(targetCurrentA - currentMaxChargeCurrentA) >= cfg.currentPublishDelta;\n\nconst shouldPublishVoltage =\n !Number.isFinite(currentMaxChargeVoltageV) ||\n Math.abs(targetVoltageV - currentMaxChargeVoltageV) >= cfg.voltagePublishDelta;\n\nconst shortReason = reason.join(' | ').slice(0, 255);\n\nconst signature = [\n now.date,\n mode,\n effectiveProfile,\n `i=${targetCurrentA}`,\n `v=${targetVoltageV}`\n].join('|');\n\nconst signatureChanged = signature !== lastSignature;\n\nconst result = {\n timestamp_paris: now.localStamp,\n\n inputs: {\n soc_percent: soc,\n battery_voltage_v: Number.isFinite(batteryVoltageV) ? batteryVoltageV : null,\n battery_current_a: Number.isFinite(batteryCurrentA) ? batteryCurrentA : null,\n pv_power_w: pvPowerW,\n edf_switch_observed: edfSwitch,\n today_remaining_kwh: todayRemainingKwh,\n tomorrow_kwh: tomorrowKwh,\n current_max_charge_current_a: currentMaxChargeCurrentA,\n current_max_charge_voltage_v: currentMaxChargeVoltageV,\n auto_enabled: autoEnabled,\n notifications_enabled: notificationsEnabled,\n last_signature: lastSignature || null,\n\n override_enabled: overrideEnabled,\n override_profile: overrideProfile,\n override_current_a: Number.isFinite(overrideCurrentA) ? overrideCurrentA : null,\n override_voltage_v: Number.isFinite(overrideVoltageV) ? overrideVoltageV : null,\n override_until: overrideUntil || null,\n override_reason: overrideReason || null\n },\n\n computed: {\n is_winter: isWinter,\n in_night_hc: inNightHC,\n in_midday_hc: inMiddayHC,\n in_day_window: inDayWindow,\n forecast_reference_kwh: round1(forecastRefKwh),\n forecast_state: forecastState,\n override_active: overrideActive\n },\n\n decision: {\n mode,\n effective_profile: effectiveProfile,\n target_current_a: targetCurrentA,\n target_voltage_v: targetVoltageV,\n should_publish_current: shouldPublishCurrent,\n should_publish_voltage: shouldPublishVoltage,\n critical_alert: criticalAlert,\n signature,\n signature_changed: signatureChanged,\n reason,\n short_reason: shortReason\n },\n\n ha_service_calls: {\n helper_current: {\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_target_charge_current',\n value: targetCurrentA\n }\n },\n\n helper_voltage: {\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_target_charge_voltage',\n value: targetVoltageV\n }\n },\n\n helper_soc_live: {\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_soc_live',\n value: soc\n }\n },\n\n helper_mode: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_charge_mode',\n value: mode\n }\n },\n\n helper_reason: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_charge_reason',\n value: shortReason || 'Aucune raison'\n }\n },\n\n helper_profile: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_effective_charge_profile',\n value: effectiveProfile\n }\n },\n\n helper_signature: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_charge_last_signature',\n value: signature\n }\n },\n\n mqtt_current: {\n domain: 'mqtt',\n service: 'publish',\n data: {\n topic: MQTT_TOPIC_CHARGE_CURRENT,\n payload: JSON.stringify({ value: targetCurrentA }),\n retain: false\n }\n },\n\n mqtt_voltage: {\n domain: 'mqtt',\n service: 'publish',\n data: {\n topic: MQTT_TOPIC_CHARGE_VOLTAGE,\n payload: JSON.stringify({ value: targetVoltageV }),\n retain: false\n }\n },\n\n logbook: {\n domain: 'logbook',\n service: 'log',\n data: {\n name: 'Victron Auto Charge',\n message: `${mode} | profile=${effectiveProfile} | I=${targetCurrentA}A | V=${targetVoltageV}V | SOC=${soc}% | ${shortReason}`,\n entity_id: 'sensor.victron_battery_soc'\n }\n },\n\n mobile_notification: {\n domain: cfg.mobileNotifyDomain,\n service: cfg.mobileNotifyService,\n data: {\n title: 'Victron Auto Charge',\n message: `${mode} | profile=${effectiveProfile} | I=${targetCurrentA}A | V=${targetVoltageV}V | SOC=${soc}%`,\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"
},
"id": "a98c5bda-cf34-447e-942e-37ae75a45d5c",
"name": "Compute Charge Targets",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
512,
0
]
},
{
"parameters": {
"jsCode": "const root = $input.first().json;\nconst actions = [];\n\n// Helpers : toujours mis à jour\nactions.push({\n json: {\n action: 'helper_mode',\n ...root.ha_service_calls.helper_mode\n }\n});\n\nactions.push({\n json: {\n action: 'helper_reason',\n ...root.ha_service_calls.helper_reason\n }\n});\n\nactions.push({\n json: {\n action: 'helper_current',\n ...root.ha_service_calls.helper_current\n }\n});\n\nactions.push({\n json: {\n action: 'helper_voltage',\n ...root.ha_service_calls.helper_voltage\n }\n});\n\nactions.push({\n json: {\n action: 'helper_soc_live',\n ...root.ha_service_calls.helper_soc_live\n }\n});\n\nif (root.ha_service_calls.helper_profile) {\n actions.push({\n json: {\n action: 'helper_profile',\n ...root.ha_service_calls.helper_profile\n }\n });\n}\n\n// Signature + log seulement si changement réel\nif (root.decision.signature_changed) {\n actions.push({\n json: {\n action: 'helper_signature',\n ...root.ha_service_calls.helper_signature\n }\n });\n\n actions.push({\n json: {\n action: 'logbook',\n ...root.ha_service_calls.logbook\n }\n });\n}\n\n// D'abord les limites Victron\nif (root.decision.should_publish_current) {\n actions.push({\n json: {\n action: 'mqtt_current',\n ...root.ha_service_calls.mqtt_current\n }\n });\n}\n\nif (root.decision.should_publish_voltage) {\n actions.push({\n json: {\n action: 'mqtt_voltage',\n ...root.ha_service_calls.mqtt_voltage\n }\n });\n}\n\n// Notification iPhone seulement si critique + activée\nif (root.inputs.notifications_enabled && root.decision.critical_alert) {\n actions.push({\n json: {\n action: 'mobile_notification',\n ...root.ha_service_calls.mobile_notification\n }\n });\n}\n\nreturn actions;\n"
},
"id": "d0a3ecb8-6c4d-46e6-a81c-cb84a789b0d7",
"name": "Build HA Service Actions",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
768,
0
]
},
{
"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": "88eb5cba-7ecb-4fca-a995-fd8612118f3a",
"name": "Execute HA Service",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1040,
0
],
"alwaysOutputData": true,
"credentials": {
"httpBearerAuth": {
"id": "uv3sknYYigxBkaVN",
"name": "Bearer Auth account 2"
}
}
}
],
"connections": {
"Schedule Trigger": {
"main": [
[
{
"node": "HA Snapshot via Template",
"type": "main",
"index": 0
}
]
]
},
"HA Snapshot via Template": {
"main": [
[
{
"node": "Compute Charge Targets",
"type": "main",
"index": 0
}
]
]
},
"Compute Charge Targets": {
"main": [
[
{
"node": "Build HA Service Actions",
"type": "main",
"index": 0
}
]
]
},
"Build HA Service 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": "de4ce3dd-bda2-4d27-ba39-72cfc4cfbcbb",
"activeVersionId": "de4ce3dd-bda2-4d27-ba39-72cfc4cfbcbb",
"versionCounter": 180,
"triggerCount": 1,
"sourceWorkflowId": null,
"shared": [
{
"updatedAt": "2026-03-13T01:18:19.440Z",
"createdAt": "2026-03-13T01:18:19.440Z",
"role": "workflow:owner",
"workflowId": "cTeeHy4ePiVeELLh",
"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-09T16:18:50.134Z",
"createdAt": "2026-07-09T16:18:50.134Z",
"versionId": "de4ce3dd-bda2-4d27-ba39-72cfc4cfbcbb",
"workflowId": "cTeeHy4ePiVeELLh",
"nodes": [
{
"parameters": {
"rule": {
"interval": [
{
"field": "minutes"
}
]
}
},
"id": "3bc36d27-0e92-4207-bbde-7f23efbc1881",
"name": "Schedule Trigger",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [
0,
0
]
},
{
"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_voltage_v\": states(\"sensor.victron_battery_voltage\"),\n \"battery_current_a\": states(\"sensor.victron_battery_current\"),\n \"pv_power_w\": states(\"sensor.victron_pv_power_system\"),\n \"edf_switch\": states(\"switch.edf_general_ok_switch\"),\n \"today_remaining_kwh\": states(\"sensor.energy_production_today_remaining\"),\n \"tomorrow_kwh\": states(\"sensor.energy_production_tomorrow\"),\n \"current_max_charge_current_a\": states(\"sensor.victron_max_charge_current_setting\"),\n \"current_max_charge_voltage_v\": states(\"sensor.victron_max_charge_voltage_setting\"),\n \"auto_enabled\": states(\"input_boolean.victron_auto_charge_enabled\"),\n \"night_takeover\": states(\"input_boolean.victron_evcs_night_grid_takeover_needed\"),\n \"night_target_soc\": states(\"input_number.victron_evcs_night_target_soc\"),\n \"notifications_enabled\": states(\"input_boolean.victron_auto_charge_notifications\"),\n \"last_signature\": states(\"input_text.victron_charge_last_signature\"),\n\n \"override_enabled\": states(\"input_boolean.victron_charge_override_enabled\"),\n \"override_profile\": states(\"input_select.victron_charge_override_profile\"),\n \"override_current_a\": states(\"input_number.victron_override_charge_current\"),\n \"override_voltage_v\": states(\"input_number.victron_override_charge_voltage\"),\n \"override_until\": states(\"input_datetime.victron_charge_override_until\"),\n \"override_reason\": states(\"input_text.victron_charge_override_reason\")\n} | tojson }}\n"
}
]
},
"options": {}
},
"id": "3aa1ccc7-0831-4c25-b511-e4fc76c0cc83",
"name": "HA Snapshot via Template",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
240,
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 timeToMinutes(hh, mm) {\n return hh * 60 + mm;\n}\n\nfunction inRange(minNow, startMin, endMin) {\n return minNow >= startMin && minNow < endMin;\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 localStamp = `${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 localStamp,\n minNow: Number(parts.hour) * 60 + Number(parts.minute),\n };\n}\n\nfunction forecastBand(kwh) {\n if (!Number.isFinite(kwh)) return 'unknown';\n if (kwh >= 25) return 'sunny';\n if (kwh >= 12) return 'mixed';\n return 'dark';\n}\n\nconst cfg = {\n winterMonths: [11, 12, 1, 2],\n\n windows: {\n nightHC_start: timeToMinutes(1, 24),\n nightHC_end: timeToMinutes(7, 24),\n\n middayHC_start: timeToMinutes(12, 24),\n middayHC_end: timeToMinutes(14, 24),\n\n winterDay_start: timeToMinutes(7, 15),\n winterDay_end: timeToMinutes(16, 45),\n\n summerDay_start: timeToMinutes(7, 30),\n summerDay_end: timeToMinutes(19, 30),\n },\n\n profiles: {\n night_slow: { current_a: 30, voltage_v: 50.1 },\n takeover_hold: { current_a: 2, voltage_v: 50.1 },\n pv_boost: { current_a: 130, voltage_v: 51.5 },\n pv_mid: { current_a: 110, voltage_v: 51.2 },\n pv_topoff: { current_a: 80, voltage_v: 51.3 },\n pv_hold: { current_a: 30, voltage_v: 50.6 },\n midday_grid_catchup: { current_a: 30, voltage_v: 50.8 },\n emergency_rescue: { current_a: 60, voltage_v: 50.4 },\n },\n\n voltagePublishDelta: 0.05,\n currentPublishDelta: 0.5,\n\n mobileNotifyDomain: 'notify',\n mobileNotifyService: 'mobile_app_iphone_16_de_tt'\n};\n\nconst MQTT_TOPIC_CHARGE_CURRENT = 'W/c0619ab34ac5/settings/0/Settings/SystemSetup/MaxChargeCurrent';\nconst MQTT_TOPIC_CHARGE_VOLTAGE = 'W/c0619ab34ac5/settings/0/Settings/SystemSetup/MaxChargeVoltage';\n\nconst raw = $input.first().json;\nconst input = extractSnapshot(raw);\nconst now = getParisNowParts();\n\nconst soc = num(input.soc_percent, 0);\nconst batteryVoltageV = num(input.battery_voltage_v, NaN);\nconst batteryCurrentA = num(input.battery_current_a, NaN);\nconst pvPowerW = num(input.pv_power_w, 0);\nconst edfSwitch = low(input.edf_switch || 'off') === 'on' ? 'on' : 'off';\n\nconst todayRemainingKwh = num(input.today_remaining_kwh, 0);\nconst tomorrowKwh = num(input.tomorrow_kwh, 0);\n\nconst currentMaxChargeCurrentA = num(input.current_max_charge_current_a, 0);\nconst currentMaxChargeVoltageV = num(input.current_max_charge_voltage_v, 0);\n\nconst autoEnabled = low(input.auto_enabled || 'off') === 'on';\nconst nightTakeover = low(input.night_takeover || 'off') === 'on';\nconst nightTargetSoc = num(input.night_target_soc, 35);\nconst notificationsEnabled = low(input.notifications_enabled || 'off') === 'on';\nconst lastSignature = (input.last_signature ?? '').toString().trim();\n\nconst overrideEnabled = low(input.override_enabled || 'off') === 'on';\nconst overrideProfile = low(input.override_profile || 'auto');\nconst overrideCurrentA = num(input.override_current_a, NaN);\nconst overrideVoltageV = num(input.override_voltage_v, NaN);\nconst overrideUntil = (input.override_until ?? '').toString().trim();\nconst overrideReason = (input.override_reason ?? '').toString().trim();\n\nconst isWinter = cfg.winterMonths.includes(now.month);\nconst inNightHC = inRange(now.minNow, cfg.windows.nightHC_start, cfg.windows.nightHC_end);\nconst inMiddayHC = inRange(now.minNow, cfg.windows.middayHC_start, cfg.windows.middayHC_end);\nconst inDayWindow = isWinter\n ? inRange(now.minNow, cfg.windows.winterDay_start, cfg.windows.winterDay_end)\n : inRange(now.minNow, cfg.windows.summerDay_start, cfg.windows.summerDay_end);\n\nconst forecastRefKwh = now.hour < 8 ? Math.max(todayRemainingKwh, tomorrowKwh, 0) : todayRemainingKwh;\nconst forecastState = forecastBand(forecastRefKwh);\n\nfunction profileValues(profileName) {\n if (profileName === 'manual') {\n return {\n current_a: Number.isFinite(overrideCurrentA) ? overrideCurrentA : cfg.profiles.pv_boost.current_a,\n voltage_v: Number.isFinite(overrideVoltageV) ? overrideVoltageV : cfg.profiles.pv_boost.voltage_v,\n };\n }\n return cfg.profiles[profileName] || null;\n}\n\nconst overrideActive =\n overrideEnabled &&\n overrideProfile !== 'auto' &&\n /^\\d{4}-\\d{2}-\\d{2}/.test(overrideUntil) &&\n now.localStamp < overrideUntil;\n\nlet mode = 'idle';\nlet effectiveProfile = 'auto_idle';\nlet targetCurrentA = currentMaxChargeCurrentA || 30;\nlet targetVoltageV = currentMaxChargeVoltageV || 50.5;\nlet criticalAlert = false;\nlet reason = [];\n\nif (!autoEnabled) {\n mode = 'automation_disabled';\n effectiveProfile = 'manual_keep_current';\n targetCurrentA = currentMaxChargeCurrentA || 30;\n targetVoltageV = currentMaxChargeVoltageV || 50.5;\n reason.push(\"Automatisation désactivée, conservation des réglages actuels\");\n} else {\n if (soc < 20 && !inNightHC && !inMiddayHC) {\n mode = 'emergency_rescue';\n effectiveProfile = 'emergency_rescue';\n targetCurrentA = cfg.profiles.emergency_rescue.current_a;\n targetVoltageV = cfg.profiles.emergency_rescue.voltage_v;\n criticalAlert = true;\n reason.push(`SOC critique ${soc}%`);\n }\n\n // P4 : takeover EVCS nocturne. EDF est ON pour la maison + le VE (HC),\n // la batterie doit seulement être RAMENÉE à la cible matin puis MAINTENUE,\n // pas rechargée à fond (cible adaptée au forecast : été ~35%, hiver ~85%).\n else if (nightTakeover && edfSwitch === 'on') {\n const holdTargetSoc = Math.min(95, Math.max(20, nightTargetSoc));\n if (soc < holdTargetSoc - 1) {\n mode = 'evcs_takeover_recover';\n effectiveProfile = 'night_slow';\n targetCurrentA = cfg.profiles.night_slow.current_a;\n targetVoltageV = cfg.profiles.night_slow.voltage_v;\n reason.push(`Takeover EVCS : recharge vers cible matin ${holdTargetSoc}% (SOC ${soc}%)`);\n } else {\n mode = 'evcs_takeover_hold';\n effectiveProfile = 'takeover_hold';\n targetCurrentA = cfg.profiles.takeover_hold.current_a;\n targetVoltageV = cfg.profiles.takeover_hold.voltage_v;\n reason.push(`Takeover EVCS : maintien SOC ~${holdTargetSoc}% (maison+VE sur HC, batterie en pause)`);\n }\n }\n\n else if (isWinter && inNightHC) {\n let targetSoc;\n if (forecastState === 'sunny') targetSoc = 40;\n else if (forecastState === 'mixed') targetSoc = 60;\n else targetSoc = 80;\n\n if (soc < targetSoc - 1) {\n mode = 'winter_night_charge_window';\n effectiveProfile = 'night_slow';\n targetCurrentA = cfg.profiles.night_slow.current_a;\n targetVoltageV = cfg.profiles.night_slow.voltage_v;\n reason.push(`Hiver HC nuit, cible SOC ${targetSoc}% selon forecast ${forecastState}`);\n } else {\n mode = 'winter_night_idle';\n effectiveProfile = 'night_slow';\n targetCurrentA = cfg.profiles.night_slow.current_a;\n targetVoltageV = cfg.profiles.night_slow.voltage_v;\n reason.push(`Hiver HC nuit, SOC ${soc}% déjà suffisant`);\n }\n }\n\n else if (!isWinter && inNightHC) {\n if (soc < 22) {\n mode = 'summer_night_rescue_window';\n effectiveProfile = 'night_slow';\n targetCurrentA = cfg.profiles.night_slow.current_a;\n targetVoltageV = cfg.profiles.night_slow.voltage_v;\n reason.push(\"Été HC nuit, secours SOC < 22%\");\n } else {\n mode = 'summer_night_idle';\n effectiveProfile = 'night_slow';\n targetCurrentA = cfg.profiles.night_slow.current_a;\n targetVoltageV = cfg.profiles.night_slow.voltage_v;\n reason.push(\"Été HC nuit, pas de besoin réseau\");\n }\n }\n\n else if (\n inMiddayHC &&\n soc < 85 &&\n forecastRefKwh < 8 &&\n pvPowerW < 1500\n ) {\n mode = 'midday_catchup_window';\n effectiveProfile = 'midday_grid_catchup';\n targetCurrentA = cfg.profiles.midday_grid_catchup.current_a;\n targetVoltageV = cfg.profiles.midday_grid_catchup.voltage_v;\n reason.push(`HC midi rattrapage : SOC ${soc}% | forecast ${forecastRefKwh} kWh | PV ${pvPowerW} W`);\n }\n\n else if (inDayWindow) {\n if (soc < 80) {\n mode = 'day_pv_boost';\n effectiveProfile = 'pv_boost';\n targetCurrentA = cfg.profiles.pv_boost.current_a;\n targetVoltageV = cfg.profiles.pv_boost.voltage_v;\n reason.push(`Jour SOC bas ${soc}% : capture PV max`);\n } else if (soc < 90) {\n mode = 'day_pv_mid';\n effectiveProfile = 'pv_mid';\n targetCurrentA = cfg.profiles.pv_mid.current_a;\n targetVoltageV = cfg.profiles.pv_mid.voltage_v;\n reason.push(`Jour SOC intermédiaire ${soc}%`);\n } else if (soc < 95) {\n mode = 'day_pv_topoff';\n effectiveProfile = 'pv_topoff';\n targetCurrentA = cfg.profiles.pv_topoff.current_a;\n targetVoltageV = cfg.profiles.pv_topoff.voltage_v;\n reason.push(`Jour top-off vers 95% | SOC ${soc}%`);\n } else {\n mode = 'day_pv_hold';\n effectiveProfile = 'pv_hold';\n targetCurrentA = cfg.profiles.pv_hold.current_a;\n targetVoltageV = cfg.profiles.pv_hold.voltage_v;\n reason.push(`Jour maintien haut SOC ${soc}%`);\n }\n\n if (\n Number.isFinite(batteryVoltageV) &&\n soc < 95 &&\n pvPowerW > 2000 &&\n batteryVoltageV >= (targetVoltageV - 0.15)\n ) {\n targetVoltageV = Math.max(targetVoltageV, 51.3);\n targetCurrentA = Math.max(targetCurrentA, 80);\n if (soc >= 90) {\n effectiveProfile = 'pv_topoff';\n mode = 'day_pv_topoff';\n } else {\n effectiveProfile = 'pv_boost';\n }\n reason.push(`Batterie proche consigne (${round1(batteryVoltageV)}V), relèvement anti-bridage`);\n }\n\n if (\n Number.isFinite(batteryCurrentA) &&\n soc < 95 &&\n pvPowerW > 6000 &&\n batteryCurrentA >= (targetCurrentA - 3)\n ) {\n targetCurrentA = Math.max(targetCurrentA, soc >= 90 ? 80 : 130);\n reason.push(`Courant batterie proche limite (${round1(batteryCurrentA)}A), relèvement du plafond`);\n }\n }\n\n\n else {\n mode = 'off_window_idle';\n effectiveProfile = 'pv_hold';\n targetCurrentA = cfg.profiles.pv_hold.current_a;\n targetVoltageV = cfg.profiles.pv_hold.voltage_v;\n reason.push(\"Hors fenêtre utile\");\n }\n}\n\nif (overrideActive) {\n const ov = profileValues(overrideProfile);\n if (ov) {\n targetCurrentA = ov.current_a;\n targetVoltageV = ov.voltage_v;\n effectiveProfile = `override_${overrideProfile}`;\n mode = `${mode}_override`;\n reason.unshift(\n `Override actif jusqu'à ${overrideUntil} | profil=${overrideProfile}` +\n (overrideReason ? ` | ${overrideReason}` : '')\n );\n }\n} else if (overrideEnabled && overrideProfile !== 'auto') {\n reason.push(\"Override configuré mais inactif ou expiré\");\n}\n\ntargetCurrentA = round1(targetCurrentA);\ntargetVoltageV = round1(targetVoltageV);\n\nconst shouldPublishCurrent =\n !Number.isFinite(currentMaxChargeCurrentA) ||\n Math.abs(targetCurrentA - currentMaxChargeCurrentA) >= cfg.currentPublishDelta;\n\nconst shouldPublishVoltage =\n !Number.isFinite(currentMaxChargeVoltageV) ||\n Math.abs(targetVoltageV - currentMaxChargeVoltageV) >= cfg.voltagePublishDelta;\n\nconst shortReason = reason.join(' | ').slice(0, 255);\n\nconst signature = [\n now.date,\n mode,\n effectiveProfile,\n `i=${targetCurrentA}`,\n `v=${targetVoltageV}`\n].join('|');\n\nconst signatureChanged = signature !== lastSignature;\n\nconst result = {\n timestamp_paris: now.localStamp,\n\n inputs: {\n soc_percent: soc,\n battery_voltage_v: Number.isFinite(batteryVoltageV) ? batteryVoltageV : null,\n battery_current_a: Number.isFinite(batteryCurrentA) ? batteryCurrentA : null,\n pv_power_w: pvPowerW,\n edf_switch_observed: edfSwitch,\n today_remaining_kwh: todayRemainingKwh,\n tomorrow_kwh: tomorrowKwh,\n current_max_charge_current_a: currentMaxChargeCurrentA,\n current_max_charge_voltage_v: currentMaxChargeVoltageV,\n auto_enabled: autoEnabled,\n notifications_enabled: notificationsEnabled,\n last_signature: lastSignature || null,\n\n override_enabled: overrideEnabled,\n override_profile: overrideProfile,\n override_current_a: Number.isFinite(overrideCurrentA) ? overrideCurrentA : null,\n override_voltage_v: Number.isFinite(overrideVoltageV) ? overrideVoltageV : null,\n override_until: overrideUntil || null,\n override_reason: overrideReason || null\n },\n\n computed: {\n is_winter: isWinter,\n in_night_hc: inNightHC,\n in_midday_hc: inMiddayHC,\n in_day_window: inDayWindow,\n forecast_reference_kwh: round1(forecastRefKwh),\n forecast_state: forecastState,\n override_active: overrideActive\n },\n\n decision: {\n mode,\n effective_profile: effectiveProfile,\n target_current_a: targetCurrentA,\n target_voltage_v: targetVoltageV,\n should_publish_current: shouldPublishCurrent,\n should_publish_voltage: shouldPublishVoltage,\n critical_alert: criticalAlert,\n signature,\n signature_changed: signatureChanged,\n reason,\n short_reason: shortReason\n },\n\n ha_service_calls: {\n helper_current: {\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_target_charge_current',\n value: targetCurrentA\n }\n },\n\n helper_voltage: {\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_target_charge_voltage',\n value: targetVoltageV\n }\n },\n\n helper_soc_live: {\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_soc_live',\n value: soc\n }\n },\n\n helper_mode: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_charge_mode',\n value: mode\n }\n },\n\n helper_reason: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_charge_reason',\n value: shortReason || 'Aucune raison'\n }\n },\n\n helper_profile: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_effective_charge_profile',\n value: effectiveProfile\n }\n },\n\n helper_signature: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_charge_last_signature',\n value: signature\n }\n },\n\n mqtt_current: {\n domain: 'mqtt',\n service: 'publish',\n data: {\n topic: MQTT_TOPIC_CHARGE_CURRENT,\n payload: JSON.stringify({ value: targetCurrentA }),\n retain: false\n }\n },\n\n mqtt_voltage: {\n domain: 'mqtt',\n service: 'publish',\n data: {\n topic: MQTT_TOPIC_CHARGE_VOLTAGE,\n payload: JSON.stringify({ value: targetVoltageV }),\n retain: false\n }\n },\n\n logbook: {\n domain: 'logbook',\n service: 'log',\n data: {\n name: 'Victron Auto Charge',\n message: `${mode} | profile=${effectiveProfile} | I=${targetCurrentA}A | V=${targetVoltageV}V | SOC=${soc}% | ${shortReason}`,\n entity_id: 'sensor.victron_battery_soc'\n }\n },\n\n mobile_notification: {\n domain: cfg.mobileNotifyDomain,\n service: cfg.mobileNotifyService,\n data: {\n title: 'Victron Auto Charge',\n message: `${mode} | profile=${effectiveProfile} | I=${targetCurrentA}A | V=${targetVoltageV}V | SOC=${soc}%`,\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"
},
"id": "a98c5bda-cf34-447e-942e-37ae75a45d5c",
"name": "Compute Charge Targets",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
512,
0
]
},
{
"parameters": {
"jsCode": "const root = $input.first().json;\nconst actions = [];\n\n// Helpers : toujours mis à jour\nactions.push({\n json: {\n action: 'helper_mode',\n ...root.ha_service_calls.helper_mode\n }\n});\n\nactions.push({\n json: {\n action: 'helper_reason',\n ...root.ha_service_calls.helper_reason\n }\n});\n\nactions.push({\n json: {\n action: 'helper_current',\n ...root.ha_service_calls.helper_current\n }\n});\n\nactions.push({\n json: {\n action: 'helper_voltage',\n ...root.ha_service_calls.helper_voltage\n }\n});\n\nactions.push({\n json: {\n action: 'helper_soc_live',\n ...root.ha_service_calls.helper_soc_live\n }\n});\n\nif (root.ha_service_calls.helper_profile) {\n actions.push({\n json: {\n action: 'helper_profile',\n ...root.ha_service_calls.helper_profile\n }\n });\n}\n\n// Signature + log seulement si changement réel\nif (root.decision.signature_changed) {\n actions.push({\n json: {\n action: 'helper_signature',\n ...root.ha_service_calls.helper_signature\n }\n });\n\n actions.push({\n json: {\n action: 'logbook',\n ...root.ha_service_calls.logbook\n }\n });\n}\n\n// D'abord les limites Victron\nif (root.decision.should_publish_current) {\n actions.push({\n json: {\n action: 'mqtt_current',\n ...root.ha_service_calls.mqtt_current\n }\n });\n}\n\nif (root.decision.should_publish_voltage) {\n actions.push({\n json: {\n action: 'mqtt_voltage',\n ...root.ha_service_calls.mqtt_voltage\n }\n });\n}\n\n// Notification iPhone seulement si critique + activée\nif (root.inputs.notifications_enabled && root.decision.critical_alert) {\n actions.push({\n json: {\n action: 'mobile_notification',\n ...root.ha_service_calls.mobile_notification\n }\n });\n}\n\nreturn actions;\n"
},
"id": "d0a3ecb8-6c4d-46e6-a81c-cb84a789b0d7",
"name": "Build HA Service Actions",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
768,
0
]
},
{
"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": "88eb5cba-7ecb-4fca-a995-fd8612118f3a",
"name": "Execute HA Service",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1040,
0
],
"alwaysOutputData": true,
"credentials": {
"httpBearerAuth": {
"id": "uv3sknYYigxBkaVN",
"name": "Bearer Auth account 2"
}
}
}
],
"connections": {
"Schedule Trigger": {
"main": [
[
{
"node": "HA Snapshot via Template",
"type": "main",
"index": 0
}
]
]
},
"HA Snapshot via Template": {
"main": [
[
{
"node": "Compute Charge Targets",
"type": "main",
"index": 0
}
]
]
},
"Compute Charge Targets": {
"main": [
[
{
"node": "Build HA Service Actions",
"type": "main",
"index": 0
}
]
]
},
"Build HA Service Actions": {
"main": [
[
{
"node": "Execute HA Service",
"type": "main",
"index": 0
}
]
]
}
},
"nodeGroups": [],
"authors": "thierry tallagrand",
"name": null,
"description": null,
"autosaved": false,
"workflowPublishHistory": [
{
"createdAt": "2026-07-09T16:18:50.209Z",
"id": 180,
"workflowId": "cTeeHy4ePiVeELLh",
"versionId": "de4ce3dd-bda2-4d27-ba39-72cfc4cfbcbb",
"event": "activated",
"userId": "8b9e9552-cd74-4863-82a3-174b3b3befa2"
},
{
"createdAt": "2026-07-09T21:45:53.307Z",
"id": 190,
"workflowId": "cTeeHy4ePiVeELLh",
"versionId": "de4ce3dd-bda2-4d27-ba39-72cfc4cfbcbb",
"event": "activated",
"userId": "8b9e9552-cd74-4863-82a3-174b3b3befa2"
},
{
"createdAt": "2026-07-09T16:18:50.185Z",
"id": 179,
"workflowId": "cTeeHy4ePiVeELLh",
"versionId": "de4ce3dd-bda2-4d27-ba39-72cfc4cfbcbb",
"event": "deactivated",
"userId": "8b9e9552-cd74-4863-82a3-174b3b3befa2"
},
{
"createdAt": "2026-07-09T21:45:53.283Z",
"id": 189,
"workflowId": "cTeeHy4ePiVeELLh",
"versionId": "de4ce3dd-bda2-4d27-ba39-72cfc4cfbcbb",
"event": "deactivated",
"userId": "8b9e9552-cd74-4863-82a3-174b3b3befa2"
}
]
}
}