211 lines
6.4 KiB
JavaScript
211 lines
6.4 KiB
JavaScript
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;
|
|
// Coupe = 0 W (07/10/2026) : le compteur Tuya remonte avec retard, et la
|
|
// derniere chauffe apparaissait apres la coupure (« arret — 1595 W »).
|
|
if (!waterHeaterSwitchOn) {
|
|
observedPowerW = 0;
|
|
} else 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
|
|
}
|
|
}
|
|
}]; |