diff --git a/EVCS b/EVCS new file mode 160000 index 0000000..874aca1 --- /dev/null +++ b/EVCS @@ -0,0 +1 @@ +Subproject commit 874aca1afa4f55158e5c3253e17a92c6dc58c836 diff --git a/HYSTERESIS/COMPUTE.js b/HYSTERESIS/COMPUTE.js new file mode 100644 index 0000000..6b9bfbe --- /dev/null +++ b/HYSTERESIS/COMPUTE.js @@ -0,0 +1,625 @@ +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 +// Si EVCS dit déjà "plus de demande", on relâche l'override. +// ====================================================== +const evcsGridOverride = + !evcsVehicleNoDemandSemantic && + ( + userStrategy === 'force_grid_now' || + userStrategy === 'force_charge_now' || + evcsPriorityActive || + evcsNightGridTakeoverNeeded || + evcsSourceMode === 'réseau forcé' || + evcsSourceMode === 'réseau hc autorisé' || + evcsSourceMode === 'grid_forced' || + evcsSourceMode === 'grid_hc_allowed' + ); + +// ====================================================== +// DÉCISION +// ====================================================== +let state = 'observe_only'; +let reason = []; +let desiredGrid = gridIsOn ? 'on' : 'off'; +let desiredLatch = latchIsOn ? 'on' : 'off'; + +// ====================================================== +// 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 (evcsPriorityActive) { + reason.push('Cause override : evcs_priority_active'); + } 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 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'}`, + `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_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 }]; + diff --git a/HYSTERESIS/COMPUTEP1.js b/HYSTERESIS/COMPUTEP1.js new file mode 100644 index 0000000..d6f3faa --- /dev/null +++ b/HYSTERESIS/COMPUTEP1.js @@ -0,0 +1,198 @@ +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); diff --git a/HYSTERESIS/COMPUTEP2.js b/HYSTERESIS/COMPUTEP2.js new file mode 100644 index 0000000..1815f26 --- /dev/null +++ b/HYSTERESIS/COMPUTEP2.js @@ -0,0 +1,201 @@ +// ====================================================== +// 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 +// Si EVCS dit déjà "plus de demande", on relâche l'override. +// ====================================================== +const evcsGridOverride = + !evcsVehicleNoDemandSemantic && + ( + userStrategy === 'force_grid_now' || + userStrategy === 'force_charge_now' || + evcsPriorityActive || + evcsNightGridTakeoverNeeded || + evcsSourceMode === 'réseau forcé' || + evcsSourceMode === 'réseau hc autorisé' || + evcsSourceMode === 'grid_forced' || + evcsSourceMode === 'grid_hc_allowed' + ); + +// ====================================================== +// DÉCISION +// ====================================================== +let state = 'observe_only'; +let reason = []; +let desiredGrid = gridIsOn ? 'on' : 'off'; +let desiredLatch = latchIsOn ? 'on' : 'off'; + +// ====================================================== +// 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 (evcsPriorityActive) { + reason.push('Cause override : evcs_priority_active'); + } 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 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'}`); diff --git a/HYSTERESIS/COMPUTEP3.js b/HYSTERESIS/COMPUTEP3.js new file mode 100644 index 0000000..f7a1286 --- /dev/null +++ b/HYSTERESIS/COMPUTEP3.js @@ -0,0 +1,224 @@ +// ====================================================== +// 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'}`, + `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_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 }]; +