Chauffe-eau en HC de nuit les jours faibles : decision quotidienne du bilan, workflow ECS sous controle du depot (VRM, prevision du jour), energie 24 h

This commit is contained in:
Tallagrand Thierry
2026-10-07 07:06:03 +02:00
parent 0e0c0a558d
commit c2824085c9
26 changed files with 3060 additions and 42 deletions

View File

@@ -613,6 +613,55 @@ function nextDeficitWindow(slots) {
const nextDeficit = nextDeficitWindow(prudent.slots);
// ---------------------------------------------------------------------
// CHAUFFE-EAU EN HC DE NUIT LES JOURS FAIBLES (07/10/2026, option b du
// proprietaire). Le 06/10 (14,5 kWh produits), le chauffe-eau a pris 5,4 kWh
// de 10 h a 15 h, soit l'energie qui manquait a la batterie pour la nuit.
// Regle : si le solaire prevu (profil habituel) ne suffit pas a amener la
// batterie au SOC requis au debut de la traversee du soir, EDF sera ferme la
// nuit de toute facon -> l'eau se chauffe en HC de nuit sur EDF, pas avec le
// solaire du jour.
// ⚠️ DECISION UNE FOIS PAR JOUR, figee jusqu'au lendemain : retirer le
// chauffe-eau de la journee AMELIORE la projection — reevaluee a chaque cycle,
// la regle detruirait sa propre cause (oscillateur, cf. §24/§36). On decide au
// premier calcul apres la fin de la fenetre HC de nuit du jour (la fenetre
// suivante se termine alors dans plus de 12 h) ; la decision vaut pour la
// journee et la nuit qui suit, jusqu'a la decision du lendemain.
// ---------------------------------------------------------------------
const ecsNuitPrev = typeof input.ecs_nuit_jour === 'string' ? input.ecs_nuit_jour : '';
const ecsNuitPrevDate = ecsNuitPrev.split('|')[0] || '';
const nowMsEcs = num(input.nowMs, Date.now());
const todayParis = new Date(nowMsEcs).toLocaleDateString('sv-SE', { timeZone: 'Europe/Paris' });
const ecsNightHcEndMs = num(input.night_hc_end_ms, NaN);
const ecsDecisionDue = ecsNuitPrevDate !== todayParis
&& Number.isFinite(ecsNightHcEndMs) && ecsNightHcEndMs - nowMsEcs > 12 * 3600000;
let ecsNight = { due: ecsDecisionDue, decided: false, date: todayParis };
if (ecsDecisionDue) {
const nd50 = nextDeficitWindow(slotsP50);
if (nd50.found) {
// Trajectoire SIGNEE jusqu'au debut de la traversee (le deficit du matin
// compte), bornee au plancher et a 100 %.
let socProj = socPct;
for (let i = 0; i < nd50.hoursFromNow; i++) {
const net = slotsP50[i].netKwh;
// socAfterEnergy ignore une energie negative : la decharge passe par
// l'energie stockee (meme courbe).
socProj = net >= 0 ? Math.min(100, socAfterEnergy(socProj, net))
: Math.max(socFloorPct, socForEnergy(Math.max(0, energyBetweenSoc(0, socProj) + net), 0));
}
const on = socProj < nd50.requiredSocAtStartPct;
ecsNight = {
due: true, decided: true, date: todayParis, on,
projectedSocPct: round1(socProj),
requiredSocPct: round1(nd50.requiredSocAtStartPct),
startHour: nd50.startHour,
reason: on
? `jour faible : batterie prevue a ${round1(socProj)} % a ${nd50.startHour} h pour ${round1(nd50.requiredSocAtStartPct)} % requis -> eau chaude en HC de nuit`
: `jour suffisant : batterie prevue a ${round1(socProj)} % a ${nd50.startHour} h pour ${round1(nd50.requiredSocAtStartPct)} % requis -> eau chaude au solaire`,
};
}
}
// ---------------------------------------------------------------------
// RÉSERVE D'ORAGE (27/08/2026)
//
@@ -749,6 +798,9 @@ return [{
surplusAvailableKwh: round2(surplusAfterCrossingKwh),
},
// Chauffe-eau en HC de nuit les jours faibles (07/10).
ecsNight,
// Nuit prevue par la soiree (28/09) : facteur applique au profil.
nightModel: {
active: nightModelActive,

View File

@@ -105,6 +105,12 @@ setNumber('input_number.evcs_balance_vehicle_surplus_kwh', balance.vehicle.surpl
setBool('input_boolean.evcs_balance_grid_needed', !!c.gridNeeded);
// Prochaine traversée déficitaire : ce que la décision immédiate ne montre pas.
// Chauffe-eau (07/10) : n'ecrit QUE le jour de la decision (une fois par jour).
const en = balance.ecsNight || {};
if (en.decided) {
setBool('input_boolean.evcs_balance_ecs_nuit', !!en.on);
setText('input_text.evcs_balance_ecs_nuit_jour', `${en.date}|${en.on ? 'on' : 'off'}|${en.reason}`);
}
const nd = balance.nextDeficit || { found: false };
if (nd.found) {
setNumber('input_number.evcs_balance_next_deficit_kwh', nd.energyKwh);

View File

@@ -81,6 +81,7 @@
"next_night_hc_start_ms": (as_timestamp(ns.nuit) * 1000) if ns.nuit != '' else none,
"night_hc_start_ms": (as_timestamp(ns.nd) * 1000) if ns.nd != '' else none,
"night_hc_end_ms": (as_timestamp(ns.nf) * 1000) if ns.nf != '' else none,
"ecs_nuit_jour": states("input_text.evcs_balance_ecs_nuit_jour"),
"nowMs": as_timestamp(now()) * 1000,
"balance_enabled": states("input_boolean.evcs_balance_enabled"),

View File

@@ -81,6 +81,8 @@ return [{
// session de nuit s'engage.
night_hc_start_ms: snapshot.night_hc_start_ms,
night_hc_end_ms: snapshot.night_hc_end_ms,
// Decision du jour pour le chauffe-eau (07/10) : « AAAA-MM-JJ|on|raison ».
ecs_nuit_jour: snapshot.ecs_nuit_jour,
// Repris pour l'écriture des helpers, sans influencer le calcul.
resourceUsable: resource.usable,

View File

@@ -0,0 +1,353 @@
function regimeRank(regime) {
switch (regime) {
case 'off': return 0;
case 'hp_low': return 1;
case 'resistive_mid': return 2;
case 'boost_high': return 3;
default: return -1;
}
}
function allowedRankForMode(mode) {
switch (mode) {
case 'off': return 0;
case 'green': return 1;
case 'auto': return 2;
case 'boost': return 3;
default: return 0;
}
}
function round1(v) {
return Math.round(v * 10) / 10;
}
const root = $input.first()?.json ?? {};
const now = root.now ?? {};
const nowMs = root.nowMs ?? null;
const normalized = root.normalized ?? {};
const observation = root.observation ?? {};
const energyModel = root.energyModel ?? {};
const baseDecision = root.baseDecision ?? {};
let {
recommendedMode,
adviceColor,
state,
reason,
baseRecommendedMode,
baseAdviceColor,
baseState
} = baseDecision;
reason = Array.isArray(reason) ? [...reason] : [];
const {
advisorEnabled,
soc,
batteryCurrentA,
pvPowerAvg10mW,
houseAcPowerW,
todayRemainingKwh,
tomorrowKwh,
baseLoadRemainingKwh,
flexibleSafetyMarginKwh,
acLoadsMaxSafeW,
hotWaterDemand,
gridIsOn,
acInAvailable,
gridAvailable
} = normalized;
const {
evcsPriorityActive,
energyPriorityReason,
waterHeaterSwitchOn,
observedPowerW,
observedRegime,
mismatchSinceAt,
mismatchSinceMsPrev,
forcedOffByAdvisorPrev,
forcedOffReasonPrev,
forcedOffAtPrev,
restoreSurplusW,
modeMismatchDelayMin,
sampleDurationMin,
energyDeltaKwh,
sampleIsUsable
} = observation;
const {
targetSoc,
energyNeededToReachTargetKwh,
greenEnergyKwh,
autoEnergyKwh,
boostEnergyKwh,
projectedGreenSoc,
projectedAutoSoc,
projectedBoostSoc,
greenBudgetMarginKwh,
autoBudgetMarginKwh,
boostBudgetMarginKwh,
forecastStrength,
netPvHeadroomW,
pvCoversHouseNow,
pvStartedWell,
greenAllowed,
autoAllowed,
boostAllowed,
batteryNotDrainingHard
} = energyModel;
if (advisorEnabled && evcsPriorityActive) {
if (recommendedMode === 'boost' || recommendedMode === 'auto') {
if (greenAllowed && batteryNotDrainingHard) {
recommendedMode = 'green';
adviceColor = 'orange';
state = 'evcs_priority_green_cap';
reason.push(`Priorité EVCS active : chauffe-eau plafonné à GREEN (${energyPriorityReason || 'raison non précisée'})`);
} else {
recommendedMode = 'off';
adviceColor = 'red';
state = 'evcs_priority_off_cap';
reason.push(`Priorité EVCS active : chauffe-eau déconseillé (${energyPriorityReason || 'raison non précisée'})`);
}
} else if (recommendedMode === 'green' && (!greenAllowed || !batteryNotDrainingHard)) {
recommendedMode = 'off';
adviceColor = 'red';
state = 'evcs_priority_green_blocked';
reason.push(`Priorité EVCS active : GREEN devient lui-même trop coûteux (${energyPriorityReason || 'raison non précisée'})`);
}
}
const selectedLoadKwh =
recommendedMode === 'green' ? greenEnergyKwh :
recommendedMode === 'auto' ? autoEnergyKwh :
recommendedMode === 'boost' ? boostEnergyKwh :
0;
const selectedProjectedSoc =
recommendedMode === 'green' ? projectedGreenSoc :
recommendedMode === 'auto' ? projectedAutoSoc :
recommendedMode === 'boost' ? projectedBoostSoc :
soc;
const selectedBudgetMarginKwh =
recommendedMode === 'green' ? greenBudgetMarginKwh :
recommendedMode === 'auto' ? autoBudgetMarginKwh :
recommendedMode === 'boost' ? boostBudgetMarginKwh :
todayRemainingKwh - baseLoadRemainingKwh - flexibleSafetyMarginKwh - energyNeededToReachTargetKwh;
const legacyAdviceOk = recommendedMode !== 'off';
const observedRank = regimeRank(observedRegime);
const allowedRank = allowedRankForMode(recommendedMode);
const mismatchActive =
waterHeaterSwitchOn &&
observedRank >= 0 &&
allowedRank >= 0 &&
observedRank > allowedRank;
let mismatchSeverity = 'none';
if (mismatchActive) {
if (recommendedMode === 'off') {
mismatchSeverity = 'critical';
} else if (observedRank - allowedRank >= 2) {
mismatchSeverity = 'high';
} else {
mismatchSeverity = 'medium';
}
}
const mismatchSinceMsEffective = mismatchActive ? (mismatchSinceMsPrev ?? nowMs) : null;
const mismatchSinceAtEffective = mismatchActive ? (mismatchSinceMsPrev ? mismatchSinceAt : now.stamp) : '';
const mismatchDurationMin = mismatchActive && mismatchSinceMsEffective !== null ? Math.max(0, (nowMs - mismatchSinceMsEffective) / 60000) : 0;
let pendingUserAction = '';
if (mismatchActive) {
if (recommendedMode === 'off') {
pendingUserAction = evcsPriorityActive
? 'Priorité EVCS : coupe temporairement le chauffe-eau ou laisse-le hors tension'
: 'Le chauffe-eau consomme alors qu\'il est déconseillé : coupe-le ou attends une meilleure fenêtre';
} else if (recommendedMode === 'green') {
pendingUserAction = observedRegime === 'boost_high'
? 'Passe le chauffe-eau en GREEN si possible, il semble en régime BOOST/résistance forte'
: 'Passe le chauffe-eau en GREEN si possible, il semble plus gourmand que prévu';
} else if (recommendedMode === 'auto') {
pendingUserAction = 'Le chauffe-eau semble trop gourmand pour AUTO : repasse-le en AUTO modéré ou GREEN si possible';
}
}
const firstMismatchRun = mismatchActive && (mismatchSinceMsPrev === null || mismatchDurationMin < 1);
const realConflictNow =
evcsPriorityActive &&
(
batteryCurrentA < -1 ||
pvPowerAvg10mW < restoreSurplusW ||
houseAcPowerW > acLoadsMaxSafeW ||
recommendedMode === 'off' ||
observedRegime === 'boost_high' ||
(recommendedMode === 'green' && observedRegime === 'resistive_mid')
);
const forceOffNow =
advisorEnabled &&
waterHeaterSwitchOn &&
!forcedOffByAdvisorPrev &&
mismatchActive &&
mismatchDurationMin >= modeMismatchDelayMin &&
realConflictNow;
const restoreEnergyOk =
pvPowerAvg10mW >= restoreSurplusW &&
batteryCurrentA >= -1 &&
houseAcPowerW <= acLoadsMaxSafeW;
const restoreNow =
advisorEnabled &&
forcedOffByAdvisorPrev &&
!waterHeaterSwitchOn &&
(
(!evcsPriorityActive && recommendedMode !== 'off') ||
restoreEnergyOk
);
let forcedOffByAdvisor = forcedOffByAdvisorPrev;
let forcedOffReason = forcedOffReasonPrev;
let forcedOffAt = forcedOffAtPrev;
let protectionState = 'none';
let powerSwitchAction = 'none';
let powerSwitchDesiredOn = waterHeaterSwitchOn;
if (forceOffNow) {
powerSwitchAction = 'turn_off';
powerSwitchDesiredOn = false;
forcedOffByAdvisor = true;
forcedOffReason = `Coupure temporaire ECS: priorité EVCS + régime ${observedRegime} trop gourmand`;
forcedOffAt = now.stamp;
protectionState = 'forced_off_now_evcs_priority';
state = 'forced_off_now_evcs_priority';
adviceColor = 'red';
reason.push(forcedOffReason);
}
else if (restoreNow) {
powerSwitchAction = 'turn_on';
powerSwitchDesiredOn = true;
forcedOffByAdvisor = false;
forcedOffReason = '';
protectionState = 'restored_after_evcs_priority';
state = 'restored_after_evcs_priority';
reason.push('Remise sous tension ECS : conflit EVCS terminé ou surplus redevenu suffisant');
}
else if (forcedOffByAdvisorPrev && !waterHeaterSwitchOn) {
protectionState = 'forced_off_wait_restore';
state = 'forced_off_wait_restore';
reason.push(`ECS temporairement coupé par l'advisor (${forcedOffReasonPrev || 'raison non précisée'})`);
}
else if (mismatchActive && evcsPriorityActive) {
protectionState = 'watch_mismatch_evcs_priority';
state = 'watch_mismatch_evcs_priority';
reason.push(`Écart de régime ECS sous priorité EVCS depuis ${round1(mismatchDurationMin)} min`);
}
if (!forceOffNow && !restoreNow && forcedOffByAdvisorPrev && waterHeaterSwitchOn) {
forcedOffByAdvisor = false;
forcedOffReason = '';
}
// =====================================================
// JOUR FAIBLE : EAU CHAUDE EN HC DE NUIT (07/10/2026, option b du proprietaire)
// =====================================================
// Le bilan decide une fois par jour (input_boolean.evcs_balance_ecs_nuit) : si
// le solaire prevu ne remplit pas la batterie pour la nuit, EDF sera ferme de
// toute facon -> le chauffe-eau est COUPE le jour et chauffe pendant une
// session reseau HC engagee (contacteur ferme), au tarif creux, jamais sur la
// batterie. Securite eau chaude : moins de ECS_EAU_FROIDE_KWH en 24 h -> il
// chauffe quand meme. Energie 24 h illisible -> aucune restriction (le
// comportement d'avant revient, jamais un ballon froid par panne de capteur).
// Jour normal : le chauffe-eau coupe par cette regle est remis sous tension.
const ECS_EAU_FROIDE_KWH = 1.5;
const {
ecsNuit = false, hcSessionCommitted = false, ecs24hKwh = NaN, offJourFaiblePrev = false,
} = normalized;
let offJourFaible = false;
let jourFaibleState = 'none';
if (advisorEnabled && ecsNuit) {
const energieConnue = Number.isFinite(ecs24hKwh);
const eauFroide = energieConnue && ecs24hKwh < ECS_EAU_FROIDE_KWH;
const heuresCreusesEdf = hcSessionCommitted && gridIsOn;
const veutChauffer = !energieConnue || heuresCreusesEdf || eauFroide;
offJourFaible = true;
jourFaibleState = !energieConnue ? 'jour_faible_energie_illisible'
: heuresCreusesEdf ? 'jour_faible_chauffe_hc_edf'
: eauFroide ? 'jour_faible_eau_froide'
: 'jour_faible_coupe';
if (veutChauffer !== waterHeaterSwitchOn) {
powerSwitchAction = veutChauffer ? 'turn_on' : 'turn_off';
powerSwitchDesiredOn = veutChauffer;
} else {
powerSwitchAction = 'none';
powerSwitchDesiredOn = waterHeaterSwitchOn;
}
forcedOffByAdvisor = false;
forcedOffReason = '';
protectionState = jourFaibleState;
state = jourFaibleState;
reason.push(
jourFaibleState === 'jour_faible_chauffe_hc_edf' ? 'Jour faible : eau chaude en heures creuses sur EDF (session reseau engagee)'
: jourFaibleState === 'jour_faible_eau_froide' ? `Jour faible mais seulement ${round1(ecs24hKwh)} kWh en 24 h : chauffe pour garantir l eau chaude`
: jourFaibleState === 'jour_faible_energie_illisible' ? 'Jour faible, energie 24 h illisible : aucune restriction'
: 'Jour faible : chauffe-eau coupe, il chauffera en heures creuses sur EDF'
);
} else if (advisorEnabled && offJourFaiblePrev && !waterHeaterSwitchOn) {
powerSwitchAction = 'turn_on';
powerSwitchDesiredOn = true;
jourFaibleState = 'jour_normal_remise_sous_tension';
protectionState = jourFaibleState;
state = jourFaibleState;
reason.push('Jour normal : chauffe-eau remis sous tension (il chauffe au solaire)');
}
return [{
json: {
...root,
meta: {
...(root.meta ?? {}),
node: 'ecs_final_arbitration_and_protection'
},
finalDecision: {
recommendedMode,
adviceColor,
state,
reason,
baseRecommendedMode,
baseAdviceColor,
baseState,
selectedLoadKwh,
selectedProjectedSoc,
selectedBudgetMarginKwh,
legacyAdviceOk,
mismatchActive,
mismatchSeverity,
mismatchSinceAtEffective,
mismatchSinceMsEffective,
mismatchDurationMin,
pendingUserAction,
firstMismatchRun,
realConflictNow,
forceOffNow,
restoreEnergyOk,
restoreNow,
forcedOffByAdvisor,
forcedOffReason,
forcedOffAt,
protectionState,
powerSwitchAction,
powerSwitchDesiredOn,
offJourFaible,
jourFaibleState
}
}
}];

View File

@@ -0,0 +1,128 @@
const root = $input.first()?.json ?? {};
const normalized = root.normalized ?? {};
const energyModel = root.energyModel ?? {};
const {
advisorEnabled,
hotWaterDemand,
soc,
pvPowerW,
pvPowerAvg10mW,
houseAcPowerW,
acLoadsMaxSafeW
} = normalized;
const {
targetSoc,
forecastStrength,
netPvHeadroomW,
pvCoversHouseNow,
acSafe,
greenAllowed,
earlyBoostStrongReady,
earlyBoostMediumReady,
autoReady,
greenReady,
projectedGreenSoc,
projectedAutoSoc,
projectedBoostSoc,
greenBudgetMarginKwh,
autoBudgetMarginKwh,
boostBudgetMarginKwh
} = energyModel;
let recommendedMode = 'off';
let adviceColor = 'red';
let state = 'off_waiting_pv';
let reason = [];
if (!advisorEnabled) {
recommendedMode = 'off';
adviceColor = 'red';
state = 'advisor_disabled';
reason.push('Advisor chauffe-eau désactivé');
}
else if (!acSafe) {
recommendedMode = 'off';
adviceColor = 'red';
state = 'off_ac_loads_too_high';
reason.push(`Charges maison trop élevées pour ECS (${Math.round(houseAcPowerW)} W > seuil ${Math.round(acLoadsMaxSafeW)} W)`);
}
else if (earlyBoostStrongReady) {
recommendedMode = 'boost';
adviceColor = 'green';
state = 'boost_early_strong_forecast';
reason.push('Boost tôt autorisé : forecast fort + SOC suffisant + PV net maison favorable');
}
else if (earlyBoostMediumReady) {
recommendedMode = 'boost';
adviceColor = 'orange';
state = 'boost_early_medium_forecast';
reason.push('Boost tôt autorisé : forecast moyen + SOC élevé + surplus net maison suffisant');
}
else if (autoReady) {
recommendedMode = 'auto';
adviceColor = 'green';
state = 'auto_from_pv_net';
reason.push('Mode auto recommandé : le PV couvre déjà la maison');
}
else if (greenReady) {
recommendedMode = 'green';
adviceColor = 'green';
state = 'green_waiting_pv_ramp';
reason.push('Mode green recommandé : démarrage PV détecté');
}
else {
recommendedMode = 'off';
adviceColor = 'red';
if (!pvCoversHouseNow) {
state = 'wait_for_pv_to_cover_house';
reason.push('Attente : le PV ne couvre pas encore la maison');
} else if (!greenAllowed) {
state = 'off_budget_guard';
reason.push('Attente : garde-fou budget / SOC fin de journée');
} else {
state = 'wait_for_pv_or_soc';
reason.push('Attente d\'un meilleur niveau PV / forecast / SOC');
}
}
reason.push(`Demande ECS: ${hotWaterDemand}`);
reason.push(`Forecast: ${forecastStrength}`);
reason.push(`SOC: ${Math.round(soc)}%`);
reason.push(`PV: ${Math.round(pvPowerW)} W | PV10: ${Math.round(pvPowerAvg10mW)} W`);
reason.push(`Maison: ${Math.round(houseAcPowerW)} W`);
reason.push(`Surplus net estimé: ${Math.round(netPvHeadroomW)} W`);
if (recommendedMode === 'boost') {
reason.push(`Boost budget: marge ${boostBudgetMarginKwh.toFixed(2)} kWh | SOC fin ${projectedBoostSoc.toFixed(1)}%`);
} else if (recommendedMode === 'auto') {
reason.push(`Auto budget: marge ${autoBudgetMarginKwh.toFixed(2)} kWh | SOC fin ${projectedAutoSoc.toFixed(1)}%`);
} else if (recommendedMode === 'green') {
reason.push(`Green budget: marge ${greenBudgetMarginKwh.toFixed(2)} kWh | SOC fin ${projectedGreenSoc.toFixed(1)}%`);
} else {
reason.push(`Target fin de jour: ${targetSoc.toFixed(1)}%`);
}
const baseRecommendedMode = recommendedMode;
const baseAdviceColor = adviceColor;
const baseState = state;
return [{
json: {
...root,
meta: {
...(root.meta ?? {}),
node: 'ecs_base_advisor_decision'
},
baseDecision: {
recommendedMode,
adviceColor,
state,
reason,
baseRecommendedMode,
baseAdviceColor,
baseState
}
}
}];

View File

@@ -0,0 +1,148 @@
const root = $input.first()?.json ?? {};
const calls = root.ha_service_calls ?? {};
const decision = root.decision ?? {};
function isObject(v) {
return v !== null && typeof v === 'object' && !Array.isArray(v);
}
function isValidParisDateTime(v) {
return typeof v === 'string' && /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(v.trim());
}
function normalizeText(v) {
return (v ?? '').toString();
}
function shouldExecute(call) {
if (!isObject(call)) return false;
return call.execute !== false;
}
function getBaseUrl() {
return ($env.HA_BASE_URL || 'http://10.10.10.17:8123').replace(/\/$/, '');
}
function getDebugEntityId(data) {
if (!isObject(data)) return '';
if (typeof data.entity_id === 'string' && data.entity_id.trim()) return data.entity_id.trim();
return '';
}
function sanitizeCall(key, call) {
if (!isObject(call)) return null;
if (!shouldExecute(call)) return null;
const domain = normalizeText(call.domain).trim();
const service = normalizeText(call.service).trim();
const data = isObject(call.data) ? { ...call.data } : {};
if (!domain || !service) return null;
// Garde-fous ciblés
if (domain === 'input_datetime' && service === 'set_datetime') {
if (!isValidParisDateTime(data.datetime)) return null;
}
if (domain === 'input_text' && service === 'set_value') {
data.value = normalizeText(data.value);
}
if (domain === 'input_select' && service === 'select_option') {
if (!normalizeText(data.option).trim()) return null;
}
if (domain === 'input_number' && service === 'set_value') {
const n = Number(data.value);
if (!Number.isFinite(n)) return null;
data.value = n;
}
if ((domain === 'switch' || domain === 'input_boolean') && !data.entity_id) {
return null;
}
if (domain === 'notify') {
data.title = normalizeText(data.title);
data.message = normalizeText(data.message);
}
const baseUrl = getBaseUrl();
const debugEntityId = getDebugEntityId(data);
const debugUrl = `${baseUrl}/api/services/${domain}/${service}`;
return {
json: {
action: key,
domain,
service,
data,
// mini debug demandé
debug_action: key,
debug_entity_id: debugEntityId || '(none)',
debug_url: debugUrl,
meta: {
source: 'water_heater_advisor',
timestamp_paris: root.timestamp_paris ?? null,
recommended_mode: decision.recommended_mode ?? null,
advice_color: decision.advice_color ?? null,
state: decision.state ?? null
}
}
};
}
// Ordre conseillé
const orderedKeys = [
'helper_recommended_mode',
'helper_advice_color',
'helper_state',
'helper_reason',
'helper_signature',
'helper_projected_soc',
'helper_budget_margin',
'helper_load_kwh',
'helper_legacy_advice_ok',
'helper_green_allowed',
'helper_auto_allowed',
'helper_boost_allowed',
'helper_observed_power',
'helper_observed_regime',
'helper_pending_user_action',
'helper_mismatch_since',
'helper_energy_prev_kwh',
'helper_energy_prev_at',
'helper_forced_off_by_advisor',
'helper_forced_off_reason',
'helper_forced_off_at',
'water_heater_power_switch',
'logbook',
'mobile_notification'
];
const actions = [];
const alreadySeen = new Set();
for (const key of orderedKeys) {
alreadySeen.add(key);
const built = sanitizeCall(key, calls[key]);
if (built) actions.push(built);
}
// Sécurité si de nouveaux appels sont ajoutés plus tard
for (const [key, call] of Object.entries(calls)) {
if (alreadySeen.has(key)) continue;
const built = sanitizeCall(key, call);
if (built) actions.push(built);
}
return actions;

View File

@@ -0,0 +1,207 @@
function clamp(v, min, max) {
return Math.min(Math.max(v, min), max);
}
const root = $input.first()?.json ?? {};
const cfg = root.cfg ?? {};
const normalized = root.normalized ?? {};
const {
soc,
batteryCurrentA,
pvPowerW,
pvPowerAvg10mW,
houseAcPowerW,
todayRemainingKwh,
tomorrowKwh,
advisorEnabled,
hotWaterDemand,
greenPowerW,
autoPowerW,
boostPowerW,
strongForecastKwh,
mediumForecastKwh,
strongPvNowW,
mediumPvNowW,
boostMinSocStrongForecastPct,
boostMinSocMediumForecastPct,
endOfDaySocGuard,
endOfDaySocTarget,
baseLoadRemainingKwh,
flexibleSafetyMarginKwh,
acLoadsMaxSafeW
} = normalized;
const targetSoc = Math.max(endOfDaySocTarget, endOfDaySocGuard);
const batteryCapacityKwh = cfg.batteryUsableCapacityKwh ?? 17.5;
const energyTable =
(cfg.energyByDemandKwh && cfg.energyByDemandKwh[hotWaterDemand]) ||
(cfg.energyByDemandKwh && cfg.energyByDemandKwh.normal) || {
green: 1.4,
auto: 2.2,
boost: 3.1
};
const greenEnergyKwh = energyTable.green;
const autoEnergyKwh = energyTable.auto;
const boostEnergyKwh = energyTable.boost;
const energyNeededToReachTargetKwh =
Math.max(0, ((targetSoc - soc) / 100) * batteryCapacityKwh);
function projectedEndSocAfterMode(loadKwh) {
const netKwh =
todayRemainingKwh -
baseLoadRemainingKwh -
flexibleSafetyMarginKwh -
loadKwh;
const deltaSoc = (netKwh / batteryCapacityKwh) * 100;
return clamp(soc + deltaSoc, 0, 100);
}
function budgetMarginKwh(loadKwh) {
return (
todayRemainingKwh -
baseLoadRemainingKwh -
flexibleSafetyMarginKwh -
loadKwh -
energyNeededToReachTargetKwh
);
}
const projectedGreenSoc = projectedEndSocAfterMode(greenEnergyKwh);
const projectedAutoSoc = projectedEndSocAfterMode(autoEnergyKwh);
const projectedBoostSoc = projectedEndSocAfterMode(boostEnergyKwh);
const greenBudgetMarginKwh = budgetMarginKwh(greenEnergyKwh);
const autoBudgetMarginKwh = budgetMarginKwh(autoEnergyKwh);
const boostBudgetMarginKwh = budgetMarginKwh(boostEnergyKwh);
// 07/10/2026 : la force de la journee se juge sur le reste du JOUR. Avant, le
// lendemain suffisait (« || tomorrowKwh ») : le 06/10, journee de 14,5 kWh, le
// boost etait conseille parce que mercredi etait annonce fort.
const strongForecast =
todayRemainingKwh >= strongForecastKwh;
const mediumForecast =
!strongForecast &&
todayRemainingKwh >= mediumForecastKwh;
const forecastStrength =
strongForecast ? 'strong' :
mediumForecast ? 'medium' :
'weak';
const pv10mW = Number.isFinite(pvPowerAvg10mW) ? pvPowerAvg10mW : pvPowerW;
const netPvHeadroomW = Math.round(pv10mW - houseAcPowerW);
const pvCoversHouseNow = pvPowerW > houseAcPowerW;
const strongPvNow = pv10mW >= strongPvNowW || pvPowerW >= strongPvNowW;
const mediumPvNow = pv10mW >= mediumPvNowW || pvPowerW >= mediumPvNowW;
const pvStartedWell =
mediumPvNow &&
pvCoversHouseNow &&
netPvHeadroomW >= Math.max(300, Math.round(greenPowerW * 0.5));
const acSafe = acLoadsMaxSafeW <= 0 ? true : houseAcPowerW <= acLoadsMaxSafeW;
const batteryNotDrainingHard = batteryCurrentA >= -2;
const greenAllowed =
advisorEnabled &&
acSafe &&
(
projectedGreenSoc >= targetSoc - 6 ||
greenBudgetMarginKwh >= -1.0 ||
soc >= targetSoc
);
const autoAllowed =
advisorEnabled &&
acSafe &&
(
autoBudgetMarginKwh >= 0 ||
projectedAutoSoc >= targetSoc ||
(strongForecast && projectedAutoSoc >= targetSoc - 2)
);
const boostAllowedByBudget =
advisorEnabled &&
acSafe &&
(
projectedBoostSoc >= targetSoc ||
boostBudgetMarginKwh >= 0 ||
(strongForecast && projectedBoostSoc >= targetSoc - 5) ||
(strongForecast && boostBudgetMarginKwh >= -1.0)
);
const earlyBoostStrongReady =
boostAllowedByBudget &&
soc >= boostMinSocStrongForecastPct &&
pvStartedWell &&
(strongForecast || strongPvNow);
const earlyBoostMediumReady =
boostAllowedByBudget &&
mediumForecast &&
soc >= boostMinSocMediumForecastPct &&
pvStartedWell &&
netPvHeadroomW >= greenPowerW;
const autoReady =
autoAllowed &&
mediumPvNow &&
pvCoversHouseNow &&
netPvHeadroomW >= 0;
const greenReady =
greenAllowed &&
(
pv10mW >= greenPowerW ||
(pvPowerW >= greenPowerW && pvCoversHouseNow)
);
const boostAllowed = earlyBoostStrongReady || earlyBoostMediumReady;
return [{
json: {
...root,
meta: {
...(root.meta ?? {}),
node: 'ecs_energy_model_and_early_boost_signals'
},
energyModel: {
targetSoc,
batteryCapacityKwh,
greenEnergyKwh,
autoEnergyKwh,
boostEnergyKwh,
energyNeededToReachTargetKwh,
projectedGreenSoc,
projectedAutoSoc,
projectedBoostSoc,
greenBudgetMarginKwh,
autoBudgetMarginKwh,
boostBudgetMarginKwh,
forecastStrength,
strongForecast,
mediumForecast,
strongPvNow,
mediumPvNow,
netPvHeadroomW,
pvCoversHouseNow,
pvStartedWell,
acSafe,
batteryNotDrainingHard,
greenAllowed,
autoAllowed,
boostAllowedByBudget,
boostAllowed,
earlyBoostStrongReady,
earlyBoostMediumReady,
autoReady,
greenReady
}
}
}];

View File

@@ -0,0 +1,91 @@
{#- 07/10/2026 : prevision lue comme le bilan (input_select.evcs_balance_forecast_source).
VRM : reste du jour = heures restantes de la courbe horaire (heure en cours au
prorata), demain = somme de la courbe de demain. Courbe absente ou
malformee -> repli sur Forecast.Solar. -#}
{%- set src = states('input_select.evcs_balance_forecast_source') -%}
{%- set vt = states('input_text.evcs_balance_vrm_today').split(',') -%}
{%- set vd = states('input_text.evcs_balance_vrm_tomorrow').split(',') -%}
{%- set p = namespace(reste=0, demain=0, ok=(src == 'vrm' and vt | length == 24 and vd | length == 24)) -%}
{%- if p.ok -%}
{%- for i in range(now().hour, 24) -%}
{%- set v = vt[i] | float(0) -%}
{%- set p.reste = p.reste + (v * (1 - now().minute / 60) if i == now().hour else v) -%}
{%- endfor -%}
{%- for v in vd -%}{%- set p.demain = p.demain + (v | float(0)) -%}{%- endfor -%}
{%- endif -%}
{{ {
"soc_percent": states("sensor.victron_battery_soc"),
"battery_current_a": states("sensor.victron_battery_current"),
"pv_power_w": states("sensor.victron_pv_power_system"),
"pv_power_10m_avg_w": states("sensor.victron_pv_power_10m_avg"),
"house_ac_power_w": states("sensor.victron_consumption_power_l1"),
"grid_power_w": states("sensor.victron_grid_power_l1"),
"grid_state": states("switch.edf_general_ok_switch"),
"victron_acin1_available": states("sensor.victron_ac_in1_available"),
"today_remaining_kwh": (p.reste | round(2)) if p.ok else states("sensor.energy_production_today_remaining"),
"tomorrow_kwh": (p.demain | round(2)) if p.ok else states("sensor.energy_production_tomorrow"),
"forecast_source": "vrm" if p.ok else "forecast_solar",
"ecs_nuit": states("input_boolean.evcs_balance_ecs_nuit"),
"ecs_nuit_jour": states("input_text.evcs_balance_ecs_nuit_jour"),
"hc_session_committed": states("input_boolean.victron_evcs_night_grid_session_committed"),
"ecs_24h_kwh": states("sensor.evcs_chauffe_eau_24h_kwh"),
"off_jour_faible_prev": states("input_boolean.victron_water_heater_off_jour_faible"),
"advisor_enabled": states("input_boolean.victron_water_heater_advisor_enabled"),
"notifications_enabled": states("input_boolean.victron_water_heater_notifications"),
"hot_water_demand": states("input_select.victron_water_heater_hot_water_demand"),
"morning_boost_priority_enabled": states("input_boolean.victron_water_heater_morning_boost_priority_enabled"),
"morning_boost_latest_time": states("input_datetime.victron_water_heater_morning_boost_latest_time"),
"late_red_time": states("input_datetime.victron_water_heater_late_red_time"),
"green_power_w": states("input_number.victron_water_heater_green_power_w"),
"auto_power_w": states("input_number.victron_water_heater_auto_power_w"),
"boost_power_w": states("input_number.victron_water_heater_boost_power_w"),
"strong_forecast_kwh": states("input_number.victron_water_heater_strong_forecast_kwh"),
"medium_forecast_kwh": states("input_number.victron_water_heater_medium_forecast_kwh"),
"strong_pv_now_w": states("input_number.victron_water_heater_strong_pv_now_w"),
"medium_pv_now_w": states("input_number.victron_water_heater_medium_pv_now_w"),
"morning_boost_min_soc_strong_day": states("input_number.victron_water_heater_morning_boost_min_soc_strong_day"),
"boost_min_soc_medium_day": states("input_number.victron_water_heater_boost_min_soc_medium_day"),
"end_of_day_soc_guard": states("input_number.victron_water_heater_end_of_day_soc_guard"),
"end_of_day_soc_target": states("input_number.victron_end_of_day_soc_target"),
"base_load_remaining_kwh": states("input_number.victron_base_load_remaining_kwh"),
"flexible_safety_margin_kwh": states("input_number.victron_flexible_safety_margin_kwh"),
"ac_loads_max_safe_w": states("input_number.victron_ac_loads_max_safe_w"),
"day_start_time": states("input_datetime.victron_flexible_day_start_time"),
"day_stop_time": states("input_datetime.victron_flexible_day_stop_time"),
"last_signature": states("input_text.victron_water_heater_advice_last_signature"),
"evcs_priority_active": states("input_boolean.victron_evcs_priority_active"),
"energy_priority_mode": states("input_select.victron_energy_priority_mode"),
"energy_priority_reason": states("input_text.victron_energy_priority_reason"),
"evcs_priority_until": states("input_datetime.victron_evcs_priority_until"),
"water_heater_switch": states("switch.tt_chauffe_eau_pleumeur_switch"),
"water_heater_energy_total_kwh": states("sensor.tt_chauffe_eau_pleumeur_energie_totale"),
"energy_total_prev_kwh": states("input_number.victron_water_heater_energy_total_prev_kwh"),
"energy_prev_at": states("input_datetime.victron_water_heater_energy_prev_at"),
"observed_regime_helper": states("input_text.victron_water_heater_observed_regime"),
"observed_power_helper_w": states("input_number.victron_water_heater_observed_power_w"),
"mode_mismatch_since": states("input_datetime.victron_water_heater_mode_mismatch_since"),
"forced_off_by_advisor": states("input_boolean.victron_water_heater_forced_off_by_advisor"),
"forced_off_reason": states("input_text.victron_water_heater_forced_off_reason"),
"forced_off_at": states("input_datetime.victron_water_heater_forced_off_at"),
"pending_user_action_prev": states("input_text.victron_water_heater_pending_user_action"),
"restore_surplus_w": states("input_number.victron_water_heater_restore_surplus_w"),
"mode_mismatch_delay_min": states("input_number.victron_water_heater_mode_mismatch_delay_min")
} | tojson }}

View File

@@ -0,0 +1,250 @@
function num(v, fallback = NaN) {
const n = Number.parseFloat(v);
return Number.isFinite(n) ? n : fallback;
}
function low(v) {
return (v ?? '').toString().trim().toLowerCase();
}
function parseMaybeJson(v) {
if (typeof v !== 'string') return v;
try {
return JSON.parse(v);
} catch {
return v;
}
}
function extractSnapshot(input) {
if (typeof input === 'string') return parseMaybeJson(input);
if (input?.data && typeof input.data === 'string') return parseMaybeJson(input.data);
if (input?.body && typeof input.body === 'string') return parseMaybeJson(input.body);
if (input?.data && typeof input.data === 'object') return input.data;
if (input?.body && typeof input.body === 'object') return input.body;
return input ?? {};
}
function clamp(v, min, max) {
return Math.min(Math.max(v, min), max);
}
function to2(n) {
return String(n).padStart(2, '0');
}
function getParisNowParts() {
const fmt = new Intl.DateTimeFormat('fr-FR', {
timeZone: 'Europe/Paris',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
const parts = Object.fromEntries(
fmt.formatToParts(new Date())
.filter(p => p.type !== 'literal')
.map(p => [p.type, p.value])
);
const hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour);
const date = `${parts.year}-${parts.month}-${parts.day}`;
const stamp = `${date} ${to2(hour)}:${parts.minute}:${parts.second}`;
return {
year: Number(parts.year),
month: Number(parts.month),
day: Number(parts.day),
hour,
minute: Number(parts.minute),
second: Number(parts.second),
date,
stamp,
minNow: hour * 60 + Number(parts.minute),
};
}
function getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') {
const dtf = new Intl.DateTimeFormat('en-US', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
const parts = Object.fromEntries(
dtf.formatToParts(new Date(utcTs))
.filter(p => p.type !== 'literal')
.map(p => [p.type, p.value])
);
const asIfUtc = Date.UTC(
Number(parts.year),
Number(parts.month) - 1,
Number(parts.day),
Number(parts.hour) === 24 ? 0 : Number(parts.hour),
Number(parts.minute),
Number(parts.second)
);
const d = new Date(utcTs);
const actualUtc = Date.UTC(
d.getUTCFullYear(),
d.getUTCMonth(),
d.getUTCDate(),
d.getUTCHours(),
d.getUTCMinutes(),
d.getUTCSeconds()
);
return asIfUtc - actualUtc;
}
function parseParisStampToMs(s) {
const text = (s ?? '').toString().trim();
const m = text.match(/^(\\d{4})-(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2})$/);
if (!m) return null;
const y = Number(m[1]);
const mo = Number(m[2]);
const d = Number(m[3]);
const h = Number(m[4]);
const mi = Number(m[5]);
const se = Number(m[6]);
if (![y, mo, d, h, mi, se].every(Number.isFinite)) return null;
const guessUtc = Date.UTC(y, mo - 1, d, h, mi, se);
const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris');
let ts = guessUtc - off1;
const off2 = getTzOffsetMs(ts, 'Europe/Paris');
if (off2 !== off1) ts = guessUtc - off2;
return ts;
}
function cleanStateText(v) {
const s = (v ?? '').toString().trim();
const x = s.toLowerCase();
return ['unknown', 'unavailable', 'none', 'null'].includes(x) ? '' : s;
}
const cfg = {
mobileNotifyDomain: 'notify',
mobileNotifyService: 'mobile_app_iphone_16_de_tt',
batteryUsableCapacityKwh: 17.5,
sampleWindowMinMin: 2,
sampleWindowMinMax: 15,
energyByDemandKwh: {
low: { green: 0.9, auto: 1.4, boost: 2.0 },
normal: { green: 1.4, auto: 2.2, boost: 3.1 },
high: { green: 2.2, auto: 3.4, boost: 4.6 },
three_showers: { green: 3.0, auto: 4.4, boost: 5.8 }
}
};
const raw = $input.first()?.json ?? {};
const input = extractSnapshot(raw);
const now = getParisNowParts();
const nowMs = Date.now();
const soc = clamp(num(input.soc_percent, 0), 0, 100);
const batteryCurrentA = num(input.battery_current_a, 0);
const pvPowerW = Math.max(0, num(input.pv_power_w, 0));
const pvPowerAvg10mW = Math.max(0, num(input.pv_power_10m_avg_w, pvPowerW));
const houseAcPowerW = Math.max(0, num(input.house_ac_power_w, 0));
const gridPowerW = num(input.grid_power_w, 0);
const gridIsOn = low(input.grid_state || 'off') === 'on';
const acInAvailable = num(input.victron_acin1_available, 0) === 1;
const gridAvailable = gridIsOn || acInAvailable;
const todayRemainingKwh = Math.max(0, num(input.today_remaining_kwh, 0));
const tomorrowKwh = Math.max(0, num(input.tomorrow_kwh, 0));
const advisorEnabled = low(input.advisor_enabled || 'off') === 'on';
const notificationsEnabled = low(input.notifications_enabled || 'off') === 'on';
const hotWaterDemandRaw = low(input.hot_water_demand || 'normal');
const hotWaterDemand = ['low', 'normal', 'high', 'three_showers'].includes(hotWaterDemandRaw)
? hotWaterDemandRaw
: 'normal';
const greenPowerW = Math.max(0, num(input.green_power_w, 620));
const autoPowerW = Math.max(0, num(input.auto_power_w, 1200));
const boostPowerW = Math.max(0, num(input.boost_power_w, 2000));
const strongForecastKwh = Math.max(0, num(input.strong_forecast_kwh, 18));
const mediumForecastKwh = Math.max(0, num(input.medium_forecast_kwh, 10));
const strongPvNowW = Math.max(0, num(input.strong_pv_now_w, 2500));
const mediumPvNowW = Math.max(0, num(input.medium_pv_now_w, 1500));
const boostMinSocStrongForecastPct = clamp(num(input.morning_boost_min_soc_strong_day, 35), 0, 100);
const boostMinSocMediumForecastPct = clamp(num(input.boost_min_soc_medium_day, 50), 0, 100);
const endOfDaySocGuard = clamp(num(input.end_of_day_soc_guard, 85), 0, 100);
const endOfDaySocTarget = clamp(num(input.end_of_day_soc_target, 85), 0, 100);
const baseLoadRemainingKwh = Math.max(0, num(input.base_load_remaining_kwh, 0));
const flexibleSafetyMarginKwh = Math.max(0, num(input.flexible_safety_margin_kwh, 0));
const acLoadsMaxSafeW = Math.max(0, num(input.ac_loads_max_safe_w, 8000));
const lastSignature = cleanStateText(input.last_signature);
// Jour faible (07/10/2026) : decision du bilan, session reseau HC engagee,
// energie du chauffe-eau sur 24 h (service d'historisation), propriete de la coupure.
const ecsNuit = low(input.ecs_nuit) === 'on';
const hcSessionCommitted = low(input.hc_session_committed) === 'on';
const ecs24hKwh = num(input.ecs_24h_kwh, NaN);
const offJourFaiblePrev = low(input.off_jour_faible_prev) === 'on';
const forecastSource = cleanStateText(input.forecast_source) || 'forecast_solar';
return [{
json: {
meta: {
workflow: 'water_heater_advisor',
architecture: 'ecs_6nodes_simple_early_boost',
node: 'ecs_normalize_inputs',
timestamp_paris: now.stamp
},
cfg,
now,
nowMs,
input,
normalized: {
soc,
batteryCurrentA,
pvPowerW,
pvPowerAvg10mW,
houseAcPowerW,
gridPowerW,
gridIsOn,
acInAvailable,
gridAvailable,
todayRemainingKwh,
tomorrowKwh,
advisorEnabled,
notificationsEnabled,
hotWaterDemand,
greenPowerW,
autoPowerW,
boostPowerW,
strongForecastKwh,
mediumForecastKwh,
strongPvNowW,
mediumPvNowW,
boostMinSocStrongForecastPct,
boostMinSocMediumForecastPct,
endOfDaySocGuard,
endOfDaySocTarget,
baseLoadRemainingKwh,
flexibleSafetyMarginKwh,
acLoadsMaxSafeW,
lastSignature,
ecsNuit,
hcSessionCommitted,
ecs24hKwh,
offJourFaiblePrev,
forecastSource
}
}
}];

View File

@@ -0,0 +1,207 @@
function num(v, fallback = NaN) {
const n = Number.parseFloat(v);
return Number.isFinite(n) ? n : fallback;
}
function low(v) {
return (v ?? '').toString().trim().toLowerCase();
}
function cleanStateText(v) {
const s = (v ?? '').toString().trim();
const x = s.toLowerCase();
return ['unknown', 'unavailable', 'none', 'null'].includes(x) ? '' : s;
}
function getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') {
const dtf = new Intl.DateTimeFormat('en-US', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
const parts = Object.fromEntries(
dtf.formatToParts(new Date(utcTs))
.filter(p => p.type !== 'literal')
.map(p => [p.type, p.value])
);
const asIfUtc = Date.UTC(
Number(parts.year),
Number(parts.month) - 1,
Number(parts.day),
Number(parts.hour) === 24 ? 0 : Number(parts.hour),
Number(parts.minute),
Number(parts.second)
);
const d = new Date(utcTs);
const actualUtc = Date.UTC(
d.getUTCFullYear(),
d.getUTCMonth(),
d.getUTCDate(),
d.getUTCHours(),
d.getUTCMinutes(),
d.getUTCSeconds()
);
return asIfUtc - actualUtc;
}
function parseParisStampToMs(s) {
const text = (s ?? '').toString().trim();
const m = text.match(/^(\\d{4})-(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2})$/);
if (!m) return null;
const y = Number(m[1]);
const mo = Number(m[2]);
const d = Number(m[3]);
const h = Number(m[4]);
const mi = Number(m[5]);
const se = Number(m[6]);
if (![y, mo, d, h, mi, se].every(Number.isFinite)) return null;
const guessUtc = Date.UTC(y, mo - 1, d, h, mi, se);
const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris');
let ts = guessUtc - off1;
const off2 = getTzOffsetMs(ts, 'Europe/Paris');
if (off2 !== off1) ts = guessUtc - off2;
return ts;
}
function cleanParisStamp(v) {
const s = cleanStateText(v);
return parseParisStampToMs(s) !== null ? s : '';
}
function minutesSince(ts, nowMs) {
if (ts === null || nowMs === null) return null;
return Math.max(0, (nowMs - ts) / 60000);
}
const root = $input.first()?.json ?? {};
const cfg = root.cfg ?? {};
const now = root.now ?? {};
const nowMs = root.nowMs ?? null;
const input = root.input ?? {};
const normalized = root.normalized ?? {};
const { greenPowerW, autoPowerW, boostPowerW } = normalized;
const evcsPriorityActiveRaw = low(input.evcs_priority_active || 'off') === 'on';
const energyPriorityMode = cleanStateText(input.energy_priority_mode || 'normal').toLowerCase() || 'normal';
const energyPriorityReason = cleanStateText(input.energy_priority_reason);
const evcsPriorityUntilAt = cleanParisStamp(input.evcs_priority_until);
const evcsPriorityUntilMs = parseParisStampToMs(evcsPriorityUntilAt);
const evcsPriorityActive =
evcsPriorityActiveRaw &&
energyPriorityMode === 'evcs_priority' &&
(
evcsPriorityUntilMs === null ||
(nowMs !== null && evcsPriorityUntilMs > nowMs + 1000)
);
const waterHeaterSwitchOn = low(input.water_heater_switch || 'off') === 'on';
const waterHeaterEnergyTotalKwh = Math.max(0, num(input.water_heater_energy_total_kwh, 0));
const energyPrevKwh = num(input.energy_total_prev_kwh, NaN);
const energyPrevAt = cleanParisStamp(input.energy_prev_at);
const energyPrevMs = parseParisStampToMs(energyPrevAt);
const observedPowerPrevW = Math.max(0, num(input.observed_power_helper_w, 0));
const observedRegimePrev = cleanStateText(input.observed_regime_helper).toLowerCase();
const mismatchSinceAt = cleanParisStamp(input.mode_mismatch_since);
const mismatchSinceMsPrev = parseParisStampToMs(mismatchSinceAt);
const forcedOffByAdvisorPrev = low(input.forced_off_by_advisor || 'off') === 'on';
const forcedOffReasonPrev = cleanStateText(input.forced_off_reason);
const forcedOffAtPrev = cleanParisStamp(input.forced_off_at);
const forcedOffAtPrevMs = parseParisStampToMs(forcedOffAtPrev);
const pendingUserActionPrev = cleanStateText(input.pending_user_action_prev);
const restoreSurplusW = Math.max(0, num(input.restore_surplus_w, 2500));
const modeMismatchDelayMin = Math.max(1, num(input.mode_mismatch_delay_min, 15));
const sampleDurationMin = minutesSince(energyPrevMs, nowMs);
const energyDeltaKwh =
Number.isFinite(energyPrevKwh)
? Math.max(0, waterHeaterEnergyTotalKwh - energyPrevKwh)
: NaN;
const sampleIsUsable =
Number.isFinite(sampleDurationMin) &&
sampleDurationMin >= (cfg.sampleWindowMinMin ?? 2) &&
sampleDurationMin <= (cfg.sampleWindowMinMax ?? 15) &&
Number.isFinite(energyDeltaKwh);
let observedPowerW = 0;
if (sampleIsUsable) {
observedPowerW = Math.max(0, (energyDeltaKwh * 60000) / sampleDurationMin);
} else if (waterHeaterSwitchOn && observedPowerPrevW > 0) {
observedPowerW = observedPowerPrevW;
}
const offThresholdW = Math.max(80, greenPowerW * 0.25);
const hpUpperW = (greenPowerW + autoPowerW) / 2;
const resistiveUpperW = (autoPowerW + boostPowerW) / 2;
let observedRegime = 'unknown';
if (!waterHeaterSwitchOn) {
observedRegime = 'off';
} else if (!sampleIsUsable && observedRegimePrev) {
observedRegime = observedRegimePrev;
} else if (observedPowerW < offThresholdW) {
observedRegime = 'off';
} else if (observedPowerW < hpUpperW) {
observedRegime = 'hp_low';
} else if (observedPowerW < resistiveUpperW) {
observedRegime = 'resistive_mid';
} else {
observedRegime = 'boost_high';
}
return [{
json: {
...root,
meta: {
...(root.meta ?? {}),
node: 'ecs_evcs_and_water_heater_observation'
},
observation: {
evcsPriorityActiveRaw,
energyPriorityMode,
energyPriorityReason,
evcsPriorityUntilAt,
evcsPriorityUntilMs,
evcsPriorityActive,
waterHeaterSwitchOn,
waterHeaterEnergyTotalKwh,
energyPrevKwh,
energyPrevAt,
energyPrevMs,
observedPowerPrevW,
observedRegimePrev,
mismatchSinceAt,
mismatchSinceMsPrev,
forcedOffByAdvisorPrev,
forcedOffReasonPrev,
forcedOffAtPrev,
forcedOffAtPrevMs,
pendingUserActionPrev,
restoreSurplusW,
modeMismatchDelayMin,
sampleDurationMin,
energyDeltaKwh,
sampleIsUsable,
observedPowerW,
observedRegime,
offThresholdW,
hpUpperW,
resistiveUpperW
}
}
}];

View File

@@ -0,0 +1,612 @@
function round1(v) {
return Math.round(v * 10) / 10;
}
function round2(v) {
return Math.round(v * 100) / 100;
}
function safeText(v, max = 255) {
return (v ?? '').toString().slice(0, max);
}
function boolService(flag) {
return flag ? 'turn_on' : 'turn_off';
}
function getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') {
const dtf = new Intl.DateTimeFormat('en-US', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
const parts = Object.fromEntries(
dtf.formatToParts(new Date(utcTs))
.filter(p => p.type !== 'literal')
.map(p => [p.type, p.value])
);
const asIfUtc = Date.UTC(
Number(parts.year),
Number(parts.month) - 1,
Number(parts.day),
Number(parts.hour) === 24 ? 0 : Number(parts.hour),
Number(parts.minute),
Number(parts.second)
);
const d = new Date(utcTs);
const actualUtc = Date.UTC(
d.getUTCFullYear(),
d.getUTCMonth(),
d.getUTCDate(),
d.getUTCHours(),
d.getUTCMinutes(),
d.getUTCSeconds()
);
return asIfUtc - actualUtc;
}
function parseParisStampToMs(s) {
const text = (s ?? '').toString().trim();
const m = text.match(/^(\\d{4})-(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2})$/);
if (!m) return null;
const y = Number(m[1]);
const mo = Number(m[2]);
const d = Number(m[3]);
const h = Number(m[4]);
const mi = Number(m[5]);
const se = Number(m[6]);
if (![y, mo, d, h, mi, se].every(Number.isFinite)) return null;
const guessUtc = Date.UTC(y, mo - 1, d, h, mi, se);
const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris');
let ts = guessUtc - off1;
const off2 = getTzOffsetMs(ts, 'Europe/Paris');
if (off2 !== off1) ts = guessUtc - off2;
return ts;
}
const root = $input.first()?.json ?? {};
const cfg = root.cfg ?? {};
const now = root.now ?? {};
const input = root.input ?? {};
const normalized = root.normalized ?? {};
const observation = root.observation ?? {};
const energyModel = root.energyModel ?? {};
const finalDecision = root.finalDecision ?? {};
const {
soc,
batteryCurrentA,
pvPowerW,
pvPowerAvg10mW,
houseAcPowerW,
gridPowerW,
gridIsOn,
acInAvailable,
gridAvailable,
todayRemainingKwh,
tomorrowKwh,
advisorEnabled,
notificationsEnabled,
hotWaterDemand,
greenPowerW,
autoPowerW,
boostPowerW,
strongForecastKwh,
mediumForecastKwh,
strongPvNowW,
mediumPvNowW,
boostMinSocStrongForecastPct,
boostMinSocMediumForecastPct,
endOfDaySocGuard,
endOfDaySocTarget,
baseLoadRemainingKwh,
flexibleSafetyMarginKwh,
acLoadsMaxSafeW,
lastSignature
} = normalized;
const {
evcsPriorityActive,
energyPriorityMode,
energyPriorityReason,
evcsPriorityUntilAt,
waterHeaterSwitchOn,
waterHeaterEnergyTotalKwh,
restoreSurplusW,
modeMismatchDelayMin,
sampleDurationMin,
energyDeltaKwh,
sampleIsUsable,
observedPowerW,
observedRegime
} = observation;
const {
targetSoc,
batteryCapacityKwh,
greenEnergyKwh,
autoEnergyKwh,
boostEnergyKwh,
energyNeededToReachTargetKwh,
projectedGreenSoc,
projectedAutoSoc,
projectedBoostSoc,
greenBudgetMarginKwh,
autoBudgetMarginKwh,
boostBudgetMarginKwh,
forecastStrength,
strongForecast,
mediumForecast,
strongPvNow,
mediumPvNow,
netPvHeadroomW,
pvCoversHouseNow,
pvStartedWell,
acSafe,
batteryNotDrainingHard,
greenAllowed,
autoAllowed,
boostAllowedByBudget,
boostAllowed,
earlyBoostStrongReady,
earlyBoostMediumReady,
autoReady,
greenReady
} = energyModel;
let {
recommendedMode,
adviceColor,
state,
reason,
baseRecommendedMode,
baseAdviceColor,
baseState,
selectedLoadKwh,
selectedProjectedSoc,
selectedBudgetMarginKwh,
legacyAdviceOk,
mismatchActive,
mismatchSeverity,
mismatchSinceAtEffective,
mismatchSinceMsEffective,
mismatchDurationMin,
pendingUserAction,
firstMismatchRun,
realConflictNow,
forceOffNow,
restoreEnergyOk,
restoreNow,
forcedOffByAdvisor,
forcedOffReason,
forcedOffAt,
protectionState,
powerSwitchAction,
powerSwitchDesiredOn,
offJourFaible = false
} = finalDecision;
reason = Array.isArray(reason) ? [...reason] : [];
const forcedOffAtSafe = forcedOffAt && parseParisStampToMs(forcedOffAt) !== null ? forcedOffAt : '';
const forcedOffReasonSafe = forcedOffByAdvisor ? safeText(forcedOffReason, 255) : '';
const pendingUserActionSafe = safeText(pendingUserAction, 255);
if (reason.length === 0) {
if (recommendedMode === 'boost') reason.push('Boost recommandé');
else if (recommendedMode === 'auto') reason.push('Mode auto recommandé');
else if (recommendedMode === 'green') reason.push('Mode green recommandé');
else reason.push('Chauffe-eau déconseillé pour le moment');
}
reason.push(`Demande ECS: ${hotWaterDemand}`);
reason.push(`Conseil final: ${recommendedMode}`);
reason.push(`Conseil initial: ${baseRecommendedMode}`);
reason.push(`Couleur conseil: ${adviceColor}`);
reason.push(`Forecast du jour: ${round1(todayRemainingKwh)} kWh`);
reason.push(`Production demain: ${round1(tomorrowKwh)} kWh`);
reason.push(`Classe forecast: ${forecastStrength}`);
reason.push(`PV actuel: ${Math.round(pvPowerW)} W | PV10: ${Math.round(pvPowerAvg10mW)} W`);
reason.push(`Charges AC maison: ${Math.round(houseAcPowerW)} W`);
reason.push(`Surplus net estimé: ${Math.round(netPvHeadroomW)} W`);
reason.push(`PV couvre maison: ${pvCoversHouseNow ? 'oui' : 'non'} | PV bien démarré: ${pvStartedWell ? 'oui' : 'non'}`);
reason.push(`Batterie: SOC ${round1(soc)}% | courant ${round1(batteryCurrentA)} A`);
reason.push(`Réseau: cmd=${gridIsOn ? 'ON' : 'OFF'} | acin=${acInAvailable ? 'OK' : 'KO'} | dispo=${gridAvailable ? 'oui' : 'non'}`);
reason.push(`Target fin de jour: ${round1(targetSoc)}%`);
reason.push(`Base load restante: ${round1(baseLoadRemainingKwh)} kWh`);
reason.push(`Marge sécurité: ${round1(flexibleSafetyMarginKwh)} kWh`);
reason.push(`Green ${round1(greenEnergyKwh)}kWh → SOC fin ${round1(projectedGreenSoc)}% | marge ${round1(greenBudgetMarginKwh)}kWh`);
reason.push(`Auto ${round1(autoEnergyKwh)}kWh → SOC fin ${round1(projectedAutoSoc)}% | marge ${round1(autoBudgetMarginKwh)}kWh`);
reason.push(`Boost ${round1(boostEnergyKwh)}kWh → SOC fin ${round1(projectedBoostSoc)}% | marge ${round1(boostBudgetMarginKwh)}kWh`);
reason.push(`Régime observé: ${observedRegime} | puissance ≈ ${Math.round(observedPowerW)} W | delta ${sampleIsUsable ? round2(energyDeltaKwh) : 'n/a'} kWh/${sampleIsUsable ? round1(sampleDurationMin) : 'n/a'} min`);
reason.push(`Prio EVCS: ${evcsPriorityActive ? 'ON' : 'OFF'}${energyPriorityReason ? ` | ${energyPriorityReason}` : ''}`);
reason.push(`Mismatch: ${mismatchActive ? 'oui' : 'non'} | sévérité=${mismatchSeverity} | durée=${round1(mismatchDurationMin)} min`);
reason.push(`Protection ECS: ${protectionState}`);
if (pendingUserActionSafe) reason.push(`Action attendue: ${pendingUserActionSafe}`);
const shortReason = reason.join(' | ').slice(0, 255);
const signature = [
now.date,
`d=${hotWaterDemand}`,
`m=${recommendedMode}`,
`base=${baseRecommendedMode}`,
`c=${adviceColor}`,
`fs=${strongForecast ? '1' : '0'}`,
`fm=${mediumForecast ? '1' : '0'}`,
`pb=${pvStartedWell ? '1' : '0'}`,
`soc=${round1(soc)}`,
`pv=${Math.round(pvPowerW)}`,
`pv10=${Math.round(pvPowerAvg10mW)}`,
`npv=${Math.round(netPvHeadroomW)}`,
`obs=${observedRegime}`,
`prio=${evcsPriorityActive ? '1' : '0'}`,
`cut=${forcedOffByAdvisor ? '1' : '0'}`
].join('|');
const signatureChanged = signature !== lastSignature;
let notifyWanted = false;
let notifyTitle = 'Water Heater Advisor';
let notifyMessage = '';
if (notificationsEnabled) {
if (forceOffNow) {
notifyWanted = true;
notifyTitle = 'ECS coupé temporairement';
notifyMessage = forcedOffReasonSafe || 'Le chauffe-eau a été coupé temporairement';
} else if (restoreNow) {
notifyWanted = true;
notifyTitle = 'ECS remis sous tension';
notifyMessage = 'Le chauffe-eau est réalimenté car la priorité EVCS est terminée ou le surplus est redevenu suffisant.';
} else if (firstMismatchRun && pendingUserActionSafe) {
notifyWanted = true;
notifyTitle = 'Action utilisateur ECS conseillée';
notifyMessage = pendingUserActionSafe;
} else if (signatureChanged) {
notifyWanted = true;
notifyTitle = `Water Heater Advisor: ${recommendedMode.toUpperCase()} / ${adviceColor.toUpperCase()}`;
notifyMessage = `Demande=${hotWaterDemand} | régime=${observedRegime} | SOC fin estimé=${round1(selectedProjectedSoc)}% | marge=${round1(selectedBudgetMarginKwh)} kWh`;
}
}
const BUDGET_MARGIN_MIN = -50;
const BUDGET_MARGIN_MAX = 50;
const result = {
timestamp_paris: now.stamp,
inputs: {
soc_percent: soc,
battery_current_a: batteryCurrentA,
pv_power_w: pvPowerW,
pv_power_10m_avg_w: pvPowerAvg10mW,
house_ac_power_w: houseAcPowerW,
grid_power_w: gridPowerW,
grid_state: gridIsOn ? 'on' : 'off',
victron_acin1_available: acInAvailable ? 1 : 0,
today_remaining_kwh: round2(todayRemainingKwh),
tomorrow_kwh: round2(tomorrowKwh),
advisor_enabled: advisorEnabled,
notifications_enabled: notificationsEnabled,
hot_water_demand: hotWaterDemand,
green_power_w: greenPowerW,
auto_power_w: autoPowerW,
boost_power_w: boostPowerW,
strong_forecast_kwh: strongForecastKwh,
medium_forecast_kwh: mediumForecastKwh,
strong_pv_now_w: strongPvNowW,
medium_pv_now_w: mediumPvNowW,
boost_min_soc_strong_forecast_pct: boostMinSocStrongForecastPct,
boost_min_soc_medium_forecast_pct: boostMinSocMediumForecastPct,
end_of_day_soc_guard: endOfDaySocGuard,
end_of_day_soc_target: endOfDaySocTarget,
base_load_remaining_kwh: baseLoadRemainingKwh,
flexible_safety_margin_kwh: flexibleSafetyMarginKwh,
ac_loads_max_safe_w: acLoadsMaxSafeW,
water_heater_switch: waterHeaterSwitchOn,
water_heater_energy_total_kwh: round2(waterHeaterEnergyTotalKwh),
last_signature: lastSignature || null,
evcs_priority_active: evcsPriorityActive,
energy_priority_mode: energyPriorityMode,
energy_priority_reason: energyPriorityReason || null,
evcs_priority_until: evcsPriorityUntilAt || null
},
computed: {
forecast_strength: forecastStrength,
strong_forecast: strongForecast,
medium_forecast: mediumForecast,
strong_pv_now: strongPvNow,
medium_pv_now: mediumPvNow,
net_pv_headroom_w: Math.round(netPvHeadroomW),
pv_covers_house_now: pvCoversHouseNow,
pv_started_well: pvStartedWell,
battery_usable_capacity_kwh_internal: batteryCapacityKwh,
energy_needed_to_reach_target_kwh: round2(energyNeededToReachTargetKwh),
green_energy_kwh: round2(greenEnergyKwh),
auto_energy_kwh: round2(autoEnergyKwh),
boost_energy_kwh: round2(boostEnergyKwh),
projected_green_soc: round1(projectedGreenSoc),
projected_auto_soc: round1(projectedAutoSoc),
projected_boost_soc: round1(projectedBoostSoc),
green_budget_margin_kwh: round2(greenBudgetMarginKwh),
auto_budget_margin_kwh: round2(autoBudgetMarginKwh),
boost_budget_margin_kwh: round2(boostBudgetMarginKwh),
ac_safe: acSafe,
battery_not_draining_hard: batteryNotDrainingHard,
green_allowed: greenAllowed,
auto_allowed: autoAllowed,
boost_allowed_by_budget: boostAllowedByBudget,
boost_allowed: boostAllowed,
early_boost_strong_ready: earlyBoostStrongReady,
early_boost_medium_ready: earlyBoostMediumReady,
auto_ready: autoReady,
green_ready: greenReady,
base_recommended_mode: baseRecommendedMode,
base_advice_color: baseAdviceColor,
base_state: baseState,
observed_power_w: Math.round(observedPowerW),
observed_regime: observedRegime,
sample_duration_min: sampleIsUsable ? round1(sampleDurationMin) : null,
energy_delta_kwh: sampleIsUsable ? round2(energyDeltaKwh) : null,
mismatch_active: mismatchActive,
mismatch_severity: mismatchSeverity,
mismatch_duration_min: round1(mismatchDurationMin),
evcs_priority_active: evcsPriorityActive,
protection_state: protectionState,
real_conflict_now: realConflictNow,
restore_energy_ok: restoreEnergyOk,
restore_surplus_w: restoreSurplusW,
mode_mismatch_delay_min: modeMismatchDelayMin
},
decision: {
recommended_mode: recommendedMode,
advice_color: adviceColor,
state,
legacy_advice_ok: legacyAdviceOk,
selected_load_kwh: round2(selectedLoadKwh),
projected_end_of_day_soc: round1(selectedProjectedSoc),
budget_margin_kwh: round2(selectedBudgetMarginKwh),
observed_power_w: Math.round(observedPowerW),
observed_regime: observedRegime,
mismatch_active: mismatchActive,
mismatch_severity: mismatchSeverity,
mismatch_duration_min: round1(mismatchDurationMin),
pending_user_action: pendingUserActionSafe,
power_switch_action: powerSwitchAction,
power_switch_desired_on: powerSwitchDesiredOn,
force_off_now: forceOffNow,
restore_now: restoreNow,
forced_off_by_advisor: forcedOffByAdvisor,
forced_off_reason: forcedOffReasonSafe,
forced_off_at: forcedOffByAdvisor ? forcedOffAtSafe || null : null,
signature,
signature_changed: signatureChanged,
should_notify: notifyWanted,
reason,
short_reason: shortReason
},
ha_service_calls: {
helper_recommended_mode: {
domain: 'input_select',
service: 'select_option',
data: {
entity_id: 'input_select.victron_water_heater_recommended_mode',
option: recommendedMode
}
},
helper_advice_color: {
domain: 'input_select',
service: 'select_option',
data: {
entity_id: 'input_select.victron_water_heater_advice_color',
option: adviceColor
}
},
helper_state: {
domain: 'input_text',
service: 'set_value',
data: {
entity_id: 'input_text.victron_water_heater_advice_state',
value: state
}
},
helper_reason: {
domain: 'input_text',
service: 'set_value',
data: {
entity_id: 'input_text.victron_water_heater_advice_reason',
value: shortReason || 'Aucune raison'
}
},
helper_signature: {
execute: signatureChanged,
domain: 'input_text',
service: 'set_value',
data: {
entity_id: 'input_text.victron_water_heater_advice_last_signature',
value: signature
}
},
helper_projected_soc: {
domain: 'input_number',
service: 'set_value',
data: {
entity_id: 'input_number.victron_water_heater_projected_end_of_day_soc',
value: round1(selectedProjectedSoc)
}
},
helper_budget_margin: {
domain: 'input_number',
service: 'set_value',
data: {
entity_id: 'input_number.victron_water_heater_budget_margin_kwh',
value: Math.max(BUDGET_MARGIN_MIN, Math.min(BUDGET_MARGIN_MAX, round2(selectedBudgetMarginKwh)))
}
},
helper_load_kwh: {
domain: 'input_number',
service: 'set_value',
data: {
entity_id: 'input_number.victron_load_water_heater_kwh',
value: round2(selectedLoadKwh)
}
},
helper_legacy_advice_ok: {
domain: 'input_boolean',
service: boolService(legacyAdviceOk),
data: {
entity_id: 'input_boolean.victron_advice_water_heater_ok'
}
},
helper_green_allowed: {
domain: 'input_boolean',
service: boolService(greenAllowed),
data: {
entity_id: 'input_boolean.victron_water_heater_green_allowed'
}
},
helper_auto_allowed: {
domain: 'input_boolean',
service: boolService(autoAllowed),
data: {
entity_id: 'input_boolean.victron_water_heater_auto_allowed'
}
},
helper_boost_allowed: {
domain: 'input_boolean',
service: boolService(boostAllowed),
data: {
entity_id: 'input_boolean.victron_water_heater_boost_allowed'
}
},
helper_observed_power: {
domain: 'input_number',
service: 'set_value',
data: {
entity_id: 'input_number.victron_water_heater_observed_power_w',
value: Math.round(observedPowerW)
}
},
helper_observed_regime: {
domain: 'input_text',
service: 'set_value',
data: {
entity_id: 'input_text.victron_water_heater_observed_regime',
value: observedRegime
}
},
helper_pending_user_action: {
domain: 'input_text',
service: 'set_value',
data: {
entity_id: 'input_text.victron_water_heater_pending_user_action',
value: pendingUserActionSafe
}
},
helper_mismatch_since: {
execute: mismatchActive && !!mismatchSinceAtEffective,
domain: 'input_datetime',
service: 'set_datetime',
data: {
entity_id: 'input_datetime.victron_water_heater_mode_mismatch_since',
datetime: mismatchSinceAtEffective
}
},
helper_energy_prev_kwh: {
domain: 'input_number',
service: 'set_value',
data: {
entity_id: 'input_number.victron_water_heater_energy_total_prev_kwh',
value: round2(waterHeaterEnergyTotalKwh)
}
},
helper_energy_prev_at: {
domain: 'input_datetime',
service: 'set_datetime',
data: {
entity_id: 'input_datetime.victron_water_heater_energy_prev_at',
datetime: now.stamp
}
},
helper_forced_off_by_advisor: {
domain: 'input_boolean',
service: boolService(forcedOffByAdvisor),
data: {
entity_id: 'input_boolean.victron_water_heater_forced_off_by_advisor'
}
},
helper_forced_off_reason: {
domain: 'input_text',
service: 'set_value',
data: {
entity_id: 'input_text.victron_water_heater_forced_off_reason',
value: forcedOffReasonSafe
}
},
helper_forced_off_at: {
execute: forceOffNow && !!forcedOffAtSafe,
domain: 'input_datetime',
service: 'set_datetime',
data: {
entity_id: 'input_datetime.victron_water_heater_forced_off_at',
datetime: forcedOffAtSafe
}
},
// Jour faible (07/10/2026) : la coupure appartient a la regle « eau chaude en HC de nuit ».
helper_off_jour_faible: {
domain: 'input_boolean',
service: boolService(offJourFaible),
data: {
entity_id: 'input_boolean.victron_water_heater_off_jour_faible'
}
},
water_heater_power_switch: {
execute: powerSwitchAction !== 'none',
domain: 'switch',
service: powerSwitchDesiredOn ? 'turn_on' : 'turn_off',
data: {
entity_id: 'switch.tt_chauffe_eau_pleumeur_switch'
}
},
logbook: {
execute: signatureChanged || forceOffNow || restoreNow,
domain: 'logbook',
service: 'log',
data: {
name: 'Water Heater Advisor',
message: `${recommendedMode}/${adviceColor} | regime=${observedRegime} | prioEVCS=${evcsPriorityActive ? 'on' : 'off'} | cut=${forcedOffByAdvisor ? 'on' : 'off'} | ${shortReason}`,
entity_id: 'switch.tt_chauffe_eau_pleumeur_switch'
}
},
mobile_notification: {
execute: notifyWanted,
domain: cfg.mobileNotifyDomain,
service: cfg.mobileNotifyService,
data: {
title: notifyTitle,
message: notifyMessage,
data: {
push: {
'interruption-level': 'time-sensitive',
sound: { name: 'default' }
},
ttl: 0,
priority: 'high'
}
}
}
}
};
return [{ json: result }];