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:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
353
src/water-heater/ARBITRATION.js
Normal file
353
src/water-heater/ARBITRATION.js
Normal 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
|
||||
}
|
||||
}
|
||||
}];
|
||||
128
src/water-heater/BASE_DECISION.js
Normal file
128
src/water-heater/BASE_DECISION.js
Normal 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
|
||||
}
|
||||
}
|
||||
}];
|
||||
148
src/water-heater/BUILD_ACTIONS.js
Normal file
148
src/water-heater/BUILD_ACTIONS.js
Normal 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;
|
||||
207
src/water-heater/ENERGY_MODEL.js
Normal file
207
src/water-heater/ENERGY_MODEL.js
Normal 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
|
||||
}
|
||||
}
|
||||
}];
|
||||
91
src/water-heater/HA_SNAPSHOT_TEMPLATE.json.tpl
Normal file
91
src/water-heater/HA_SNAPSHOT_TEMPLATE.json.tpl
Normal 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 }}
|
||||
250
src/water-heater/NORMALIZE.js
Normal file
250
src/water-heater/NORMALIZE.js
Normal 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
|
||||
}
|
||||
}
|
||||
}];
|
||||
207
src/water-heater/OBSERVATION.js
Normal file
207
src/water-heater/OBSERVATION.js
Normal 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
|
||||
}
|
||||
}
|
||||
}];
|
||||
612
src/water-heater/OUTPUT_BUILDER.js
Normal file
612
src/water-heater/OUTPUT_BUILDER.js
Normal 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 }];
|
||||
Reference in New Issue
Block a user