diff --git a/HANDOFF.md b/HANDOFF.md index 182e742..d36e52a 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -6005,3 +6005,76 @@ ceux que l observateur ecrit**. Les compteurs ont survecu au rechargement --- *Document mis a jour le : 13/09/2026 — campagne conseiller : quatre defauts du prompt corriges, incidents 39/40, table `observation`* + +## 64. Session du 13/09/2026 — Le parc butait sur NOTRE plafond, pas sur le sien + +### La question du proprietaire +Le conseiller affichait « ressource refusee » avec 6 chargeurs sur 7 brides, et +il a demande si c'etait une config restant a finir ou un choix delibere de +charger plus lentement. La reponse se mesure. + +### La mesure (13/09, 10:39 -> 12:50) +| | | +|---|---:| +| DVCC appliquee par l'ACM | **130 A** (profil `pv_boost`, SOC < 80) | +| courant reellement pris par le parc | **114 a 127 A**, soit 88 a 98 % du plafond | +| tension du parc | **50,6-50,7 V**, plate | +| notre consigne de tension | 51,2 V (`pv_mid`) | +| ce que le BMS declare autoriser | **250 A** et **53,2 V** | +| ecart entre les 135 cellules | **2 mV** (3,327 a 3,329 V) | +| modules bloquant la charge / alarmes | **0** / aucune | + +La tension plate a 0,5 V sous notre propre consigne est le point decisif : +**il n'y avait aucune phase d'absorption**. Le courant ne retombait pas de +lui-meme comme le ferait un LiFePO4 en fin de charge — il butait sur le +plafond. Ce n'etait donc ni le materiel, ni le BMS, ni un choix de charge +lente : c'etait la table, calibree pour l'ANCIEN parc dont une cellule montait +a 3 649 mV et faisait effondrer le courant de tous (§48-§53). Ces modules sont +sortis le 12/09. + +### Defaut visible dans la table : la colonne des tensions n'etait pas monotone +`51,5 → 51,2 → 52,4 → 50,6`. Elle REDESCENDAIT entre 80 et 90 % avant de +remonter — reste du 06/09, ou seul `pv_topoff` avait ete releve parce que +c'etait lui qui plafonnait le parc a 93 %. + +### Deux changements (patch `tools/patch-20260913-plafond-150a-et-pvmid-51v5.mjs`) +- `pv_boost` : **130 → 150 A**. Un palier, pas une cible : 150 A reste a 60 % + de ce que le BMS declare. On regarde ce qu'il libere et l'ecart entre + cellules avant d'aller plus loin. +- `pv_mid` : **51,2 → 51,5 V**, ce qui rend la colonne monotone. + +NON touches : `pv_mid.current_a` (110 A), `pv_topoff` (regle le 06/09 sur +mesure), `pv_hold` (30 A / 50,6 V — delibere : on atteint le haut, on n'y +sejourne pas). + +⚠️ **L'effet du 150 A ne se verra que sous 80 % de SOC**, donc a la charge du +MATIN : au-dessus, c'est `pv_mid` et son plafond de 110 A qui s'applique. + +### Golden ACM 20/20 apres mise a jour de 3 attendus +La suite a fait son travail : `acm-jour-boost`, `acm-jour-mid` et +`acm-override-expire-sans-effet` sont tombes exactement sur les valeurs +changees. Attendus mis a jour dans le meme commit, mesure en commentaire. + +### Verification en reel (13:15-13:21, apres le PUT de 13:11) +Consigne 51,5 V appliquee, `tension_de_cellule_elevee` = `no_alarm`, cellules +toujours dans 2 mV, courant 54-72 A (bien sous les 110 A de `pv_mid`). +**Plus aucun chargeur ne se retenait (0/7 contre 6/7 une heure plus tot)** et la +production a touche **10 116 W**, le plus haut releve de la journee. +⚠️ A NE PAS SUR-INTERPRETER : la charge de la maison est passee de 3,9 a 5,9 kW +dans le meme intervalle, donc le 0/7 ne s'attribue pas proprement a la tension. +Le vrai test du 150 A est la matinee suivante, sous 80 % de SOC. + +### Correction de documentation trouvee en route +**L'ACM tourne toutes les 5 MINUTES**, pas toutes les 30 s comme l'annoncaient +le manuel et MEMORY.md (executions verifiees : 11:00, 11:05, 11:10 UTC). C'est +pourquoi une consigne poussee a 11:11 ne s'applique qu'a 11:15. + +### A surveiller demain matin +1. Sous 80 % de SOC, DVCC a **150 A** et courant reellement pris — s'approche-t-il + du nouveau plafond, ou plafonne-t-il ailleurs ? +2. `tension_minimale/maximale_de_la_cellule` : l'ecart doit rester de quelques + millivolts. S'il s'ouvre, on s'arrete a ce palier. +3. Nombre de chargeurs `voltage_current_limited` a charge maison comparable. + +--- +*Document mis a jour le : 13/09/2026 — plafond de charge 130 → 150 A, pv_mid 51,2 → 51,5 V, golden-acm 20/20* diff --git a/docs/MANUEL_COMPLET.md b/docs/MANUEL_COMPLET.md index b7a3358..595c82f 100644 --- a/docs/MANUEL_COMPLET.md +++ b/docs/MANUEL_COMPLET.md @@ -331,7 +331,7 @@ production. |---|---|---|---| | `ATswvGfG4KJUls0K` | HA Snapshot EVCS Surplus Controller V2.6 | 30s | Pilotage borne EVCS, décision charge solaire/réseau | | `7iIlu7LLTW7TZA8o` | Victron - Battery / Grid Hysteresis Fix V1 | 1 min | Hystérésis réseau EDF (ON ≤20%, OFF ≥30%) | -| `cTeeHy4ePiVeELLh` | Victron - Auto Charge Manager V1.2 | 30s | Limites DVCC batterie (MaxChargeCurrent/Voltage) | +| `cTeeHy4ePiVeELLh` | Victron - Auto Charge Manager V1.2 | **5 min** | Limites DVCC batterie (MaxChargeCurrent/Voltage) | ### 2.2 Gestion Charges diff --git a/tests/golden-acm/scenarios.mjs b/tests/golden-acm/scenarios.mjs index 426f114..c983187 100644 --- a/tests/golden-acm/scenarios.mjs +++ b/tests/golden-acm/scenarios.mjs @@ -139,18 +139,25 @@ export const scenarios = [ // ------------------------------------------------------------ jour { + // 13/09/2026 — 130 -> 150 A. Mesure de 10:39 a 12:50 : la DVCC etait a + // 130 A et la batterie prenait 114 a 127 A, soit 88 a 98 % du plafond, + // avec une tension plate a 50,6 V (donc aucune absorption : le courant ne + // retombait pas de lui-meme, il butait). Le BMS declare 250 A et 53,2 V, + // les 135 cellules tiennent dans 2 mV, aucun module ne bloque la charge. + // Le 130 A venait de l'ANCIEN parc, sorti le 12/09. 150 A est un palier, + // pas une cible : on regarde ce qu'il libere avant d'aller plus loin. id: 'acm-jour-boost', - label: 'Jour, SOC 60 sous 80 : capture PV maximale, 130 A a 51,5 V', + label: 'Jour, SOC 60 sous 80 : capture PV maximale, 150 A a 51,5 V (releve du 13/09 : la batterie butait sur 130)', time: h(ETE, '11:00'), overrides: { ...parcNeuf, soc_percent: '60', pv_power_w: '4000' }, - expect: { mode: 'day_pv_boost', profil: 'pv_boost', courantA: 130, tensionV: 51.5 }, + expect: { mode: 'day_pv_boost', profil: 'pv_boost', courantA: 150, tensionV: 51.5 }, }, { id: 'acm-jour-mid', - label: 'Jour, SOC 85 entre 80 et 90 : 110 A a 51,2 V', + label: 'Jour, SOC 85 entre 80 et 90 : 110 A a 51,5 V (la colonne des tensions redescendait entre 80 et 90)', time: h(ETE, '11:00'), overrides: { ...parcNeuf, soc_percent: '85', pv_power_w: '4000', battery_voltage_v: '50.5' }, - expect: { mode: 'day_pv_mid', profil: 'pv_mid', courantA: 110, tensionV: 51.2 }, + expect: { mode: 'day_pv_mid', profil: 'pv_mid', courantA: 110, tensionV: 51.5 }, }, { id: 'acm-jour-topoff-52v4', @@ -191,7 +198,7 @@ export const scenarios = [ overrides: { ...parcNeuf, soc_percent: '60', override_enabled: 'on', override_profile: 'manual', override_current_a: '12', override_voltage_v: '50.0', override_until: '2026-07-15 09:00:00' }, - expect: { overrideActif: false, mode: 'day_pv_boost', courantA: 130 }, + expect: { overrideActif: false, mode: 'day_pv_boost', courantA: 150 }, }, // ------------------------------------------------------------ publication diff --git a/tools/patch-20260913-plafond-150a-et-pvmid-51v5.mjs b/tools/patch-20260913-plafond-150a-et-pvmid-51v5.mjs new file mode 100644 index 0000000..bc42fde --- /dev/null +++ b/tools/patch-20260913-plafond-150a-et-pvmid-51v5.mjs @@ -0,0 +1,73 @@ +#!/usr/bin/env node +// Le parc butait sur NOTRE plafond, pas sur le sien. +// +// Mesure du 13/09, de 10:39 a 12:50 : +// DVCC appliquee par l'ACM : 130 A (profil pv_boost, SOC < 80) +// courant reellement pris : 114 a 127 A, soit 88 a 98 % du plafond +// tension du parc : 50,6-50,7 V, plate, a 0,5 V sous notre +// propre consigne — donc AUCUNE phase +// d'absorption : le courant ne retombait +// pas de lui-meme, il butait. +// ce que le BMS declare autoriser : 250 A de charge, 53,2 V +// ecart entre les 135 cellules : 2 mV (3,327 a 3,329 V) +// modules bloquant la charge : 0, aucune alarme +// Pendant ce temps six chargeurs sur sept se declaraient +// `voltage_current_limited` : la ressource etait refusee faute de preneur, et +// sans injection elle est simplement perdue. +// +// Origine du 130 A : la table a ete calibree pour l'ANCIEN parc, dont une +// cellule montait a 3 649 mV et faisait effondrer le courant de tout le monde +// (HANDOFF §48-§53). Ces modules sont sortis le 12/09. Le parc actuel — quatre +// US5000 et cinq US2000C — tient ses cellules dans 2 mV. +// +// DEUX CHANGEMENTS, ET DEUX SEULEMENT : +// +// 1. `pv_boost` : 130 -> 150 A. C'est le profil de la tranche SOC < 80, celle +// ou se fait l'essentiel de la charge du matin et celle ou la mesure montre +// la batterie collee au plafond. 150 A reste a 60 % de ce que le BMS +// declare : c'est un palier, pas une cible. On regarde ce qu'il libere et +// l'ecart entre cellules avant d'aller plus loin. +// +// 2. `pv_mid` : 51,2 -> 51,5 V. La colonne des tensions descendait entre 80 et +// 90 % avant de remonter — 51,5 puis 51,2 puis 52,4 — reste du 06/09 ou +// seul `pv_topoff` avait ete releve parce que c'etait lui qui plafonnait le +// parc a 93 %. 51,5 V l'aligne sur `pv_boost` et rend la colonne monotone. +// +// NE SONT PAS TOUCHES : +// `pv_mid.current_a` (110 A) — voir la note ci-dessous ; +// `pv_topoff` (80 A / 52,4 V) — regle le 06/09, mesure a l'appui ; +// `pv_hold` (30 A / 50,6 V) — deliberé : on atteint le haut, on n'y sejourne +// pas, c'est le bon comportement pour du LiFePO4. +// +// ⚠️ CONSEQUENCE A CONNAITRE : au-dessus de 80 % de SOC c'est `pv_mid` qui +// s'applique, donc son plafond de 110 A reste le plus bas des deux. L'effet du +// 150 A se verra sur la charge du MATIN, sous 80 %. Si l'on veut aussi le voir +// dans la tranche 80-90, c'est `pv_mid.current_a` qu'il faut relever, et c'est +// une decision distincte. +// +// node tools/patch-20260913-plafond-150a-et-pvmid-51v5.mjs +import fs from 'fs'; + +const F = 'workflows/victron-auto-charge-manager-v1.2.json'; +const w = JSON.parse(fs.readFileSync(F, 'utf8')); +const n = w.nodes.find((x) => x.name === 'Compute Charge Targets'); +if (!n) { console.error('noeud "Compute Charge Targets" introuvable'); process.exit(1); } + +const remplacements = [ + [' pv_boost: { current_a: 130, voltage_v: 51.5 },', + ' pv_boost: { current_a: 150, voltage_v: 51.5 },'], + [' pv_mid: { current_a: 110, voltage_v: 51.2 },', + ' pv_mid: { current_a: 110, voltage_v: 51.5 },'], +]; + +let code = n.parameters.jsCode; +for (const [avant, apres] of remplacements) { + if (code.includes(apres)) { console.log('deja applique : ' + apres.trim()); continue; } + if (!code.includes(avant)) { console.error('ancre introuvable : ' + avant.trim()); process.exit(1); } + code = code.replace(avant, apres); + console.log(apres.trim()); +} +n.parameters.jsCode = code; +fs.writeFileSync(F, JSON.stringify(w, null, 2) + '\n'); +console.log('\n' + F + ' patche. Pousser avec :'); +console.log(' node tools/n8n-push.mjs ' + F); diff --git a/workflows/victron-auto-charge-manager-v1.2.json b/workflows/victron-auto-charge-manager-v1.2.json index 1a95065..69fcc3c 100644 --- a/workflows/victron-auto-charge-manager-v1.2.json +++ b/workflows/victron-auto-charge-manager-v1.2.json @@ -73,7 +73,7 @@ }, { "parameters": { - "jsCode": "function num(v, fallback = NaN) {\n const n = Number.parseFloat(v);\n return Number.isFinite(n) ? n : fallback;\n}\n\nfunction low(v) {\n return (v ?? '').toString().trim().toLowerCase();\n}\n\nfunction parseMaybeJson(v) {\n if (typeof v !== 'string') return v;\n try {\n return JSON.parse(v);\n } catch {\n return v;\n }\n}\n\nfunction extractSnapshot(input) {\n if (typeof input === 'string') return parseMaybeJson(input);\n if (input?.data && typeof input.data === 'string') return parseMaybeJson(input.data);\n if (input?.body && typeof input.body === 'string') return parseMaybeJson(input.body);\n if (input?.data && typeof input.data === 'object') return input.data;\n if (input?.body && typeof input.body === 'object') return input.body;\n return input;\n}\n\nfunction round1(v) {\n return Math.round(v * 10) / 10;\n}\n\nfunction 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: 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" + "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: 110, 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",