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 round1(v) { return Math.round(v * 10) / 10; } function clamp(v, min, max) { return Math.min(Math.max(v, min), max); } function pickDefined(...values) { for (const v of values) { if (v === undefined || v === null) continue; if (typeof v === 'string' && v.trim() === '') continue; return v; } return undefined; } function isOnLike(v) { const s = low(v); return s === 'on' || s === 'true' || s === '1' || s === 'yes'; } 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 date = `${parts.year}-${parts.month}-${parts.day}`; const stamp = `${date} ${parts.hour}:${parts.minute}:${parts.second}`; return { year: Number(parts.year), month: Number(parts.month), day: Number(parts.day), hour: Number(parts.hour), minute: Number(parts.minute), second: Number(parts.second), date, stamp, }; } function parseLocalStampToMs(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; return new Date(y, mo - 1, d, h, mi, se).getTime(); } const cfg = { mobileNotifyDomain: 'notify', mobileNotifyService: 'mobile_app_iphone_16_de_tt', }; const raw = $input.first().json; const input = extractSnapshot(raw); const now = getParisNowParts(); const nowMs = parseLocalStampToMs(now.stamp); // ====================================================== // INPUTS PRINCIPAUX // ====================================================== const soc = clamp(num(input.soc_percent, 0), 0, 100); const gridIsOn = low(input.grid_state || 'off') === 'on'; const pvPowerW = Math.max(0, num(input.pv_power_w, 0)); const houseAcPowerW = Math.max(0, num(input.house_ac_power_w, 0)); const gridPowerW = num(input.grid_power_w, 0); const evcsAcPowerW = Math.max(0, num(input.evcs_ac_power_w, 0)); const evcsCharging = low(input.evcs_charging || 'off') === 'on'; const evcsStatusRaw = num(input.evcs_status_raw, NaN); const hysteresisEnabled = low(input.hysteresis_enabled || 'off') === 'on'; const notificationsEnabled = low(input.notifications_enabled || 'off') === 'on'; const latchIsOn = low(input.grid_latch || 'off') === 'on'; const gridSocOn = clamp(num(input.grid_soc_on, 22), 0, 100); const gridSocOff = clamp(num(input.grid_soc_off, 35), 0, 100); const gridMinOnMinutes = Math.max(0, num(input.grid_min_on_minutes, 45)); const gridCooldownMinutes = Math.max(0, num(input.grid_cooldown_minutes, 15)); const acLoadsMaxSafeW = Math.max(0, num(input.ac_loads_max_safe_w, 7800)); const lastOnAt = (input.last_on_at ?? '').toString().trim(); const lastOffAt = (input.last_off_at ?? '').toString().trim(); const lastSignature = (input.last_signature ?? '').toString().trim(); const lastOnMs = parseLocalStampToMs(lastOnAt); const lastOffMs = parseLocalStampToMs(lastOffAt); const minutesSinceLastOn = lastOnMs !== null && nowMs !== null ? Math.max(0, (nowMs - lastOnMs) / 60000) : null; const minutesSinceLastOff = lastOffMs !== null && nowMs !== null ? Math.max(0, (nowMs - lastOffMs) / 60000) : null; const minOnElapsed = minutesSinceLastOn === null ? true : minutesSinceLastOn >= gridMinOnMinutes; const cooldownElapsed = minutesSinceLastOff === null ? true : minutesSinceLastOff >= gridCooldownMinutes; const socBand = soc <= gridSocOn ? 'low' : soc < gridSocOff ? 'mid' : 'high'; const evcsSeemsActive = evcsCharging || evcsAcPowerW > 300; const acOverSafeLimit = houseAcPowerW >= acLoadsMaxSafeW; // ====================================================== // EVCS OVERRIDE INPUTS // ====================================================== const evcsPriorityActive = isOnLike( pickDefined( input.evcs_priority_active, input.victron_evcs_priority_active, input['input_boolean.victron_evcs_priority_active'] ) ); const evcsNightGridTakeoverNeeded = isOnLike( pickDefined( input.night_grid_takeover_needed, input.victron_evcs_night_grid_takeover_needed, input['input_boolean.victron_evcs_night_grid_takeover_needed'] ) ); const evcsSourceModeRaw = ( pickDefined( input.evcs_source_mode, input.victron_evcs_source_mode, input['input_text.victron_evcs_source_mode'] ) ?? '' ).toString().trim(); const evcsSourceMode = low(evcsSourceModeRaw); const userStrategyRaw = ( pickDefined( input.user_strategy, input.victron_evcs_user_strategy, input['input_select.victron_evcs_user_strategy'] ) ?? '' ).toString().trim(); const userStrategy = low(userStrategyRaw);