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);