diff --git a/src/water-heater/ARBITRATION.js b/src/water-heater/ARBITRATION.js index e0c9089..ccf4b62 100644 --- a/src/water-heater/ARBITRATION.js +++ b/src/water-heater/ARBITRATION.js @@ -29,6 +29,7 @@ const normalized = root.normalized ?? {}; const observation = root.observation ?? {}; const energyModel = root.energyModel ?? {}; const baseDecision = root.baseDecision ?? {}; +const input = root.input ?? {}; let { recommendedMode, @@ -333,29 +334,37 @@ if (advisorEnabled && ecsNuit) { // ALERTE « PASSE EN BOOST » (07/10/2026, proprietaire) // ===================================================== // En heures creuses sur EDF, le ballon doit consommer AU MAXIMUM toute la -// fenetre : en AUTO il reste sur la pompe a chaleur (lent, 55 °C) et la douche -// ne sera pas a 65 °C (20 h apres la HC de midi, 06 h apres celle de nuit). -// Si, 15 min apres le debut de la fenetre, session engagee, contacteur ferme et -// ballon sous tension, il ne tire que la puissance de la pompe a chaleur -// (regime hp_low), on le SIGNALE : input_text.evcs_alerte_chauffe_eau (JSON, -// `since` = debut de la fenetre -> un seul message par fenetre, envoye par le -// concierge OpenClaw). 0 W = thermostat satisfait : pas d'alerte. +// fenetre (douches 06:00 et 20:00 a 65 °C). Constat du 07/10 a midi : en AUTO +// il etait ARRETE (0-36 W) alors que l'eau n'etait pas a 65 °C — passe en BOOST +// a 12:35 il est reparti a 1 235 W. AUTO s'arrete plus bas : 0 W ne prouve +// donc PAS une eau chaude. Regle : 15 min apres le debut de la fenetre, session +// engagee, contacteur ferme, ballon sous tension, et la puissance de la +// RESISTANCE jamais atteinte depuis le debut de la fenetre -> on le SIGNALE. +// Memoire de la fenetre (puissance max vue) dans le helper lui-meme ; le +// concierge n'envoie que `active: true`, une fois par `since` (debut de la +// fenetre). Limite assumee : ballon deja a 65 °C en BOOST = 0 W aussi, donc un +// message pour rien au plus par fenetre (aucune temperature dans HA). const ALERTE_DELAI_MIN = 15; +const seuilResistanceW = ((normalized.greenPowerW ?? 620) + (normalized.autoPowerW ?? 1200)) / 2; const minNow = Number.isFinite(now.minNow) ? now.minNow : null; const fenetreHc = (normalized.hcWindows || []).find((w) => minNow !== null && ( w.debut <= w.fin ? (minNow >= w.debut && minNow < w.fin) : (minNow >= w.debut || minNow < w.fin))); -const depuisDebutMin = fenetreHc ? ((minNow - fenetreHc.debut + 1440) % 1440) : null; -const alerteBoost = !!fenetreHc && depuisDebutMin >= ALERTE_DELAI_MIN - && hcSessionCommitted && gridIsOn && waterHeaterSwitchOn && observedRegime === 'hp_low'; let alerteChauffeEau = ''; -if (alerteBoost) { +if (fenetreHc) { + const depuisDebutMin = (minNow - fenetreHc.debut + 1440) % 1440; const h = String(Math.floor(fenetreHc.debut / 60)).padStart(2, '0') + ':' + String(fenetreHc.debut % 60).padStart(2, '0'); const jourFenetre = (fenetreHc.debut > fenetreHc.fin && minNow < fenetreHc.fin) ? new Date(nowMs - 86400000).toLocaleDateString('sv-SE', { timeZone: 'Europe/Paris' }) : now.date; + const since = `${jourFenetre} ${h}`; + let prev = null; + try { prev = JSON.parse(input.alerte_prev || ''); } catch { prev = null; } + const maxPrev = prev && prev.since === since && Number.isFinite(prev.max_w) ? prev.max_w : 0; + const maxW = Math.round(Math.max(maxPrev, waterHeaterSwitchOn ? observedPowerW : 0)); + const active = depuisDebutMin >= ALERTE_DELAI_MIN && hcSessionCommitted && gridIsOn + && waterHeaterSwitchOn && maxW < seuilResistanceW; alerteChauffeEau = JSON.stringify({ - active: true, since: `${jourFenetre} ${h}`, fenetre: fenetreHc.nom, - puissance_w: Math.round(observedPowerW), - message: `🚿 Heures creuses EDF : le chauffe-eau ne tire que ${Math.round(observedPowerW)} W (pompe a chaleur). Passe-le en BOOST pour avoir 65 °C a ${fenetreHc.douche}.`, + active, since, fenetre: fenetreHc.nom, max_w: maxW, puissance_w: Math.round(observedPowerW), + ...(active ? { message: `🚿 Heures creuses EDF : le chauffe-eau ne chauffe pas a pleine puissance (${Math.round(observedPowerW)} W). Si tu n'es pas en BOOST, passe-le pour avoir 65 °C a ${fenetreHc.douche}.` } : {}), }); } diff --git a/src/water-heater/HA_SNAPSHOT_TEMPLATE.json.tpl b/src/water-heater/HA_SNAPSHOT_TEMPLATE.json.tpl index 00800a8..21713ea 100644 --- a/src/water-heater/HA_SNAPSHOT_TEMPLATE.json.tpl +++ b/src/water-heater/HA_SNAPSHOT_TEMPLATE.json.tpl @@ -33,6 +33,7 @@ "ecs_24h_kwh": states("sensor.evcs_chauffe_eau_24h_kwh"), "ecs_jour_kwh": states("sensor.evcs_chauffe_eau_jour_kwh"), "off_jour_faible_prev": states("input_boolean.victron_water_heater_off_jour_faible"), + "alerte_prev": states("input_text.evcs_alerte_chauffe_eau"), "hc_midday_start": states("input_datetime.victron_evcs_hc_midday_start"), "hc_midday_stop": states("input_datetime.victron_evcs_hc_midday_stop"), "hc_night_start": states("input_datetime.victron_evcs_hc_night_start"), diff --git a/tests/kilometrage/run.mjs b/tests/kilometrage/run.mjs index 3e2cfb6..2bf75eb 100644 --- a/tests/kilometrage/run.mjs +++ b/tests/kilometrage/run.mjs @@ -53,7 +53,8 @@ eq('helper corrompu repart a vide', JSON.parse(ajouterReleve('pas du json', { ve const journ = async () => {}; const appels = []; const etiq = async (b, n, m) => { appels.push([b.toISOString(), n, m]); return 1; }; - const etat = (km, niv, maj, branchee = 'on', borne = 'on') => new Map([ + const etat = (km, niv, maj, branchee = 'on', borne = 'on', contact = 'off') => new Map([ + [JEEP.contact, { state: contact }], [JEEP.odometre, { state: String(km) }], [JEEP.niveau, { state: String(niv) }], [JEEP.majA, { state: maj }], [JEEP.branchee, { state: branchee }], ['binary_sensor.victron_evcs_vehicle_connected', { state: borne }], ['input_number.victron_evcs_capacity_jeep_kwh', { state: '8.7' }], ['input_number.victron_evcs_autonomie_jeep_km', { state: '50' }]]); @@ -83,6 +84,11 @@ eq('helper corrompu repart a vide', JSON.parse(ajouterReleve('pas du json', { ve base.branche = new Date('2026-10-09T17:06:00Z'); base.etiquette = 'VW'; await ingererJeep(sql, lire, etat(112950, 40, '2026-10-09T17:05:00+00:00', 'off', 'on'), journ, etiq); eq('jeep : proprietaire a deja dit VW -> rien', [appels.length, base.niveaux.length], [2, 2]); + // 07/10 : une autre voiture est branchee ; la Jeep publie APRES le branchement + // avec le contact mis et se declare « branchee » (cas extreme) -> pas JEEP. + base.branche = new Date('2026-10-10T10:32:00Z'); base.etiquette = null; + await ingererJeep(sql, lire, etat(113000, 50, '2026-10-10T10:37:20+00:00', 'on', 'on', 'on'), journ, etiq); + eq('jeep : contact mis apres le branchement -> pas d identification', appels.length, 2); } if (process.argv.includes('--vues')) { diff --git a/tests/water-heater/run.mjs b/tests/water-heater/run.mjs index 44fe3e7..b1f77b3 100644 --- a/tests/water-heater/run.mjs +++ b/tests/water-heater/run.mjs @@ -81,25 +81,28 @@ for (const c of cas) { if (o.observedPowerW === 0 && o.observedRegime === 'off') { ok++; console.log('✅ coupe-zero-watt'); } else { ko++; console.log(`❌ coupe-zero-watt | ${o.observedPowerW} W ${o.observedRegime}`); } } -// Alerte BOOST (07/10) : en HC sur EDF, le ballon ne tire que la pompe a chaleur. +// Alerte BOOST (07/10) : en HC sur EDF, la resistance n'a jamais ete atteinte. { - const minuit = new Date(new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Paris' }) + 'T00:00:00'); const nowMin = (() => { const p = new Intl.DateTimeFormat('fr-FR', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date()).split(':'); return Number(p[0]) * 60 + Number(p[1]); })(); const hm = (m) => { const x = ((m % 1440) + 1440) % 1440; return `${String(Math.floor(x / 60)).padStart(2, '0')}:${String(x % 60).padStart(2, '0')}:00`; }; const stamp = new Date(Date.now() - 5 * 60000).toLocaleString('sv-SE', { timeZone: 'Europe/Paris' }); const fenetre = { hc_midday_start: hm(nowMin - 30), hc_midday_stop: hm(nowMin + 60), hc_night_start: hm(nowMin + 300), hc_night_stop: hm(nowMin + 600) }; const base = { ...BASE, ...fenetre, grid_state: 'on', hc_session_committed: 'on', water_heater_switch: 'on', - water_heater_energy_total_kwh: '18.65', energy_total_prev_kwh: '18.60', energy_prev_at: stamp }; // 0,05 kWh / 5 min = 600 W : pompe a chaleur - const verif = (nom, snap, attenduAlerte) => { + water_heater_energy_total_kwh: '18.65', energy_total_prev_kwh: '18.60', energy_prev_at: stamp }; // 600 W : pompe a chaleur + const debut = new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Paris' }) + ' ' + hm(nowMin - 30).slice(0, 5); + const verif = (nom, snap, attendu) => { const s = executer({ ...base, ...snap }); const a = s.ARBITRATION[0].json.finalDecision.alerteChauffeEau; - const ok1 = attenduAlerte ? (a && JSON.parse(a).active === true && JSON.parse(a).message.includes('BOOST')) : a === ''; + let j = null; try { j = JSON.parse(a); } catch {} + const actif = !!(j && j.active === true && j.message && j.message.includes('BOOST')); const appel = s.BUILD_ACTIONS.map((x) => x.json).find((c) => c.data?.entity_id === 'input_text.evcs_alerte_chauffe_eau'); - if (ok1 && appel) { ok++; console.log(`✅ ${nom}`); } else { ko++; console.log(`❌ ${nom} | alerte=${a} appel=${!!appel}`); } + if (actif === attendu && appel) { ok++; console.log(`✅ ${nom}`); } else { ko++; console.log(`❌ ${nom} | alerte=${a} appel=${!!appel}`); } }; verif('alerte-boost-pompe-a-chaleur-en-hc', {}, true); + verif('alerte-boost-auto-arrete-0-w-en-hc', { water_heater_energy_total_kwh: '18.60' }, true); verif('alerte-boost-resistance-pas-d-alerte', { water_heater_energy_total_kwh: '18.75' }, false); - verif('alerte-boost-thermostat-satisfait-pas-d-alerte', { water_heater_energy_total_kwh: '18.60' }, false); + verif('alerte-boost-resistance-deja-vue-dans-la-fenetre', { water_heater_energy_total_kwh: '18.60', + alerte_prev: JSON.stringify({ active: false, since: debut, max_w: 1235 }) }, false); verif('alerte-boost-debut-de-fenetre-attente', { hc_midday_start: hm(nowMin - 5) }, false); verif('alerte-boost-edf-ouvert-pas-d-alerte', { grid_state: 'off' }, false); } diff --git a/tools/historise-evcs.mjs b/tools/historise-evcs.mjs index e07e3c9..7931f88 100644 --- a/tools/historise-evcs.mjs +++ b/tools/historise-evcs.mjs @@ -470,6 +470,25 @@ async function transitionsCachees(id, depart, depuisIso, jusquaIso) { return transitionsDansSerie(series[0], depart, depuisIso, jusquaIso); } +// RELECTURE UCONNECT AU BRANCHEMENT (07/10/2026, accord du proprietaire) : +// l'integration ne relit le cloud Stellantis que toutes les 15 min ; au +// branchement de la borne on demande `uconnect.update`, une RELECTURE du cloud +// (ce que la voiture a deja publie : fin de trajet, contact). AUCUNE commande +// vers la voiture : deep_refresh / refresh_location la reveillent et restent +// interdits, comme charge_now, lights, doors. Une fois par branchement. +let derniereRelectureUconnect = 0; +async function relireUconnect() { + if (DRY || Date.now() - derniereRelectureUconnect < 5 * 60000) return; + derniereRelectureUconnect = Date.now(); + try { + const r = await fetch(`${HA_URL}/api/services/uconnect/update`, { + method: 'POST', headers: { Authorization: `Bearer ${HA_TOKEN}`, 'Content-Type': 'application/json' }, + body: '{}', signal: AbortSignal.timeout(20000), + }); + console.log(` relecture Uconnect au branchement : HTTP ${r.status}`); + } catch (e) { console.log(` relecture Uconnect au branchement : ${e.message}`); } +} + async function detecterEvenements(sql, st) { if (!memoire.amorce) { for (const [id] of TRANSITIONS_SUIVIES.map((t) => [t[0]])) { @@ -533,6 +552,7 @@ async function detecterEvenements(sql, st) { }, tr.a); n++; } + if (id === 'binary_sensor.victron_evcs_vehicle_connected' && cachees.some((x) => x.apres === 'on')) await relireUconnect(); continue; } const qui = await auteurDeLaTransition(id, e.last_changed); @@ -551,6 +571,7 @@ async function detecterEvenements(sql, st) { }); memoire.valeurs.set(id, cur); n++; + if (id === 'binary_sensor.victron_evcs_vehicle_connected' && cur === 'on') await relireUconnect(); } return n; } diff --git a/tools/kilometrage.mjs b/tools/kilometrage.mjs index dede24c..b61cb6f 100644 --- a/tools/kilometrage.mjs +++ b/tools/kilometrage.mjs @@ -274,6 +274,7 @@ export const JEEP = { niveau: 'sensor.jeep_renegade_phev_hvbattery_charge', majA: 'sensor.jeep_renegade_phev_last_info_update_at', branchee: 'binary_sensor.jeep_renegade_phev_ev_charger', + contact: 'binary_sensor.jeep_renegade_phev_ignition', }; // `etiqueter(branche, niveau)` (fourni par le service) : pose l'etiquette JEEP @@ -327,6 +328,12 @@ export async function ingererJeep(sql, lire, st, journaliser, etiqueter = null) [new Date(branche.getTime() - 10 * 60000), branche]); if (arr.length) preuve = { ts: arr[0].ts, niveau: Number(arr[0].niveau_pct), motif: 'arrivee' }; } + // Jeep vue AILLEURS apres le branchement (07/10/2026, accord du proprietaire) : + // une information Uconnect posterieure au branchement avec le contact MIS + // dit qu'elle roule — ce n'est pas elle sur la borne. (La position ne sert + // pas : la Jeep garee a la maison est a 2,2 km de zone.home, donc toujours + // « not_home ».) + if (preuve && branche && st.get(JEEP.contact)?.state === 'on' && ts > branche) preuve = null; // Le proprietaire a deja nomme un AUTRE vehicule pour ce branchement : on n'y touche pas. let autre = false; if (preuve) { diff --git a/workflows/water-heater-ecs-v1.json b/workflows/water-heater-ecs-v1.json index 706cff8..419261f 100644 --- a/workflows/water-heater-ecs-v1.json +++ b/workflows/water-heater-ecs-v1.json @@ -61,7 +61,7 @@ }, { "parameters": { - "jsCode": "function regimeRank(regime) {\n switch (regime) {\n case 'off': return 0;\n case 'hp_low': return 1;\n case 'resistive_mid': return 2;\n case 'boost_high': return 3;\n default: return -1;\n }\n}\n\nfunction allowedRankForMode(mode) {\n switch (mode) {\n case 'off': return 0;\n case 'green': return 1;\n case 'auto': return 2;\n case 'boost': return 3;\n default: return 0;\n }\n}\n\nfunction round1(v) {\n return Math.round(v * 10) / 10;\n}\n\nconst root = $input.first()?.json ?? {};\nconst now = root.now ?? {};\nconst nowMs = root.nowMs ?? null;\nconst normalized = root.normalized ?? {};\nconst observation = root.observation ?? {};\nconst energyModel = root.energyModel ?? {};\nconst baseDecision = root.baseDecision ?? {};\n\nlet {\n recommendedMode,\n adviceColor,\n state,\n reason,\n baseRecommendedMode,\n baseAdviceColor,\n baseState\n} = baseDecision;\n\nreason = Array.isArray(reason) ? [...reason] : [];\n\nconst {\n advisorEnabled,\n soc,\n batteryCurrentA,\n pvPowerAvg10mW,\n houseAcPowerW,\n todayRemainingKwh,\n tomorrowKwh,\n baseLoadRemainingKwh,\n flexibleSafetyMarginKwh,\n acLoadsMaxSafeW,\n hotWaterDemand,\n gridIsOn,\n acInAvailable,\n gridAvailable\n} = normalized;\n\nconst {\n evcsPriorityActive,\n energyPriorityReason,\n waterHeaterSwitchOn,\n observedPowerW,\n observedRegime,\n mismatchSinceAt,\n mismatchSinceMsPrev,\n forcedOffByAdvisorPrev,\n forcedOffReasonPrev,\n forcedOffAtPrev,\n restoreSurplusW,\n modeMismatchDelayMin,\n sampleDurationMin,\n energyDeltaKwh,\n sampleIsUsable\n} = observation;\n\nconst {\n targetSoc,\n energyNeededToReachTargetKwh,\n greenEnergyKwh,\n autoEnergyKwh,\n boostEnergyKwh,\n projectedGreenSoc,\n projectedAutoSoc,\n projectedBoostSoc,\n greenBudgetMarginKwh,\n autoBudgetMarginKwh,\n boostBudgetMarginKwh,\n forecastStrength,\n netPvHeadroomW,\n pvCoversHouseNow,\n pvStartedWell,\n greenAllowed,\n autoAllowed,\n boostAllowed,\n batteryNotDrainingHard\n} = energyModel;\n\nif (advisorEnabled && evcsPriorityActive) {\n if (recommendedMode === 'boost' || recommendedMode === 'auto') {\n if (greenAllowed && batteryNotDrainingHard) {\n recommendedMode = 'green';\n adviceColor = 'orange';\n state = 'evcs_priority_green_cap';\n reason.push(`Priorité EVCS active : chauffe-eau plafonné à GREEN (${energyPriorityReason || 'raison non précisée'})`);\n } else {\n recommendedMode = 'off';\n adviceColor = 'red';\n state = 'evcs_priority_off_cap';\n reason.push(`Priorité EVCS active : chauffe-eau déconseillé (${energyPriorityReason || 'raison non précisée'})`);\n }\n } else if (recommendedMode === 'green' && (!greenAllowed || !batteryNotDrainingHard)) {\n recommendedMode = 'off';\n adviceColor = 'red';\n state = 'evcs_priority_green_blocked';\n reason.push(`Priorité EVCS active : GREEN devient lui-même trop coûteux (${energyPriorityReason || 'raison non précisée'})`);\n }\n}\n\nconst selectedLoadKwh =\n recommendedMode === 'green' ? greenEnergyKwh :\n recommendedMode === 'auto' ? autoEnergyKwh :\n recommendedMode === 'boost' ? boostEnergyKwh :\n 0;\n\nconst selectedProjectedSoc =\n recommendedMode === 'green' ? projectedGreenSoc :\n recommendedMode === 'auto' ? projectedAutoSoc :\n recommendedMode === 'boost' ? projectedBoostSoc :\n soc;\n\nconst selectedBudgetMarginKwh =\n recommendedMode === 'green' ? greenBudgetMarginKwh :\n recommendedMode === 'auto' ? autoBudgetMarginKwh :\n recommendedMode === 'boost' ? boostBudgetMarginKwh :\n todayRemainingKwh - baseLoadRemainingKwh - flexibleSafetyMarginKwh - energyNeededToReachTargetKwh;\n\nconst legacyAdviceOk = recommendedMode !== 'off';\n\nconst observedRank = regimeRank(observedRegime);\nconst allowedRank = allowedRankForMode(recommendedMode);\n\nconst mismatchActive =\n waterHeaterSwitchOn &&\n observedRank >= 0 &&\n allowedRank >= 0 &&\n observedRank > allowedRank;\n\nlet mismatchSeverity = 'none';\nif (mismatchActive) {\n if (recommendedMode === 'off') {\n mismatchSeverity = 'critical';\n } else if (observedRank - allowedRank >= 2) {\n mismatchSeverity = 'high';\n } else {\n mismatchSeverity = 'medium';\n }\n}\n\nconst mismatchSinceMsEffective = mismatchActive ? (mismatchSinceMsPrev ?? nowMs) : null;\nconst mismatchSinceAtEffective = mismatchActive ? (mismatchSinceMsPrev ? mismatchSinceAt : now.stamp) : '';\nconst mismatchDurationMin = mismatchActive && mismatchSinceMsEffective !== null ? Math.max(0, (nowMs - mismatchSinceMsEffective) / 60000) : 0;\n\nlet pendingUserAction = '';\nif (mismatchActive) {\n if (recommendedMode === 'off') {\n pendingUserAction = evcsPriorityActive\n ? 'Priorité EVCS : coupe temporairement le chauffe-eau ou laisse-le hors tension'\n : 'Le chauffe-eau consomme alors qu\\'il est déconseillé : coupe-le ou attends une meilleure fenêtre';\n } else if (recommendedMode === 'green') {\n pendingUserAction = observedRegime === 'boost_high'\n ? 'Passe le chauffe-eau en GREEN si possible, il semble en régime BOOST/résistance forte'\n : 'Passe le chauffe-eau en GREEN si possible, il semble plus gourmand que prévu';\n } else if (recommendedMode === 'auto') {\n pendingUserAction = 'Le chauffe-eau semble trop gourmand pour AUTO : repasse-le en AUTO modéré ou GREEN si possible';\n }\n}\n\nconst firstMismatchRun = mismatchActive && (mismatchSinceMsPrev === null || mismatchDurationMin < 1);\n\nconst realConflictNow =\n evcsPriorityActive &&\n (\n batteryCurrentA < -1 ||\n pvPowerAvg10mW < restoreSurplusW ||\n houseAcPowerW > acLoadsMaxSafeW ||\n recommendedMode === 'off' ||\n observedRegime === 'boost_high' ||\n (recommendedMode === 'green' && observedRegime === 'resistive_mid')\n );\n\nconst forceOffNow =\n advisorEnabled &&\n waterHeaterSwitchOn &&\n !forcedOffByAdvisorPrev &&\n mismatchActive &&\n mismatchDurationMin >= modeMismatchDelayMin &&\n realConflictNow;\n\nconst restoreEnergyOk =\n pvPowerAvg10mW >= restoreSurplusW &&\n batteryCurrentA >= -1 &&\n houseAcPowerW <= acLoadsMaxSafeW;\n\nconst restoreNow =\n advisorEnabled &&\n forcedOffByAdvisorPrev &&\n !waterHeaterSwitchOn &&\n (\n (!evcsPriorityActive && recommendedMode !== 'off') ||\n restoreEnergyOk\n );\n\nlet forcedOffByAdvisor = forcedOffByAdvisorPrev;\nlet forcedOffReason = forcedOffReasonPrev;\nlet forcedOffAt = forcedOffAtPrev;\nlet protectionState = 'none';\nlet powerSwitchAction = 'none';\nlet powerSwitchDesiredOn = waterHeaterSwitchOn;\n\nif (forceOffNow) {\n powerSwitchAction = 'turn_off';\n powerSwitchDesiredOn = false;\n forcedOffByAdvisor = true;\n forcedOffReason = `Coupure temporaire ECS: priorité EVCS + régime ${observedRegime} trop gourmand`;\n forcedOffAt = now.stamp;\n protectionState = 'forced_off_now_evcs_priority';\n state = 'forced_off_now_evcs_priority';\n adviceColor = 'red';\n reason.push(forcedOffReason);\n}\nelse if (restoreNow) {\n powerSwitchAction = 'turn_on';\n powerSwitchDesiredOn = true;\n forcedOffByAdvisor = false;\n forcedOffReason = '';\n protectionState = 'restored_after_evcs_priority';\n state = 'restored_after_evcs_priority';\n reason.push('Remise sous tension ECS : conflit EVCS terminé ou surplus redevenu suffisant');\n}\nelse if (forcedOffByAdvisorPrev && !waterHeaterSwitchOn) {\n protectionState = 'forced_off_wait_restore';\n state = 'forced_off_wait_restore';\n reason.push(`ECS temporairement coupé par l'advisor (${forcedOffReasonPrev || 'raison non précisée'})`);\n}\nelse if (mismatchActive && evcsPriorityActive) {\n protectionState = 'watch_mismatch_evcs_priority';\n state = 'watch_mismatch_evcs_priority';\n reason.push(`Écart de régime ECS sous priorité EVCS depuis ${round1(mismatchDurationMin)} min`);\n}\n\nif (!forceOffNow && !restoreNow && forcedOffByAdvisorPrev && waterHeaterSwitchOn) {\n forcedOffByAdvisor = false;\n forcedOffReason = '';\n}\n\n// =====================================================\n// JOUR FAIBLE : EAU CHAUDE EN HC DE NUIT (07/10/2026, option b du proprietaire)\n// =====================================================\n// Le bilan decide une fois par jour (input_boolean.evcs_balance_ecs_nuit) : si\n// le solaire prevu ne remplit pas la batterie pour la nuit, EDF sera ferme de\n// toute facon -> le chauffe-eau est COUPE le jour et chauffe pendant une\n// session reseau HC engagee (contacteur ferme), au tarif creux, jamais sur la\n// batterie. Securite eau chaude : moins de ECS_EAU_FROIDE_KWH en 24 h -> il\n// chauffe quand meme. Energie 24 h illisible -> aucune restriction (le\n// comportement d'avant revient, jamais un ballon froid par panne de capteur).\n// Jour normal : le chauffe-eau coupe par cette regle est remis sous tension.\nconst ECS_EAU_FROIDE_KWH = 1.5;\n// Douches a 06:00 et 20:00 tous les jours (proprietaire, 07/10) : couper toute\n// la journee laisserait l'eau froide a 20 h. Un jour faible, il chauffe donc\n// AUSSI sur un vrai surplus solaire (jamais sur la batterie), et a partir de\n// 16 h s'il n'a presque pas chauffe depuis 07:24 (securite de la douche du soir).\nconst ECS_SOIR_DEBUT_H = 16;\nconst ECS_SOIR_FIN_H = 20;\nconst {\n ecsNuit = false, hcSessionCommitted = false, ecs24hKwh = NaN, ecsJourKwh = NaN,\n offJourFaiblePrev = false, pvPowerW = 0, autoPowerW = 1200,\n} = normalized;\nlet offJourFaible = false;\nlet jourFaibleState = 'none';\nif (advisorEnabled && ecsNuit) {\n const energieConnue = Number.isFinite(ecs24hKwh);\n const eauFroide = energieConnue && ecs24hKwh < ECS_EAU_FROIDE_KWH;\n const heuresCreusesEdf = hcSessionCommitted && gridIsOn;\n // Surplus reel : sous tension, la production couvre la maison (chauffe-eau\n // compris) ; hors tension, il reste de quoi l'alimenter. Reseau ouvert.\n const solaireCouvre = !gridIsOn && (waterHeaterSwitchOn\n ? pvPowerW >= houseAcPowerW\n : pvPowerW - houseAcPowerW >= autoPowerW);\n const heure = Number.isFinite(now.hour) ? now.hour : -1;\n const securiteSoir = heure >= ECS_SOIR_DEBUT_H && heure < ECS_SOIR_FIN_H\n && !(Number.isFinite(ecsJourKwh) && ecsJourKwh >= ECS_EAU_FROIDE_KWH);\n const veutChauffer = !energieConnue || heuresCreusesEdf || eauFroide || solaireCouvre || securiteSoir;\n offJourFaible = true;\n jourFaibleState = !energieConnue ? 'jour_faible_energie_illisible'\n : heuresCreusesEdf ? 'jour_faible_chauffe_hc_edf'\n : eauFroide ? 'jour_faible_eau_froide'\n : securiteSoir ? 'jour_faible_securite_soir'\n : solaireCouvre ? 'jour_faible_surplus_solaire'\n : 'jour_faible_coupe';\n if (veutChauffer !== waterHeaterSwitchOn) {\n powerSwitchAction = veutChauffer ? 'turn_on' : 'turn_off';\n powerSwitchDesiredOn = veutChauffer;\n } else {\n powerSwitchAction = 'none';\n powerSwitchDesiredOn = waterHeaterSwitchOn;\n }\n forcedOffByAdvisor = false;\n forcedOffReason = '';\n protectionState = jourFaibleState;\n state = jourFaibleState;\n reason.push(\n jourFaibleState === 'jour_faible_chauffe_hc_edf' ? 'Jour faible : eau chaude en heures creuses sur EDF (session reseau engagee)'\n : jourFaibleState === 'jour_faible_eau_froide' ? `Jour faible mais seulement ${round1(ecs24hKwh)} kWh en 24 h : chauffe pour garantir l eau chaude`\n : jourFaibleState === 'jour_faible_energie_illisible' ? 'Jour faible, energie 24 h illisible : aucune restriction'\n : jourFaibleState === 'jour_faible_securite_soir' ? 'Jour faible : chauffe pour la douche de 20 h (peu chauffe depuis 07:24)'\n : jourFaibleState === 'jour_faible_surplus_solaire' ? 'Jour faible : surplus solaire reel, il chauffe sans toucher la batterie'\n : 'Jour faible : chauffe-eau coupe, il chauffera en heures creuses sur EDF'\n );\n} else if (advisorEnabled && offJourFaiblePrev && !waterHeaterSwitchOn) {\n powerSwitchAction = 'turn_on';\n powerSwitchDesiredOn = true;\n jourFaibleState = 'jour_normal_remise_sous_tension';\n protectionState = jourFaibleState;\n state = jourFaibleState;\n reason.push('Jour normal : chauffe-eau remis sous tension (il chauffe au solaire)');\n}\n\n// =====================================================\n// ALERTE « PASSE EN BOOST » (07/10/2026, proprietaire)\n// =====================================================\n// En heures creuses sur EDF, le ballon doit consommer AU MAXIMUM toute la\n// fenetre : en AUTO il reste sur la pompe a chaleur (lent, 55 °C) et la douche\n// ne sera pas a 65 °C (20 h apres la HC de midi, 06 h apres celle de nuit).\n// Si, 15 min apres le debut de la fenetre, session engagee, contacteur ferme et\n// ballon sous tension, il ne tire que la puissance de la pompe a chaleur\n// (regime hp_low), on le SIGNALE : input_text.evcs_alerte_chauffe_eau (JSON,\n// `since` = debut de la fenetre -> un seul message par fenetre, envoye par le\n// concierge OpenClaw). 0 W = thermostat satisfait : pas d'alerte.\nconst ALERTE_DELAI_MIN = 15;\nconst minNow = Number.isFinite(now.minNow) ? now.minNow : null;\nconst fenetreHc = (normalized.hcWindows || []).find((w) => minNow !== null && (\n w.debut <= w.fin ? (minNow >= w.debut && minNow < w.fin) : (minNow >= w.debut || minNow < w.fin)));\nconst depuisDebutMin = fenetreHc ? ((minNow - fenetreHc.debut + 1440) % 1440) : null;\nconst alerteBoost = !!fenetreHc && depuisDebutMin >= ALERTE_DELAI_MIN\n && hcSessionCommitted && gridIsOn && waterHeaterSwitchOn && observedRegime === 'hp_low';\nlet alerteChauffeEau = '';\nif (alerteBoost) {\n const h = String(Math.floor(fenetreHc.debut / 60)).padStart(2, '0') + ':' + String(fenetreHc.debut % 60).padStart(2, '0');\n const jourFenetre = (fenetreHc.debut > fenetreHc.fin && minNow < fenetreHc.fin)\n ? new Date(nowMs - 86400000).toLocaleDateString('sv-SE', { timeZone: 'Europe/Paris' }) : now.date;\n alerteChauffeEau = JSON.stringify({\n active: true, since: `${jourFenetre} ${h}`, fenetre: fenetreHc.nom,\n puissance_w: Math.round(observedPowerW),\n message: `🚿 Heures creuses EDF : le chauffe-eau ne tire que ${Math.round(observedPowerW)} W (pompe a chaleur). Passe-le en BOOST pour avoir 65 °C a ${fenetreHc.douche}.`,\n });\n}\n\nreturn [{\n json: {\n ...root,\n meta: {\n ...(root.meta ?? {}),\n node: 'ecs_final_arbitration_and_protection'\n },\n finalDecision: {\n recommendedMode,\n adviceColor,\n state,\n reason,\n baseRecommendedMode,\n baseAdviceColor,\n baseState,\n selectedLoadKwh,\n selectedProjectedSoc,\n selectedBudgetMarginKwh,\n legacyAdviceOk,\n mismatchActive,\n mismatchSeverity,\n mismatchSinceAtEffective,\n mismatchSinceMsEffective,\n mismatchDurationMin,\n pendingUserAction,\n firstMismatchRun,\n realConflictNow,\n forceOffNow,\n restoreEnergyOk,\n restoreNow,\n forcedOffByAdvisor,\n forcedOffReason,\n forcedOffAt,\n protectionState,\n powerSwitchAction,\n powerSwitchDesiredOn,\n offJourFaible,\n jourFaibleState,\n alerteChauffeEau\n }\n }\n}];" + "jsCode": "function regimeRank(regime) {\n switch (regime) {\n case 'off': return 0;\n case 'hp_low': return 1;\n case 'resistive_mid': return 2;\n case 'boost_high': return 3;\n default: return -1;\n }\n}\n\nfunction allowedRankForMode(mode) {\n switch (mode) {\n case 'off': return 0;\n case 'green': return 1;\n case 'auto': return 2;\n case 'boost': return 3;\n default: return 0;\n }\n}\n\nfunction round1(v) {\n return Math.round(v * 10) / 10;\n}\n\nconst root = $input.first()?.json ?? {};\nconst now = root.now ?? {};\nconst nowMs = root.nowMs ?? null;\nconst normalized = root.normalized ?? {};\nconst observation = root.observation ?? {};\nconst energyModel = root.energyModel ?? {};\nconst baseDecision = root.baseDecision ?? {};\nconst input = root.input ?? {};\n\nlet {\n recommendedMode,\n adviceColor,\n state,\n reason,\n baseRecommendedMode,\n baseAdviceColor,\n baseState\n} = baseDecision;\n\nreason = Array.isArray(reason) ? [...reason] : [];\n\nconst {\n advisorEnabled,\n soc,\n batteryCurrentA,\n pvPowerAvg10mW,\n houseAcPowerW,\n todayRemainingKwh,\n tomorrowKwh,\n baseLoadRemainingKwh,\n flexibleSafetyMarginKwh,\n acLoadsMaxSafeW,\n hotWaterDemand,\n gridIsOn,\n acInAvailable,\n gridAvailable\n} = normalized;\n\nconst {\n evcsPriorityActive,\n energyPriorityReason,\n waterHeaterSwitchOn,\n observedPowerW,\n observedRegime,\n mismatchSinceAt,\n mismatchSinceMsPrev,\n forcedOffByAdvisorPrev,\n forcedOffReasonPrev,\n forcedOffAtPrev,\n restoreSurplusW,\n modeMismatchDelayMin,\n sampleDurationMin,\n energyDeltaKwh,\n sampleIsUsable\n} = observation;\n\nconst {\n targetSoc,\n energyNeededToReachTargetKwh,\n greenEnergyKwh,\n autoEnergyKwh,\n boostEnergyKwh,\n projectedGreenSoc,\n projectedAutoSoc,\n projectedBoostSoc,\n greenBudgetMarginKwh,\n autoBudgetMarginKwh,\n boostBudgetMarginKwh,\n forecastStrength,\n netPvHeadroomW,\n pvCoversHouseNow,\n pvStartedWell,\n greenAllowed,\n autoAllowed,\n boostAllowed,\n batteryNotDrainingHard\n} = energyModel;\n\nif (advisorEnabled && evcsPriorityActive) {\n if (recommendedMode === 'boost' || recommendedMode === 'auto') {\n if (greenAllowed && batteryNotDrainingHard) {\n recommendedMode = 'green';\n adviceColor = 'orange';\n state = 'evcs_priority_green_cap';\n reason.push(`Priorité EVCS active : chauffe-eau plafonné à GREEN (${energyPriorityReason || 'raison non précisée'})`);\n } else {\n recommendedMode = 'off';\n adviceColor = 'red';\n state = 'evcs_priority_off_cap';\n reason.push(`Priorité EVCS active : chauffe-eau déconseillé (${energyPriorityReason || 'raison non précisée'})`);\n }\n } else if (recommendedMode === 'green' && (!greenAllowed || !batteryNotDrainingHard)) {\n recommendedMode = 'off';\n adviceColor = 'red';\n state = 'evcs_priority_green_blocked';\n reason.push(`Priorité EVCS active : GREEN devient lui-même trop coûteux (${energyPriorityReason || 'raison non précisée'})`);\n }\n}\n\nconst selectedLoadKwh =\n recommendedMode === 'green' ? greenEnergyKwh :\n recommendedMode === 'auto' ? autoEnergyKwh :\n recommendedMode === 'boost' ? boostEnergyKwh :\n 0;\n\nconst selectedProjectedSoc =\n recommendedMode === 'green' ? projectedGreenSoc :\n recommendedMode === 'auto' ? projectedAutoSoc :\n recommendedMode === 'boost' ? projectedBoostSoc :\n soc;\n\nconst selectedBudgetMarginKwh =\n recommendedMode === 'green' ? greenBudgetMarginKwh :\n recommendedMode === 'auto' ? autoBudgetMarginKwh :\n recommendedMode === 'boost' ? boostBudgetMarginKwh :\n todayRemainingKwh - baseLoadRemainingKwh - flexibleSafetyMarginKwh - energyNeededToReachTargetKwh;\n\nconst legacyAdviceOk = recommendedMode !== 'off';\n\nconst observedRank = regimeRank(observedRegime);\nconst allowedRank = allowedRankForMode(recommendedMode);\n\nconst mismatchActive =\n waterHeaterSwitchOn &&\n observedRank >= 0 &&\n allowedRank >= 0 &&\n observedRank > allowedRank;\n\nlet mismatchSeverity = 'none';\nif (mismatchActive) {\n if (recommendedMode === 'off') {\n mismatchSeverity = 'critical';\n } else if (observedRank - allowedRank >= 2) {\n mismatchSeverity = 'high';\n } else {\n mismatchSeverity = 'medium';\n }\n}\n\nconst mismatchSinceMsEffective = mismatchActive ? (mismatchSinceMsPrev ?? nowMs) : null;\nconst mismatchSinceAtEffective = mismatchActive ? (mismatchSinceMsPrev ? mismatchSinceAt : now.stamp) : '';\nconst mismatchDurationMin = mismatchActive && mismatchSinceMsEffective !== null ? Math.max(0, (nowMs - mismatchSinceMsEffective) / 60000) : 0;\n\nlet pendingUserAction = '';\nif (mismatchActive) {\n if (recommendedMode === 'off') {\n pendingUserAction = evcsPriorityActive\n ? 'Priorité EVCS : coupe temporairement le chauffe-eau ou laisse-le hors tension'\n : 'Le chauffe-eau consomme alors qu\\'il est déconseillé : coupe-le ou attends une meilleure fenêtre';\n } else if (recommendedMode === 'green') {\n pendingUserAction = observedRegime === 'boost_high'\n ? 'Passe le chauffe-eau en GREEN si possible, il semble en régime BOOST/résistance forte'\n : 'Passe le chauffe-eau en GREEN si possible, il semble plus gourmand que prévu';\n } else if (recommendedMode === 'auto') {\n pendingUserAction = 'Le chauffe-eau semble trop gourmand pour AUTO : repasse-le en AUTO modéré ou GREEN si possible';\n }\n}\n\nconst firstMismatchRun = mismatchActive && (mismatchSinceMsPrev === null || mismatchDurationMin < 1);\n\nconst realConflictNow =\n evcsPriorityActive &&\n (\n batteryCurrentA < -1 ||\n pvPowerAvg10mW < restoreSurplusW ||\n houseAcPowerW > acLoadsMaxSafeW ||\n recommendedMode === 'off' ||\n observedRegime === 'boost_high' ||\n (recommendedMode === 'green' && observedRegime === 'resistive_mid')\n );\n\nconst forceOffNow =\n advisorEnabled &&\n waterHeaterSwitchOn &&\n !forcedOffByAdvisorPrev &&\n mismatchActive &&\n mismatchDurationMin >= modeMismatchDelayMin &&\n realConflictNow;\n\nconst restoreEnergyOk =\n pvPowerAvg10mW >= restoreSurplusW &&\n batteryCurrentA >= -1 &&\n houseAcPowerW <= acLoadsMaxSafeW;\n\nconst restoreNow =\n advisorEnabled &&\n forcedOffByAdvisorPrev &&\n !waterHeaterSwitchOn &&\n (\n (!evcsPriorityActive && recommendedMode !== 'off') ||\n restoreEnergyOk\n );\n\nlet forcedOffByAdvisor = forcedOffByAdvisorPrev;\nlet forcedOffReason = forcedOffReasonPrev;\nlet forcedOffAt = forcedOffAtPrev;\nlet protectionState = 'none';\nlet powerSwitchAction = 'none';\nlet powerSwitchDesiredOn = waterHeaterSwitchOn;\n\nif (forceOffNow) {\n powerSwitchAction = 'turn_off';\n powerSwitchDesiredOn = false;\n forcedOffByAdvisor = true;\n forcedOffReason = `Coupure temporaire ECS: priorité EVCS + régime ${observedRegime} trop gourmand`;\n forcedOffAt = now.stamp;\n protectionState = 'forced_off_now_evcs_priority';\n state = 'forced_off_now_evcs_priority';\n adviceColor = 'red';\n reason.push(forcedOffReason);\n}\nelse if (restoreNow) {\n powerSwitchAction = 'turn_on';\n powerSwitchDesiredOn = true;\n forcedOffByAdvisor = false;\n forcedOffReason = '';\n protectionState = 'restored_after_evcs_priority';\n state = 'restored_after_evcs_priority';\n reason.push('Remise sous tension ECS : conflit EVCS terminé ou surplus redevenu suffisant');\n}\nelse if (forcedOffByAdvisorPrev && !waterHeaterSwitchOn) {\n protectionState = 'forced_off_wait_restore';\n state = 'forced_off_wait_restore';\n reason.push(`ECS temporairement coupé par l'advisor (${forcedOffReasonPrev || 'raison non précisée'})`);\n}\nelse if (mismatchActive && evcsPriorityActive) {\n protectionState = 'watch_mismatch_evcs_priority';\n state = 'watch_mismatch_evcs_priority';\n reason.push(`Écart de régime ECS sous priorité EVCS depuis ${round1(mismatchDurationMin)} min`);\n}\n\nif (!forceOffNow && !restoreNow && forcedOffByAdvisorPrev && waterHeaterSwitchOn) {\n forcedOffByAdvisor = false;\n forcedOffReason = '';\n}\n\n// =====================================================\n// JOUR FAIBLE : EAU CHAUDE EN HC DE NUIT (07/10/2026, option b du proprietaire)\n// =====================================================\n// Le bilan decide une fois par jour (input_boolean.evcs_balance_ecs_nuit) : si\n// le solaire prevu ne remplit pas la batterie pour la nuit, EDF sera ferme de\n// toute facon -> le chauffe-eau est COUPE le jour et chauffe pendant une\n// session reseau HC engagee (contacteur ferme), au tarif creux, jamais sur la\n// batterie. Securite eau chaude : moins de ECS_EAU_FROIDE_KWH en 24 h -> il\n// chauffe quand meme. Energie 24 h illisible -> aucune restriction (le\n// comportement d'avant revient, jamais un ballon froid par panne de capteur).\n// Jour normal : le chauffe-eau coupe par cette regle est remis sous tension.\nconst ECS_EAU_FROIDE_KWH = 1.5;\n// Douches a 06:00 et 20:00 tous les jours (proprietaire, 07/10) : couper toute\n// la journee laisserait l'eau froide a 20 h. Un jour faible, il chauffe donc\n// AUSSI sur un vrai surplus solaire (jamais sur la batterie), et a partir de\n// 16 h s'il n'a presque pas chauffe depuis 07:24 (securite de la douche du soir).\nconst ECS_SOIR_DEBUT_H = 16;\nconst ECS_SOIR_FIN_H = 20;\nconst {\n ecsNuit = false, hcSessionCommitted = false, ecs24hKwh = NaN, ecsJourKwh = NaN,\n offJourFaiblePrev = false, pvPowerW = 0, autoPowerW = 1200,\n} = normalized;\nlet offJourFaible = false;\nlet jourFaibleState = 'none';\nif (advisorEnabled && ecsNuit) {\n const energieConnue = Number.isFinite(ecs24hKwh);\n const eauFroide = energieConnue && ecs24hKwh < ECS_EAU_FROIDE_KWH;\n const heuresCreusesEdf = hcSessionCommitted && gridIsOn;\n // Surplus reel : sous tension, la production couvre la maison (chauffe-eau\n // compris) ; hors tension, il reste de quoi l'alimenter. Reseau ouvert.\n const solaireCouvre = !gridIsOn && (waterHeaterSwitchOn\n ? pvPowerW >= houseAcPowerW\n : pvPowerW - houseAcPowerW >= autoPowerW);\n const heure = Number.isFinite(now.hour) ? now.hour : -1;\n const securiteSoir = heure >= ECS_SOIR_DEBUT_H && heure < ECS_SOIR_FIN_H\n && !(Number.isFinite(ecsJourKwh) && ecsJourKwh >= ECS_EAU_FROIDE_KWH);\n const veutChauffer = !energieConnue || heuresCreusesEdf || eauFroide || solaireCouvre || securiteSoir;\n offJourFaible = true;\n jourFaibleState = !energieConnue ? 'jour_faible_energie_illisible'\n : heuresCreusesEdf ? 'jour_faible_chauffe_hc_edf'\n : eauFroide ? 'jour_faible_eau_froide'\n : securiteSoir ? 'jour_faible_securite_soir'\n : solaireCouvre ? 'jour_faible_surplus_solaire'\n : 'jour_faible_coupe';\n if (veutChauffer !== waterHeaterSwitchOn) {\n powerSwitchAction = veutChauffer ? 'turn_on' : 'turn_off';\n powerSwitchDesiredOn = veutChauffer;\n } else {\n powerSwitchAction = 'none';\n powerSwitchDesiredOn = waterHeaterSwitchOn;\n }\n forcedOffByAdvisor = false;\n forcedOffReason = '';\n protectionState = jourFaibleState;\n state = jourFaibleState;\n reason.push(\n jourFaibleState === 'jour_faible_chauffe_hc_edf' ? 'Jour faible : eau chaude en heures creuses sur EDF (session reseau engagee)'\n : jourFaibleState === 'jour_faible_eau_froide' ? `Jour faible mais seulement ${round1(ecs24hKwh)} kWh en 24 h : chauffe pour garantir l eau chaude`\n : jourFaibleState === 'jour_faible_energie_illisible' ? 'Jour faible, energie 24 h illisible : aucune restriction'\n : jourFaibleState === 'jour_faible_securite_soir' ? 'Jour faible : chauffe pour la douche de 20 h (peu chauffe depuis 07:24)'\n : jourFaibleState === 'jour_faible_surplus_solaire' ? 'Jour faible : surplus solaire reel, il chauffe sans toucher la batterie'\n : 'Jour faible : chauffe-eau coupe, il chauffera en heures creuses sur EDF'\n );\n} else if (advisorEnabled && offJourFaiblePrev && !waterHeaterSwitchOn) {\n powerSwitchAction = 'turn_on';\n powerSwitchDesiredOn = true;\n jourFaibleState = 'jour_normal_remise_sous_tension';\n protectionState = jourFaibleState;\n state = jourFaibleState;\n reason.push('Jour normal : chauffe-eau remis sous tension (il chauffe au solaire)');\n}\n\n// =====================================================\n// ALERTE « PASSE EN BOOST » (07/10/2026, proprietaire)\n// =====================================================\n// En heures creuses sur EDF, le ballon doit consommer AU MAXIMUM toute la\n// fenetre (douches 06:00 et 20:00 a 65 °C). Constat du 07/10 a midi : en AUTO\n// il etait ARRETE (0-36 W) alors que l'eau n'etait pas a 65 °C — passe en BOOST\n// a 12:35 il est reparti a 1 235 W. AUTO s'arrete plus bas : 0 W ne prouve\n// donc PAS une eau chaude. Regle : 15 min apres le debut de la fenetre, session\n// engagee, contacteur ferme, ballon sous tension, et la puissance de la\n// RESISTANCE jamais atteinte depuis le debut de la fenetre -> on le SIGNALE.\n// Memoire de la fenetre (puissance max vue) dans le helper lui-meme ; le\n// concierge n'envoie que `active: true`, une fois par `since` (debut de la\n// fenetre). Limite assumee : ballon deja a 65 °C en BOOST = 0 W aussi, donc un\n// message pour rien au plus par fenetre (aucune temperature dans HA).\nconst ALERTE_DELAI_MIN = 15;\nconst seuilResistanceW = ((normalized.greenPowerW ?? 620) + (normalized.autoPowerW ?? 1200)) / 2;\nconst minNow = Number.isFinite(now.minNow) ? now.minNow : null;\nconst fenetreHc = (normalized.hcWindows || []).find((w) => minNow !== null && (\n w.debut <= w.fin ? (minNow >= w.debut && minNow < w.fin) : (minNow >= w.debut || minNow < w.fin)));\nlet alerteChauffeEau = '';\nif (fenetreHc) {\n const depuisDebutMin = (minNow - fenetreHc.debut + 1440) % 1440;\n const h = String(Math.floor(fenetreHc.debut / 60)).padStart(2, '0') + ':' + String(fenetreHc.debut % 60).padStart(2, '0');\n const jourFenetre = (fenetreHc.debut > fenetreHc.fin && minNow < fenetreHc.fin)\n ? new Date(nowMs - 86400000).toLocaleDateString('sv-SE', { timeZone: 'Europe/Paris' }) : now.date;\n const since = `${jourFenetre} ${h}`;\n let prev = null;\n try { prev = JSON.parse(input.alerte_prev || ''); } catch { prev = null; }\n const maxPrev = prev && prev.since === since && Number.isFinite(prev.max_w) ? prev.max_w : 0;\n const maxW = Math.round(Math.max(maxPrev, waterHeaterSwitchOn ? observedPowerW : 0));\n const active = depuisDebutMin >= ALERTE_DELAI_MIN && hcSessionCommitted && gridIsOn\n && waterHeaterSwitchOn && maxW < seuilResistanceW;\n alerteChauffeEau = JSON.stringify({\n active, since, fenetre: fenetreHc.nom, max_w: maxW, puissance_w: Math.round(observedPowerW),\n ...(active ? { message: `🚿 Heures creuses EDF : le chauffe-eau ne chauffe pas a pleine puissance (${Math.round(observedPowerW)} W). Si tu n'es pas en BOOST, passe-le pour avoir 65 °C a ${fenetreHc.douche}.` } : {}),\n });\n}\n\nreturn [{\n json: {\n ...root,\n meta: {\n ...(root.meta ?? {}),\n node: 'ecs_final_arbitration_and_protection'\n },\n finalDecision: {\n recommendedMode,\n adviceColor,\n state,\n reason,\n baseRecommendedMode,\n baseAdviceColor,\n baseState,\n selectedLoadKwh,\n selectedProjectedSoc,\n selectedBudgetMarginKwh,\n legacyAdviceOk,\n mismatchActive,\n mismatchSeverity,\n mismatchSinceAtEffective,\n mismatchSinceMsEffective,\n mismatchDurationMin,\n pendingUserAction,\n firstMismatchRun,\n realConflictNow,\n forceOffNow,\n restoreEnergyOk,\n restoreNow,\n forcedOffByAdvisor,\n forcedOffReason,\n forcedOffAt,\n protectionState,\n powerSwitchAction,\n powerSwitchDesiredOn,\n offJourFaible,\n jourFaibleState,\n alerteChauffeEau\n }\n }\n}];" }, "id": "994b3242-616d-46e7-83ed-d236a117d15c", "name": "ECS Final Arbitration & Protection", @@ -167,7 +167,7 @@ "parameters": [ { "name": "template", - "value": "{#- 07/10/2026 : prevision lue comme le bilan (input_select.evcs_balance_forecast_source).\n VRM : reste du jour = heures restantes de la courbe horaire (heure en cours au\n prorata), demain = somme de la courbe de demain. Courbe absente ou\n malformee -> repli sur Forecast.Solar. -#}\n{%- set src = states('input_select.evcs_balance_forecast_source') -%}\n{%- set vt = states('input_text.evcs_balance_vrm_today').split(',') -%}\n{%- set vd = states('input_text.evcs_balance_vrm_tomorrow').split(',') -%}\n{%- set p = namespace(reste=0, demain=0, ok=(src == 'vrm' and vt | length == 24 and vd | length == 24)) -%}\n{%- if p.ok -%}\n {%- for i in range(now().hour, 24) -%}\n {%- set v = vt[i] | float(0) -%}\n {%- set p.reste = p.reste + (v * (1 - now().minute / 60) if i == now().hour else v) -%}\n {%- endfor -%}\n {%- for v in vd -%}{%- set p.demain = p.demain + (v | float(0)) -%}{%- endfor -%}\n{%- endif -%}\n{{ {\n \"soc_percent\": states(\"sensor.victron_battery_soc\"),\n \"battery_current_a\": states(\"sensor.victron_battery_current\"),\n \"pv_power_w\": states(\"sensor.victron_pv_power_system\"),\n \"pv_power_10m_avg_w\": states(\"sensor.victron_pv_power_10m_avg\"),\n \"house_ac_power_w\": states(\"sensor.victron_consumption_power_l1\"),\n \"grid_power_w\": states(\"sensor.victron_grid_power_l1\"),\n \"grid_state\": states(\"switch.edf_general_ok_switch\"),\n \"victron_acin1_available\": states(\"sensor.victron_ac_in1_available\"),\n\n \"today_remaining_kwh\": (p.reste | round(2)) if p.ok else states(\"sensor.energy_production_today_remaining\"),\n \"tomorrow_kwh\": (p.demain | round(2)) if p.ok else states(\"sensor.energy_production_tomorrow\"),\n \"forecast_source\": \"vrm\" if p.ok else \"forecast_solar\",\n\n \"ecs_nuit\": states(\"input_boolean.evcs_balance_ecs_nuit\"),\n \"ecs_nuit_jour\": states(\"input_text.evcs_balance_ecs_nuit_jour\"),\n \"hc_session_committed\": states(\"input_boolean.victron_evcs_night_grid_session_committed\"),\n \"ecs_24h_kwh\": states(\"sensor.evcs_chauffe_eau_24h_kwh\"),\n \"ecs_jour_kwh\": states(\"sensor.evcs_chauffe_eau_jour_kwh\"),\n \"off_jour_faible_prev\": states(\"input_boolean.victron_water_heater_off_jour_faible\"),\n \"hc_midday_start\": states(\"input_datetime.victron_evcs_hc_midday_start\"),\n \"hc_midday_stop\": states(\"input_datetime.victron_evcs_hc_midday_stop\"),\n \"hc_night_start\": states(\"input_datetime.victron_evcs_hc_night_start\"),\n \"hc_night_stop\": states(\"input_datetime.victron_evcs_hc_night_stop\"),\n\n \"advisor_enabled\": states(\"input_boolean.victron_water_heater_advisor_enabled\"),\n \"notifications_enabled\": states(\"input_boolean.victron_water_heater_notifications\"),\n\n \"hot_water_demand\": states(\"input_select.victron_water_heater_hot_water_demand\"),\n \"morning_boost_priority_enabled\": states(\"input_boolean.victron_water_heater_morning_boost_priority_enabled\"),\n \"morning_boost_latest_time\": states(\"input_datetime.victron_water_heater_morning_boost_latest_time\"),\n \"late_red_time\": states(\"input_datetime.victron_water_heater_late_red_time\"),\n\n \"green_power_w\": states(\"input_number.victron_water_heater_green_power_w\"),\n \"auto_power_w\": states(\"input_number.victron_water_heater_auto_power_w\"),\n \"boost_power_w\": states(\"input_number.victron_water_heater_boost_power_w\"),\n\n \"strong_forecast_kwh\": states(\"input_number.victron_water_heater_strong_forecast_kwh\"),\n \"medium_forecast_kwh\": states(\"input_number.victron_water_heater_medium_forecast_kwh\"),\n \"strong_pv_now_w\": states(\"input_number.victron_water_heater_strong_pv_now_w\"),\n \"medium_pv_now_w\": states(\"input_number.victron_water_heater_medium_pv_now_w\"),\n\n \"morning_boost_min_soc_strong_day\": states(\"input_number.victron_water_heater_morning_boost_min_soc_strong_day\"),\n \"boost_min_soc_medium_day\": states(\"input_number.victron_water_heater_boost_min_soc_medium_day\"),\n \"end_of_day_soc_guard\": states(\"input_number.victron_water_heater_end_of_day_soc_guard\"),\n\n \"end_of_day_soc_target\": states(\"input_number.victron_end_of_day_soc_target\"),\n \"base_load_remaining_kwh\": states(\"input_number.victron_base_load_remaining_kwh\"),\n \"flexible_safety_margin_kwh\": states(\"input_number.victron_flexible_safety_margin_kwh\"),\n \"ac_loads_max_safe_w\": states(\"input_number.victron_ac_loads_max_safe_w\"),\n\n \"day_start_time\": states(\"input_datetime.victron_flexible_day_start_time\"),\n \"day_stop_time\": states(\"input_datetime.victron_flexible_day_stop_time\"),\n\n \"last_signature\": states(\"input_text.victron_water_heater_advice_last_signature\"),\n\n \"evcs_priority_active\": states(\"input_boolean.victron_evcs_priority_active\"),\n \"energy_priority_mode\": states(\"input_select.victron_energy_priority_mode\"),\n \"energy_priority_reason\": states(\"input_text.victron_energy_priority_reason\"),\n \"evcs_priority_until\": states(\"input_datetime.victron_evcs_priority_until\"),\n\n \"water_heater_switch\": states(\"switch.tt_chauffe_eau_pleumeur_switch\"),\n \"water_heater_energy_total_kwh\": states(\"sensor.tt_chauffe_eau_pleumeur_energie_totale\"),\n\n \"energy_total_prev_kwh\": states(\"input_number.victron_water_heater_energy_total_prev_kwh\"),\n \"energy_prev_at\": states(\"input_datetime.victron_water_heater_energy_prev_at\"),\n\n \"observed_regime_helper\": states(\"input_text.victron_water_heater_observed_regime\"),\n \"observed_power_helper_w\": states(\"input_number.victron_water_heater_observed_power_w\"),\n\n \"mode_mismatch_since\": states(\"input_datetime.victron_water_heater_mode_mismatch_since\"),\n\n \"forced_off_by_advisor\": states(\"input_boolean.victron_water_heater_forced_off_by_advisor\"),\n \"forced_off_reason\": states(\"input_text.victron_water_heater_forced_off_reason\"),\n \"forced_off_at\": states(\"input_datetime.victron_water_heater_forced_off_at\"),\n\n \"pending_user_action_prev\": states(\"input_text.victron_water_heater_pending_user_action\"),\n\n \"restore_surplus_w\": states(\"input_number.victron_water_heater_restore_surplus_w\"),\n \"mode_mismatch_delay_min\": states(\"input_number.victron_water_heater_mode_mismatch_delay_min\")\n} | tojson }}\n" + "value": "{#- 07/10/2026 : prevision lue comme le bilan (input_select.evcs_balance_forecast_source).\n VRM : reste du jour = heures restantes de la courbe horaire (heure en cours au\n prorata), demain = somme de la courbe de demain. Courbe absente ou\n malformee -> repli sur Forecast.Solar. -#}\n{%- set src = states('input_select.evcs_balance_forecast_source') -%}\n{%- set vt = states('input_text.evcs_balance_vrm_today').split(',') -%}\n{%- set vd = states('input_text.evcs_balance_vrm_tomorrow').split(',') -%}\n{%- set p = namespace(reste=0, demain=0, ok=(src == 'vrm' and vt | length == 24 and vd | length == 24)) -%}\n{%- if p.ok -%}\n {%- for i in range(now().hour, 24) -%}\n {%- set v = vt[i] | float(0) -%}\n {%- set p.reste = p.reste + (v * (1 - now().minute / 60) if i == now().hour else v) -%}\n {%- endfor -%}\n {%- for v in vd -%}{%- set p.demain = p.demain + (v | float(0)) -%}{%- endfor -%}\n{%- endif -%}\n{{ {\n \"soc_percent\": states(\"sensor.victron_battery_soc\"),\n \"battery_current_a\": states(\"sensor.victron_battery_current\"),\n \"pv_power_w\": states(\"sensor.victron_pv_power_system\"),\n \"pv_power_10m_avg_w\": states(\"sensor.victron_pv_power_10m_avg\"),\n \"house_ac_power_w\": states(\"sensor.victron_consumption_power_l1\"),\n \"grid_power_w\": states(\"sensor.victron_grid_power_l1\"),\n \"grid_state\": states(\"switch.edf_general_ok_switch\"),\n \"victron_acin1_available\": states(\"sensor.victron_ac_in1_available\"),\n\n \"today_remaining_kwh\": (p.reste | round(2)) if p.ok else states(\"sensor.energy_production_today_remaining\"),\n \"tomorrow_kwh\": (p.demain | round(2)) if p.ok else states(\"sensor.energy_production_tomorrow\"),\n \"forecast_source\": \"vrm\" if p.ok else \"forecast_solar\",\n\n \"ecs_nuit\": states(\"input_boolean.evcs_balance_ecs_nuit\"),\n \"ecs_nuit_jour\": states(\"input_text.evcs_balance_ecs_nuit_jour\"),\n \"hc_session_committed\": states(\"input_boolean.victron_evcs_night_grid_session_committed\"),\n \"ecs_24h_kwh\": states(\"sensor.evcs_chauffe_eau_24h_kwh\"),\n \"ecs_jour_kwh\": states(\"sensor.evcs_chauffe_eau_jour_kwh\"),\n \"off_jour_faible_prev\": states(\"input_boolean.victron_water_heater_off_jour_faible\"),\n \"alerte_prev\": states(\"input_text.evcs_alerte_chauffe_eau\"),\n \"hc_midday_start\": states(\"input_datetime.victron_evcs_hc_midday_start\"),\n \"hc_midday_stop\": states(\"input_datetime.victron_evcs_hc_midday_stop\"),\n \"hc_night_start\": states(\"input_datetime.victron_evcs_hc_night_start\"),\n \"hc_night_stop\": states(\"input_datetime.victron_evcs_hc_night_stop\"),\n\n \"advisor_enabled\": states(\"input_boolean.victron_water_heater_advisor_enabled\"),\n \"notifications_enabled\": states(\"input_boolean.victron_water_heater_notifications\"),\n\n \"hot_water_demand\": states(\"input_select.victron_water_heater_hot_water_demand\"),\n \"morning_boost_priority_enabled\": states(\"input_boolean.victron_water_heater_morning_boost_priority_enabled\"),\n \"morning_boost_latest_time\": states(\"input_datetime.victron_water_heater_morning_boost_latest_time\"),\n \"late_red_time\": states(\"input_datetime.victron_water_heater_late_red_time\"),\n\n \"green_power_w\": states(\"input_number.victron_water_heater_green_power_w\"),\n \"auto_power_w\": states(\"input_number.victron_water_heater_auto_power_w\"),\n \"boost_power_w\": states(\"input_number.victron_water_heater_boost_power_w\"),\n\n \"strong_forecast_kwh\": states(\"input_number.victron_water_heater_strong_forecast_kwh\"),\n \"medium_forecast_kwh\": states(\"input_number.victron_water_heater_medium_forecast_kwh\"),\n \"strong_pv_now_w\": states(\"input_number.victron_water_heater_strong_pv_now_w\"),\n \"medium_pv_now_w\": states(\"input_number.victron_water_heater_medium_pv_now_w\"),\n\n \"morning_boost_min_soc_strong_day\": states(\"input_number.victron_water_heater_morning_boost_min_soc_strong_day\"),\n \"boost_min_soc_medium_day\": states(\"input_number.victron_water_heater_boost_min_soc_medium_day\"),\n \"end_of_day_soc_guard\": states(\"input_number.victron_water_heater_end_of_day_soc_guard\"),\n\n \"end_of_day_soc_target\": states(\"input_number.victron_end_of_day_soc_target\"),\n \"base_load_remaining_kwh\": states(\"input_number.victron_base_load_remaining_kwh\"),\n \"flexible_safety_margin_kwh\": states(\"input_number.victron_flexible_safety_margin_kwh\"),\n \"ac_loads_max_safe_w\": states(\"input_number.victron_ac_loads_max_safe_w\"),\n\n \"day_start_time\": states(\"input_datetime.victron_flexible_day_start_time\"),\n \"day_stop_time\": states(\"input_datetime.victron_flexible_day_stop_time\"),\n\n \"last_signature\": states(\"input_text.victron_water_heater_advice_last_signature\"),\n\n \"evcs_priority_active\": states(\"input_boolean.victron_evcs_priority_active\"),\n \"energy_priority_mode\": states(\"input_select.victron_energy_priority_mode\"),\n \"energy_priority_reason\": states(\"input_text.victron_energy_priority_reason\"),\n \"evcs_priority_until\": states(\"input_datetime.victron_evcs_priority_until\"),\n\n \"water_heater_switch\": states(\"switch.tt_chauffe_eau_pleumeur_switch\"),\n \"water_heater_energy_total_kwh\": states(\"sensor.tt_chauffe_eau_pleumeur_energie_totale\"),\n\n \"energy_total_prev_kwh\": states(\"input_number.victron_water_heater_energy_total_prev_kwh\"),\n \"energy_prev_at\": states(\"input_datetime.victron_water_heater_energy_prev_at\"),\n\n \"observed_regime_helper\": states(\"input_text.victron_water_heater_observed_regime\"),\n \"observed_power_helper_w\": states(\"input_number.victron_water_heater_observed_power_w\"),\n\n \"mode_mismatch_since\": states(\"input_datetime.victron_water_heater_mode_mismatch_since\"),\n\n \"forced_off_by_advisor\": states(\"input_boolean.victron_water_heater_forced_off_by_advisor\"),\n \"forced_off_reason\": states(\"input_text.victron_water_heater_forced_off_reason\"),\n \"forced_off_at\": states(\"input_datetime.victron_water_heater_forced_off_at\"),\n\n \"pending_user_action_prev\": states(\"input_text.victron_water_heater_pending_user_action\"),\n\n \"restore_surplus_w\": states(\"input_number.victron_water_heater_restore_surplus_w\"),\n \"mode_mismatch_delay_min\": states(\"input_number.victron_water_heater_mode_mismatch_delay_min\")\n} | tojson }}\n" } ] },