function round1(v) { return Math.round(v * 10) / 10; } function round2(v) { return Math.round(v * 100) / 100; } function clamp(v, min, max) { return Math.min(Math.max(v, min), max); } function to2(n) { return String(n).padStart(2, '0'); } function formatParisStampFromMs(ms) { 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); return `${parts.year}-${parts.month}-${parts.day} ${to2(hour)}:${parts.minute}:${parts.second}`; } const prev = $input.first().json; const { input, now, nowMs, cfg, parsed, params, history, strategy, windows, context, derived, batteryTune, batteryPolicy, locks, cloud, modes, safeguards, conditions, decision, } = prev; // ===================================================== // FINAL ACTIONS // ===================================================== let desiredCurrentA = decision.desiredCurrentA; desiredCurrentA = decision.desiredCharging ? clamp( desiredCurrentA, params.currentMinA, Math.max(params.currentMinA, derived.physicalCapA || params.currentMinA) ) : params.currentMinA; const chargingAction = decision.desiredCharging === parsed.evcsChargingSwitchOn ? 'none' : decision.desiredCharging ? 'turn_on' : 'turn_off'; const permissionAction = decision.desiredPermission === parsed.chargePermissionNow ? 'none' : decision.desiredPermission ? 'turn_on' : 'turn_off'; const latchAction = decision.desiredLatch === parsed.controlLatch ? 'none' : decision.desiredLatch ? 'turn_on' : 'turn_off'; const autoStartAction = parsed.controllerEnabled && parsed.evcsVehicleConnected && parsed.evcsAutoStartSwitchOn ? 'turn_off' : 'none'; const currentAction = decision.desiredCharging && ( !Number.isFinite(derived.currentReferenceA) || Math.abs(desiredCurrentA - derived.currentReferenceA) >= 0.5 ) ? 'set' : 'none'; const setLastStartAt = chargingAction === 'turn_on'; const setLastStopAt = chargingAction === 'turn_off'; const setLastCurrentChangeAt = currentAction === 'set'; // ===================================================== // CLEAN BLOCKING LOGIC // ===================================================== const blockedUntilToWrite = decision.nextBlockReason === 'none' ? now.stamp : decision.nextBlockedUntilStamp; const needsBlockReasonUpdate = decision.nextBlockReason !== history.lastBlockReason; // ===================================================== // GLOBAL EVCS PRIORITY // AUTO + OVERRIDE MANUEL // ===================================================== const forceWindowMinutes = Math.max( 30, batteryTune?.global?.forceModeAutoRevertMinutes ?? 120 ); const activeChargeIntent = decision.desiredCharging || ['start', 'hold', 'step_up', 'step_down', 'step_down_fast'].includes(decision.currentDecision); // ------------------------- // PRIORITÉ AUTO // ------------------------- let autoEvcsPriorityActive = false; let autoEnergyPriorityMode = 'normal'; let autoEnergyPriorityReason = 'Aucune priorité EVCS'; let autoEvcsPriorityUntil = now.stamp; if (parsed.evcsVehicleConnected && !strategy.isStopMode) { if (strategy.forceChargeRequested || strategy.forceGridRequested) { autoEvcsPriorityActive = true; autoEnergyPriorityMode = 'evcs_priority'; autoEnergyPriorityReason = strategy.forceGridRequested ? 'EVCS prioritaire : force_grid_now' : 'EVCS prioritaire : force_charge_now'; autoEvcsPriorityUntil = formatParisStampFromMs(nowMs + forceWindowMinutes * 60000); } else if (context.departurePriorityActive && Number.isFinite(prev.lifecycle.minutesUntilDeparture)) { autoEvcsPriorityActive = true; autoEnergyPriorityMode = 'evcs_priority'; autoEnergyPriorityReason = `EVCS prioritaire : départ dans ${Math.round(prev.lifecycle.minutesUntilDeparture)} min`; autoEvcsPriorityUntil = formatParisStampFromMs( nowMs + Math.max(1, prev.lifecycle.minutesUntilDeparture) * 60000 ); } else if (activeChargeIntent && context.profile === 'midday_hc_or_solar') { autoEvcsPriorityActive = true; autoEnergyPriorityMode = 'evcs_priority'; autoEnergyPriorityReason = 'EVCS prioritaire : recharge active fenêtre midi'; autoEvcsPriorityUntil = formatParisStampFromMs(nowMs + 90 * 60000); } } // ------------------------- // OVERRIDE MANUEL // ------------------------- const manualPriorityOverrideActive = parsed.evcsPriorityManualOverride && parsed.evcsPriorityManualMode === 'evcs_priority' && history.evcsPriorityManualUntilMs !== null && history.evcsPriorityManualUntilMs > nowMs + 1000; let evcsPriorityActive = autoEvcsPriorityActive; let energyPriorityMode = autoEnergyPriorityMode; let energyPriorityReason = autoEnergyPriorityReason; let evcsPriorityUntil = autoEvcsPriorityUntil; let evcsPrioritySource = 'auto'; if (manualPriorityOverrideActive) { evcsPriorityActive = true; energyPriorityMode = 'evcs_priority'; energyPriorityReason = parsed.evcsPriorityManualReason || 'EVCS prioritaire : override manuel'; evcsPriorityUntil = history.evcsPriorityManualUntilAt || autoEvcsPriorityUntil; evcsPrioritySource = 'manual'; } // ===================================================== // REASONS / LABELS / SIGNATURE // ===================================================== const reason = [...decision.reason]; reason.push(`Stratégie=${strategy.userStrategy}`); reason.push(`Profil=${context.profile}`); reason.push(`Source=${context.sourceMode}`); reason.push( `Mode=${ modes.batteryFullHarvestMode ? 'battery_full_harvest_mode' : modes.forecastCommitMode ? 'forecast_commit_mode' : 'battery_priority_mode' }` ); reason.push(`SOC=${round1(parsed.soc)}%`); reason.push(`Batt=${round1(parsed.batteryCurrentA)}A`); reason.push(`PV=${Math.round(parsed.pvPowerW)}W`); reason.push(`PV10=${Math.round(parsed.pvPowerAvg10mW)}W`); reason.push(`Grid=${parsed.gridAvailable ? 'on' : 'off'} (${Math.round(parsed.gridPowerW)}W)`); reason.push(`Cap=${derived.physicalCapA}A`); reason.push(`Ref=${round1(derived.currentReferenceA)}A`); reason.push(`Target=${round1(desiredCurrentA)}A`); reason.push(`Debt=${round2(cloud.cloudDebtNextKwh)}kWh`); reason.push(`Vehicle=${parsed.evcsVehicleConnected ? 'on' : 'off'}`); reason.push(`EVCS mode=${parsed.evcsModeRaw}`); reason.push(`VE.Bus AC=${Math.round(parsed.vebusAcOutPowerW)}W`); reason.push( `VE.Bus DC=${ Number.isFinite(parsed.vebusDcVoltageV) ? round1(parsed.vebusDcVoltageV) : 'n/a' }V` ); reason.push(`PrioEVCS=${evcsPriorityActive ? 'on' : 'off'}(${evcsPrioritySource})`); const shortReason = reason.join(' | ').slice(0, 255); const sourceModeLabel = context.sourceMode === 'blocked' ? 'Bloqué' : context.sourceMode === 'solar_only' ? 'Solaire' : context.sourceMode === 'solar_potential' ? 'Potentiel solaire' : context.sourceMode === 'hybrid_probe' ? 'Probe hybride' : context.sourceMode === 'grid_hc_allowed' ? 'Réseau HC autorisé' : context.sourceMode === 'grid_forced' ? 'Réseau forcé' : context.sourceMode; const signature = [ decision.state, `profile=${context.profile}`, `source=${context.sourceMode}`, `chg=${decision.desiredCharging ? '1' : '0'}`, `perm=${decision.desiredPermission ? '1' : '0'}`, `l=${decision.desiredLatch ? '1' : '0'}`, `cur=${round1(desiredCurrentA)}`, `block=${decision.nextBlockReason}`, `stop=${decision.mustStop ? '1' : '0'}`, `prio=${evcsPriorityActive ? '1' : '0'}` ].join('|'); const signatureChanged = signature !== history.lastSignature; // ===================================================== // NOTIFICATIONS // ===================================================== let notifyWanted = false; let notifyTitle = 'Victron EVCS Surplus'; let notifyMessage = shortReason; if (parsed.notificationsEnabled && signatureChanged) { if (decision.currentDecision === 'start') { notifyWanted = true; notifyTitle = '⚡ EVCS démarrage'; } else if (decision.currentDecision === 'step_up') { notifyWanted = true; notifyTitle = '⬆️ EVCS hausse courant'; } else if ( decision.currentDecision === 'step_down' || decision.currentDecision === 'step_down_fast' ) { notifyWanted = true; notifyTitle = '⬇️ EVCS baisse courant'; } else if ( decision.mustStop || decision.currentDecision === 'stop' || decision.currentDecision === 'stop_hard' || decision.currentDecision === 'stop_power_cap' ) { notifyWanted = true; notifyTitle = '⛔ EVCS arrêt'; } else if (decision.state === 'mode_auto_conflict') { notifyWanted = true; notifyTitle = '⚠️ EVCS mode Auto'; } } // ===================================================== // TIMESTAMPS TO WRITE // ===================================================== const cloudDebtStartedAtToWrite = cloud.cloudDebtStartedMs ? formatParisStampFromMs(cloud.cloudDebtStartedMs) : now.stamp; const probeStartedAtToWrite = now.stamp; const lastStartAtToWrite = now.stamp; const lastStopAtToWrite = now.stamp; const lastCurrentChangeAtToWrite = now.stamp; // ===================================================== // OUTPUT // ===================================================== return [{ json: { timestamp_paris: now.stamp, inputs: input, computed: { operating_mode: context.operatingMode, profile: context.profile, source_mode: context.sourceMode, source_mode_label: sourceModeLabel, user_strategy: strategy.userStrategy, in_night_hc_window: windows.inNightHcWindow, in_morning_window: windows.inMorningWindow, in_midday_window: windows.inMiddayWindow, in_evening_window: windows.inEveningWindow, departure_priority_active: context.departurePriorityActive, minutes_until_departure: prev.lifecycle.minutesUntilDeparture, battery_priority_mode: modes.batteryPriorityMode, battery_full_harvest_mode: modes.batteryFullHarvestMode, forecast_commit_mode: modes.forecastCommitMode, physical_cap_current_a: derived.physicalCapA, can_run_at_min_current: derived.canRunAtMinCurrent, forecast_buffer_kwh: round2(derived.forecastBufferKwh), projected_end_soc_after_charge: round1(derived.projectedEndSocAfterCharge), cloud_debt_next_kwh: round2(cloud.cloudDebtNextKwh), cloud_debt_duration_minutes: round1(cloud.cloudDebtDurationMinutes), cloud_debt_active: cloud.cloudDebtActive, cloud_tolerance_ok: cloud.cloudToleranceOk, start_blocked_by_reason: locks.startBlockedByReason, probe_window_active: conditions.probeWindowActive, probe_success_now: conditions.probeSuccessNow, probe_failed_now: conditions.probeFailedNow, any_vebus_overload: derived.anyVebusOverload, any_vebus_low_battery: derived.anyVebusLowBattery, vebus_ac_out_soft_reached: derived.vebusAcOutSoftReached, vebus_ac_out_hard_reached: derived.vebusAcOutHardReached, vebus_dc_voltage_soft_low: derived.vebusDcVoltageSoftLow, vebus_dc_voltage_hard_low: derived.vebusDcVoltageHardLow, soc_band: batteryPolicy.socBand, batt_discharge_soft_dynamic_a: round1(batteryPolicy.batteryDischargeSoftDynamicA), batt_discharge_hard_dynamic_a: round1(batteryPolicy.batteryDischargeHardDynamicA), batt_charge_reserve_dynamic_a: round1(batteryPolicy.batteryChargeReserveDynamicA), batt_soft_tolerance_min: round1(batteryPolicy.batterySoftToleranceMinutes), batt_hard_tolerance_sec: round1(batteryPolicy.batteryHardToleranceSeconds), batt_recovery_min: round1(batteryPolicy.batteryRecoveryMinutes), grid_battery_soft_dynamic_a: round1(batteryPolicy.gridBatterySoftDynamicA), grid_battery_hard_dynamic_a: round1(batteryPolicy.gridBatteryHardDynamicA), house_spike_detected: batteryPolicy.houseSpikeDetected, solar_potential_context: batteryPolicy.solarPotentialContext, evcs_priority_active: evcsPriorityActive, energy_priority_mode: energyPriorityMode, energy_priority_reason: energyPriorityReason, evcs_priority_until: evcsPriorityUntil, evcs_priority_source: evcsPrioritySource, evcs_priority_manual_override_active: manualPriorityOverrideActive, }, decision: { state: decision.state, current_decision: decision.currentDecision, desired_charging: decision.desiredCharging, desired_permission: decision.desiredPermission, desired_latch: decision.desiredLatch, desired_current_a: round1(desiredCurrentA), charging_action: chargingAction, permission_action: permissionAction, latch_action: latchAction, current_action: currentAction, auto_start_action: autoStartAction, set_last_start_at: setLastStartAt, set_last_stop_at: setLastStopAt, set_last_current_change_at: setLastCurrentChangeAt, set_cloud_debt_started_at: decision.setCloudDebtStartedAt, set_probe_started_at: decision.setProbeStartedAt, set_block_reason: needsBlockReasonUpdate || !parsed.controllerEnabled, block_reason: decision.nextBlockReason, set_blocked_until: true, blocked_until: blockedUntilToWrite, dynamic_ok: decision.dynamicOk, must_stop: decision.mustStop, short_reason: shortReason, reason, signature, signature_changed: signatureChanged, should_notify: notifyWanted, notify_title: notifyTitle, notify_message: notifyMessage, evcs_priority_active: evcsPriorityActive, energy_priority_mode: energyPriorityMode, energy_priority_reason: energyPriorityReason, evcs_priority_until: evcsPriorityUntil, evcs_priority_source: evcsPrioritySource, }, ha_service_calls: { helper_state: { execute: true, domain: 'input_text', service: 'set_value', data: { entity_id: 'input_text.victron_evcs_surplus_state', value: decision.state } }, helper_current_decision: { execute: true, domain: 'input_text', service: 'set_value', data: { entity_id: 'input_text.victron_evcs_surplus_current_decision', value: decision.currentDecision } }, helper_reason: { execute: true, domain: 'input_text', service: 'set_value', data: { entity_id: 'input_text.victron_evcs_surplus_reason', value: shortReason } }, helper_operating_mode: { execute: true, domain: 'input_text', service: 'set_value', data: { entity_id: 'input_text.victron_evcs_operating_mode', value: context.operatingMode } }, helper_source_mode: { execute: true, domain: 'input_text', service: 'set_value', data: { entity_id: 'input_text.victron_evcs_source_mode', value: sourceModeLabel } }, helper_target_current: { execute: true, domain: 'input_number', service: 'set_value', data: { entity_id: 'input_number.victron_evcs_target_current_auto', value: round1(desiredCurrentA) } }, helper_budget_remaining: { execute: true, domain: 'input_number', service: 'set_value', data: { entity_id: 'input_number.victron_evcs_budget_remaining_kwh', value: round2(derived.forecastBufferKwh) } }, helper_projected_soc: { execute: true, domain: 'input_number', service: 'set_value', data: { entity_id: 'input_number.victron_evcs_projected_end_soc_after_charge', value: round1(derived.projectedEndSocAfterCharge) } }, helper_cloud_debt: { execute: true, domain: 'input_number', service: 'set_value', data: { entity_id: 'input_number.victron_evcs_cloud_debt_kwh', value: round2(cloud.cloudDebtNextKwh) } }, helper_dynamic_ok: { execute: true, domain: 'input_boolean', service: decision.dynamicOk ? 'turn_on' : 'turn_off', data: { entity_id: 'input_boolean.victron_evcs_dynamic_ok' } }, helper_must_stop: { execute: true, domain: 'input_boolean', service: decision.mustStop ? 'turn_on' : 'turn_off', data: { entity_id: 'input_boolean.victron_evcs_must_stop' } }, helper_cloud_debt_started_at: { execute: true, domain: 'input_datetime', service: 'set_datetime', data: { entity_id: 'input_datetime.victron_evcs_cloud_debt_started_at', datetime: cloudDebtStartedAtToWrite } }, helper_last_current_change_at: { execute: setLastCurrentChangeAt, domain: 'input_datetime', service: 'set_datetime', data: { entity_id: 'input_datetime.victron_evcs_last_current_change_at', datetime: lastCurrentChangeAtToWrite } }, helper_last_start_at: { execute: setLastStartAt, domain: 'input_datetime', service: 'set_datetime', data: { entity_id: 'input_datetime.victron_evcs_last_start_at', datetime: lastStartAtToWrite } }, helper_last_stop_at: { execute: setLastStopAt, domain: 'input_datetime', service: 'set_datetime', data: { entity_id: 'input_datetime.victron_evcs_last_stop_at', datetime: lastStopAtToWrite } }, helper_signature: { execute: true, domain: 'input_text', service: 'set_value', data: { entity_id: 'input_text.victron_evcs_control_last_signature', value: signature } }, helper_block_reason: { execute: needsBlockReasonUpdate || !parsed.controllerEnabled, domain: 'input_text', service: 'set_value', data: { entity_id: 'input_text.victron_evcs_last_block_reason', value: decision.nextBlockReason } }, helper_blocked_until: { execute: true, domain: 'input_datetime', service: 'set_datetime', data: { entity_id: 'input_datetime.victron_evcs_blocked_until', datetime: blockedUntilToWrite } }, helper_probe_started_at: { execute: decision.setProbeStartedAt, domain: 'input_datetime', service: 'set_datetime', data: { entity_id: 'input_datetime.victron_evcs_probe_started_at', datetime: probeStartedAtToWrite } }, helper_evcs_priority_active: { execute: true, domain: 'input_boolean', service: evcsPriorityActive ? 'turn_on' : 'turn_off', data: { entity_id: 'input_boolean.victron_evcs_priority_active' } }, helper_energy_priority_mode: { execute: true, domain: 'input_select', service: 'select_option', data: { entity_id: 'input_select.victron_energy_priority_mode', option: energyPriorityMode } }, helper_energy_priority_reason: { execute: true, domain: 'input_text', service: 'set_value', data: { entity_id: 'input_text.victron_energy_priority_reason', value: energyPriorityReason } }, helper_evcs_priority_until: { execute: true, domain: 'input_datetime', service: 'set_datetime', data: { entity_id: 'input_datetime.victron_evcs_priority_until', datetime: evcsPriorityUntil } }, evcs_auto_start_off: { execute: autoStartAction === 'turn_off', domain: 'switch', service: 'turn_off', data: { entity_id: 'switch.victron_evcs_auto_start' } }, evcs_charging: { execute: chargingAction !== 'none', domain: 'switch', service: decision.desiredCharging ? 'turn_on' : 'turn_off', data: { entity_id: 'switch.victron_evcs_charging' } }, helper_permission: { execute: permissionAction !== 'none', domain: 'input_boolean', service: decision.desiredPermission ? 'turn_on' : 'turn_off', data: { entity_id: 'input_boolean.victron_evcs_charge_permission_now' } }, helper_latch: { execute: latchAction !== 'none', domain: 'input_boolean', service: decision.desiredLatch ? 'turn_on' : 'turn_off', data: { entity_id: 'input_boolean.victron_evcs_control_latch' } }, evcs_set_current: { execute: currentAction === 'set', domain: 'number', service: 'set_value', data: { entity_id: 'number.victron_evcs_set_current_command', value: round1(desiredCurrentA) } }, logbook: { execute: signatureChanged, domain: 'logbook', service: 'log', data: { name: 'Victron EVCS Surplus', message: shortReason } }, mobile_notification: { execute: notifyWanted, domain: cfg.mobileNotifyDomain, service: cfg.mobileNotifyService, data: { title: notifyTitle, message: notifyMessage || shortReason } } } } }];