Files
EVCS/decoupesurplus/INPUT_PARSE.js
2026-04-12 05:41:42 +02:00

247 lines
10 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 clamp(v, min, max) {
return Math.min(Math.max(v, min), max);
}
function round1(v) {
return Math.round(v * 10) / 10;
}
function round2(v) {
return Math.round(v * 100) / 100;
}
function to2(n) {
return String(n).padStart(2, '0');
}
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 parseTimeToMinutes(v, fallback = null) {
const s = (v ?? '').toString().trim();
const m = s.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/);
if (!m) return fallback;
const hh = Number(m[1]);
const mm = Number(m[2]);
if (!Number.isFinite(hh) || !Number.isFinite(mm)) return fallback;
return (hh % 24) * 60 + mm;
}
function getParisParts(ms = Date.now()) {
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(ms))
.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 minutesSince(ts, nowMs) {
if (ts === null || nowMs === null) return null;
return Math.max(0, (nowMs - ts) / 60000);
}
function secondsSince(ts, nowMs) {
if (ts === null || nowMs === null) return null;
return Math.max(0, (nowMs - ts) / 1000);
}
function isWithinWindow(nowMin, startMin, stopMin) {
if (!Number.isFinite(startMin) || !Number.isFinite(stopMin)) return false;
if (startMin === stopMin) return false;
if (startMin < stopMin) return nowMin >= startMin && nowMin < stopMin;
return nowMin >= startMin || nowMin < stopMin;
}
function hhmmssFromMinutes(minOfDay) {
const mm = ((minOfDay % 1440) + 1440) % 1440;
const hh = Math.floor(mm / 60);
const mi = mm % 60;
return `${to2(hh)}:${to2(mi)}:00`;
}
function formatParisStampFromMs(ms) {
return getParisParts(ms).stamp;
}
function nextDailyOccurrenceMs(startMin, refMs) {
if (!Number.isFinite(startMin)) return null;
const today = getParisParts(refMs).date;
const todayStamp = `${today} ${hhmmssFromMinutes(startMin)}`;
const todayMs = parseParisStampToMs(todayStamp);
if (todayMs !== null && todayMs > refMs + 1000) return todayMs;
const tomorrow = getParisParts(refMs + 36 _3600_ 1000).date;
const tomorrowStamp = `${tomorrow} ${hhmmssFromMinutes(startMin)}`;
return parseParisStampToMs(tomorrowStamp);
}
const CFG = {
mobileNotifyDomain: 'notify',
mobileNotifyService: 'mobile_app_iphone_16_de_tt',
batteryUsableCapacityKwh: 17.5,
nominalAcVoltageV: 230,
evcsDedicatedMaxW: 4500,
systemReserveW: 300,
vebusDcVoltageSoftFloorV: 49.0,
vebusDcVoltageHardFloorV: 48.6,
departurePriorityWindowMinutes: 240,
sampleSeconds: 30,
cloudDebtRecoveryThresholdKwh: 0.05,
cloudDebtMeaningfulThresholdKwh: 0.01,
};
const raw = $input.first().json;
const input = extractSnapshot(raw);
const nowMs = Date.now();
const now = getParisParts(nowMs);
// === INPUTS ===
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 pvTrendW = num(input.pv_trend_w, 0);
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 todayRemainingKwh = Math.max(0, num(input.today_remaining_kwh, 0));
const evcsDeviceConnected = low(input.evcs_device_connected || 'off') === 'on';
const evcsVehicleConnected = low(input.evcs_vehicle_connected || 'off') === 'on';
const evcsStatusRaw = (input.evcs_status_raw ?? '').toString().trim();
const evcsModeRaw = [input.evcs_mode_sensor, input.evcs_mode_fallback, input.evcs_mode]
.map(v => (v ?? '').toString().trim())
.find(v => v && low(v) !== 'unknown' && low(v) !== 'unavailable') || 'unknown';
const evcsModeIsAuto = low(evcsModeRaw).includes('auto');
const evcsSetCurrentA = num(input.evcs_set_current_a, NaN);
const evcsCommandCurrentA = num(input.evcs_command_current_a, NaN);
const evcsCurrentA = Math.max(0, num(input.evcs_current_a, 0));
const evcsAcPowerW = Math.max(0, num(input.evcs_ac_power_w, 0));
const evcsChargingSwitchOn = low(input.evcs_charging_switch || 'off') === 'on';
const evcsAutoStartSwitchOn = low(input.evcs_auto_start_switch || 'off') === 'on';
const controllerEnabled = low(input.controller_enabled || 'off') === 'on';
const notificationsEnabled = low(input.notifications_enabled || 'off') === 'on';
const hardStopEnabledLegacy = low(input.hard_stop_enabled || 'off') === 'on';
const chargePermissionNow = low(input.charge_permission_now || 'off') === 'on';
const controlLatch = low(input.control_latch || 'off') === 'on';
const departurePriorityEnabled = low(input.departure_priority_enabled || 'off') === 'on';
const forecastModeEnabled = low(input.forecast_mode_enabled || 'off') === 'on';
const targetCurrentAutoA = clamp(num(input.target_current_auto_a, 6), 0, 32);
const pvStartMinW = Math.max(0, num(input.pv_start_min_w, 1800));
const pvHoldMinW = Math.max(0, num(input.pv_hold_min_w, 1200));
const houseAcHeadroomW = Math.max(0, num(input.house_ac_headroom_w, 1200));
const batteryDischargeSoftA = num(input.battery_discharge_soft_a, -5);
const batteryDischargeHardA = num(input.battery_discharge_hard_a, -15);
const batteryReserveUpA = Math.max(0, num(input.battery_reserve_up_a, 8));
const batteryNeutralBandA = Math.max(0, num(input.battery_neutral_band_a, 3));
const socStartMin = clamp(num(input.soc_start_min, 60), 0, 100);
const socStopMin = clamp(num(input.soc_stop_min, 35), 0, 100);
const currentMinA = clamp(num(input.current_min_a, 6), 6, 32);
const rawCurrentMaxAutoA = input.current_max_auto_a ?? null;
const parsedCurrentMaxAutoA = num(rawCurrentMaxAutoA, 16);
const currentMaxAutoA = clamp(parsedCurrentMaxAutoA, currentMinA, 32);
const currentStepUpA = clamp(num(input.current_step_up_a, 1), 1, 5);
const currentStepDownA = clamp(num(input.current_step_down_a, 2), 1, 8);
const currentChangeCooldownSeconds = Math.max(5, num(input.current_change_cooldown_seconds, 60));
const minRunMinutes = Math.max(0, num(input.min_run_minutes, 3));
const minStopMinutes = Math.max(0, num(input.min_stop_minutes, 10));
const requiredEnergyKwh = Math.max(0, num(input.required_energy_kwh, 0));
const socComfortTarget = clamp(num(input.soc_comfort_target, 85), 0, 100);
const endOfDaySocGuard = clamp(num(input.end_of_day_soc_guard, 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 endOfDaySocTarget = clamp(num(input.end_of_day_soc_target, 85), 0, 100);
const acLoadsMaxSafeW = Math.max(0, num(input.ac_loads_max_safe_w, 8500));
const batteryFullSoc = clamp(num(input.battery_full_soc, 90), 0, 100);
const batteryFullForecastMinKwh = Math.max(0, num(input.battery_full_forecast_min_kwh, 8));
const forecastCommitSocFloor = clamp(num(input.forecast_commit_soc_floor, 35), 0, 100);
const forecastCommitMinKwh = Math.max(0, num(input.forecast_commit_min_kwh, 25));
const cloudDebtPrevKwh = Math.max(0, num(input.cloud_debt_kwh, 0));
const cloudDebtMaxKwh = Math.max(0.05, num(input.cloud_debt_max_kwh, 1.2));
const cloudToleranceMinutes = Math.max(1, num(input.cloud_tolerance_minutes, 20));
const departureTimeMin = parseTimeToMinutes(input.departure_time, null);
const dayStartTimeMin = parseTimeToMinutes(input.day_start_time, 7 * 60 + 30);
const dayStopTimeMin = parseTimeToMinutes(input.day_stop_time, 20 * 60 + 30);