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); // ====================================================== // EVCS SEMANTIC STATE // Ici on ne détecte PAS la voiture pleine par seuils. // On consomme seulement l'état sémantique déjà produit // par le workflow EVCS. // ====================================================== const evcsSurplusStateRaw = ( pickDefined( input.evcs_surplus_state, input.victron_evcs_surplus_state, input['input_text.victron_evcs_surplus_state'] ) ?? '' ).toString().trim(); const evcsSurplusState = low(evcsSurplusStateRaw); const evcsVehicleNoDemandSemantic = evcsSurplusState === 'vehicle_full_or_paused' || evcsSurplusState === 'vehicle_not_requesting_power' || evcsSurplusState === 'vehicle_full' || evcsSurplusState === 'charge_complete' || evcsSurplusState.includes('full_or_paused') || evcsSurplusState.includes('not_requesting_power') || evcsSurplusState.includes('charge_complete') || evcsSurplusState.includes('no_demand'); // ====================================================== // OVERRIDE EVCS VERS GRID // CORRECTION: // - evcsPriorityActive seul ne doit PLUS suffire à allumer EDF // - seuls les cas explicitement réseau peuvent forcer le grid // - ainsi "Réseau HC midi = OFF" redevient réellement respecté // ====================================================== const evcsExplicitGridRequest = userStrategy === 'force_grid_now' || userStrategy === 'force_charge_now' || evcsNightGridTakeoverNeeded || evcsSourceMode === 'réseau forcé' || evcsSourceMode === 'réseau hc autorisé' || evcsSourceMode === 'grid_forced' || evcsSourceMode === 'grid_hc_allowed'; const evcsGridOverride = !evcsVehicleNoDemandSemantic && evcsExplicitGridRequest; // ====================================================== // DÉCISION // ====================================================== let state = 'observe_only'; let reason = []; let desiredGrid = gridIsOn ? 'on' : 'off'; let desiredLatch = latchIsOn ? 'on' : 'off'; // ====================================================== // FORCE_GRID + VEHICLE FULL IMMEDIATE CUT // FIX V2.5: Si force_grid_now actif et voiture pleine, // couper EDF immédiatement sans attendre minOnRuntime. // ====================================================== if ( userStrategy === 'force_grid_now' && evcsVehicleNoDemandSemantic && latchIsOn ) { desiredGrid = 'off'; desiredLatch = 'off'; state = 'turn_off_force_grid_vehicle_full'; reason.push('Force grid + voiture pleine : coupure immediate du reseau'); } // ====================================================== // CORE HYSTERESIS LOGIC // ====================================================== if (evcsGridOverride) { desiredGrid = 'on'; desiredLatch = 'on'; if (!gridIsOn) { state = 'turn_on_evcs_override'; reason.push('Override EVCS actif : activation EDF forcée'); } else if (!latchIsOn) { state = 'adopt_existing_grid_on_evcs_override'; reason.push('Override EVCS actif : EDF déjà ON, adoption par latch'); } else { state = 'hold_on_evcs_override'; reason.push('Override EVCS actif : maintien EDF ON'); } if (userStrategy === 'force_grid_now') { reason.push('Cause override : force_grid_now'); } else if (userStrategy === 'force_charge_now') { reason.push('Cause override : force_charge_now'); } else if (evcsNightGridTakeoverNeeded) { reason.push('Cause override : night_grid_takeover_needed'); } else { reason.push(`Cause override : source_mode=${evcsSourceModeRaw || 'n/a'}`); } } else if (!hysteresisEnabled) { state = 'hysteresis_disabled'; desiredGrid = gridIsOn ? 'on' : 'off'; desiredLatch = latchIsOn ? 'on' : 'off'; reason.push('Automatisme hystérésis réseau désactivé'); } else if (soc <= gridSocOn) { desiredGrid = 'on'; desiredLatch = 'on'; if (!cooldownElapsed && !gridIsOn) { state = 'turn_on_low_soc_cooldown_bypassed'; reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : activation EDF malgré cooldown`); } else if (!gridIsOn) { state = 'turn_on_low_soc'; reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : activation EDF`); } else if (!latchIsOn) { state = 'adopt_existing_grid_on_low_soc'; reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : EDF déjà ON, prise de contrôle par latch`); } else { state = 'hold_on_low_soc'; reason.push(`SOC encore bas ${round1(soc)}% : maintien EDF ON`); } } else if (gridIsOn && !latchIsOn) { if (soc < gridSocOff) { desiredGrid = 'on'; desiredLatch = 'on'; state = 'adopt_existing_grid_on'; reason.push(`EDF déjà ON avec latch OFF et SOC ${round1(soc)}% < seuil OFF ${gridSocOff}% : adoption de l'état ON`); } else { desiredGrid = 'off'; desiredLatch = 'off'; state = 'turn_off_stray_grid_on'; reason.push(`EDF ON avec latch OFF et SOC ${round1(soc)}% >= seuil OFF ${gridSocOff}% : coupure de reprise de contrôle`); } } else if (latchIsOn) { if (!minOnElapsed) { desiredGrid = 'on'; desiredLatch = 'on'; state = 'hold_on_min_runtime'; reason.push(`EDF maintenu ON : durée mini ${gridMinOnMinutes} min non atteinte (${round1(minutesSinceLastOn || 0)} min)`); } else if (soc < gridSocOff) { desiredGrid = 'on'; desiredLatch = 'on'; state = 'hold_on_until_soc_off'; reason.push(`EDF maintenu ON : SOC ${round1(soc)}% < seuil OFF ${gridSocOff}%`); } else { desiredGrid = 'off'; desiredLatch = 'off'; state = 'turn_off_recovered'; reason.push(`SOC ${round1(soc)}% >= seuil OFF ${gridSocOff}% : coupure EDF`); } } else { desiredGrid = 'off'; desiredLatch = 'off'; state = 'observe_only'; reason.push('Aucune action : hors zone d’hystérésis'); } // ====================================================== // COMPARE DESIRED VS CURRENT // ====================================================== const gridAction = desiredGrid === (gridIsOn ? 'on' : 'off') ? 'none' : desiredGrid === 'on' ? 'turn_on' : 'turn_off'; const latchAction = desiredLatch === (latchIsOn ? 'on' : 'off') ? 'none' : desiredLatch === 'on' ? 'turn_on' : 'turn_off'; // ====================================================== // TIMESTAMPS // ====================================================== const setLastOnAt = gridAction === 'turn_on' || state === 'adopt_existing_grid_on' || state === 'adopt_existing_grid_on_low_soc' || state === 'adopt_existing_grid_on_evcs_override'; const setLastOffAt = gridAction === 'turn_off'; // ====================================================== // CONTEXT / ALERTS // ====================================================== reason.push(`Grid actuel ${gridIsOn ? 'ON' : 'OFF'}`); reason.push(`Latch actuel ${latchIsOn ? 'ON' : 'OFF'}`); reason.push(`SOC ${round1(soc)}% | seuils ${gridSocOn}%/${gridSocOff}%`); reason.push(`PV ${Math.round(pvPowerW)}W`); reason.push(`Charge AC maison ${Math.round(houseAcPowerW)}W`); reason.push(`Puissance réseau ${Math.round(gridPowerW)}W`); reason.push(`EVCS ${evcsSeemsActive ? 'actif' : 'inactif'} | ${Math.round(evcsAcPowerW)}W | status_raw=${Number.isFinite(evcsStatusRaw) ? evcsStatusRaw : 'n/a'}`); if (minutesSinceLastOn !== null) { reason.push(`Dernier ON il y a ${round1(minutesSinceLastOn)} min`); } if (minutesSinceLastOff !== null) { reason.push(`Dernier OFF il y a ${round1(minutesSinceLastOff)} min`); } if (acOverSafeLimit) { reason.push(`⚠️ Charge AC maison ${Math.round(houseAcPowerW)}W >= limite sûre ${Math.round(acLoadsMaxSafeW)}W`); } reason.push(`EVCS override ${evcsGridOverride ? 'ON' : 'OFF'}`); reason.push(`EVCS explicit grid request ${evcsExplicitGridRequest ? 'ON' : 'OFF'}`); reason.push(`EVCS priority ${evcsPriorityActive ? 'ON' : 'OFF'}`); reason.push(`EVCS night takeover ${evcsNightGridTakeoverNeeded ? 'ON' : 'OFF'}`); reason.push(`EVCS source ${evcsSourceModeRaw || 'n/a'}`); reason.push(`EVCS strategy ${userStrategyRaw || 'n/a'}`); reason.push(`EVCS surplus state ${evcsSurplusStateRaw || 'n/a'}`); reason.push(`EVCS no-demand semantic ${evcsVehicleNoDemandSemantic ? 'ON' : 'OFF'}`); // ====================================================== // NOTIFICATIONS // ====================================================== let notifyTitle = 'Victron Grid Hysteresis'; let notifyMessage = ''; let notifyWanted = false; if ( state === 'turn_on_evcs_override' || state === 'adopt_existing_grid_on_evcs_override' || state === 'hold_on_evcs_override' ) { notifyTitle = '🚗 EDF forcé par EVCS'; notifyMessage = `Priorité EVCS active. EDF maintenu ON pour la recharge véhicule. Cause: ${userStrategyRaw || evcsSourceModeRaw || 'override EVCS'}.`; notifyWanted = true; } else if (state === 'turn_on_low_soc' || state === 'turn_on_low_soc_cooldown_bypassed') { notifyTitle = '⚡ EDF activé sur SOC bas'; notifyMessage = `SOC ${round1(soc)}% <= ${gridSocOn}%. EDF activé et maintenu jusqu'à au moins ${gridSocOff}% (min ${gridMinOnMinutes} min).`; notifyWanted = true; } else if (state === 'adopt_existing_grid_on_low_soc' || state === 'adopt_existing_grid_on') { notifyTitle = '🔁 EDF ON repris par hystérésis'; notifyMessage = `EDF était déjà ON. Le workflow reprend la main et le maintient jusqu'à ${gridSocOff}% (min ${gridMinOnMinutes} min).`; notifyWanted = true; } else if (state === 'turn_off_recovered') { notifyTitle = '✅ EDF coupé après recharge'; notifyMessage = `SOC ${round1(soc)}% >= ${gridSocOff}%. Retour hors réseau autorisé.`; notifyWanted = true; } else if (state === 'turn_off_stray_grid_on') { notifyTitle = '✅ EDF coupé (reprise de contrôle)'; notifyMessage = `EDF était ON sans latch. SOC ${round1(soc)}% >= ${gridSocOff}%, coupure réseau appliquée.`; notifyWanted = true; } else if (acOverSafeLimit && !gridIsOn) { notifyTitle = '🚨 Charge AC élevée hors réseau'; notifyMessage = `Charge AC maison ${Math.round(houseAcPowerW)}W au-dessus de la limite sûre ${Math.round(acLoadsMaxSafeW)}W alors que EDF est OFF.`; notifyWanted = true; } else if (acOverSafeLimit && gridIsOn) { notifyTitle = '⚠️ Charge AC élevée'; notifyMessage = `Charge AC maison ${Math.round(houseAcPowerW)}W proche/au-dessus de la limite sûre ${Math.round(acLoadsMaxSafeW)}W.`; notifyWanted = true; } const shortReason = reason.join(' | ').slice(0, 255); const signature = [ now.date, state, `dg=${desiredGrid}`, `dl=${desiredLatch}`, `sb=${socBand}`, `ac=${acOverSafeLimit ? '1' : '0'}`, `ov=${evcsGridOverride ? '1' : '0'}`, `eg=${evcsExplicitGridRequest ? '1' : '0'}`, `ep=${evcsPriorityActive ? '1' : '0'}`, `es=${evcsSurplusState || 'na'}` ].join('|'); const signatureChanged = signature !== lastSignature; // ====================================================== // OUTPUT // ====================================================== const result = { timestamp_paris: now.stamp, inputs: { soc_percent: soc, grid_state: gridIsOn ? 'on' : 'off', pv_power_w: pvPowerW, house_ac_power_w: houseAcPowerW, grid_power_w: gridPowerW, evcs_ac_power_w: evcsAcPowerW, evcs_charging: evcsCharging, evcs_status_raw: Number.isFinite(evcsStatusRaw) ? evcsStatusRaw : null, hysteresis_enabled: hysteresisEnabled, notifications_enabled: notificationsEnabled, grid_latch: latchIsOn, grid_soc_on: gridSocOn, grid_soc_off: gridSocOff, grid_min_on_minutes: gridMinOnMinutes, grid_cooldown_minutes: gridCooldownMinutes, ac_loads_max_safe_w: acLoadsMaxSafeW, last_on_at: lastOnAt || null, last_off_at: lastOffAt || null, last_signature: lastSignature || null, evcs_priority_active: evcsPriorityActive, night_grid_takeover_needed: evcsNightGridTakeoverNeeded, evcs_source_mode: evcsSourceModeRaw || null, user_strategy: userStrategyRaw || null, evcs_surplus_state: evcsSurplusStateRaw || null }, computed: { desired_grid: desiredGrid, desired_latch: desiredLatch, minutes_since_last_on: minutesSinceLastOn !== null ? round1(minutesSinceLastOn) : null, minutes_since_last_off: minutesSinceLastOff !== null ? round1(minutesSinceLastOff) : null, min_on_elapsed: minOnElapsed, cooldown_elapsed: cooldownElapsed, ac_over_safe_limit: acOverSafeLimit, evcs_seems_active: evcsSeemsActive, evcs_grid_override: evcsGridOverride, evcs_explicit_grid_request: evcsExplicitGridRequest, evcs_priority_active: evcsPriorityActive, evcs_night_grid_takeover_needed: evcsNightGridTakeoverNeeded, evcs_source_mode: evcsSourceModeRaw || null, evcs_user_strategy: userStrategyRaw || null, evcs_surplus_state: evcsSurplusStateRaw || null, evcs_vehicle_no_demand_semantic: evcsVehicleNoDemandSemantic }, decision: { state, grid_action: gridAction, latch_action: latchAction, set_last_on_at: setLastOnAt, set_last_off_at: setLastOffAt, should_notify: notificationsEnabled && signatureChanged && notifyWanted, signature, signature_changed: signatureChanged, reason, short_reason: shortReason }, ha_service_calls: { helper_state: { domain: 'input_text', service: 'set_value', data: { entity_id: 'input_text.victron_grid_control_state', value: state } }, helper_reason: { domain: 'input_text', service: 'set_value', data: { entity_id: 'input_text.victron_grid_control_reason', value: shortReason || 'Aucune raison' } }, helper_signature: { domain: 'input_text', service: 'set_value', data: { entity_id: 'input_text.victron_grid_control_last_signature', value: signature } }, helper_latch: { domain: 'input_boolean', service: desiredLatch === 'on' ? 'turn_on' : 'turn_off', data: { entity_id: 'input_boolean.victron_grid_hysteresis_latch' } }, helper_last_on_at: { domain: 'input_datetime', service: 'set_datetime', data: { entity_id: 'input_datetime.victron_grid_last_on_at', datetime: now.stamp } }, helper_last_off_at: { domain: 'input_datetime', service: 'set_datetime', data: { entity_id: 'input_datetime.victron_grid_last_off_at', datetime: now.stamp } }, grid: { domain: 'switch', service: desiredGrid === 'on' ? 'turn_on' : 'turn_off', data: { entity_id: 'switch.edf_general_ok_switch' } }, logbook: { domain: 'logbook', service: 'log', data: { name: 'Victron Grid Hysteresis', message: `${state} | Grid=${desiredGrid} | Latch=${desiredLatch} | SOC=${round1(soc)}% | AC=${Math.round(houseAcPowerW)}W | ${shortReason}`, entity_id: 'switch.edf_general_ok_switch' } }, mobile_notification: { 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 }];