R2: git = source de vérité — src/nodes/ + build/extract intégrés au push

- src/nodes/*.js (6 nodes + template snapshot) = source de vérité unique.
- n8n-push.mjs reconstruit le JSON depuis src/ avant syntaxe+golden+PUT
  (src gagne toujours; n8n-extract.mjs pour resynchroniser si édition UI).
- n8n-build.mjs --check pour vérifier la synchro.
- Copies historiques déplacées dans archive/ (mortes, dérivées de la prod).
- .gitignore: backups locaux de rollback.
- AGENTS.md: règle R2 pour toutes les futures sessions (dont Qwen local).
- Validé: idempotence, mutation/restauration, pipeline push complet en réel.
This commit is contained in:
Tallagrand Thierry
2026-08-07 21:03:52 +02:00
parent b10c90c74f
commit 1ae8129658
78 changed files with 5132 additions and 0 deletions

View File

@@ -0,0 +1,217 @@
// === REASON-BASED LOCKS ===
const nextWindowCandidates = [
nextDailyOccurrenceMs(morningStartMin, nowMs),
nextDailyOccurrenceMs(hcMiddayStartMin, nowMs),
nextDailyOccurrenceMs(eveningStartMin, nowMs),
nextDailyOccurrenceMs(hcNightStartMin, nowMs),
].filter(v => Number.isFinite(v));
const nextAllowedWindowStartMs =
nextWindowCandidates.length > 0 ? Math.min(...nextWindowCandidates) : null;
const blockActive = blockedUntilMs !== null && blockedUntilMs > nowMs + 1000;
const blockStillRelevant =
lastBlockReason === 'outside_window' ? profile === 'outside_window' :
lastBlockReason === 'probe_failed' ? (profile === 'morning_solar' || profile === 'midday_hc_or_solar' || profile === 'outside_window') :
lastBlockReason === 'battery_protection_stop' ? (
batteryCurrentA < -batteryNeutralBandA || anyVebusLowBattery || vebusDcVoltageSoftLow
) :
lastBlockReason === 'grid_battery_guard' ? sourceMode === 'grid_hc_allowed' :
false;
const startBlockedByReason = blockActive && blockStillRelevant;
// === CLOUD DEBT ===
const sessionObservedActive = evcsChargingSwitchOn || controlLatch || chargePermissionNow;
const batteryNetPowerW = batteryCurrentA * batteryVoltageRef;
let cloudDebtNextKwh = cloudDebtPrevKwh;
if (sessionObservedActive) {
if (batteryNetPowerW < 0 && sourceMode !== 'grid_hc_allowed') {
cloudDebtNextKwh += (Math.abs(batteryNetPowerW) * CFG.sampleSeconds) / 3600000;
} else if (batteryNetPowerW > 0) {
cloudDebtNextKwh -= (batteryNetPowerW * CFG.sampleSeconds) / 3600000;
}
} else if (batteryNetPowerW > 0) {
cloudDebtNextKwh -= (batteryNetPowerW * CFG.sampleSeconds) / 3600000;
}
cloudDebtNextKwh = clamp(cloudDebtNextKwh, 0, cloudDebtMaxKwh * 4);
const cloudDebtMeaningful = cloudDebtNextKwh > CFG.cloudDebtMeaningfulThresholdKwh;
const cloudEventDischargingNow =
sessionObservedActive &&
sourceMode !== 'grid_hc_allowed' &&
batteryCurrentA < -Math.max(1, batteryNeutralBandA) &&
cloudDebtMeaningful;
const continuingCloudEvent =
cloudEventDischargingNow &&
cloudDebtStartedMsPrev !== null &&
cloudDebtPrevKwh > CFG.cloudDebtRecoveryThresholdKwh;
const enteringCloudDebt = cloudEventDischargingNow && !continuingCloudEvent;
const cloudDebtActive = cloudEventDischargingNow;
const cloudDebtStartedMs =
cloudEventDischargingNow
? (continuingCloudEvent ? cloudDebtStartedMsPrev : nowMs)
: null;
const cloudDebtDurationMinutes =
cloudDebtActive && cloudDebtStartedMs !== null
? Math.max(0, (nowMs - cloudDebtStartedMs) / 60000)
: 0;
const cloudToleranceOk =
cloudDebtNextKwh <= cloudDebtMaxKwh &&
(
!cloudDebtActive ||
cloudDebtDurationMinutes <= cloudToleranceMinutes
);
// === LEGACY SOLAR MODES KEPT ===
const batteryFullHarvestMode =
forecastModeEnabled &&
profile !== 'night_hc' &&
profile !== 'outside_window' &&
soc >= batteryFullSoc &&
todayRemainingKwh >= batteryFullForecastMinKwh &&
projectedEndSocAfterCharge >= (endDayGuardSoc - 2);
const forecastCommitMode =
forecastModeEnabled &&
profile !== 'night_hc' &&
profile !== 'outside_window' &&
soc >= forecastCommitSocFloor &&
todayRemainingKwh >= forecastCommitMinKwh &&
projectedEndSocAfterCharge >= endDayGuardSoc;
const batteryPriorityMode = !batteryFullHarvestMode && !forecastCommitMode;
// === BASE CONDITIONS ===
const commonSafetyStartOk =
canRunAtMinCurrent &&
!anyVebusOverload &&
!anyVebusLowBattery &&
!vebusDcVoltageSoftLow &&
!vebusAcOutSoftReached &&
evcsDeviceConnected &&
evcsVehicleConnected &&
!evcsModeIsAuto;
const commonSafetyHoldOk =
canRunAtMinCurrent &&
!anyVebusOverload &&
!anyVebusLowBattery &&
!vebusDcVoltageHardLow &&
evcsDeviceConnected &&
evcsVehicleConnected &&
!evcsModeIsAuto;
const profileSocStartFloor =
profile === 'morning_solar' ? morningSocMin :
profile === 'midday_hc_or_solar' ? middaySocMin :
profile === 'evening_high_soc' ? eveningSocStartMin :
socStartMin;
const profileSocHoldFloor =
profile === 'morning_solar' ? Math.max(socStopMin, morningSocMin - 10) :
profile === 'midday_hc_or_solar' ? Math.max(socStopMin, middaySocMin - 15) :
profile === 'evening_high_soc' ? eveningSocStopMin :
socStopMin;
const profileBatteryDischargeFloorA =
profile === 'morning_solar' ? morningBatteryMaxDischargeA :
profile === 'midday_hc_or_solar' ? middayBatteryMaxDischargeA :
profile === 'evening_high_soc' ? eveningBatteryMaxDischargeA :
batteryDischargeSoftA;
const solarStartOk =
sourceMode === 'solar_only' || sourceMode === 'solar_potential' &&
commonSafetyStartOk &&
soc >= profileSocStartFloor &&
batteryCurrentA >= profileBatteryDischargeFloorA &&
(sourceMode === 'solar_potential' || pvPowerW >= effectivePvStartMinW);
const solarHoldOk =
commonSafetyHoldOk &&
soc >= profileSocHoldFloor &&
batteryCurrentA >= profileBatteryDischargeFloorA &&
(pvPowerW >= pvHoldMinW || cloudToleranceOk);
const gridStartOk =
(sourceMode === 'grid_hc_allowed' || sourceMode === 'grid_forced') &&
commonSafetyStartOk &&
gridIsOn &&
batteryCurrentA >= gridBatterySoftA;
const gridHoldOk =
commonSafetyHoldOk &&
(sourceMode === 'grid_hc_allowed' || sourceMode === 'grid_forced') &&
gridIsOn &&
batteryCurrentA >= gridBatteryHardA;
const probeStartOk =
sourceMode === 'hybrid_probe' &&
commonSafetyStartOk &&
(morningProbeEligible || middayProbeEligible || afternoonProbeEligible);
const probeHoldGuardOk =
commonSafetyHoldOk &&
batteryCurrentA >= profileBatteryDischargeFloorA;
const probeWindowActive =
sessionObservedActive &&
probeStartedMs !== null &&
minutesSinceProbeStart !== null &&
minutesSinceProbeStart < probeMinutes;
const probeElapsedReached =
sessionObservedActive &&
probeStartedMs !== null &&
minutesSinceProbeStart !== null &&
minutesSinceProbeStart >= probeMinutes;
const probeSuccessNow =
sessionObservedActive &&
probeStartedMs !== null &&
(
pvPowerW >= pvHoldMinW &&
batteryCurrentA >= Math.min(0, batteryDischargeSoftA)
);
const probeFailedNow = probeElapsedReached && !probeSuccessNow;
// === STEP LOGIC ===
const stepUpByBatteryReserve =
batteryPriorityMode &&
batteryCurrentA >= batteryReserveUpA;
const stepUpByForecastMode =
!batteryPriorityMode &&
(pvPowerW >= pvHoldMinW || pvPowerAvg10mW >= pvHoldMinW) &&
forecastBufferKwh >= -1 &&
projectedEndSocAfterCharge >= (endDayGuardSoc - 5) &&
cloudToleranceOk;
const solarShouldStepUp =
currentCooldownElapsed &&
currentReferenceA < physicalCapA &&
physicalCapA >= currentMinA &&
!vebusAcOutSoftReached &&
!vebusDcVoltageSoftLow &&
!houseLoadHighVsHeadroom &&
(stepUpByBatteryReserve || stepUpByForecastMode || pvPowerW >= (effectivePvStartMinW + 400));
const gridShouldStepUp =
currentCooldownElapsed &&
currentReferenceA < physicalCapA &&
physicalCapA >= currentMinA &&
!vebusAcOutSoftReached &&
!vebusDcVoltageSoftLow &&
batteryCurrentA >= gridBatterySoftA &&
!houseLoadHighVsHeadroom;
const solarShouldStepDownBattery =
batteryCurrentA <= batteryDischargeSoftA &&
currentCooldownElapsed &&
currentReferenceA > currentMinA;
const solarShouldStepDownCloudDebt =
!cloudToleranceOk &&
currentCooldownElapsed &&
currentReferenceA > currentMinA;
const gridShouldStepDownBattery =
batteryCurrentA < gridBatterySoftA &&
currentCooldownElapsed &&
currentReferenceA > currentMinA;
const shouldStepDownVebusPower =
vebusAcOutSoftReached &&
currentCooldownElapsed &&
currentReferenceA > currentMinA;
const shouldStepDownVebusVoltage =
vebusDcVoltageSoftLow &&
currentCooldownElapsed &&
currentReferenceA > currentMinA;
const batteryCurrentHardExceeded = batteryCurrentA <= batteryDischargeHardA;
// === HARD STOPS ===
const mustStopHard =
anyVebusOverload ||
anyVebusLowBattery ||
vebusDcVoltageHardLow ||
vebusAcOutHardReached ||
batteryCurrentHardExceeded;
const gridBatteryProtectionStop =
sessionObservedActive &&
sourceMode === 'grid_hc_allowed' &&
batteryCurrentA < gridBatteryHardA;

View File

@@ -0,0 +1,468 @@
// === DECISION INIT ===
let desiredCharging = false;
let desiredPermission = false;
let desiredLatch = false;
let desiredCurrentA = currentMinA;
let state = 'idle';
let currentDecision = 'hold';
let dynamicOk = false;
let mustStop = false;
let reason = [];
let setLastStartAt = false;
let setLastStopAt = false;
let setLastCurrentChangeAt = false;
let setCloudDebtStartedAt = false;
let setProbeStartedAt = false;
let notifyWanted = false;
let notifyTitle = 'Victron EVCS Surplus';
let notifyMessage = '';
let nextBlockReason = 'none';
let nextBlockedUntilStamp = now.stamp;
// === DECISION TREE (WITH STRATEGY LAYERS) ===
if (!controllerEnabled) {
desiredCharging = evcsChargingSwitchOn;
desiredPermission = chargePermissionNow;
desiredLatch = controlLatch;
desiredCurrentA = clamp(currentReferenceA || currentMinA, currentMinA, currentMaxAutoA);
state = 'controller_disabled';
reason.push("Contrôleur désactivé");
}
else if (isStopMode) {
desiredCharging = false;
desiredPermission = false;
desiredLatch = false;
desiredCurrentA = currentMinA;
state = 'stopped_user';
currentDecision = 'stop_stop_mode';
dynamicOk = false;
mustStop = false;
nextBlockReason = 'manual_stop';
nextBlockedUntilStamp = now.stamp;
reason.push("Arrêt manuel via Stratégie STOP");
}
else if (evcsModeIsAuto) {
desiredCharging = false;
desiredPermission = false;
desiredLatch = false;
desiredCurrentA = currentMinA;
state = 'mode_auto_conflict';
currentDecision = 'stop_auto_mode_conflict';
dynamicOk = false;
mustStop = true;
reason.push("Borne en mode Auto interne");
}
else if (!evcsDeviceConnected || !evcsVehicleConnected) {
desiredCharging = false;
desiredPermission = false;
desiredLatch = false;
desiredCurrentA = currentMinA;
state = 'vehicle_not_connected';
currentDecision = 'stop_not_connected';
dynamicOk = false;
mustStop = false;
nextBlockReason = 'vehicle_not_connected';
nextBlockedUntilStamp = now.stamp;
reason.push("Véhicule ou borne non connecté");
}
else if (mustStopHard) {
desiredCharging = false;
desiredPermission = false;
desiredLatch = false;
desiredCurrentA = currentMinA;
state =
anyVebusOverload ? 'vebus_overload_hard_stop' :
anyVebusLowBattery ? 'vebus_low_battery_hard_stop' :
vebusDcVoltageHardLow ? 'vebus_dc_voltage_hard_stop' :
vebusAcOutHardReached ? 'vebus_ac_hard_stop' :
batteryCurrentHardExceeded ? 'battery_current_hard_stop' :
'hard_stop';
currentDecision = 'stop_hard';
dynamicOk = false;
mustStop = true;
nextBlockReason = 'battery_protection_stop';
nextBlockedUntilStamp = formatParisStampFromMs(nowMs + 15 * 60000);
reason.push("Hard stop de protection");
}
else if (sessionObservedActive) {
if (enteringCloudDebt) {
setCloudDebtStartedAt = true;
}
if (gridBatteryProtectionStop) {
desiredCharging = false;
desiredPermission = false;
desiredLatch = false;
desiredCurrentA = currentMinA;
state = 'stop_grid_battery_guard';
currentDecision = 'stop';
dynamicOk = false;
mustStop = true;
nextBlockReason = 'grid_battery_guard';
nextBlockedUntilStamp = formatParisStampFromMs(nowMs + 20 * 60000);
reason.push(`Arrêt HC réseau: batterie trop sollicitée (${round1(batteryCurrentA)}A)`);
}
else if (probeFailedNow) {
desiredCharging = false;
desiredPermission = false;
desiredLatch = false;
desiredCurrentA = currentMinA;
state = 'probe_failed_lock';
currentDecision = 'stop';
dynamicOk = false;
mustStop = true;
nextBlockReason = 'probe_failed';
nextBlockedUntilStamp = formatParisStampFromMs(nowMs + probeRetryMinutes * 60000);
reason.push("Mode sonde échoué");
}
else if (physicalCapA < currentMinA) {
if (minRunElapsed) {
desiredCharging = false;
desiredPermission = false;
desiredLatch = false;
desiredCurrentA = currentMinA;
state = 'stop_power_cap';
currentDecision = 'stop_power_cap';
dynamicOk = false;
mustStop = true;
nextBlockReason = 'battery_protection_stop';
nextBlockedUntilStamp = formatParisStampFromMs(nowMs + 10 * 60000);
reason.push("Capacité système insuffisante");
} else {
desiredCharging = true;
desiredPermission = true;
desiredLatch = true;
desiredCurrentA = currentMinA;
state = 'hold_min_run_power_cap';
currentDecision = 'hold';
dynamicOk = false;
mustStop = false;
reason.push("Maintien jusqu'au temps mini ON");
}
}
else if (probeWindowActive && !probeSuccessNow) {
if (!probeHoldGuardOk) {
desiredCharging = false;
desiredPermission = false;
desiredLatch = false;
desiredCurrentA = currentMinA;
state = 'probe_stop_guard';
currentDecision = 'stop';
dynamicOk = false;
mustStop = true;
nextBlockReason = 'probe_failed';
nextBlockedUntilStamp = formatParisStampFromMs(nowMs + probeRetryMinutes * 60000);
reason.push("Sonde interrompue: garde-fous non respectés");
} else {
desiredCharging = true;
desiredPermission = true;
desiredLatch = true;
desiredCurrentA = clamp(probeCurrentA, currentMinA, physicalCapA);
state = 'probe_hold';
currentDecision = 'hold';
dynamicOk = true;
mustStop = false;
reason.push(`Mode sonde actif à ${round1(desiredCurrentA)}A`);
}
}
else {
const activeSourceMode = probeSuccessNow ? 'solar_only' : sourceMode;
const holdConditionsOk =
activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced' ? gridHoldOk : // Ajout ici aussi
activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential' ? solarHoldOk :
probeHoldGuardOk;
if (activeSourceMode === 'blocked') {
if (minRunElapsed) {
desiredCharging = false;
desiredPermission = false;
desiredLatch = false;
desiredCurrentA = currentMinA;
state = 'stop_source_lost';
currentDecision = 'stop';
dynamicOk = false;
mustStop = true;
nextBlockReason = profile === 'outside_window' ? 'outside_window' : 'source_unavailable';
nextBlockedUntilStamp =
profile === 'outside_window' && nextAllowedWindowStartMs !== null
? formatParisStampFromMs(nextAllowedWindowStartMs)
: formatParisStampFromMs(nowMs + minStopMinutes * 60000);
reason.push("Source autorisée perdue");
} else {
desiredCharging = true;
desiredPermission = true;
desiredLatch = true;
desiredCurrentA = clamp(Math.min(currentReferenceA, physicalCapA), currentMinA, physicalCapA);
state = 'hold_min_run_source_lost';
currentDecision = 'hold';
dynamicOk = false;
mustStop = false;
reason.push("Source perdue mais maintien jusqu'au mini ON");
}
}
else if (!holdConditionsOk) {
if (minRunElapsed) {
desiredCharging = false;
desiredPermission = false;
desiredLatch = false;
desiredCurrentA = currentMinA;
state = 'stop_conditions_lost';
currentDecision = 'stop';
dynamicOk = false;
mustStop = true;
nextBlockReason = 'battery_protection_stop';
nextBlockedUntilStamp = formatParisStampFromMs(nowMs + 10 * 60000);
reason.push("Conditions de maintien perdues");
} else {
desiredCharging = true;
desiredPermission = true;
desiredLatch = true;
desiredCurrentA = clamp(Math.min(currentReferenceA, physicalCapA), currentMinA, physicalCapA);
state = 'hold_min_run';
currentDecision = 'hold';
dynamicOk = true;
mustStop = false;
reason.push("Maintien jusqu'au temps mini ON");
}
}
else if ((currentReferenceA > physicalCapA) && currentCooldownElapsed) {
desiredCharging = true;
desiredPermission = true;
desiredLatch = true;
desiredCurrentA = Math.max(
currentMinA,
Math.min(currentReferenceA - Math.max(currentStepDownA, 2), physicalCapA)
);
state = 'charging_step_down_system_cap';
currentDecision = 'step_down';
dynamicOk = true;
reason.push("Réduction pour respecter la capacité système");
}
else if (shouldStepDownVebusPower) {
desiredCharging = true;
desiredPermission = true;
desiredLatch = true;
desiredCurrentA = Math.max(
currentMinA,
Math.min(currentReferenceA - Math.max(currentStepDownA + 1, 3), physicalCapA)
);
state = 'charging_step_down_vebus_power';
currentDecision = 'step_down_fast';
dynamicOk = true;
reason.push("Baisse rapide: puissance VE.Bus élevée");
}
else if (shouldStepDownVebusVoltage) {
desiredCharging = true;
desiredPermission = true;
desiredLatch = true;
desiredCurrentA = Math.max(
currentMinA,
Math.min(currentReferenceA - Math.max(currentStepDownA + 1, 3), physicalCapA)
);
state = 'charging_step_down_dc_voltage';
currentDecision = 'step_down_fast';
dynamicOk = true;
reason.push("Baisse rapide: tension DC basse");
}
else if (activeSourceMode === 'grid_hc_allowed' && gridShouldStepDownBattery) {
desiredCharging = true;
desiredPermission = true;
desiredLatch = true;
desiredCurrentA = Math.max(
currentMinA,
Math.min(currentReferenceA - currentStepDownA, physicalCapA)
);
state = 'charging_step_down_grid_guard';
currentDecision = 'step_down';
dynamicOk = true;
reason.push("Réduction HC: la batterie commence à participer");
}
else if (activeSourceMode === 'solar_only' && solarShouldStepDownCloudDebt) {
desiredCharging = true;
desiredPermission = true;
desiredLatch = true;
desiredCurrentA = Math.max(
currentMinA,
Math.min(currentReferenceA - Math.max(currentStepDownA, 2), physicalCapA)
);
state = 'charging_step_down_cloud_debt';
currentDecision = 'step_down';
dynamicOk = true;
reason.push("Réduction: dette nuage trop élevée");
}
else if (activeSourceMode === 'solar_only' && solarShouldStepDownBattery) {
desiredCharging = true;
desiredPermission = true;
desiredLatch = true;
desiredCurrentA = Math.max(
currentMinA,
Math.min(currentReferenceA - currentStepDownA, physicalCapA)
);
state = 'charging_step_down_battery';
currentDecision = 'step_down';
dynamicOk = true;
reason.push("Réduction: batterie trop sollicitée");
}
else if (activeSourceMode === 'grid_hc_allowed' && gridShouldStepUp) {
desiredCharging = true;
desiredPermission = true;
desiredLatch = true;
desiredCurrentA = clamp(currentReferenceA + currentStepUpA, currentMinA, physicalCapA);
state = 'charging_step_up_grid_hc';
currentDecision = 'step_up';
dynamicOk = true;
reason.push("Hausse prudente du courant en HC réseau");
}
else if (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') { // Potentiel autorise step up aussi
desiredCharging = true;
desiredPermission = true;
desiredLatch = true;
desiredCurrentA = clamp(currentReferenceA + currentStepUpA, currentMinA, physicalCapA);
state = probeSuccessNow
? 'probe_success_step_up'
: (batteryPriorityMode ? 'charging_step_up_battery' : 'charging_step_up_forecast');
currentDecision = 'step_up';
dynamicOk = true;
reason.push("Hausse prudente du courant");
}
else {
desiredCharging = true;
desiredPermission = true;
desiredLatch = true;
desiredCurrentA = clamp(Math.min(currentReferenceA, physicalCapA), currentMinA, physicalCapA);
state =
probeSuccessNow ? 'probe_success_hold' :
activeSourceMode === 'grid_hc_allowed' ? 'charging_hold_grid_hc' :
cloudDebtActive ? 'charging_hold_cloud_tolerance' :
'charging_hold';
currentDecision = 'hold';
dynamicOk = true;
mustStop = false;
reason.push("Maintien du courant");
}
}
}
else {
const startConditionsOk =
(sourceMode === 'grid_hc_allowed' || sourceMode === 'grid_forced') ? gridStartOk : // Ajout de 'grid_forced'
sourceMode === 'solar_only' || sourceMode === 'solar_potential' ? solarStartOk : // Potentiel autorise démarrage aussi
sourceMode === 'hybrid_probe' ? probeStartOk :
false;
if (profile === 'outside_window') {
// outside_window n'est plus un mur absolu en mode force ou avec potentiel
if (sourceMode === 'solar_potential' || isForceMode) {
// Continue à chercher startConditionsOk...
} else {
desiredCharging = false;
desiredPermission = false;
desiredLatch = false;
desiredCurrentA = currentMinA;
state = 'blocked_outside_window';
currentDecision = 'hold';
dynamicOk = false;
mustStop = false;
nextBlockReason = 'outside_window';
nextBlockedUntilStamp =
nextAllowedWindowStartMs !== null
? formatParisStampFromMs(nextAllowedWindowStartMs)
: now.stamp;
reason.push("Hors fenêtre autorisée (pas de potentiel confirmé ni forcé)");
}
}
else if (!canRunAtMinCurrent) {
desiredCharging = false;
desiredPermission = false;
desiredLatch = false;
desiredCurrentA = currentMinA;
state = 'blocked_by_capacity_min_current';
currentDecision = 'hold';
dynamicOk = false;
mustStop = false;
nextBlockReason = 'battery_protection_stop';
nextBlockedUntilStamp = formatParisStampFromMs(nowMs + 10 * 60000);
reason.push("Capacité insuffisante pour courant mini");
}
else if (sourceMode === 'blocked') {
desiredCharging = false;
desiredPermission = false;
desiredLatch = false;
desiredCurrentA = currentMinA;
state = profile === 'night_hc' ? 'night_no_grid_hc_lock' : 'idle_wait_source';
currentDecision = 'hold';
dynamicOk = false;
mustStop = false;
nextBlockReason = profile === 'night_hc' ? 'outside_window' : 'source_unavailable';
nextBlockedUntilStamp =
profile === 'night_hc' && nextAllowedWindowStartMs !== null
? formatParisStampFromMs(nextAllowedWindowStartMs)
: formatParisStampFromMs(nowMs + 15 * 60000);
reason.push(profile === 'night_hc' ? "Nuit sans autorisation HC réseau" : "Source non autorisée ou insuffisante");
}
else if (startBlockedByReason) {
desiredCharging = false;
desiredPermission = false;
desiredLatch = false;
desiredCurrentA = currentMinA;
state = 'wait_reason_lock';
currentDecision = 'hold';
dynamicOk = false;
mustStop = false;
nextBlockReason = lastBlockReason;
nextBlockedUntilStamp = blockedUntilAt || now.stamp;
reason.push(`Blocage anti-cliquetis: ${lastBlockReason}`);
}
else if (!startConditionsOk) {
desiredCharging = false;
desiredPermission = false;
desiredLatch = false;
desiredCurrentA = currentMinA;
state = 'idle_wait_conditions';
currentDecision = 'hold';
dynamicOk = false;
mustStop = false;
reason.push("Conditions de démarrage non réunies");
}
else if (!minStopElapsed) {
desiredCharging = false;
desiredPermission = false;
desiredLatch = false;
desiredCurrentA = currentMinA;
state = 'wait_min_stop';
currentDecision = 'hold';
dynamicOk = false;
mustStop = false;
reason.push("Temps mini OFF non écoulé");
}
else {
desiredCharging = true;
desiredPermission = true;
desiredLatch = true;
desiredCurrentA = sourceMode === 'hybrid_probe'
? clamp(probeCurrentA, currentMinA, physicalCapA)
: currentMinA;
state =
sourceMode === 'grid_hc_allowed' ? 'start_grid_hc_min_current' :
sourceMode === 'hybrid_probe' ? 'probe_start_6a' :
sourceMode === 'solar_potential' ? 'start_forecast_solar_potential' : // NOUVEAU STATE
batteryFullHarvestMode ? 'start_min_current_battery_full_harvest' :
forecastCommitMode ? 'start_min_current_forecast_commit' :
'start_min_current';
currentDecision = 'start';
dynamicOk = true;
mustStop = false;
nextBlockReason = 'none'; // Clean blocking logic: set reason to none on start
nextBlockedUntilStamp = now.stamp;
setProbeStartedAt = sourceMode === 'hybrid_probe';
reason.push(
sourceMode === 'grid_hc_allowed'
? "Démarrage en fenêtre HC réseau"
: sourceMode === 'hybrid_probe'
? "Démarrage en mode sonde"
: sourceMode === 'solar_potential'
? "Démarrage par potentiel solaire confirmé (SOC>=85%)" // NOUVEAU REASON TEXT
: batteryFullHarvestMode
? "Démarrage harvest solaire"
: forecastCommitMode
? "Démarrage forecast commit"
: "Démarrage prudent à courant mini"
);
}
}

View File

@@ -0,0 +1,155 @@
// === DECISION INIT ===
let desiredCharging = false;
let desiredPermission = false;
let desiredLatch = false;
let desiredCurrentA = currentMinA;
let state = 'idle';
let currentDecision = 'hold';
let dynamicOk = false;
let mustStop = false;
let reason = [];
let setLastStartAt = false;
let setLastStopAt = false;
let setLastCurrentChangeAt = false;
let setCloudDebtStartedAt = false;
let setProbeStartedAt = false;
let notifyWanted = false;
let notifyTitle = 'Victron EVCS Surplus';
let notifyMessage = '';
let nextBlockReason = 'none';
let nextBlockedUntilStamp = now.stamp;
// === DECISION TREE (WITH STRATEGY LAYERS) ===
if (!controllerEnabled) {
desiredCharging = evcsChargingSwitchOn;
desiredPermission = chargePermissionNow;
desiredLatch = controlLatch;
desiredCurrentA = clamp(currentReferenceA || currentMinA, currentMinA, currentMaxAutoA);
state = 'controller_disabled';
reason.push("Contrôleur désactivé");
}
else if (isStopMode) {
desiredCharging = false;
desiredPermission = false;
desiredLatch = false;
desiredCurrentA = currentMinA;
state = 'stopped_user';
currentDecision = 'stop_stop_mode';
dynamicOk = false;
mustStop = false;
nextBlockReason = 'manual_stop';
nextBlockedUntilStamp = now.stamp;
reason.push("Arrêt manuel via Stratégie STOP");
}
else if (evcsModeIsAuto) {
desiredCharging = false;
desiredPermission = false;
desiredLatch = false;
desiredCurrentA = currentMinA;
state = 'mode_auto_conflict';
currentDecision = 'stop_auto_mode_conflict';
dynamicOk = false;
mustStop = true;
reason.push("Borne en mode Auto interne");
}
else if (!evcsDeviceConnected || !evcsVehicleConnected) {
desiredCharging = false;
desiredPermission = false;
desiredLatch = false;
desiredCurrentA = currentMinA;
state = 'vehicle_not_connected';
currentDecision = 'stop_not_connected';
dynamicOk = false;
mustStop = false;
nextBlockReason = 'vehicle_not_connected';
nextBlockedUntilStamp = now.stamp;
reason.push("Véhicule ou borne non connecté");
}
else if (mustStopHard) {
desiredCharging = false;
desiredPermission = false;
desiredLatch = false;
desiredCurrentA = currentMinA;
state =
anyVebusOverload ? 'vebus_overload_hard_stop' :
anyVebusLowBattery ? 'vebus_low_battery_hard_stop' :
vebusDcVoltageHardLow ? 'vebus_dc_voltage_hard_stop' :
vebusAcOutHardReached ? 'vebus_ac_hard_stop' :
batteryCurrentHardExceeded ? 'battery_current_hard_stop' :
'hard_stop';
currentDecision = 'stop_hard';
dynamicOk = false;
mustStop = true;
nextBlockReason = 'battery_protection_stop';
nextBlockedUntilStamp = formatParisStampFromMs(nowMs + 15 * 60000);
reason.push("Hard stop de protection");
}
else if (sessionObservedActive) {
if (enteringCloudDebt) {
setCloudDebtStartedAt = true;
}
if (gridBatteryProtectionStop) {
desiredCharging = false;
desiredPermission = false;
desiredLatch = false;
desiredCurrentA = currentMinA;
state = 'stop_grid_battery_guard';
currentDecision = 'stop';
dynamicOk = false;
mustStop = true;
nextBlockReason = 'grid_battery_guard';
nextBlockedUntilStamp = formatParisStampFromMs(nowMs + 20 * 60000);
reason.push(`Arrêt HC réseau: batterie trop sollicitée (${round1(batteryCurrentA)}A)`);
}
else if (probeFailedNow) {
desiredCharging = false;
desiredPermission = false;
desiredLatch = false;
desiredCurrentA = currentMinA;
state = 'probe_failed_lock';
currentDecision = 'stop';
dynamicOk = false;
mustStop = true;
nextBlockReason = 'probe_failed';
nextBlockedUntilStamp = formatParisStampFromMs(nowMs + probeRetryMinutes * 60000);
reason.push("Mode sonde échoué");
}
else if (physicalCapA < currentMinA) {
if (minRunElapsed) {
desiredCharging = false;
desiredPermission = false;
desiredLatch = false;
desiredCurrentA = currentMinA;
state = 'stop_power_cap';
currentDecision = 'stop_power_cap';
dynamicOk = false;
mustStop = true;
nextBlockReason = 'battery_protection_stop';
nextBlockedUntilStamp = formatParisStampFromMs(nowMs + 10 * 60000);
reason.push("Capacité système insuffisante");
} else {
desiredCharging = true;
desiredPermission = true;
desiredLatch = true;
desiredCurrentA = currentMinA;
state = 'hold_min_run_power_cap';
currentDecision = 'hold';
dynamicOk = false;
mustStop = false;
reason.push("Maintien jusqu'au temps mini ON");
}
}
else if (probeWindowActive && !probeSuccessNow) {
if (!probeHoldGuardOk) {
desiredCharging = false;
desiredPermission = false;
desiredLatch = false;
desiredCurrentA = currentMinA;
state = 'probe_stop_guard';
currentDecision = 'stop';
dynamicOk = false;
mustStop = true;
nextBlockReason = 'probe_failed';
nextBlockedUntilStamp = formatParisStampFromMs(nowMs + probeRetryMinutes * 60000);
reason.push("Sonde interrompue: garde-fous non respectés");
} else {

View File

@@ -0,0 +1,155 @@
desiredPermission = true;
desiredLatch = true;
desiredCurrentA = clamp(probeCurrentA, currentMinA, physicalCapA);
state = 'probe_hold';
currentDecision = 'hold';
dynamicOk = true;
mustStop = false;
reason.push(`Mode sonde actif à ${round1(desiredCurrentA)}A`);
}
}
else {
const activeSourceMode = probeSuccessNow ? 'solar_only' : sourceMode;
const holdConditionsOk =
activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced' ? gridHoldOk : // Ajout ici aussi
activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential' ? solarHoldOk :
probeHoldGuardOk;
if (activeSourceMode === 'blocked') {
if (minRunElapsed) {
desiredCharging = false;
desiredPermission = false;
desiredLatch = false;
desiredCurrentA = currentMinA;
state = 'stop_source_lost';
currentDecision = 'stop';
dynamicOk = false;
mustStop = true;
nextBlockReason = profile === 'outside_window' ? 'outside_window' : 'source_unavailable';
nextBlockedUntilStamp =
profile === 'outside_window' && nextAllowedWindowStartMs !== null
? formatParisStampFromMs(nextAllowedWindowStartMs)
: formatParisStampFromMs(nowMs + minStopMinutes * 60000);
reason.push("Source autorisée perdue");
} else {
desiredCharging = true;
desiredPermission = true;
desiredLatch = true;
desiredCurrentA = clamp(Math.min(currentReferenceA, physicalCapA), currentMinA, physicalCapA);
state = 'hold_min_run_source_lost';
currentDecision = 'hold';
dynamicOk = false;
mustStop = false;
reason.push("Source perdue mais maintien jusqu'au mini ON");
}
}
else if (!holdConditionsOk) {
if (minRunElapsed) {
desiredCharging = false;
desiredPermission = false;
desiredLatch = false;
desiredCurrentA = currentMinA;
state = 'stop_conditions_lost';
currentDecision = 'stop';
dynamicOk = false;
mustStop = true;
nextBlockReason = 'battery_protection_stop';
nextBlockedUntilStamp = formatParisStampFromMs(nowMs + 10 * 60000);
reason.push("Conditions de maintien perdues");
} else {
desiredCharging = true;
desiredPermission = true;
desiredLatch = true;
desiredCurrentA = clamp(Math.min(currentReferenceA, physicalCapA), currentMinA, physicalCapA);
state = 'hold_min_run';
currentDecision = 'hold';
dynamicOk = true;
mustStop = false;
reason.push("Maintien jusqu'au temps mini ON");
}
}
else if ((currentReferenceA > physicalCapA) && currentCooldownElapsed) {
desiredCharging = true;
desiredPermission = true;
desiredLatch = true;
desiredCurrentA = Math.max(
currentMinA,
Math.min(currentReferenceA - Math.max(currentStepDownA, 2), physicalCapA)
);
state = 'charging_step_down_system_cap';
currentDecision = 'step_down';
dynamicOk = true;
reason.push("Réduction pour respecter la capacité système");
}
else if (shouldStepDownVebusPower) {
desiredCharging = true;
desiredPermission = true;
desiredLatch = true;
desiredCurrentA = Math.max(
currentMinA,
Math.min(currentReferenceA - Math.max(currentStepDownA + 1, 3), physicalCapA)
);
state = 'charging_step_down_vebus_power';
currentDecision = 'step_down_fast';
dynamicOk = true;
reason.push("Baisse rapide: puissance VE.Bus élevée");
}
else if (shouldStepDownVebusVoltage) {
desiredCharging = true;
desiredPermission = true;
desiredLatch = true;
desiredCurrentA = Math.max(
currentMinA,
Math.min(currentReferenceA - Math.max(currentStepDownA + 1, 3), physicalCapA)
);
state = 'charging_step_down_dc_voltage';
currentDecision = 'step_down_fast';
dynamicOk = true;
reason.push("Baisse rapide: tension DC basse");
}
else if (activeSourceMode === 'grid_hc_allowed' && gridShouldStepDownBattery) {
desiredCharging = true;
desiredPermission = true;
desiredLatch = true;
desiredCurrentA = Math.max(
currentMinA,
Math.min(currentReferenceA - currentStepDownA, physicalCapA)
);
state = 'charging_step_down_grid_guard';
currentDecision = 'step_down';
dynamicOk = true;
reason.push("Réduction HC: la batterie commence à participer");
}
else if (activeSourceMode === 'solar_only' && solarShouldStepDownCloudDebt) {
desiredCharging = true;
desiredPermission = true;
desiredLatch = true;
desiredCurrentA = Math.max(
currentMinA,
Math.min(currentReferenceA - Math.max(currentStepDownA, 2), physicalCapA)
);
state = 'charging_step_down_cloud_debt';
currentDecision = 'step_down';
dynamicOk = true;
reason.push("Réduction: dette nuage trop élevée");
}
else if (activeSourceMode === 'solar_only' && solarShouldStepDownBattery) {
desiredCharging = true;
desiredPermission = true;
desiredLatch = true;
desiredCurrentA = Math.max(
currentMinA,
Math.min(currentReferenceA - currentStepDownA, physicalCapA)
);
state = 'charging_step_down_battery';
currentDecision = 'step_down';
dynamicOk = true;
reason.push("Réduction: batterie trop sollicitée");
}
else if (activeSourceMode === 'grid_hc_allowed' && gridShouldStepUp) {
desiredCharging = true;
desiredPermission = true;
desiredLatch = true;
desiredCurrentA = clamp(currentReferenceA + currentStepUpA, currentMinA, physicalCapA);
state = 'charging_step_up_grid_hc';
currentDecision = 'step_up';
dynamicOk = true;

View File

@@ -0,0 +1,156 @@
}
else if (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') { // Potentiel autorise step up aussi
desiredCharging = true;
desiredPermission = true;
desiredLatch = true;
desiredCurrentA = clamp(currentReferenceA + currentStepUpA, currentMinA, physicalCapA);
state = probeSuccessNow
? 'probe_success_step_up'
: (batteryPriorityMode ? 'charging_step_up_battery' : 'charging_step_up_forecast');
currentDecision = 'step_up';
dynamicOk = true;
reason.push("Hausse prudente du courant");
}
else {
desiredCharging = true;
desiredPermission = true;
desiredLatch = true;
desiredCurrentA = clamp(Math.min(currentReferenceA, physicalCapA), currentMinA, physicalCapA);
state =
probeSuccessNow ? 'probe_success_hold' :
activeSourceMode === 'grid_hc_allowed' ? 'charging_hold_grid_hc' :
cloudDebtActive ? 'charging_hold_cloud_tolerance' :
'charging_hold';
currentDecision = 'hold';
dynamicOk = true;
mustStop = false;
reason.push("Maintien du courant");
}
}
}
else {
const startConditionsOk =
(sourceMode === 'grid_hc_allowed' || sourceMode === 'grid_forced') ? gridStartOk : // Ajout de 'grid_forced'
sourceMode === 'solar_only' || sourceMode === 'solar_potential' ? solarStartOk : // Potentiel autorise démarrage aussi
sourceMode === 'hybrid_probe' ? probeStartOk :
false;
if (profile === 'outside_window') {
// outside_window n'est plus un mur absolu en mode force ou avec potentiel
if (sourceMode === 'solar_potential' || isForceMode) {
// Continue à chercher startConditionsOk...
} else {
desiredCharging = false;
desiredPermission = false;
desiredLatch = false;
desiredCurrentA = currentMinA;
state = 'blocked_outside_window';
currentDecision = 'hold';
dynamicOk = false;
mustStop = false;
nextBlockReason = 'outside_window';
nextBlockedUntilStamp =
nextAllowedWindowStartMs !== null
? formatParisStampFromMs(nextAllowedWindowStartMs)
: now.stamp;
reason.push("Hors fenêtre autorisée (pas de potentiel confirmé ni forcé)");
}
}
else if (!canRunAtMinCurrent) {
desiredCharging = false;
desiredPermission = false;
desiredLatch = false;
desiredCurrentA = currentMinA;
state = 'blocked_by_capacity_min_current';
currentDecision = 'hold';
dynamicOk = false;
mustStop = false;
nextBlockReason = 'battery_protection_stop';
nextBlockedUntilStamp = formatParisStampFromMs(nowMs + 10 * 60000);
reason.push("Capacité insuffisante pour courant mini");
}
else if (sourceMode === 'blocked') {
desiredCharging = false;
desiredPermission = false;
desiredLatch = false;
desiredCurrentA = currentMinA;
state = profile === 'night_hc' ? 'night_no_grid_hc_lock' : 'idle_wait_source';
currentDecision = 'hold';
dynamicOk = false;
mustStop = false;
nextBlockReason = profile === 'night_hc' ? 'outside_window' : 'source_unavailable';
nextBlockedUntilStamp =
profile === 'night_hc' && nextAllowedWindowStartMs !== null
? formatParisStampFromMs(nextAllowedWindowStartMs)
: formatParisStampFromMs(nowMs + 15 * 60000);
reason.push(profile === 'night_hc' ? "Nuit sans autorisation HC réseau" : "Source non autorisée ou insuffisante");
}
else if (startBlockedByReason) {
desiredCharging = false;
desiredPermission = false;
desiredLatch = false;
desiredCurrentA = currentMinA;
state = 'wait_reason_lock';
currentDecision = 'hold';
dynamicOk = false;
mustStop = false;
nextBlockReason = lastBlockReason;
nextBlockedUntilStamp = blockedUntilAt || now.stamp;
reason.push(`Blocage anti-cliquetis: ${lastBlockReason}`);
}
else if (!startConditionsOk) {
desiredCharging = false;
desiredPermission = false;
desiredLatch = false;
desiredCurrentA = currentMinA;
state = 'idle_wait_conditions';
currentDecision = 'hold';
dynamicOk = false;
mustStop = false;
reason.push("Conditions de démarrage non réunies");
}
else if (!minStopElapsed) {
desiredCharging = false;
desiredPermission = false;
desiredLatch = false;
desiredCurrentA = currentMinA;
state = 'wait_min_stop';
currentDecision = 'hold';
dynamicOk = false;
mustStop = false;
reason.push("Temps mini OFF non écoulé");
}
else {
desiredCharging = true;
desiredPermission = true;
desiredLatch = true;
desiredCurrentA = sourceMode === 'hybrid_probe'
? clamp(probeCurrentA, currentMinA, physicalCapA)
: currentMinA;
state =
sourceMode === 'grid_hc_allowed' ? 'start_grid_hc_min_current' :
sourceMode === 'hybrid_probe' ? 'probe_start_6a' :
sourceMode === 'solar_potential' ? 'start_forecast_solar_potential' : // NOUVEAU STATE
batteryFullHarvestMode ? 'start_min_current_battery_full_harvest' :
forecastCommitMode ? 'start_min_current_forecast_commit' :
'start_min_current';
currentDecision = 'start';
dynamicOk = true;
mustStop = false;
nextBlockReason = 'none'; // Clean blocking logic: set reason to none on start
nextBlockedUntilStamp = now.stamp;
setProbeStartedAt = sourceMode === 'hybrid_probe';
reason.push(
sourceMode === 'grid_hc_allowed'
? "Démarrage en fenêtre HC réseau"
: sourceMode === 'hybrid_probe'
? "Démarrage en mode sonde"
: sourceMode === 'solar_potential'
? "Démarrage par potentiel solaire confirmé (SOC>=85%)" // NOUVEAU REASON TEXT
: batteryFullHarvestMode
? "Démarrage harvest solaire"
: forecastCommitMode
? "Démarrage forecast commit"
: "Démarrage prudent à courant mini"
);
}
}

View File

@@ -0,0 +1,381 @@
// === FINAL ACTIONS & BLOQUAGE PROPRE ===
desiredCurrentA = desiredCharging
? clamp(desiredCurrentA, currentMinA, Math.max(currentMinA, physicalCapA || currentMinA))
: currentMinA;
const chargingAction =
desiredCharging === evcsChargingSwitchOn
? 'none'
: desiredCharging ? 'turn_on' : 'turn_off';
const permissionAction =
desiredPermission === chargePermissionNow
? 'none'
: desiredPermission ? 'turn_on' : 'turn_off';
const latchAction =
desiredLatch === controlLatch
? 'none'
: desiredLatch ? 'turn_on' : 'turn_off';
const autoStartAction =
controllerEnabled &&
evcsVehicleConnected &&
evcsAutoStartSwitchOn
? 'turn_off'
: 'none';
const currentAction =
desiredCharging &&
(
!Number.isFinite(currentReferenceA) ||
Math.abs(desiredCurrentA - currentReferenceA) >= 0.5
)
? 'set'
: 'none';
setLastStartAt = chargingAction === 'turn_on';
setLastStopAt = chargingAction === 'turn_off';
setLastCurrentChangeAt = currentAction === 'set';
// === BLOQUAGE PROPRE (Clean Blocking Logic) ===
const needsBlockReasonUpdate = (nextBlockReason !== 'none');
// === REASONS / SIGNATURE ===
const strategyLabel = isForceMode ? userStrategy : (userStrategy === 'auto_solar_plus' ? 'auto_plus' : 'auto');
reason.push(`Stratégie=${strategyLabel}`);
reason.push(`Profil=${profile}`);
reason.push(`Source=${sourceMode}`);
reason.push(`Mode=${batteryFullHarvestMode ? 'battery_full_harvest_mode' : forecastCommitMode ? 'forecast_commit_mode' : 'battery_priority_mode'}`);
reason.push(`SOC=${round1(soc)}%`);
reason.push(`Batt=${round1(batteryCurrentA)}A`);
reason.push(`PV=${Math.round(pvPowerW)}W`);
reason.push(`PV10=${Math.round(pvPowerAvg10mW)}W`);
reason.push(`Grid=${gridIsOn ? 'on' : 'off'} (${Math.round(gridPowerW)}W)`);
reason.push(`Cap=${physicalCapA}A`);
reason.push(`Ref=${round1(currentReferenceA)}A`);
reason.push(`Target=${round1(desiredCurrentA)}A`);
reason.push(`Debt=${round2(cloudDebtNextKwh)}kWh`);
reason.push(`Vehicle=${evcsVehicleConnected ? 'on' : 'off'}`);
reason.push(`EVCS mode=${evcsModeRaw}`);
reason.push(`VE.Bus AC=${Math.round(vebusAcOutPowerW)}W`);
reason.push(`VE.Bus DC=${Number.isFinite(vebusDcVoltageV) ? round1(vebusDcVoltageV) : 'n/a'}V`);
const shortReason = reason.join(' | ').slice(0, 255);
const sourceModeLabel =
sourceMode === 'blocked' ? 'Bloqué' :
sourceMode === 'solar_only' ? 'Solaire' :
sourceMode === 'solar_potential' ? 'Potentiel solaire' :
sourceMode === 'hybrid_probe' ? 'Probe hybride' :
sourceMode === 'grid_hc_allowed' ? 'Réseau HC autorisé' :
sourceMode === 'grid_forced' ? 'Réseau forcé' :
sourceMode;
const signature = [
state,
`profile=${profile}`,
`source=${sourceMode}`,
`chg=${desiredCharging ? '1' : '0'}`,
`perm=${desiredPermission ? '1' : '0'}`,
`l=${desiredLatch ? '1' : '0'}`,
`cur=${round1(desiredCurrentA)}`,
`block=${nextBlockReason}`,
`stop=${mustStop ? '1' : '0'}`
].join('|');
const signatureChanged = signature !== lastSignature;
// === NOTIFICATIONS ===
if (notificationsEnabled && signatureChanged) {
if (currentDecision === 'start') {
notifyWanted = true;
notifyTitle = '⚡ EVCS démarrage';
notifyMessage = shortReason;
} else if (currentDecision === 'step_up') {
notifyWanted = true;
notifyTitle = '⬆️ EVCS hausse courant';
notifyMessage = shortReason;
} else if (currentDecision === 'step_down' || currentDecision === 'step_down_fast') {
notifyWanted = true;
notifyTitle = '⬇️ EVCS baisse courant';
notifyMessage = shortReason;
} else if (mustStop || currentDecision === 'stop' || currentDecision === 'stop_hard' || currentDecision === 'stop_power_cap') {
notifyWanted = true;
notifyTitle = '⛔ EVCS arrêt';
notifyMessage = shortReason;
} else if (state === 'mode_auto_conflict') {
notifyWanted = true;
notifyTitle = '⚠️ EVCS mode Auto';
notifyMessage = shortReason;
}
}
// === OUTPUT ===
return [{
json: {
timestamp_paris: now.stamp,
inputs: input,
computed: {
operating_mode: operatingMode,
profile,
source_mode: sourceMode,
in_night_hc_window: inNightHcWindow,
in_morning_window: inMorningWindow,
in_midday_window: inMiddayWindow,
in_evening_window: inEveningWindow,
departure_priority_active: departurePriorityActive,
minutes_until_departure: minutesUntilDeparture,
battery_priority_mode: batteryPriorityMode,
battery_full_harvest_mode: batteryFullHarvestMode,
forecast_commit_mode: forecastCommitMode,
physical_cap_current_a: physicalCapA,
can_run_at_min_current: canRunAtMinCurrent,
forecast_buffer_kwh: round2(forecastBufferKwh),
projected_end_soc_after_charge: round1(projectedEndSocAfterCharge),
cloud_debt_next_kwh: round2(cloudDebtNextKwh),
cloud_debt_duration_minutes: round1(cloudDebtDurationMinutes),
cloud_debt_active: cloudDebtActive,
cloud_tolerance_ok: cloudToleranceOk,
start_blocked_by_reason: startBlockedByReason,
probe_window_active: probeWindowActive,
probe_success_now: probeSuccessNow,
probe_failed_now: probeFailedNow,
any_vebus_overload: anyVebusOverload,
any_vebus_low_battery: anyVebusLowBattery,
vebus_ac_out_soft_reached: vebusAcOutSoftReached,
vebus_ac_out_hard_reached: vebusAcOutHardReached,
vebus_dc_voltage_soft_low: vebusDcVoltageSoftLow,
vebus_dc_voltage_hard_low: vebusDcVoltageHardLow,
},
decision: {
state,
current_decision: currentDecision,
desired_charging: desiredCharging,
desired_permission: desiredPermission,
desired_latch: 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: setCloudDebtStartedAt,
set_probe_started_at: setProbeStartedAt,
// MODIFIÉ: Ne met à jour que si besoin réel
set_block_reason: needsBlockReasonUpdate || !controllerEnabled,
block_reason: nextBlockReason,
// FORÇAGE : Mettre à jour systématiquement pour éviter les timestamps passés
set_blocked_until: true,
// Si blocage actif = heure futur. Si pas de blocage = Maintenant (pour effacer l'ancien).
blocked_until: nextBlockReason === 'none' ? now.stamp : nextBlockedUntilStamp,
dynamic_ok: dynamicOk,
must_stop: mustStop,
short_reason: shortReason,
reason,
signature,
signature_changed: signatureChanged,
should_notify: notifyWanted,
notify_title: notifyTitle,
notify_message: notifyMessage,
},
ha_service_calls: {
helper_state: {
domain: 'input_text',
service: 'set_value',
data: {
entity_id: 'input_text.victron_evcs_surplus_state',
value: state
}
},
helper_current_decision: {
domain: 'input_text',
service: 'set_value',
data: {
entity_id: 'input_text.victron_evcs_surplus_current_decision',
value: currentDecision
}
},
helper_reason: {
domain: 'input_text',
service: 'set_value',
data: {
entity_id: 'input_text.victron_evcs_surplus_reason',
value: shortReason
}
},
helper_operating_mode: {
domain: 'input_text',
service: 'set_value',
data: {
entity_id: 'input_text.victron_evcs_operating_mode',
value: operatingMode
}
},
helper_target_current: {
domain: 'input_number',
service: 'set_value',
data: {
entity_id: 'input_number.victron_evcs_target_current_auto',
value: round1(desiredCurrentA)
}
},
helper_budget_remaining: {
domain: 'input_number',
service: 'set_value',
data: {
entity_id: 'input_number.victron_evcs_budget_remaining_kwh',
value: round2(forecastBufferKwh)
}
},
helper_projected_soc: {
domain: 'input_number',
service: 'set_value',
data: {
entity_id: 'input_number.victron_evcs_projected_end_soc_after_charge',
value: round1(projectedEndSocAfterCharge)
}
},
helper_cloud_debt: {
domain: 'input_number',
service: 'set_value',
data: {
entity_id: 'input_number.victron_evcs_cloud_debt_kwh',
value: round2(cloudDebtNextKwh)
}
},
helper_dynamic_ok: {
domain: 'input_boolean',
service: dynamicOk ? 'turn_on' : 'turn_off',
data: {
entity_id: 'input_boolean.victron_evcs_dynamic_ok'
}
},
helper_must_stop: {
domain: 'input_boolean',
service: mustStop ? 'turn_on' : 'turn_off',
data: {
entity_id: 'input_boolean.victron_evcs_must_stop'
}
},
helper_cloud_debt_started_at: {
domain: 'input_datetime',
service: 'set_datetime',
data: {
entity_id: 'input_datetime.victron_evcs_cloud_debt_started_at',
datetime: cloudDebtStartedMs ? formatParisStampFromMs(cloudDebtStartedMs) : now.stamp
}
},
helper_last_current_change_at: {
domain: 'input_datetime',
service: 'set_datetime',
data: {
entity_id: 'input_datetime.victron_evcs_last_current_change_at',
datetime: now.stamp
}
},
helper_last_start_at: {
domain: 'input_datetime',
service: 'set_datetime',
data: {
entity_id: 'input_datetime.victron_evcs_last_start_at',
datetime: now.stamp
}
},
helper_last_stop_at: {
domain: 'input_datetime',
service: 'set_datetime',
data: {
entity_id: 'input_datetime.victron_evcs_last_stop_at',
datetime: now.stamp
}
},
helper_signature: {
domain: 'input_text',
service: 'set_value',
data: {
entity_id: 'input_text.victron_evcs_control_last_signature',
value: signature
}
},
helper_block_reason: {
domain: 'input_text',
service: 'set_value',
data: {
entity_id: 'input_text.victron_evcs_last_block_reason',
value: nextBlockReason
}
},
helper_blocked_until: {
domain: 'input_datetime',
service: 'set_datetime',
data: {
entity_id: 'input_datetime.victron_evcs_blocked_until',
datetime: nextBlockedUntilStamp
}
},
helper_probe_started_at: {
domain: 'input_datetime',
service: 'set_datetime',
data: {
entity_id: 'input_datetime.victron_evcs_probe_started_at',
datetime: now.stamp
}
},
evcs_auto_start_off: {
domain: 'switch',
service: 'turn_off',
data: {
entity_id: 'switch.victron_evcs_auto_start'
}
},
evcs_charging: {
domain: 'switch',
service: desiredCharging ? 'turn_on' : 'turn_off',
data: {
entity_id: 'switch.victron_evcs_charging'
}
},
helper_permission: {
domain: 'input_boolean',
service: desiredPermission ? 'turn_on' : 'turn_off',
data: {
entity_id: 'input_boolean.victron_evcs_charge_permission_now'
}
},
helper_latch: {
domain: 'input_boolean',
service: desiredLatch ? 'turn_on' : 'turn_off',
data: {
entity_id: 'input_boolean.victron_evcs_control_latch'
}
},
helper_source_mode: {
execute: true,
domain: 'input_text',
service: 'set_value',
data: {
entity_id: 'input_text.victron_evcs_source_mode',
value: sourceModeLabel
}
},
evcs_set_current: {
domain: 'number',
service: 'set_value',
data: {
entity_id: 'number.victron_evcs_set_current_command',
value: round1(desiredCurrentA)
}
},
logbook: {
domain: 'logbook',
service: 'log',
data: {
name: 'Victron EVCS Surplus',
message: shortReason
}
},
mobile_notification: {
domain: CFG.mobileNotifyDomain,
service: CFG.mobileNotifyService,
data: {
title: notifyTitle,
message: notifyMessage || shortReason
}
}
}
}
}];

View File

@@ -0,0 +1,246 @@
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);

View File

@@ -0,0 +1,709 @@
function clamp(v, min, max) {
return Math.min(Math.max(v, min), max);
}
function round1(v) {
return Math.round(v * 10) / 10;
}
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 {
now,
nowMs,
parsed,
params,
history,
strategy,
lifecycle,
context,
derived,
locks,
cloud,
modes,
safeguards,
conditions,
batteryTune,
} = prev;
// =====================================================
// DECISION INIT
// =====================================================
let desiredCharging = false;
let desiredPermission = false;
let desiredLatch = false;
let desiredCurrentA = params.currentMinA;
let state = 'idle';
let currentDecision = 'hold';
let dynamicOk = false;
let mustStop = false;
let reason = [];
let setCloudDebtStartedAt = false;
let setProbeStartedAt = false;
let notifyWanted = false;
let notifyTitle = 'Victron EVCS Surplus';
let notifyMessage = '';
let nextBlockReason = 'none';
let nextBlockedUntilStamp = now.stamp;
// =====================================================
// HELPERS
// =====================================================
function setOff({
stateName,
decisionName = 'stop',
reasonText,
dynamic = false,
mustStopFlag = false,
blockReason = 'none',
blockedUntilStamp = now.stamp,
}) {
desiredCharging = false;
desiredPermission = false;
desiredLatch = false;
desiredCurrentA = params.currentMinA;
state = stateName;
currentDecision = decisionName;
dynamicOk = dynamic;
mustStop = mustStopFlag;
nextBlockReason = blockReason;
nextBlockedUntilStamp = blockedUntilStamp;
if (reasonText) reason.push(reasonText);
}
function setOn({
stateName,
decisionName = 'hold',
currentA = params.currentMinA,
reasonText,
dynamic = true,
mustStopFlag = false,
}) {
desiredCharging = true;
desiredPermission = true;
desiredLatch = true;
desiredCurrentA = clamp(
currentA,
params.currentMinA,
Math.max(params.currentMinA, derived.physicalCapA || params.currentMinA)
);
state = stateName;
currentDecision = decisionName;
dynamicOk = dynamic;
mustStop = mustStopFlag;
if (reasonText) reason.push(reasonText);
}
// =====================================================
// DECISION TREE
// =====================================================
if (!parsed.controllerEnabled) {
desiredCharging = parsed.evcsChargingSwitchOn;
desiredPermission = parsed.chargePermissionNow;
desiredLatch = parsed.controlLatch;
desiredCurrentA = clamp(
derived.currentReferenceA || params.currentMinA,
params.currentMinA,
params.currentMaxAutoA
);
state = 'controller_disabled';
currentDecision = 'hold';
dynamicOk = false;
mustStop = false;
reason.push('Contrôleur désactivé');
}
else if (strategy.isStopMode) {
setOff({
stateName: 'stopped_user',
decisionName: 'stop_stop_mode',
reasonText: 'Arrêt manuel via Stratégie STOP',
dynamic: false,
mustStopFlag: false,
blockReason: 'manual_stop',
blockedUntilStamp: now.stamp,
});
}
else if (parsed.evcsModeIsAuto) {
setOff({
stateName: 'mode_auto_conflict',
decisionName: 'stop_auto_mode_conflict',
reasonText: 'Borne en mode Auto interne',
dynamic: false,
mustStopFlag: true,
blockReason: 'mode_auto_conflict',
blockedUntilStamp: now.stamp,
});
}
else if (!parsed.evcsDeviceConnected || !parsed.evcsVehicleConnected) {
setOff({
stateName: 'vehicle_not_connected',
decisionName: 'stop_not_connected',
reasonText: 'Véhicule ou borne non connecté',
dynamic: false,
mustStopFlag: false,
blockReason: 'vehicle_not_connected',
blockedUntilStamp: now.stamp,
});
}
else if (safeguards.mustStopHard) {
setOff({
stateName:
derived.anyVebusLowBattery ? 'vebus_low_battery_hard_stop' :
derived.vebusDcVoltageHardLow ? 'vebus_dc_voltage_hard_stop' :
derived.vebusAcOutHardReached ? 'vebus_ac_hard_stop' :
safeguards.batteryCurrentHardExceeded ? 'battery_current_hard_stop' :
'hard_stop',
decisionName: 'stop_hard',
reasonText: 'Hard stop de protection',
dynamic: false,
mustStopFlag: true,
blockReason:
safeguards.batteryCurrentHardExceeded ? 'battery_current_hard_stop' :
derived.vebusDcVoltageHardLow ? 'vebus_dc_voltage_stop' :
derived.anyVebusLowBattery ? 'vebus_low_battery_stop' :
derived.vebusAcOutHardReached ? 'vebus_ac_hard_stop' :
'battery_protection_stop',
blockedUntilStamp: formatParisStampFromMs(nowMs + 15 * 60000),
});
}
else if (cloud.sessionObservedActive) {
if (cloud.enteringCloudDebt) {
setCloudDebtStartedAt = true;
}
if (conditions.shouldStopOverloadAtMin) {
setOff({
stateName: 'vebus_overload_stop_min_current',
decisionName: 'stop',
reasonText: 'Arrêt: surcharge convertisseur persistante au courant mini',
dynamic: false,
mustStopFlag: true,
blockReason: 'vebus_overload_persistent',
blockedUntilStamp: formatParisStampFromMs(
nowMs + batteryTune.global.overloadStopAfterSeconds * 1000
),
});
}
else if (safeguards.gridBatteryProtectionStop) {
setOff({
stateName: 'stop_grid_battery_guard',
decisionName: 'stop',
reasonText: `Arrêt charge réseau: batterie trop sollicitée (${round1(parsed.batteryCurrentA)}A)`,
dynamic: false,
mustStopFlag: true,
blockReason: 'grid_battery_guard',
blockedUntilStamp: formatParisStampFromMs(nowMs + 20 * 60000),
});
}
else if (conditions.probeFailedNow) {
setOff({
stateName: 'probe_failed_lock',
decisionName: 'stop',
reasonText: 'Mode sonde échoué',
dynamic: false,
mustStopFlag: true,
blockReason: 'probe_failed',
blockedUntilStamp: formatParisStampFromMs(nowMs + params.probeRetryMinutes * 60000),
});
}
else if (derived.physicalCapA < params.currentMinA) {
if (lifecycle.minRunElapsed) {
setOff({
stateName: 'stop_power_cap',
decisionName: 'stop_power_cap',
reasonText: 'Capacité système insuffisante',
dynamic: false,
mustStopFlag: true,
blockReason: 'system_capacity_stop',
blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),
});
} else {
setOn({
stateName: 'hold_min_run_power_cap',
decisionName: 'hold',
currentA: params.currentMinA,
reasonText: "Maintien jusqu'au temps mini ON",
dynamic: false,
mustStopFlag: false,
});
}
}
else if (conditions.probeWindowActive && !conditions.probeSuccessNow) {
if (!conditions.probeHoldGuardOk) {
setOff({
stateName: 'probe_stop_guard',
decisionName: 'stop',
reasonText: 'Sonde interrompue: garde-fous non respectés',
dynamic: false,
mustStopFlag: true,
blockReason: 'probe_failed',
blockedUntilStamp: formatParisStampFromMs(nowMs + params.probeRetryMinutes * 60000),
});
} else {
setOn({
stateName: 'probe_hold',
decisionName: 'hold',
currentA: Math.min(params.probeCurrentA, derived.physicalCapA),
reasonText: `Mode sonde actif à ${round1(Math.min(params.probeCurrentA, derived.physicalCapA))}A`,
dynamic: true,
mustStopFlag: false,
});
}
}
else {
const activeSourceMode = conditions.probeSuccessNow ? 'solar_only' : context.sourceMode;
const holdConditionsOk =
(activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced')
? conditions.gridHoldOk
: (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential')
? conditions.solarHoldOk
: (activeSourceMode === 'hybrid_probe')
? conditions.probeHoldGuardOk
: false;
if (activeSourceMode === 'blocked') {
if (lifecycle.minRunElapsed) {
setOff({
stateName: 'stop_source_lost',
decisionName: 'stop',
reasonText: 'Source autorisée perdue',
dynamic: false,
mustStopFlag: true,
blockReason: context.profile === 'outside_window' ? 'outside_window' : 'source_unavailable',
blockedUntilStamp:
context.profile === 'outside_window' && Number.isFinite(locks.nextAllowedWindowStartMs)
? formatParisStampFromMs(locks.nextAllowedWindowStartMs)
: formatParisStampFromMs(nowMs + params.minStopMinutes * 60000),
});
} else {
setOn({
stateName: 'hold_min_run_source_lost',
decisionName: 'hold',
currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),
reasonText: "Source perdue mais maintien jusqu'au mini ON",
dynamic: false,
mustStopFlag: false,
});
}
}
else if (!holdConditionsOk) {
if (lifecycle.minRunElapsed) {
setOff({
stateName: 'stop_conditions_lost',
decisionName: 'stop',
reasonText: 'Conditions de maintien perdues',
dynamic: false,
mustStopFlag: true,
blockReason: 'hold_conditions_lost',
blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),
});
} else {
setOn({
stateName: 'hold_min_run',
decisionName: 'hold',
currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),
reasonText: "Maintien jusqu'au temps mini ON",
dynamic: true,
mustStopFlag: false,
});
}
}
else if ((derived.currentReferenceA > derived.physicalCapA) && lifecycle.currentCooldownElapsed) {
setOn({
stateName: 'charging_step_down_system_cap',
decisionName: 'step_down',
currentA: Math.max(
params.currentMinA,
Math.min(
derived.currentReferenceA - Math.max(params.currentStepDownA, 2),
derived.physicalCapA
)
),
reasonText: 'Réduction pour respecter la capacité système',
dynamic: true,
mustStopFlag: false,
});
}
else if (conditions.shouldStepDownOverload) {
setOn({
stateName: 'charging_step_down_overload',
decisionName: 'step_down_fast',
currentA: Math.max(
params.currentMinA,
Math.min(
derived.currentReferenceA - Math.max(batteryTune.global.overloadStepDownA, params.currentStepDownA),
derived.physicalCapA
)
),
reasonText: 'Baisse rapide: surcharge convertisseur',
dynamic: true,
mustStopFlag: false,
});
}
else if (conditions.shouldStepDownVebusPower) {
setOn({
stateName: 'charging_step_down_vebus_power',
decisionName: 'step_down_fast',
currentA: Math.max(
params.currentMinA,
Math.min(
derived.currentReferenceA - Math.max(params.currentStepDownA + 1, 3),
derived.physicalCapA
)
),
reasonText: 'Baisse rapide: puissance VE.Bus élevée',
dynamic: true,
mustStopFlag: false,
});
}
else if (conditions.shouldStepDownVebusVoltage) {
setOn({
stateName: 'charging_step_down_dc_voltage',
decisionName: 'step_down_fast',
currentA: Math.max(
params.currentMinA,
Math.min(
derived.currentReferenceA - Math.max(params.currentStepDownA + 1, 3),
derived.physicalCapA
)
),
reasonText: 'Baisse rapide: tension DC basse',
dynamic: true,
mustStopFlag: false,
});
}
else if (
(activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&
conditions.gridShouldStepDownBattery
) {
setOn({
stateName: 'charging_step_down_grid_guard',
decisionName: 'step_down',
currentA: Math.max(
params.currentMinA,
Math.min(
derived.currentReferenceA - params.currentStepDownA,
derived.physicalCapA
)
),
reasonText: 'Réduction réseau: la batterie commence à participer',
dynamic: true,
mustStopFlag: false,
});
}
else if (
(activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&
conditions.solarShouldStepDownCloudDebt
) {
setOn({
stateName: 'charging_step_down_cloud_debt',
decisionName: 'step_down',
currentA: Math.max(
params.currentMinA,
Math.min(
derived.currentReferenceA - Math.max(params.currentStepDownA, 2),
derived.physicalCapA
)
),
reasonText: 'Réduction: dette nuage trop élevée',
dynamic: true,
mustStopFlag: false,
});
}
else if (
(activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&
conditions.solarShouldStepDownBattery
) {
setOn({
stateName: 'charging_step_down_battery',
decisionName: 'step_down',
currentA: Math.max(
params.currentMinA,
Math.min(
derived.currentReferenceA - params.currentStepDownA,
derived.physicalCapA
)
),
reasonText: 'Réduction: batterie trop sollicitée',
dynamic: true,
mustStopFlag: false,
});
}
else if (
(activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&
conditions.gridShouldStepUp
) {
setOn({
stateName:
activeSourceMode === 'grid_forced'
? 'charging_step_up_grid_forced'
: 'charging_step_up_grid_hc',
decisionName: 'step_up',
currentA: clamp(
derived.currentReferenceA + params.currentStepUpA,
params.currentMinA,
derived.physicalCapA
),
reasonText:
activeSourceMode === 'grid_forced'
? 'Hausse prudente du courant en charge réseau forcée'
: 'Hausse prudente du courant en HC réseau',
dynamic: true,
mustStopFlag: false,
});
}
else if (
(activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&
conditions.solarShouldStepUp
) {
setOn({
stateName:
conditions.probeSuccessNow
? 'probe_success_step_up'
: (modes.batteryPriorityMode
? 'charging_step_up_battery'
: 'charging_step_up_forecast'),
decisionName: 'step_up',
currentA: clamp(
derived.currentReferenceA + params.currentStepUpA,
params.currentMinA,
derived.physicalCapA
),
reasonText: 'Hausse prudente du courant',
dynamic: true,
mustStopFlag: false,
});
}
else {
setOn({
stateName:
conditions.probeSuccessNow ? 'probe_success_hold' :
(activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') ? 'charging_hold_grid' :
cloud.cloudDebtActive ? 'charging_hold_cloud_tolerance' :
'charging_hold',
decisionName: 'hold',
currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),
reasonText: 'Maintien du courant',
dynamic: true,
mustStopFlag: false,
});
}
}
}
// =====================================================
// START BRANCH
// =====================================================
else {
const startConditionsOk =
(context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced')
? conditions.gridStartOk
: (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential')
? conditions.solarStartOk
: context.sourceMode === 'hybrid_probe'
? conditions.probeStartOk
: false;
if (!derived.canRunAtMinCurrent) {
setOff({
stateName: 'blocked_by_capacity_min_current',
decisionName: 'hold',
reasonText: 'Capacité insuffisante pour courant mini',
dynamic: false,
mustStopFlag: false,
blockReason: 'system_capacity_stop',
blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),
});
}
else if (context.sourceMode === 'blocked') {
setOff({
stateName:
context.profile === 'outside_window'
? 'blocked_outside_window'
: (context.profile === 'night_hc'
? 'night_no_grid_hc_lock'
: 'idle_wait_source'),
decisionName: 'hold',
reasonText:
context.profile === 'outside_window'
? 'Hors fenêtre préférée sans source suffisante'
: context.profile === 'night_hc'
? 'Nuit sans autorisation HC réseau'
: 'Source non autorisée ou insuffisante',
dynamic: false,
mustStopFlag: false,
blockReason:
context.profile === 'outside_window'
? 'outside_window'
: context.profile === 'night_hc'
? 'grid_hc_unavailable'
: 'source_unavailable',
blockedUntilStamp:
Number.isFinite(locks.nextAllowedWindowStartMs)
? formatParisStampFromMs(locks.nextAllowedWindowStartMs)
: formatParisStampFromMs(nowMs + 15 * 60000),
});
}
else if (locks.startBlockedByReason) {
setOff({
stateName: 'wait_reason_lock',
decisionName: 'hold',
reasonText: `Blocage anti-cliquetis: ${history.lastBlockReason}`,
dynamic: false,
mustStopFlag: false,
blockReason: history.lastBlockReason,
blockedUntilStamp: history.blockedUntilAt || now.stamp,
});
}
else if (!startConditionsOk) {
setOff({
stateName: 'idle_wait_conditions',
decisionName: 'hold',
reasonText: 'Conditions de démarrage non réunies',
dynamic: false,
mustStopFlag: false,
blockReason: 'none',
blockedUntilStamp: now.stamp,
});
}
else if (!lifecycle.minStopElapsed) {
setOff({
stateName: 'wait_min_stop',
decisionName: 'hold',
reasonText: 'Temps mini OFF non écoulé',
dynamic: false,
mustStopFlag: false,
blockReason: 'none',
blockedUntilStamp: now.stamp,
});
}
else {
const startCurrentA =
context.sourceMode === 'hybrid_probe'
? clamp(params.probeCurrentA, params.currentMinA, derived.physicalCapA)
: params.currentMinA;
setOn({
stateName:
context.sourceMode === 'grid_forced' ? 'start_grid_forced_min_current' :
context.sourceMode === 'grid_hc_allowed' ? 'start_grid_hc_min_current' :
context.sourceMode === 'hybrid_probe' ? 'probe_start_6a' :
context.sourceMode === 'solar_potential' ? 'start_forecast_solar_potential' :
modes.batteryFullHarvestMode ? 'start_min_current_battery_full_harvest' :
modes.forecastCommitMode ? 'start_min_current_forecast_commit' :
'start_min_current',
decisionName: 'start',
currentA: startCurrentA,
reasonText:
context.sourceMode === 'grid_forced'
? 'Démarrage en charge réseau forcée'
: context.sourceMode === 'grid_hc_allowed'
? 'Démarrage en fenêtre HC réseau'
: context.sourceMode === 'hybrid_probe'
? 'Démarrage en mode sonde'
: context.sourceMode === 'solar_potential'
? 'Démarrage par potentiel solaire confirmé'
: modes.batteryFullHarvestMode
? 'Démarrage harvest solaire'
: modes.forecastCommitMode
? 'Démarrage forecast commit'
: 'Démarrage prudent à courant mini',
dynamic: true,
mustStopFlag: false,
});
nextBlockReason = 'none';
nextBlockedUntilStamp = now.stamp;
setProbeStartedAt = context.sourceMode === 'hybrid_probe';
}
}
// =====================================================
// OUTPUT
// =====================================================
return [{
json: {
...prev,
decision: {
desiredCharging,
desiredPermission,
desiredLatch,
desiredCurrentA,
state,
currentDecision,
dynamicOk,
mustStop,
reason,
nextBlockReason,
nextBlockedUntilStamp,
setCloudDebtStartedAt,
setProbeStartedAt,
notifyWanted,
notifyTitle,
notifyMessage,
}
}
}];

View File

@@ -0,0 +1,129 @@
function clamp(v, min, max) {
return Math.min(Math.max(v, min), max);
}
function round1(v) {
return Math.round(v * 10) / 10;
}
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 {
now,
nowMs,
parsed,
params,
history,
strategy,
lifecycle,
context,
derived,
locks,
cloud,
modes,
safeguards,
conditions,
batteryTune,
} = prev;
// =====================================================
// DECISION INIT
// =====================================================
let desiredCharging = false;
let desiredPermission = false;
let desiredLatch = false;
let desiredCurrentA = params.currentMinA;
let state = 'idle';
let currentDecision = 'hold';
let dynamicOk = false;
let mustStop = false;
let reason = [];
let setCloudDebtStartedAt = false;
let setProbeStartedAt = false;
let notifyWanted = false;
let notifyTitle = 'Victron EVCS Surplus';
let notifyMessage = '';
let nextBlockReason = 'none';
let nextBlockedUntilStamp = now.stamp;
// =====================================================
// HELPERS
// =====================================================
function setOff({
stateName,
decisionName = 'stop',
reasonText,
dynamic = false,
mustStopFlag = false,
blockReason = 'none',
blockedUntilStamp = now.stamp,
}) {
desiredCharging = false;
desiredPermission = false;
desiredLatch = false;
desiredCurrentA = params.currentMinA;
state = stateName;
currentDecision = decisionName;
dynamicOk = dynamic;
mustStop = mustStopFlag;
nextBlockReason = blockReason;
nextBlockedUntilStamp = blockedUntilStamp;
if (reasonText) reason.push(reasonText);
}
function setOn({
stateName,
decisionName = 'hold',
currentA = params.currentMinA,
reasonText,
dynamic = true,
mustStopFlag = false,
}) {
desiredCharging = true;
desiredPermission = true;
desiredLatch = true;
desiredCurrentA = clamp(
currentA,
params.currentMinA,
Math.max(params.currentMinA, derived.physicalCapA || params.currentMinA)
);
state = stateName;
currentDecision = decisionName;
dynamicOk = dynamic;
mustStop = mustStopFlag;
if (reasonText) reason.push(reasonText);
}

View File

@@ -0,0 +1,419 @@
// =====================================================
// DECISION TREE
// =====================================================
if (!parsed.controllerEnabled) {
desiredCharging = parsed.evcsChargingSwitchOn;
desiredPermission = parsed.chargePermissionNow;
desiredLatch = parsed.controlLatch;
desiredCurrentA = clamp(
derived.currentReferenceA || params.currentMinA,
params.currentMinA,
params.currentMaxAutoA
);
state = 'controller_disabled';
currentDecision = 'hold';
dynamicOk = false;
mustStop = false;
reason.push('Contrôleur désactivé');
}
else if (strategy.isStopMode) {
setOff({
stateName: 'stopped_user',
decisionName: 'stop_stop_mode',
reasonText: 'Arrêt manuel via Stratégie STOP',
dynamic: false,
mustStopFlag: false,
blockReason: 'manual_stop',
blockedUntilStamp: now.stamp,
});
}
else if (parsed.evcsModeIsAuto) {
setOff({
stateName: 'mode_auto_conflict',
decisionName: 'stop_auto_mode_conflict',
reasonText: 'Borne en mode Auto interne',
dynamic: false,
mustStopFlag: true,
blockReason: 'mode_auto_conflict',
blockedUntilStamp: now.stamp,
});
}
else if (!parsed.evcsDeviceConnected || !parsed.evcsVehicleConnected) {
setOff({
stateName: 'vehicle_not_connected',
decisionName: 'stop_not_connected',
reasonText: 'Véhicule ou borne non connecté',
dynamic: false,
mustStopFlag: false,
blockReason: 'vehicle_not_connected',
blockedUntilStamp: now.stamp,
});
}
else if (safeguards.mustStopHard) {
setOff({
stateName:
derived.anyVebusLowBattery ? 'vebus_low_battery_hard_stop' :
derived.vebusDcVoltageHardLow ? 'vebus_dc_voltage_hard_stop' :
derived.vebusAcOutHardReached ? 'vebus_ac_hard_stop' :
safeguards.batteryCurrentHardExceeded ? 'battery_current_hard_stop' :
'hard_stop',
decisionName: 'stop_hard',
reasonText: 'Hard stop de protection',
dynamic: false,
mustStopFlag: true,
blockReason:
safeguards.batteryCurrentHardExceeded ? 'battery_current_hard_stop' :
derived.vebusDcVoltageHardLow ? 'vebus_dc_voltage_stop' :
derived.anyVebusLowBattery ? 'vebus_low_battery_stop' :
derived.vebusAcOutHardReached ? 'vebus_ac_hard_stop' :
'battery_protection_stop',
blockedUntilStamp: formatParisStampFromMs(nowMs + 15 * 60000),
});
}
else if (cloud.sessionObservedActive) {
if (cloud.enteringCloudDebt) {
setCloudDebtStartedAt = true;
}
if (conditions.shouldStopOverloadAtMin) {
setOff({
stateName: 'vebus_overload_stop_min_current',
decisionName: 'stop',
reasonText: 'Arrêt: surcharge convertisseur persistante au courant mini',
dynamic: false,
mustStopFlag: true,
blockReason: 'vebus_overload_persistent',
blockedUntilStamp: formatParisStampFromMs(
nowMs + batteryTune.global.overloadStopAfterSeconds * 1000
),
});
}
else if (safeguards.gridBatteryProtectionStop) {
setOff({
stateName: 'stop_grid_battery_guard',
decisionName: 'stop',
reasonText: `Arrêt charge réseau: batterie trop sollicitée (${round1(parsed.batteryCurrentA)}A)`,
dynamic: false,
mustStopFlag: true,
blockReason: 'grid_battery_guard',
blockedUntilStamp: formatParisStampFromMs(nowMs + 20 * 60000),
});
}
else if (conditions.probeFailedNow) {
setOff({
stateName: 'probe_failed_lock',
decisionName: 'stop',
reasonText: 'Mode sonde échoué',
dynamic: false,
mustStopFlag: true,
blockReason: 'probe_failed',
blockedUntilStamp: formatParisStampFromMs(nowMs + params.probeRetryMinutes * 60000),
});
}
else if (derived.physicalCapA < params.currentMinA) {
if (lifecycle.minRunElapsed) {
setOff({
stateName: 'stop_power_cap',
decisionName: 'stop_power_cap',
reasonText: 'Capacité système insuffisante',
dynamic: false,
mustStopFlag: true,
blockReason: 'system_capacity_stop',
blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),
});
} else {
setOn({
stateName: 'hold_min_run_power_cap',
decisionName: 'hold',
currentA: params.currentMinA,
reasonText: "Maintien jusqu'au temps mini ON",
dynamic: false,
mustStopFlag: false,
});
}
}
else if (conditions.probeWindowActive && !conditions.probeSuccessNow) {
if (!conditions.probeHoldGuardOk) {
setOff({
stateName: 'probe_stop_guard',
decisionName: 'stop',
reasonText: 'Sonde interrompue: garde-fous non respectés',
dynamic: false,
mustStopFlag: true,
blockReason: 'probe_failed',
blockedUntilStamp: formatParisStampFromMs(nowMs + params.probeRetryMinutes * 60000),
});
} else {
setOn({
stateName: 'probe_hold',
decisionName: 'hold',
currentA: Math.min(params.probeCurrentA, derived.physicalCapA),
reasonText: `Mode sonde actif à ${round1(Math.min(params.probeCurrentA, derived.physicalCapA))}A`,
dynamic: true,
mustStopFlag: false,
});
}
}
else {
const activeSourceMode = conditions.probeSuccessNow ? 'solar_only' : context.sourceMode;
const holdConditionsOk =
(activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced')
? conditions.gridHoldOk
: (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential')
? conditions.solarHoldOk
: (activeSourceMode === 'hybrid_probe')
? conditions.probeHoldGuardOk
: false;
if (activeSourceMode === 'blocked') {
if (lifecycle.minRunElapsed) {
setOff({
stateName: 'stop_source_lost',
decisionName: 'stop',
reasonText: 'Source autorisée perdue',
dynamic: false,
mustStopFlag: true,
blockReason: context.profile === 'outside_window' ? 'outside_window' : 'source_unavailable',
blockedUntilStamp:
context.profile === 'outside_window' && Number.isFinite(locks.nextAllowedWindowStartMs)
? formatParisStampFromMs(locks.nextAllowedWindowStartMs)
: formatParisStampFromMs(nowMs + params.minStopMinutes * 60000),
});
} else {
setOn({
stateName: 'hold_min_run_source_lost',
decisionName: 'hold',
currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),
reasonText: "Source perdue mais maintien jusqu'au mini ON",
dynamic: false,
mustStopFlag: false,
});
}
}
else if (!holdConditionsOk) {
if (lifecycle.minRunElapsed) {
setOff({
stateName: 'stop_conditions_lost',
decisionName: 'stop',
reasonText: 'Conditions de maintien perdues',
dynamic: false,
mustStopFlag: true,
blockReason: 'hold_conditions_lost',
blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),
});
} else {
setOn({
stateName: 'hold_min_run',
decisionName: 'hold',
currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),
reasonText: "Maintien jusqu'au temps mini ON",
dynamic: true,
mustStopFlag: false,
});
}
}
else if ((derived.currentReferenceA > derived.physicalCapA) && lifecycle.currentCooldownElapsed) {
setOn({
stateName: 'charging_step_down_system_cap',
decisionName: 'step_down',
currentA: Math.max(
params.currentMinA,
Math.min(
derived.currentReferenceA - Math.max(params.currentStepDownA, 2),
derived.physicalCapA
)
),
reasonText: 'Réduction pour respecter la capacité système',
dynamic: true,
mustStopFlag: false,
});
}
else if (conditions.shouldStepDownOverload) {
setOn({
stateName: 'charging_step_down_overload',
decisionName: 'step_down_fast',
currentA: Math.max(
params.currentMinA,
Math.min(
derived.currentReferenceA - Math.max(batteryTune.global.overloadStepDownA, params.currentStepDownA),
derived.physicalCapA
)
),
reasonText: 'Baisse rapide: surcharge convertisseur',
dynamic: true,
mustStopFlag: false,
});
}
else if (conditions.shouldStepDownVebusPower) {
setOn({
stateName: 'charging_step_down_vebus_power',
decisionName: 'step_down_fast',
currentA: Math.max(
params.currentMinA,
Math.min(
derived.currentReferenceA - Math.max(params.currentStepDownA + 1, 3),
derived.physicalCapA
)
),
reasonText: 'Baisse rapide: puissance VE.Bus élevée',
dynamic: true,
mustStopFlag: false,
});
}
else if (conditions.shouldStepDownVebusVoltage) {
setOn({
stateName: 'charging_step_down_dc_voltage',
decisionName: 'step_down_fast',
currentA: Math.max(
params.currentMinA,
Math.min(
derived.currentReferenceA - Math.max(params.currentStepDownA + 1, 3),
derived.physicalCapA
)
),
reasonText: 'Baisse rapide: tension DC basse',
dynamic: true,
mustStopFlag: false,
});
}
else if (
(activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&
conditions.gridShouldStepDownBattery
) {
setOn({
stateName: 'charging_step_down_grid_guard',
decisionName: 'step_down',
currentA: Math.max(
params.currentMinA,
Math.min(
derived.currentReferenceA - params.currentStepDownA,
derived.physicalCapA
)
),
reasonText: 'Réduction réseau: la batterie commence à participer',
dynamic: true,
mustStopFlag: false,
});
}
else if (
(activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&
conditions.solarShouldStepDownCloudDebt
) {
setOn({
stateName: 'charging_step_down_cloud_debt',
decisionName: 'step_down',
currentA: Math.max(
params.currentMinA,
Math.min(
derived.currentReferenceA - Math.max(params.currentStepDownA, 2),
derived.physicalCapA
)
),
reasonText: 'Réduction: dette nuage trop élevée',
dynamic: true,
mustStopFlag: false,
});
}
else if (
(activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&
conditions.solarShouldStepDownBattery
) {
setOn({
stateName: 'charging_step_down_battery',
decisionName: 'step_down',
currentA: Math.max(
params.currentMinA,
Math.min(
derived.currentReferenceA - params.currentStepDownA,
derived.physicalCapA
)
),
reasonText: 'Réduction: batterie trop sollicitée',
dynamic: true,
mustStopFlag: false,
});
}
else if (
(activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&
conditions.gridShouldStepUp
) {
setOn({
stateName:
activeSourceMode === 'grid_forced'
? 'charging_step_up_grid_forced'
: 'charging_step_up_grid_hc',
decisionName: 'step_up',
currentA: clamp(
derived.currentReferenceA + params.currentStepUpA,
params.currentMinA,
derived.physicalCapA
),
reasonText:
activeSourceMode === 'grid_forced'
? 'Hausse prudente du courant en charge réseau forcée'
: 'Hausse prudente du courant en HC réseau',
dynamic: true,
mustStopFlag: false,
});
}
else if (
(activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&
conditions.solarShouldStepUp
) {
setOn({
stateName:
conditions.probeSuccessNow
? 'probe_success_step_up'
: (modes.batteryPriorityMode
? 'charging_step_up_battery'
: 'charging_step_up_forecast'),
decisionName: 'step_up',
currentA: clamp(
derived.currentReferenceA + params.currentStepUpA,
params.currentMinA,
derived.physicalCapA
),
reasonText: 'Hausse prudente du courant',
dynamic: true,
mustStopFlag: false,
});
}
else {
setOn({
stateName:
conditions.probeSuccessNow ? 'probe_success_hold' :
(activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') ? 'charging_hold_grid' :
cloud.cloudDebtActive ? 'charging_hold_cloud_tolerance' :
'charging_hold',
decisionName: 'hold',
currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),
reasonText: 'Maintien du courant',
dynamic: true,
mustStopFlag: false,
});
}
}
}

View File

@@ -0,0 +1,159 @@
// =====================================================
// START BRANCH
// =====================================================
else {
const startConditionsOk =
(context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced')
? conditions.gridStartOk
: (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential')
? conditions.solarStartOk
: context.sourceMode === 'hybrid_probe'
? conditions.probeStartOk
: false;
if (!derived.canRunAtMinCurrent) {
setOff({
stateName: 'blocked_by_capacity_min_current',
decisionName: 'hold',
reasonText: 'Capacité insuffisante pour courant mini',
dynamic: false,
mustStopFlag: false,
blockReason: 'system_capacity_stop',
blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),
});
}
else if (context.sourceMode === 'blocked') {
setOff({
stateName:
context.profile === 'outside_window'
? 'blocked_outside_window'
: (context.profile === 'night_hc'
? 'night_no_grid_hc_lock'
: 'idle_wait_source'),
decisionName: 'hold',
reasonText:
context.profile === 'outside_window'
? 'Hors fenêtre préférée sans source suffisante'
: context.profile === 'night_hc'
? 'Nuit sans autorisation HC réseau'
: 'Source non autorisée ou insuffisante',
dynamic: false,
mustStopFlag: false,
blockReason:
context.profile === 'outside_window'
? 'outside_window'
: context.profile === 'night_hc'
? 'grid_hc_unavailable'
: 'source_unavailable',
blockedUntilStamp:
Number.isFinite(locks.nextAllowedWindowStartMs)
? formatParisStampFromMs(locks.nextAllowedWindowStartMs)
: formatParisStampFromMs(nowMs + 15 * 60000),
});
}
else if (locks.startBlockedByReason) {
setOff({
stateName: 'wait_reason_lock',
decisionName: 'hold',
reasonText: `Blocage anti-cliquetis: ${history.lastBlockReason}`,
dynamic: false,
mustStopFlag: false,
blockReason: history.lastBlockReason,
blockedUntilStamp: history.blockedUntilAt || now.stamp,
});
}
else if (!startConditionsOk) {
setOff({
stateName: 'idle_wait_conditions',
decisionName: 'hold',
reasonText: 'Conditions de démarrage non réunies',
dynamic: false,
mustStopFlag: false,
blockReason: 'none',
blockedUntilStamp: now.stamp,
});
}
else if (!lifecycle.minStopElapsed) {
setOff({
stateName: 'wait_min_stop',
decisionName: 'hold',
reasonText: 'Temps mini OFF non écoulé',
dynamic: false,
mustStopFlag: false,
blockReason: 'none',
blockedUntilStamp: now.stamp,
});
}
else {
const startCurrentA =
context.sourceMode === 'hybrid_probe'
? clamp(params.probeCurrentA, params.currentMinA, derived.physicalCapA)
: params.currentMinA;
setOn({
stateName:
context.sourceMode === 'grid_forced' ? 'start_grid_forced_min_current' :
context.sourceMode === 'grid_hc_allowed' ? 'start_grid_hc_min_current' :
context.sourceMode === 'hybrid_probe' ? 'probe_start_6a' :
context.sourceMode === 'solar_potential' ? 'start_forecast_solar_potential' :
modes.batteryFullHarvestMode ? 'start_min_current_battery_full_harvest' :
modes.forecastCommitMode ? 'start_min_current_forecast_commit' :
'start_min_current',
decisionName: 'start',
currentA: startCurrentA,
reasonText:
context.sourceMode === 'grid_forced'
? 'Démarrage en charge réseau forcée'
: context.sourceMode === 'grid_hc_allowed'
? 'Démarrage en fenêtre HC réseau'
: context.sourceMode === 'hybrid_probe'
? 'Démarrage en mode sonde'
: context.sourceMode === 'solar_potential'
? 'Démarrage par potentiel solaire confirmé'
: modes.batteryFullHarvestMode
? 'Démarrage harvest solaire'
: modes.forecastCommitMode
? 'Démarrage forecast commit'
: 'Démarrage prudent à courant mini',
dynamic: true,
mustStopFlag: false,
});
nextBlockReason = 'none';
nextBlockedUntilStamp = now.stamp;
setProbeStartedAt = context.sourceMode === 'hybrid_probe';
}
}
// =====================================================
// OUTPUT
// =====================================================
return [{
json: {
...prev,
decision: {
desiredCharging,
desiredPermission,
desiredLatch,
desiredCurrentA,
state,
currentDecision,
dynamicOk,
mustStop,
reason,
nextBlockReason,
nextBlockedUntilStamp,
setCloudDebtStartedAt,
setProbeStartedAt,
notifyWanted,
notifyTitle,
notifyMessage,
}
}
}];

View File

@@ -0,0 +1,896 @@
function clamp(v, min, max) {
return Math.min(Math.max(v, min), max);
}
function round1(v) {
return Math.round(v * 10) / 10;
}
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 {
now,
nowMs,
parsed,
params,
history,
strategy,
lifecycle,
context,
derived,
locks,
cloud,
modes,
safeguards,
conditions,
batteryTune,
nightEvcs,
} = prev;
// =====================================================
// DECISION INIT
// =====================================================
let desiredCharging = false;
let desiredPermission = false;
let desiredLatch = false;
let desiredCurrentA = params.currentMinA;
let state = 'idle';
let currentDecision = 'hold';
let dynamicOk = false;
let mustStop = false;
let reason = [];
let setCloudDebtStartedAt = false;
let setProbeStartedAt = false;
// EVCS night session memory
let setNightSessionStart = false;
let clearNightSessionStart = false;
let notifyWanted = false;
let notifyTitle = 'Victron EVCS Surplus';
let notifyMessage = '';
let nextBlockReason = 'none';
let nextBlockedUntilStamp = now.stamp;
// =====================================================
// HELPERS
// =====================================================
function setOff({
stateName,
decisionName = 'stop',
reasonText,
dynamic = false,
mustStopFlag = false,
blockReason = 'none',
blockedUntilStamp = now.stamp,
}) {
desiredCharging = false;
desiredPermission = false;
desiredLatch = false;
desiredCurrentA = params.currentMinA;
state = stateName;
currentDecision = decisionName;
dynamicOk = dynamic;
mustStop = mustStopFlag;
nextBlockReason = blockReason;
nextBlockedUntilStamp = blockedUntilStamp;
if (reasonText) reason.push(reasonText);
}
function setOn({
stateName,
decisionName = 'hold',
currentA = params.currentMinA,
reasonText,
dynamic = true,
mustStopFlag = false,
}) {
desiredCharging = true;
desiredPermission = true;
desiredLatch = true;
desiredCurrentA = clamp(
currentA,
params.currentMinA,
Math.max(params.currentMinA, derived.physicalCapA || params.currentMinA)
);
state = stateName;
currentDecision = decisionName;
dynamicOk = dynamic;
mustStop = mustStopFlag;
if (reasonText) reason.push(reasonText);
}
// =====================================================
// SESSION MEMORY SIDE EFFECTS
// =====================================================
if (nightEvcs?.nightSessionNeedsCapture) {
setNightSessionStart = true;
}
if (nightEvcs?.nightSessionNeedsClear) {
clearNightSessionStart = true;
}
// =====================================================
// DECISION TREE
// =====================================================
if (!parsed.controllerEnabled) {
desiredCharging = parsed.evcsChargingSwitchOn;
desiredPermission = parsed.chargePermissionNow;
desiredLatch = parsed.controlLatch;
desiredCurrentA = clamp(
derived.currentReferenceA || params.currentMinA,
params.currentMinA,
params.currentMaxAutoA
);
state = 'controller_disabled';
currentDecision = 'hold';
dynamicOk = false;
mustStop = false;
reason.push('Contrôleur désactivé');
}
else if (strategy.isStopMode) {
setOff({
stateName: 'stopped_user',
decisionName: 'stop_stop_mode',
reasonText: 'Arrêt manuel via Stratégie STOP',
dynamic: false,
mustStopFlag: false,
blockReason: 'manual_stop',
blockedUntilStamp: now.stamp,
});
}
else if (parsed.evcsModeIsAuto) {
setOff({
stateName: 'mode_auto_conflict',
decisionName: 'stop_auto_mode_conflict',
reasonText: 'Borne en mode Auto interne',
dynamic: false,
mustStopFlag: true,
blockReason: 'mode_auto_conflict',
blockedUntilStamp: now.stamp,
});
}
else if (!parsed.evcsDeviceConnected || !parsed.evcsVehicleConnected) {
setOff({
stateName: 'vehicle_not_connected',
decisionName: 'stop_not_connected',
reasonText: 'Véhicule ou borne non connecté',
dynamic: false,
mustStopFlag: false,
blockReason: 'vehicle_not_connected',
blockedUntilStamp: now.stamp,
});
}
else if (safeguards.mustStopHard) {
setOff({
stateName:
derived.anyVebusLowBattery ? 'vebus_low_battery_hard_stop' :
derived.vebusDcVoltageHardLow ? 'vebus_dc_voltage_hard_stop' :
derived.vebusAcOutHardReached ? 'vebus_ac_hard_stop' :
safeguards.batteryCurrentHardExceeded ? 'battery_current_hard_stop' :
'hard_stop',
decisionName: 'stop_hard',
reasonText: 'Hard stop de protection',
dynamic: false,
mustStopFlag: true,
blockReason:
safeguards.batteryCurrentHardExceeded ? 'battery_current_hard_stop' :
derived.vebusDcVoltageHardLow ? 'vebus_dc_voltage_stop' :
derived.anyVebusLowBattery ? 'vebus_low_battery_stop' :
derived.vebusAcOutHardReached ? 'vebus_ac_hard_stop' :
'battery_protection_stop',
blockedUntilStamp: formatParisStampFromMs(nowMs + 15 * 60000),
});
}
// =====================================================
// ACTIVE SESSION BRANCH
// =====================================================
else if (cloud.sessionObservedActive) {
if (cloud.enteringCloudDebt) {
setCloudDebtStartedAt = true;
}
if (conditions.shouldStopOverloadAtMin) {
setOff({
stateName: 'vebus_overload_stop_min_current',
decisionName: 'stop',
reasonText: 'Arrêt: surcharge convertisseur persistante au courant mini',
dynamic: false,
mustStopFlag: true,
blockReason: 'vebus_overload_persistent',
blockedUntilStamp: formatParisStampFromMs(
nowMs + batteryTune.global.overloadStopAfterSeconds * 1000
),
});
}
else if (safeguards.gridBatteryProtectionStop) {
setOff({
stateName: 'stop_grid_battery_guard',
decisionName: 'stop',
reasonText: `Arrêt charge réseau: batterie trop sollicitée (${round1(parsed.batteryCurrentA)}A)`,
dynamic: false,
mustStopFlag: true,
blockReason: 'grid_battery_guard',
blockedUntilStamp: formatParisStampFromMs(nowMs + 20 * 60000),
});
}
else if (conditions.probeFailedNow) {
setOff({
stateName: 'probe_failed_lock',
decisionName: 'stop',
reasonText: 'Mode sonde échoué',
dynamic: false,
mustStopFlag: true,
blockReason: 'probe_failed',
blockedUntilStamp: formatParisStampFromMs(nowMs + params.probeRetryMinutes * 60000),
});
}
else if (derived.physicalCapA < params.currentMinA) {
if (lifecycle.minRunElapsed) {
setOff({
stateName: 'stop_power_cap',
decisionName: 'stop_power_cap',
reasonText: 'Capacité système insuffisante',
dynamic: false,
mustStopFlag: true,
blockReason: 'system_capacity_stop',
blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),
});
} else {
setOn({
stateName: 'hold_min_run_power_cap',
decisionName: 'hold',
currentA: params.currentMinA,
reasonText: "Maintien jusqu'au temps mini ON",
dynamic: false,
mustStopFlag: false,
});
}
}
else if (conditions.probeWindowActive && !conditions.probeSuccessNow) {
if (!conditions.probeHoldGuardOk) {
setOff({
stateName: 'probe_stop_guard',
decisionName: 'stop',
reasonText: 'Sonde interrompue: garde-fous non respectés',
dynamic: false,
mustStopFlag: true,
blockReason: 'probe_failed',
blockedUntilStamp: formatParisStampFromMs(nowMs + params.probeRetryMinutes * 60000),
});
} else {
setOn({
stateName: 'probe_hold',
decisionName: 'hold',
currentA: Math.min(params.probeCurrentA, derived.physicalCapA),
reasonText: `Mode sonde actif à ${round1(Math.min(params.probeCurrentA, derived.physicalCapA))}A`,
dynamic: true,
mustStopFlag: false,
});
}
}
else {
const activeSourceMode =
conditions.probeSuccessNow
? 'solar_only'
: context.sourceMode;
const holdConditionsOk =
(activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced')
? conditions.gridHoldOk
: (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential')
? conditions.solarHoldOk
: activeSourceMode === 'hybrid_probe'
? conditions.probeHoldGuardOk
: activeSourceMode === 'battery_assist_night'
? conditions.batteryAssistNightHoldOk
: false;
if (activeSourceMode === 'blocked') {
if (lifecycle.minRunElapsed) {
setOff({
stateName: 'stop_source_lost',
decisionName: 'stop',
reasonText: 'Source autorisée perdue',
dynamic: false,
mustStopFlag: true,
blockReason: context.profile === 'outside_window' ? 'outside_window' : 'source_unavailable',
blockedUntilStamp:
context.profile === 'outside_window' && Number.isFinite(locks.nextAllowedWindowStartMs)
? formatParisStampFromMs(locks.nextAllowedWindowStartMs)
: formatParisStampFromMs(nowMs + params.minStopMinutes * 60000),
});
} else {
setOn({
stateName: 'hold_min_run_source_lost',
decisionName: 'hold',
currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),
reasonText: "Source perdue mais maintien jusqu'au mini ON",
dynamic: false,
mustStopFlag: false,
});
}
}
else if (
(activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&
!parsed.gridAvailable
) {
if (lifecycle.minRunElapsed) {
setOff({
stateName:
context.profile === 'night_hc'
? 'night_wait_grid_takeover_runtime'
: 'wait_grid_source_runtime',
decisionName: 'hold',
reasonText:
context.profile === 'night_hc'
? 'Relais réseau demandé pendant la session nuit EVCS'
: 'Source réseau demandée mais pas encore disponible',
dynamic: false,
mustStopFlag: false,
blockReason:
context.profile === 'night_hc' ? 'grid_hc_unavailable' : 'grid_unavailable',
blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),
});
} else {
setOn({
stateName: 'hold_min_run_wait_grid_takeover',
decisionName: 'hold',
currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),
reasonText: "Maintien jusqu'au mini ON en attente du réseau",
dynamic: false,
mustStopFlag: false,
});
}
}
else if (!holdConditionsOk) {
if (lifecycle.minRunElapsed) {
setOff({
stateName: 'stop_conditions_lost',
decisionName: 'stop',
reasonText: 'Conditions de maintien perdues',
dynamic: false,
mustStopFlag: true,
blockReason: 'hold_conditions_lost',
blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),
});
} else {
setOn({
stateName: 'hold_min_run',
decisionName: 'hold',
currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),
reasonText: "Maintien jusqu'au temps mini ON",
dynamic: true,
mustStopFlag: false,
});
}
}
else if ((derived.currentReferenceA > derived.physicalCapA) && lifecycle.currentCooldownElapsed) {
setOn({
stateName: 'charging_step_down_system_cap',
decisionName: 'step_down',
currentA: Math.max(
params.currentMinA,
Math.min(
derived.currentReferenceA - Math.max(params.currentStepDownA, 2),
derived.physicalCapA
)
),
reasonText: 'Réduction pour respecter la capacité système',
dynamic: true,
mustStopFlag: false,
});
}
else if (conditions.shouldStepDownOverload) {
setOn({
stateName: 'charging_step_down_overload',
decisionName: 'step_down_fast',
currentA: Math.max(
params.currentMinA,
Math.min(
derived.currentReferenceA - Math.max(batteryTune.global.overloadStepDownA, params.currentStepDownA),
derived.physicalCapA
)
),
reasonText: 'Baisse rapide: surcharge convertisseur',
dynamic: true,
mustStopFlag: false,
});
}
else if (conditions.shouldStepDownVebusPower) {
setOn({
stateName: 'charging_step_down_vebus_power',
decisionName: 'step_down_fast',
currentA: Math.max(
params.currentMinA,
Math.min(
derived.currentReferenceA - Math.max(params.currentStepDownA + 1, 3),
derived.physicalCapA
)
),
reasonText: 'Baisse rapide: puissance VE.Bus élevée',
dynamic: true,
mustStopFlag: false,
});
}
else if (conditions.shouldStepDownVebusVoltage) {
setOn({
stateName: 'charging_step_down_dc_voltage',
decisionName: 'step_down_fast',
currentA: Math.max(
params.currentMinA,
Math.min(
derived.currentReferenceA - Math.max(params.currentStepDownA + 1, 3),
derived.physicalCapA
)
),
reasonText: 'Baisse rapide: tension DC basse',
dynamic: true,
mustStopFlag: false,
});
}
else if (
activeSourceMode === 'battery_assist_night' &&
conditions.batteryAssistNightShouldStepDown
) {
setOn({
stateName: 'charging_step_down_battery_assist_night',
decisionName: 'step_down',
currentA: Math.max(
params.currentMinA,
Math.min(
derived.currentReferenceA - params.currentStepDownA,
derived.physicalCapA
)
),
reasonText:
nightEvcs?.batteryRecoveryUrgent
? 'Réduction: approche départ, préparation takeover réseau'
: 'Réduction: assistance batterie nuit devient trop coûteuse',
dynamic: true,
mustStopFlag: false,
});
}
else if (
(activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&
conditions.gridShouldStepDownBattery
) {
setOn({
stateName:
activeSourceMode === 'grid_forced'
? 'charging_step_down_grid_forced_guard'
: 'charging_step_down_grid_guard',
decisionName: 'step_down',
currentA: Math.max(
params.currentMinA,
Math.min(
derived.currentReferenceA - params.currentStepDownA,
derived.physicalCapA
)
),
reasonText:
activeSourceMode === 'grid_forced'
? 'Réduction réseau forcé: la batterie commence à participer'
: 'Réduction réseau: la batterie commence à participer',
dynamic: true,
mustStopFlag: false,
});
}
else if (
(activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&
conditions.solarShouldStepDownCloudDebt
) {
setOn({
stateName: 'charging_step_down_cloud_debt',
decisionName: 'step_down',
currentA: Math.max(
params.currentMinA,
Math.min(
derived.currentReferenceA - Math.max(params.currentStepDownA, 2),
derived.physicalCapA
)
),
reasonText: 'Réduction: dette nuage trop élevée',
dynamic: true,
mustStopFlag: false,
});
}
else if (
(activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&
conditions.solarShouldStepDownBattery
) {
setOn({
stateName: 'charging_step_down_battery',
decisionName: 'step_down',
currentA: Math.max(
params.currentMinA,
Math.min(
derived.currentReferenceA - params.currentStepDownA,
derived.physicalCapA
)
),
reasonText: 'Réduction: batterie trop sollicitée',
dynamic: true,
mustStopFlag: false,
});
}
else if (
(activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&
conditions.gridShouldStepUp
) {
setOn({
stateName:
activeSourceMode === 'grid_forced'
? (
nightEvcs?.batteryRecoveryUrgent
? 'night_departure_deadline_grid_forced'
: 'charging_step_up_grid_forced'
)
: 'charging_step_up_grid_hc',
decisionName: 'step_up',
currentA: clamp(
derived.currentReferenceA + params.currentStepUpA,
params.currentMinA,
derived.physicalCapA
),
reasonText:
activeSourceMode === 'grid_forced'
? (
nightEvcs?.batteryRecoveryUrgent
? 'Hausse prudente: priorité départ, réseau forcé'
: 'Hausse prudente du courant en charge réseau forcée'
)
: 'Hausse prudente du courant en HC réseau',
dynamic: true,
mustStopFlag: false,
});
}
else if (
activeSourceMode === 'battery_assist_night' &&
conditions.batteryAssistNightShouldStepUp
) {
setOn({
stateName: 'charging_step_up_battery_assist_night',
decisionName: 'step_up',
currentA: clamp(
derived.currentReferenceA + params.currentStepUpA,
params.currentMinA,
derived.physicalCapA
),
reasonText: 'Hausse prudente du courant en assistance batterie nuit',
dynamic: true,
mustStopFlag: false,
});
}
else if (
(activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&
conditions.solarShouldStepUp
) {
setOn({
stateName:
conditions.probeSuccessNow
? 'probe_success_step_up'
: (modes.batteryPriorityMode
? 'charging_step_up_battery'
: 'charging_step_up_forecast'),
decisionName: 'step_up',
currentA: clamp(
derived.currentReferenceA + params.currentStepUpA,
params.currentMinA,
derived.physicalCapA
),
reasonText: 'Hausse prudente du courant',
dynamic: true,
mustStopFlag: false,
});
}
else {
setOn({
stateName:
conditions.probeSuccessNow ? 'probe_success_hold' :
activeSourceMode === 'battery_assist_night' ? 'charging_hold_battery_assist_night' :
(activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') ? 'charging_hold_grid' :
cloud.cloudDebtActive ? 'charging_hold_cloud_tolerance' :
'charging_hold',
decisionName: 'hold',
currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),
reasonText:
activeSourceMode === 'battery_assist_night'
? 'Maintien du courant en assistance batterie nuit'
: 'Maintien du courant',
dynamic: true,
mustStopFlag: false,
});
}
}
}
// =====================================================
// START BRANCH
// =====================================================
else {
const startConditionsOk =
(context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced')
? conditions.gridStartOk
: (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential')
? conditions.solarStartOk
: context.sourceMode === 'hybrid_probe'
? conditions.probeStartOk
: context.sourceMode === 'battery_assist_night'
? conditions.batteryAssistNightStartOk
: false;
if (!derived.canRunAtMinCurrent) {
setOff({
stateName: 'blocked_by_capacity_min_current',
decisionName: 'hold',
reasonText: 'Capacité insuffisante pour courant mini',
dynamic: false,
mustStopFlag: false,
blockReason: 'system_capacity_stop',
blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),
});
}
else if (context.sourceMode === 'blocked') {
setOff({
stateName:
context.profile === 'outside_window'
? 'blocked_outside_window'
: (context.profile === 'night_hc'
? 'night_no_grid_hc_lock'
: 'idle_wait_source'),
decisionName: 'hold',
reasonText:
context.profile === 'outside_window'
? 'Hors fenêtre préférée sans source suffisante'
: context.profile === 'night_hc'
? 'Nuit sans autorisation HC réseau'
: 'Source non autorisée ou insuffisante',
dynamic: false,
mustStopFlag: false,
blockReason:
context.profile === 'outside_window'
? 'outside_window'
: context.profile === 'night_hc'
? 'grid_hc_unavailable'
: 'source_unavailable',
blockedUntilStamp:
Number.isFinite(locks.nextAllowedWindowStartMs)
? formatParisStampFromMs(locks.nextAllowedWindowStartMs)
: formatParisStampFromMs(nowMs + 15 * 60000),
});
}
else if (
(context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced') &&
!parsed.gridAvailable
) {
setOff({
stateName:
context.profile === 'night_hc'
? 'night_wait_grid_takeover'
: 'wait_grid_source',
decisionName: 'hold',
reasonText:
context.profile === 'night_hc'
? (
nightEvcs?.batteryRecoveryUrgent
? 'Réseau demandé: urgence recharge batterie avant départ'
: 'Prise de relais réseau demandée pour session nuit EVCS'
)
: 'Source réseau demandée mais pas encore disponible',
dynamic: false,
mustStopFlag: false,
blockReason:
context.profile === 'night_hc'
? 'grid_hc_unavailable'
: 'grid_unavailable',
blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),
});
}
else if (locks.startBlockedByReason) {
setOff({
stateName: 'wait_reason_lock',
decisionName: 'hold',
reasonText: `Blocage anti-cliquetis: ${history.lastBlockReason}`,
dynamic: false,
mustStopFlag: false,
blockReason: history.lastBlockReason,
blockedUntilStamp: history.blockedUntilAt || now.stamp,
});
}
else if (!startConditionsOk) {
setOff({
stateName: 'idle_wait_conditions',
decisionName: 'hold',
reasonText:
context.sourceMode === 'battery_assist_night'
? 'Conditions de démarrage nuit batterie non réunies'
: 'Conditions de démarrage non réunies',
dynamic: false,
mustStopFlag: false,
blockReason: 'none',
blockedUntilStamp: now.stamp,
});
}
else if (!lifecycle.minStopElapsed) {
setOff({
stateName: 'wait_min_stop',
decisionName: 'hold',
reasonText: 'Temps mini OFF non écoulé',
dynamic: false,
mustStopFlag: false,
blockReason: 'none',
blockedUntilStamp: now.stamp,
});
}
else {
const startCurrentA =
context.sourceMode === 'hybrid_probe'
? clamp(params.probeCurrentA, params.currentMinA, derived.physicalCapA)
: params.currentMinA;
setOn({
stateName:
context.sourceMode === 'grid_forced' ? (
nightEvcs?.batteryRecoveryUrgent
? 'night_departure_deadline_grid_forced'
: 'start_grid_forced_min_current'
) :
context.sourceMode === 'grid_hc_allowed' ? 'start_grid_hc_min_current' :
context.sourceMode === 'battery_assist_night' ? 'start_battery_assist_night_min_current' :
context.sourceMode === 'hybrid_probe' ? 'probe_start_6a' :
context.sourceMode === 'solar_potential' ? 'start_forecast_solar_potential' :
modes.batteryFullHarvestMode ? 'start_min_current_battery_full_harvest' :
modes.forecastCommitMode ? 'start_min_current_forecast_commit' :
'start_min_current',
decisionName: 'start',
currentA: startCurrentA,
reasonText:
context.sourceMode === 'grid_forced'
? (
nightEvcs?.batteryRecoveryUrgent
? 'Démarrage réseau forcé: priorité départ / recharge batterie'
: 'Démarrage en charge réseau forcée'
)
: context.sourceMode === 'grid_hc_allowed'
? 'Démarrage en fenêtre HC réseau'
: context.sourceMode === 'battery_assist_night'
? 'Démarrage nuit en assistance batterie contrôlée'
: context.sourceMode === 'hybrid_probe'
? 'Démarrage en mode sonde'
: context.sourceMode === 'solar_potential'
? 'Démarrage par potentiel solaire confirmé'
: modes.batteryFullHarvestMode
? 'Démarrage harvest solaire'
: modes.forecastCommitMode
? 'Démarrage forecast commit'
: 'Démarrage prudent à courant mini',
dynamic: true,
mustStopFlag: false,
});
nextBlockReason = 'none';
nextBlockedUntilStamp = now.stamp;
setProbeStartedAt = context.sourceMode === 'hybrid_probe';
}
}
// =====================================================
// CONTEXT REASONS
// =====================================================
if (nightEvcs?.nightSessionActive) {
reason.push(
`Nuit EVCS: target=${round1(nightEvcs.batteryMorningTargetPct)}% | trigger réseau=${round1(nightEvcs.gridTakeoverTriggerPct)}%`
);
if (Number.isFinite(nightEvcs.nightSessionStartSocPct)) {
reason.push(
`SOC départ session nuit=${round1(nightEvcs.nightSessionStartSocPct)}% | décharge autorisée=${round1(nightEvcs.maxBatteryDischargePoints)} pts`
);
}
if (nightEvcs.batteryRecoveryUrgent) {
reason.push("Urgence départ: il faut anticiper la reprise réseau");
}
}
// =====================================================
// OUTPUT
// =====================================================
return [{
json: {
...prev,
decision: {
desiredCharging,
desiredPermission,
desiredLatch,
desiredCurrentA,
state,
currentDecision,
dynamicOk,
mustStop,
reason,
nextBlockReason,
nextBlockedUntilStamp,
setCloudDebtStartedAt,
setProbeStartedAt,
setNightSessionStart,
clearNightSessionStart,
notifyWanted,
notifyTitle,
notifyMessage,
}
}
}];

View File

@@ -0,0 +1,224 @@
function clamp(v, min, max) {
return Math.min(Math.max(v, min), max);
}
function round1(v) {
return Math.round(v * 10) / 10;
}
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 {
now,
nowMs,
parsed,
params,
history,
strategy,
lifecycle,
context,
derived,
locks,
cloud,
modes,
safeguards,
conditions,
batteryTune,
nightEvcs,
} = prev;
// =====================================================
// DECISION INIT
// =====================================================
let desiredCharging = false;
let desiredPermission = false;
let desiredLatch = false;
let desiredCurrentA = params.currentMinA;
let state = 'idle';
let currentDecision = 'hold';
let dynamicOk = false;
let mustStop = false;
let reason = [];
let setCloudDebtStartedAt = false;
let setProbeStartedAt = false;
// EVCS night session memory
let setNightSessionStart = false;
let clearNightSessionStart = false;
let notifyWanted = false;
let notifyTitle = 'Victron EVCS Surplus';
let notifyMessage = '';
let nextBlockReason = 'none';
let nextBlockedUntilStamp = now.stamp;
// =====================================================
// HELPERS
// =====================================================
function setOff({
stateName,
decisionName = 'stop',
reasonText,
dynamic = false,
mustStopFlag = false,
blockReason = 'none',
blockedUntilStamp = now.stamp,
}) {
desiredCharging = false;
desiredPermission = false;
desiredLatch = false;
desiredCurrentA = params.currentMinA;
state = stateName;
currentDecision = decisionName;
dynamicOk = dynamic;
mustStop = mustStopFlag;
nextBlockReason = blockReason;
nextBlockedUntilStamp = blockedUntilStamp;
if (reasonText) reason.push(reasonText);
}
function setOn({
stateName,
decisionName = 'hold',
currentA = params.currentMinA,
reasonText,
dynamic = true,
mustStopFlag = false,
}) {
desiredCharging = true;
desiredPermission = true;
desiredLatch = true;
desiredCurrentA = clamp(
currentA,
params.currentMinA,
Math.max(params.currentMinA, derived.physicalCapA || params.currentMinA)
);
state = stateName;
currentDecision = decisionName;
dynamicOk = dynamic;
mustStop = mustStopFlag;
if (reasonText) reason.push(reasonText);
}
// =====================================================
// SESSION MEMORY SIDE EFFECTS
// =====================================================
if (nightEvcs?.nightSessionNeedsCapture) {
setNightSessionStart = true;
}
if (nightEvcs?.nightSessionNeedsClear) {
clearNightSessionStart = true;
}
// =====================================================
// DECISION TREE
// =====================================================
if (!parsed.controllerEnabled) {
desiredCharging = parsed.evcsChargingSwitchOn;
desiredPermission = parsed.chargePermissionNow;
desiredLatch = parsed.controlLatch;
desiredCurrentA = clamp(
derived.currentReferenceA || params.currentMinA,
params.currentMinA,
params.currentMaxAutoA
);
state = 'controller_disabled';
currentDecision = 'hold';
dynamicOk = false;
mustStop = false;
reason.push('Contrôleur désactivé');
}
else if (strategy.isStopMode) {
setOff({
stateName: 'stopped_user',
decisionName: 'stop_stop_mode',
reasonText: 'Arrêt manuel via Stratégie STOP',
dynamic: false,
mustStopFlag: false,
blockReason: 'manual_stop',
blockedUntilStamp: now.stamp,
});
}
else if (parsed.evcsModeIsAuto) {
setOff({
stateName: 'mode_auto_conflict',
decisionName: 'stop_auto_mode_conflict',
reasonText: 'Borne en mode Auto interne',
dynamic: false,
mustStopFlag: true,
blockReason: 'mode_auto_conflict',
blockedUntilStamp: now.stamp,
});
}
else if (!parsed.evcsDeviceConnected || !parsed.evcsVehicleConnected) {
setOff({
stateName: 'vehicle_not_connected',
decisionName: 'stop_not_connected',
reasonText: 'Véhicule ou borne non connecté',
dynamic: false,
mustStopFlag: false,
blockReason: 'vehicle_not_connected',
blockedUntilStamp: now.stamp,
});
}
else if (safeguards.mustStopHard) {
setOff({
stateName:
derived.anyVebusLowBattery ? 'vebus_low_battery_hard_stop' :
derived.vebusDcVoltageHardLow ? 'vebus_dc_voltage_hard_stop' :
derived.vebusAcOutHardReached ? 'vebus_ac_hard_stop' :
safeguards.batteryCurrentHardExceeded ? 'battery_current_hard_stop' :
'hard_stop',
decisionName: 'stop_hard',
reasonText: 'Hard stop de protection',
dynamic: false,
mustStopFlag: true,
blockReason:
safeguards.batteryCurrentHardExceeded ? 'battery_current_hard_stop' :
derived.vebusDcVoltageHardLow ? 'vebus_dc_voltage_stop' :
derived.anyVebusLowBattery ? 'vebus_low_battery_stop' :
derived.vebusAcOutHardReached ? 'vebus_ac_hard_stop' :
'battery_protection_stop',
blockedUntilStamp: formatParisStampFromMs(nowMs + 15 * 60000),
});
}

View File

@@ -0,0 +1,241 @@
// =====================================================
// ACTIVE SESSION BRANCH
// =====================================================
else if (cloud.sessionObservedActive) {
if (cloud.enteringCloudDebt) {
setCloudDebtStartedAt = true;
}
if (conditions.shouldStopOverloadAtMin) {
setOff({
stateName: 'vebus_overload_stop_min_current',
decisionName: 'stop',
reasonText: 'Arrêt: surcharge convertisseur persistante au courant mini',
dynamic: false,
mustStopFlag: true,
blockReason: 'vebus_overload_persistent',
blockedUntilStamp: formatParisStampFromMs(
nowMs + batteryTune.global.overloadStopAfterSeconds * 1000
),
});
}
else if (safeguards.gridBatteryProtectionStop) {
setOff({
stateName: 'stop_grid_battery_guard',
decisionName: 'stop',
reasonText: `Arrêt charge réseau: batterie trop sollicitée (${round1(parsed.batteryCurrentA)}A)`,
dynamic: false,
mustStopFlag: true,
blockReason: 'grid_battery_guard',
blockedUntilStamp: formatParisStampFromMs(nowMs + 20 * 60000),
});
}
else if (conditions.probeFailedNow) {
setOff({
stateName: 'probe_failed_lock',
decisionName: 'stop',
reasonText: 'Mode sonde échoué',
dynamic: false,
mustStopFlag: true,
blockReason: 'probe_failed',
blockedUntilStamp: formatParisStampFromMs(nowMs + params.probeRetryMinutes * 60000),
});
}
else if (derived.physicalCapA < params.currentMinA) {
if (lifecycle.minRunElapsed) {
setOff({
stateName: 'stop_power_cap',
decisionName: 'stop_power_cap',
reasonText: 'Capacité système insuffisante',
dynamic: false,
mustStopFlag: true,
blockReason: 'system_capacity_stop',
blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),
});
} else {
setOn({
stateName: 'hold_min_run_power_cap',
decisionName: 'hold',
currentA: params.currentMinA,
reasonText: "Maintien jusqu'au temps mini ON",
dynamic: false,
mustStopFlag: false,
});
}
}
else if (conditions.probeWindowActive && !conditions.probeSuccessNow) {
if (!conditions.probeHoldGuardOk) {
setOff({
stateName: 'probe_stop_guard',
decisionName: 'stop',
reasonText: 'Sonde interrompue: garde-fous non respectés',
dynamic: false,
mustStopFlag: true,
blockReason: 'probe_failed',
blockedUntilStamp: formatParisStampFromMs(nowMs + params.probeRetryMinutes * 60000),
});
} else {
setOn({
stateName: 'probe_hold',
decisionName: 'hold',
currentA: Math.min(params.probeCurrentA, derived.physicalCapA),
reasonText: `Mode sonde actif à ${round1(Math.min(params.probeCurrentA, derived.physicalCapA))}A`,
dynamic: true,
mustStopFlag: false,
});
}
}
else {
const activeSourceMode =
conditions.probeSuccessNow
? 'solar_only'
: context.sourceMode;
const holdConditionsOk =
(activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced')
? conditions.gridHoldOk
: (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential')
? conditions.solarHoldOk
: activeSourceMode === 'hybrid_probe'
? conditions.probeHoldGuardOk
: activeSourceMode === 'battery_assist_night'
? conditions.batteryAssistNightHoldOk
: false;
if (activeSourceMode === 'blocked') {
if (lifecycle.minRunElapsed) {
setOff({
stateName: 'stop_source_lost',
decisionName: 'stop',
reasonText: 'Source autorisée perdue',
dynamic: false,
mustStopFlag: true,
blockReason: context.profile === 'outside_window' ? 'outside_window' : 'source_unavailable',
blockedUntilStamp:
context.profile === 'outside_window' && Number.isFinite(locks.nextAllowedWindowStartMs)
? formatParisStampFromMs(locks.nextAllowedWindowStartMs)
: formatParisStampFromMs(nowMs + params.minStopMinutes * 60000),
});
} else {
setOn({
stateName: 'hold_min_run_source_lost',
decisionName: 'hold',
currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),
reasonText: "Source perdue mais maintien jusqu'au mini ON",
dynamic: false,
mustStopFlag: false,
});
}
}
else if (
(activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&
!parsed.gridAvailable
) {
if (lifecycle.minRunElapsed) {
setOff({
stateName:
context.profile === 'night_hc'
? 'night_wait_grid_takeover_runtime'
: 'wait_grid_source_runtime',
decisionName: 'hold',
reasonText:
context.profile === 'night_hc'
? 'Relais réseau demandé pendant la session nuit EVCS'
: 'Source réseau demandée mais pas encore disponible',
dynamic: false,
mustStopFlag: false,
blockReason:
context.profile === 'night_hc' ? 'grid_hc_unavailable' : 'grid_unavailable',
blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),
});
} else {
setOn({
stateName: 'hold_min_run_wait_grid_takeover',
decisionName: 'hold',
currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),
reasonText: "Maintien jusqu'au mini ON en attente du réseau",
dynamic: false,
mustStopFlag: false,
});
}
}
else if (!holdConditionsOk) {
if (lifecycle.minRunElapsed) {
setOff({
stateName: 'stop_conditions_lost',
decisionName: 'stop',
reasonText: 'Conditions de maintien perdues',
dynamic: false,
mustStopFlag: true,
blockReason: 'hold_conditions_lost',
blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),
});
} else {
setOn({
stateName: 'hold_min_run',
decisionName: 'hold',
currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),
reasonText: "Maintien jusqu'au temps mini ON",
dynamic: true,
mustStopFlag: false,
});
}
}
else if ((derived.currentReferenceA > derived.physicalCapA) && lifecycle.currentCooldownElapsed) {
setOn({
stateName: 'charging_step_down_system_cap',
decisionName: 'step_down',
currentA: Math.max(
params.currentMinA,
Math.min(
derived.currentReferenceA - Math.max(params.currentStepDownA, 2),
derived.physicalCapA
)
),
reasonText: 'Réduction pour respecter la capacité système',
dynamic: true,
mustStopFlag: false,
});
}
else if (conditions.shouldStepDownOverload) {
setOn({
stateName: 'charging_step_down_overload',
decisionName: 'step_down_fast',
currentA: Math.max(
params.currentMinA,
Math.min(
derived.currentReferenceA - Math.max(batteryTune.global.overloadStepDownA, params.currentStepDownA),
derived.physicalCapA
)
),
reasonText: 'Baisse rapide: surcharge convertisseur',
dynamic: true,
mustStopFlag: false,
});
}
else if (conditions.shouldStepDownVebusPower) {
setOn({
stateName: 'charging_step_down_vebus_power',
decisionName: 'step_down_fast',
currentA: Math.max(
params.currentMinA,
Math.min(
derived.currentReferenceA - Math.max(params.currentStepDownA + 1, 3),
derived.physicalCapA
)
),
reasonText: 'Baisse rapide: puissance VE.Bus élevée',
dynamic: true,
mustStopFlag: false,
});
}

View File

@@ -0,0 +1,429 @@
else if (conditions.shouldStepDownVebusVoltage) {
setOn({
stateName: 'charging_step_down_dc_voltage',
decisionName: 'step_down_fast',
currentA: Math.max(
params.currentMinA,
Math.min(
derived.currentReferenceA - Math.max(params.currentStepDownA + 1, 3),
derived.physicalCapA
)
),
reasonText: 'Baisse rapide: tension DC basse',
dynamic: true,
mustStopFlag: false,
});
}
else if (
activeSourceMode === 'battery_assist_night' &&
conditions.batteryAssistNightShouldStepDown
) {
setOn({
stateName: 'charging_step_down_battery_assist_night',
decisionName: 'step_down',
currentA: Math.max(
params.currentMinA,
Math.min(
derived.currentReferenceA - params.currentStepDownA,
derived.physicalCapA
)
),
reasonText:
nightEvcs?.batteryRecoveryUrgent
? 'Réduction: approche départ, préparation takeover réseau'
: 'Réduction: assistance batterie nuit devient trop coûteuse',
dynamic: true,
mustStopFlag: false,
});
}
else if (
(activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&
conditions.gridShouldStepDownBattery
) {
setOn({
stateName:
activeSourceMode === 'grid_forced'
? 'charging_step_down_grid_forced_guard'
: 'charging_step_down_grid_guard',
decisionName: 'step_down',
currentA: Math.max(
params.currentMinA,
Math.min(
derived.currentReferenceA - params.currentStepDownA,
derived.physicalCapA
)
),
reasonText:
activeSourceMode === 'grid_forced'
? 'Réduction réseau forcé: la batterie commence à participer'
: 'Réduction réseau: la batterie commence à participer',
dynamic: true,
mustStopFlag: false,
});
}
else if (
(activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&
conditions.solarShouldStepDownCloudDebt
) {
setOn({
stateName: 'charging_step_down_cloud_debt',
decisionName: 'step_down',
currentA: Math.max(
params.currentMinA,
Math.min(
derived.currentReferenceA - Math.max(params.currentStepDownA, 2),
derived.physicalCapA
)
),
reasonText: 'Réduction: dette nuage trop élevée',
dynamic: true,
mustStopFlag: false,
});
}
else if (
(activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&
conditions.solarShouldStepDownBattery
) {
setOn({
stateName: 'charging_step_down_battery',
decisionName: 'step_down',
currentA: Math.max(
params.currentMinA,
Math.min(
derived.currentReferenceA - params.currentStepDownA,
derived.physicalCapA
)
),
reasonText: 'Réduction: batterie trop sollicitée',
dynamic: true,
mustStopFlag: false,
});
}
else if (
(activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&
conditions.gridShouldStepUp
) {
setOn({
stateName:
activeSourceMode === 'grid_forced'
? (
nightEvcs?.batteryRecoveryUrgent
? 'night_departure_deadline_grid_forced'
: 'charging_step_up_grid_forced'
)
: 'charging_step_up_grid_hc',
decisionName: 'step_up',
currentA: clamp(
derived.currentReferenceA + params.currentStepUpA,
params.currentMinA,
derived.physicalCapA
),
reasonText:
activeSourceMode === 'grid_forced'
? (
nightEvcs?.batteryRecoveryUrgent
? 'Hausse prudente: priorité départ, réseau forcé'
: 'Hausse prudente du courant en charge réseau forcée'
)
: 'Hausse prudente du courant en HC réseau',
dynamic: true,
mustStopFlag: false,
});
}
else if (
activeSourceMode === 'battery_assist_night' &&
conditions.batteryAssistNightShouldStepUp
) {
setOn({
stateName: 'charging_step_up_battery_assist_night',
decisionName: 'step_up',
currentA: clamp(
derived.currentReferenceA + params.currentStepUpA,
params.currentMinA,
derived.physicalCapA
),
reasonText: 'Hausse prudente du courant en assistance batterie nuit',
dynamic: true,
mustStopFlag: false,
});
}
else if (
(activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&
conditions.solarShouldStepUp
) {
setOn({
stateName:
conditions.probeSuccessNow
? 'probe_success_step_up'
: (modes.batteryPriorityMode
? 'charging_step_up_battery'
: 'charging_step_up_forecast'),
decisionName: 'step_up',
currentA: clamp(
derived.currentReferenceA + params.currentStepUpA,
params.currentMinA,
derived.physicalCapA
),
reasonText: 'Hausse prudente du courant',
dynamic: true,
mustStopFlag: false,
});
}
else {
setOn({
stateName:
conditions.probeSuccessNow ? 'probe_success_hold' :
activeSourceMode === 'battery_assist_night' ? 'charging_hold_battery_assist_night' :
(activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') ? 'charging_hold_grid' :
cloud.cloudDebtActive ? 'charging_hold_cloud_tolerance' :
'charging_hold',
decisionName: 'hold',
currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),
reasonText:
activeSourceMode === 'battery_assist_night'
? 'Maintien du courant en assistance batterie nuit'
: 'Maintien du courant',
dynamic: true,
mustStopFlag: false,
});
}
}
}
// =====================================================
// START BRANCH
// =====================================================
else {
const startConditionsOk =
(context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced')
? conditions.gridStartOk
: (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential')
? conditions.solarStartOk
: context.sourceMode === 'hybrid_probe'
? conditions.probeStartOk
: context.sourceMode === 'battery_assist_night'
? conditions.batteryAssistNightStartOk
: false;
if (!derived.canRunAtMinCurrent) {
setOff({
stateName: 'blocked_by_capacity_min_current',
decisionName: 'hold',
reasonText: 'Capacité insuffisante pour courant mini',
dynamic: false,
mustStopFlag: false,
blockReason: 'system_capacity_stop',
blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),
});
}
else if (context.sourceMode === 'blocked') {
setOff({
stateName:
context.profile === 'outside_window'
? 'blocked_outside_window'
: (context.profile === 'night_hc'
? 'night_no_grid_hc_lock'
: 'idle_wait_source'),
decisionName: 'hold',
reasonText:
context.profile === 'outside_window'
? 'Hors fenêtre préférée sans source suffisante'
: context.profile === 'night_hc'
? 'Nuit sans autorisation HC réseau'
: 'Source non autorisée ou insuffisante',
dynamic: false,
mustStopFlag: false,
blockReason:
context.profile === 'outside_window'
? 'outside_window'
: context.profile === 'night_hc'
? 'grid_hc_unavailable'
: 'source_unavailable',
blockedUntilStamp:
Number.isFinite(locks.nextAllowedWindowStartMs)
? formatParisStampFromMs(locks.nextAllowedWindowStartMs)
: formatParisStampFromMs(nowMs + 15 * 60000),
});
}
else if (
(context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced') &&
!parsed.gridAvailable
) {
setOff({
stateName:
context.profile === 'night_hc'
? 'night_wait_grid_takeover'
: 'wait_grid_source',
decisionName: 'hold',
reasonText:
context.profile === 'night_hc'
? (
nightEvcs?.batteryRecoveryUrgent
? 'Réseau demandé: urgence recharge batterie avant départ'
: 'Prise de relais réseau demandée pour session nuit EVCS'
)
: 'Source réseau demandée mais pas encore disponible',
dynamic: false,
mustStopFlag: false,
blockReason:
context.profile === 'night_hc'
? 'grid_hc_unavailable'
: 'grid_unavailable',
blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),
});
}
else if (locks.startBlockedByReason) {
setOff({
stateName: 'wait_reason_lock',
decisionName: 'hold',
reasonText: `Blocage anti-cliquetis: ${history.lastBlockReason}`,
dynamic: false,
mustStopFlag: false,
blockReason: history.lastBlockReason,
blockedUntilStamp: history.blockedUntilAt || now.stamp,
});
}
else if (!startConditionsOk) {
setOff({
stateName: 'idle_wait_conditions',
decisionName: 'hold',
reasonText:
context.sourceMode === 'battery_assist_night'
? 'Conditions de démarrage nuit batterie non réunies'
: 'Conditions de démarrage non réunies',
dynamic: false,
mustStopFlag: false,
blockReason: 'none',
blockedUntilStamp: now.stamp,
});
}
else if (!lifecycle.minStopElapsed) {
setOff({
stateName: 'wait_min_stop',
decisionName: 'hold',
reasonText: 'Temps mini OFF non écoulé',
dynamic: false,
mustStopFlag: false,
blockReason: 'none',
blockedUntilStamp: now.stamp,
});
}
else {
const startCurrentA =
context.sourceMode === 'hybrid_probe'
? clamp(params.probeCurrentA, params.currentMinA, derived.physicalCapA)
: params.currentMinA;
setOn({
stateName:
context.sourceMode === 'grid_forced' ? (
nightEvcs?.batteryRecoveryUrgent
? 'night_departure_deadline_grid_forced'
: 'start_grid_forced_min_current'
) :
context.sourceMode === 'grid_hc_allowed' ? 'start_grid_hc_min_current' :
context.sourceMode === 'battery_assist_night' ? 'start_battery_assist_night_min_current' :
context.sourceMode === 'hybrid_probe' ? 'probe_start_6a' :
context.sourceMode === 'solar_potential' ? 'start_forecast_solar_potential' :
modes.batteryFullHarvestMode ? 'start_min_current_battery_full_harvest' :
modes.forecastCommitMode ? 'start_min_current_forecast_commit' :
'start_min_current',
decisionName: 'start',
currentA: startCurrentA,
reasonText:
context.sourceMode === 'grid_forced'
? (
nightEvcs?.batteryRecoveryUrgent
? 'Démarrage réseau forcé: priorité départ / recharge batterie'
: 'Démarrage en charge réseau forcée'
)
: context.sourceMode === 'grid_hc_allowed'
? 'Démarrage en fenêtre HC réseau'
: context.sourceMode === 'battery_assist_night'
? 'Démarrage nuit en assistance batterie contrôlée'
: context.sourceMode === 'hybrid_probe'
? 'Démarrage en mode sonde'
: context.sourceMode === 'solar_potential'
? 'Démarrage par potentiel solaire confirmé'
: modes.batteryFullHarvestMode
? 'Démarrage harvest solaire'
: modes.forecastCommitMode
? 'Démarrage forecast commit'
: 'Démarrage prudent à courant mini',
dynamic: true,
mustStopFlag: false,
});
nextBlockReason = 'none';
nextBlockedUntilStamp = now.stamp;
setProbeStartedAt = context.sourceMode === 'hybrid_probe';
}
}
// =====================================================
// CONTEXT REASONS
// =====================================================
if (nightEvcs?.nightSessionActive) {
reason.push(
`Nuit EVCS: target=${round1(nightEvcs.batteryMorningTargetPct)}% | trigger réseau=${round1(nightEvcs.gridTakeoverTriggerPct)}%`
);
if (Number.isFinite(nightEvcs.nightSessionStartSocPct)) {
reason.push(
`SOC départ session nuit=${round1(nightEvcs.nightSessionStartSocPct)}% | décharge autorisée=${round1(nightEvcs.maxBatteryDischargePoints)} pts`
);
}
if (nightEvcs.batteryRecoveryUrgent) {
reason.push("Urgence départ: il faut anticiper la reprise réseau");
}
}
// =====================================================
// OUTPUT
// =====================================================
return [{
json: {
...prev,
decision: {
desiredCharging,
desiredPermission,
desiredLatch,
desiredCurrentA,
state,
currentDecision,
dynamicOk,
mustStop,
reason,
nextBlockReason,
nextBlockedUntilStamp,
setCloudDebtStartedAt,
setProbeStartedAt,
setNightSessionStart,
clearNightSessionStart,
notifyWanted,
notifyTitle,
notifyMessage,
}
}
}];

View File

@@ -0,0 +1,199 @@
else if (conditions.shouldStepDownVebusVoltage) {
setOn({
stateName: 'charging_step_down_dc_voltage',
decisionName: 'step_down_fast',
currentA: Math.max(
params.currentMinA,
Math.min(
derived.currentReferenceA - Math.max(params.currentStepDownA + 1, 3),
derived.physicalCapA
)
),
reasonText: 'Baisse rapide: tension DC basse',
dynamic: true,
mustStopFlag: false,
});
}
else if (
activeSourceMode === 'battery_assist_night' &&
conditions.batteryAssistNightShouldStepDown
) {
setOn({
stateName: 'charging_step_down_battery_assist_night',
decisionName: 'step_down',
currentA: Math.max(
params.currentMinA,
Math.min(
derived.currentReferenceA - params.currentStepDownA,
derived.physicalCapA
)
),
reasonText:
nightEvcs?.batteryRecoveryUrgent
? 'Réduction: approche départ, préparation takeover réseau'
: 'Réduction: assistance batterie nuit devient trop coûteuse',
dynamic: true,
mustStopFlag: false,
});
}
else if (
(activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&
conditions.gridShouldStepDownBattery
) {
setOn({
stateName:
activeSourceMode === 'grid_forced'
? 'charging_step_down_grid_forced_guard'
: 'charging_step_down_grid_guard',
decisionName: 'step_down',
currentA: Math.max(
params.currentMinA,
Math.min(
derived.currentReferenceA - params.currentStepDownA,
derived.physicalCapA
)
),
reasonText:
activeSourceMode === 'grid_forced'
? 'Réduction réseau forcé: la batterie commence à participer'
: 'Réduction réseau: la batterie commence à participer',
dynamic: true,
mustStopFlag: false,
});
}
else if (
(activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&
conditions.solarShouldStepDownCloudDebt
) {
setOn({
stateName: 'charging_step_down_cloud_debt',
decisionName: 'step_down',
currentA: Math.max(
params.currentMinA,
Math.min(
derived.currentReferenceA - Math.max(params.currentStepDownA, 2),
derived.physicalCapA
)
),
reasonText: 'Réduction: dette nuage trop élevée',
dynamic: true,
mustStopFlag: false,
});
}
else if (
(activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&
conditions.solarShouldStepDownBattery
) {
setOn({
stateName: 'charging_step_down_battery',
decisionName: 'step_down',
currentA: Math.max(
params.currentMinA,
Math.min(
derived.currentReferenceA - params.currentStepDownA,
derived.physicalCapA
)
),
reasonText: 'Réduction: batterie trop sollicitée',
dynamic: true,
mustStopFlag: false,
});
}
else if (
(activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&
conditions.gridShouldStepUp
) {
setOn({
stateName:
activeSourceMode === 'grid_forced'
? (
nightEvcs?.batteryRecoveryUrgent
? 'night_departure_deadline_grid_forced'
: 'charging_step_up_grid_forced'
)
: 'charging_step_up_grid_hc',
decisionName: 'step_up',
currentA: clamp(
derived.currentReferenceA + params.currentStepUpA,
params.currentMinA,
derived.physicalCapA
),
reasonText:
activeSourceMode === 'grid_forced'
? (
nightEvcs?.batteryRecoveryUrgent
? 'Hausse prudente: priorité départ, réseau forcé'
: 'Hausse prudente du courant en charge réseau forcée'
)
: 'Hausse prudente du courant en HC réseau',
dynamic: true,
mustStopFlag: false,
});
}
else if (
activeSourceMode === 'battery_assist_night' &&
conditions.batteryAssistNightShouldStepUp
) {
setOn({
stateName: 'charging_step_up_battery_assist_night',
decisionName: 'step_up',
currentA: clamp(
derived.currentReferenceA + params.currentStepUpA,
params.currentMinA,
derived.physicalCapA
),
reasonText: 'Hausse prudente du courant en assistance batterie nuit',
dynamic: true,
mustStopFlag: false,
});
}
else if (
(activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&
conditions.solarShouldStepUp
) {
setOn({
stateName:
conditions.probeSuccessNow
? 'probe_success_step_up'
: (modes.batteryPriorityMode
? 'charging_step_up_battery'
: 'charging_step_up_forecast'),
decisionName: 'step_up',
currentA: clamp(
derived.currentReferenceA + params.currentStepUpA,
params.currentMinA,
derived.physicalCapA
),
reasonText: 'Hausse prudente du courant',
dynamic: true,
mustStopFlag: false,
});
}
else {
setOn({
stateName:
conditions.probeSuccessNow ? 'probe_success_hold' :
activeSourceMode === 'battery_assist_night' ? 'charging_hold_battery_assist_night' :
(activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') ? 'charging_hold_grid' :
cloud.cloudDebtActive ? 'charging_hold_cloud_tolerance' :
'charging_hold',
decisionName: 'hold',
currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),
reasonText:
activeSourceMode === 'battery_assist_night'
? 'Maintien du courant en assistance batterie nuit'
: 'Maintien du courant',
dynamic: true,
mustStopFlag: false,
});
}
}
}

View File

@@ -0,0 +1,229 @@
// =====================================================
// START BRANCH
// =====================================================
else {
const startConditionsOk =
(context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced')
? conditions.gridStartOk
: (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential')
? conditions.solarStartOk
: context.sourceMode === 'hybrid_probe'
? conditions.probeStartOk
: context.sourceMode === 'battery_assist_night'
? conditions.batteryAssistNightStartOk
: false;
if (!derived.canRunAtMinCurrent) {
setOff({
stateName: 'blocked_by_capacity_min_current',
decisionName: 'hold',
reasonText: 'Capacité insuffisante pour courant mini',
dynamic: false,
mustStopFlag: false,
blockReason: 'system_capacity_stop',
blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),
});
}
else if (context.sourceMode === 'blocked') {
setOff({
stateName:
context.profile === 'outside_window'
? 'blocked_outside_window'
: (context.profile === 'night_hc'
? 'night_no_grid_hc_lock'
: 'idle_wait_source'),
decisionName: 'hold',
reasonText:
context.profile === 'outside_window'
? 'Hors fenêtre préférée sans source suffisante'
: context.profile === 'night_hc'
? 'Nuit sans autorisation HC réseau'
: 'Source non autorisée ou insuffisante',
dynamic: false,
mustStopFlag: false,
blockReason:
context.profile === 'outside_window'
? 'outside_window'
: context.profile === 'night_hc'
? 'grid_hc_unavailable'
: 'source_unavailable',
blockedUntilStamp:
Number.isFinite(locks.nextAllowedWindowStartMs)
? formatParisStampFromMs(locks.nextAllowedWindowStartMs)
: formatParisStampFromMs(nowMs + 15 * 60000),
});
}
else if (
(context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced') &&
!parsed.gridAvailable
) {
setOff({
stateName:
context.profile === 'night_hc'
? 'night_wait_grid_takeover'
: 'wait_grid_source',
decisionName: 'hold',
reasonText:
context.profile === 'night_hc'
? (
nightEvcs?.batteryRecoveryUrgent
? 'Réseau demandé: urgence recharge batterie avant départ'
: 'Prise de relais réseau demandée pour session nuit EVCS'
)
: 'Source réseau demandée mais pas encore disponible',
dynamic: false,
mustStopFlag: false,
blockReason:
context.profile === 'night_hc'
? 'grid_hc_unavailable'
: 'grid_unavailable',
blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),
});
}
else if (locks.startBlockedByReason) {
setOff({
stateName: 'wait_reason_lock',
decisionName: 'hold',
reasonText: `Blocage anti-cliquetis: ${history.lastBlockReason}`,
dynamic: false,
mustStopFlag: false,
blockReason: history.lastBlockReason,
blockedUntilStamp: history.blockedUntilAt || now.stamp,
});
}
else if (!startConditionsOk) {
setOff({
stateName: 'idle_wait_conditions',
decisionName: 'hold',
reasonText:
context.sourceMode === 'battery_assist_night'
? 'Conditions de démarrage nuit batterie non réunies'
: 'Conditions de démarrage non réunies',
dynamic: false,
mustStopFlag: false,
blockReason: 'none',
blockedUntilStamp: now.stamp,
});
}
else if (!lifecycle.minStopElapsed) {
setOff({
stateName: 'wait_min_stop',
decisionName: 'hold',
reasonText: 'Temps mini OFF non écoulé',
dynamic: false,
mustStopFlag: false,
blockReason: 'none',
blockedUntilStamp: now.stamp,
});
}
else {
const startCurrentA =
context.sourceMode === 'hybrid_probe'
? clamp(params.probeCurrentA, params.currentMinA, derived.physicalCapA)
: params.currentMinA;
setOn({
stateName:
context.sourceMode === 'grid_forced' ? (
nightEvcs?.batteryRecoveryUrgent
? 'night_departure_deadline_grid_forced'
: 'start_grid_forced_min_current'
) :
context.sourceMode === 'grid_hc_allowed' ? 'start_grid_hc_min_current' :
context.sourceMode === 'battery_assist_night' ? 'start_battery_assist_night_min_current' :
context.sourceMode === 'hybrid_probe' ? 'probe_start_6a' :
context.sourceMode === 'solar_potential' ? 'start_forecast_solar_potential' :
modes.batteryFullHarvestMode ? 'start_min_current_battery_full_harvest' :
modes.forecastCommitMode ? 'start_min_current_forecast_commit' :
'start_min_current',
decisionName: 'start',
currentA: startCurrentA,
reasonText:
context.sourceMode === 'grid_forced'
? (
nightEvcs?.batteryRecoveryUrgent
? 'Démarrage réseau forcé: priorité départ / recharge batterie'
: 'Démarrage en charge réseau forcée'
)
: context.sourceMode === 'grid_hc_allowed'
? 'Démarrage en fenêtre HC réseau'
: context.sourceMode === 'battery_assist_night'
? 'Démarrage nuit en assistance batterie contrôlée'
: context.sourceMode === 'hybrid_probe'
? 'Démarrage en mode sonde'
: context.sourceMode === 'solar_potential'
? 'Démarrage par potentiel solaire confirmé'
: modes.batteryFullHarvestMode
? 'Démarrage harvest solaire'
: modes.forecastCommitMode
? 'Démarrage forecast commit'
: 'Démarrage prudent à courant mini',
dynamic: true,
mustStopFlag: false,
});
nextBlockReason = 'none';
nextBlockedUntilStamp = now.stamp;
setProbeStartedAt = context.sourceMode === 'hybrid_probe';
}
}
// =====================================================
// CONTEXT REASONS
// =====================================================
if (nightEvcs?.nightSessionActive) {
reason.push(
`Nuit EVCS: target=${round1(nightEvcs.batteryMorningTargetPct)}% | trigger réseau=${round1(nightEvcs.gridTakeoverTriggerPct)}%`
);
if (Number.isFinite(nightEvcs.nightSessionStartSocPct)) {
reason.push(
`SOC départ session nuit=${round1(nightEvcs.nightSessionStartSocPct)}% | décharge autorisée=${round1(nightEvcs.maxBatteryDischargePoints)} pts`
);
}
if (nightEvcs.batteryRecoveryUrgent) {
reason.push("Urgence départ: il faut anticiper la reprise réseau");
}
}
// =====================================================
// OUTPUT
// =====================================================
return [{
json: {
...prev,
decision: {
desiredCharging,
desiredPermission,
desiredLatch,
desiredCurrentA,
state,
currentDecision,
dynamicOk,
mustStop,
reason,
nextBlockReason,
nextBlockedUntilStamp,
setCloudDebtStartedAt,
setProbeStartedAt,
setNightSessionStart,
clearNightSessionStart,
notifyWanted,
notifyTitle,
notifyMessage,
}
}
}];

View File

@@ -0,0 +1,719 @@
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
}
}
}
}
}];

View File

@@ -0,0 +1,191 @@
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';
}

View File

@@ -0,0 +1,184 @@
// =====================================================
// 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,
},

View File

@@ -0,0 +1,183 @@
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
}
},

View File

@@ -0,0 +1,158 @@
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
}
}
}
}
}];

View File

@@ -0,0 +1,557 @@
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 cleanText(v, fallback = '') {
const s = (v ?? '').toString().trim();
const x = s.toLowerCase();
if (['unknown', 'unavailable', 'none', 'null', 'undefined'].includes(x)) return fallback;
return s;
}
function finiteOrNull(v) {
return Number.isFinite(v) ? v : null;
}
const raw = $input.first().json;
const input = extractSnapshot(raw);
const nowMs = Date.now();
const now = getParisParts(nowMs);
const CFG = {
mobileNotifyDomain: 'notify',
mobileNotifyService: 'mobile_app_iphone_16_de_tt',
batteryUsableCapacityKwh: 17.5,
nominalAcVoltageV: 230,
evcsDedicatedMaxW: 4900,
systemReserveW: 300,
vebusDcVoltageSoftFloorV: 49.0,
vebusDcVoltageHardFloorV: 48.6,
departurePriorityWindowMinutes: 240,
sampleSeconds: 30,
cloudDebtRecoveryThresholdKwh: 0.05,
cloudDebtMeaningfulThresholdKwh: 0.01,
};
// =====================================================
// PARSED SNAPSHOT
// =====================================================
const parsed = {
soc: clamp(num(input.soc_percent, 0), 0, 100),
batteryCurrentA: num(input.battery_current_a, 0),
pvPowerW: Math.max(0, num(input.pv_power_w, 0)),
pvPowerAvg10mW: Math.max(0, num(input.pv_power_10m_avg_w, num(input.pv_power_w, 0))),
pvTrendW: num(input.pv_trend_w, 0),
houseAcPowerW: Math.max(0, num(input.house_ac_power_w, 0)),
houseAcPowerPrevW: Math.max(0, num(input.house_ac_power_prev_w, num(input.house_ac_power_w, 0))),
gridPowerW: num(input.grid_power_w, 0),
gridIsOn: low(input.grid_state || 'off') === 'on',
acInAvailable: num(input.victron_acin1_available ?? input.ac_in1_available, 0) === 1,
gridAvailable:
(low(input.grid_state || 'off') === 'on') ||
(num(input.victron_acin1_available ?? input.ac_in1_available, 0) === 1),
todayRemainingKwh: Math.max(0, num(input.today_remaining_kwh, 0)),
evcsDeviceConnected: low(input.evcs_device_connected || 'off') === 'on',
evcsVehicleConnected: low(input.evcs_vehicle_connected || 'off') === 'on',
evcsStatusRaw: cleanText(input.evcs_status_raw),
evcsModeRaw: [
input.evcs_mode_sensor,
input.evcs_mode_fallback,
input.evcs_mode
]
.map(v => cleanText(v))
.find(v => v) || 'unknown',
evcsSetCurrentA: num(input.evcs_set_current_a, NaN),
evcsCommandCurrentA: num(input.evcs_command_current_a, NaN),
evcsCurrentA: Math.max(0, num(input.evcs_current_a, 0)),
evcsAcPowerW: Math.max(0, num(input.evcs_ac_power_w, 0)),
evcsChargingSwitchOn: low(input.evcs_charging_switch || 'off') === 'on',
evcsAutoStartSwitchOn: low(input.evcs_auto_start_switch || 'off') === 'on',
controllerEnabled: low(input.controller_enabled || 'off') === 'on',
notificationsEnabled: low(input.notifications_enabled || 'off') === 'on',
hardStopEnabledLegacy: low(input.hard_stop_enabled || 'off') === 'on',
chargePermissionNow: low(input.charge_permission_now || 'off') === 'on',
controlLatch: low(input.control_latch || 'off') === 'on',
departurePriorityEnabled: low(input.departure_priority_enabled || 'off') === 'on',
forecastModeEnabled: low(input.forecast_mode_enabled || 'off') === 'on',
evcsPriorityManualOverride: low(input.evcs_priority_manual_override || 'off') === 'on',
evcsPriorityManualMode: cleanText(input.evcs_priority_manual_mode || 'normal', 'normal').toLowerCase(),
evcsPriorityManualReason: cleanText(input.evcs_priority_manual_reason),
vebusOverload: low(input.vebus_overload || 'off') === 'on',
vebusL1Overload: low(input.vebus_l1_overload || 'off') === 'on',
vebusLowBattery: low(input.vebus_low_battery || 'off') === 'on',
vebusL1LowBattery: low(input.vebus_l1_low_battery || 'off') === 'on',
vebusAcOutPowerW: Math.max(0, num(input.vebus_ac_out_l1_power_w, 0)),
vebusAcOutCurrentA: Math.max(0, num(input.vebus_ac_out_l1_current_a, 0)),
vebusAcOutVoltageV: num(input.vebus_ac_out_l1_voltage_v, CFG.nominalAcVoltageV),
vebusDcVoltageV: num(input.vebus_dc_voltage_v, NaN),
vebusDcCurrentA: num(input.vebus_dc_current_a, NaN),
vebusDcPowerW: num(input.vebus_dc_power_w, NaN),
vebusState: num(input.vebus_state, NaN),
vebusMainState: num(input.vebus_main_state, NaN),
};
parsed.evcsModeIsAuto = low(parsed.evcsModeRaw).includes('auto');
// =====================================================
// PARAMETERS
// =====================================================
const params = {
userStrategyRaw: input.victron_evcs_strategy || 'auto_solar',
targetCurrentAutoA: clamp(num(input.target_current_auto_a, 6), 0, 32),
pvStartMinW: Math.max(0, num(input.pv_start_min_w, 1800)),
pvHoldMinW: Math.max(0, num(input.pv_hold_min_w, 1200)),
houseAcHeadroomW: Math.max(0, num(input.house_ac_headroom_w, 1200)),
batteryDischargeSoftA: num(input.battery_discharge_soft_a, -5),
batteryDischargeHardA: num(input.battery_discharge_hard_a, -15),
batteryReserveUpA: Math.max(0, num(input.battery_reserve_up_a, 8)),
batteryNeutralBandA: Math.max(0, num(input.battery_neutral_band_a, 3)),
socStartMin: clamp(num(input.soc_start_min, 60), 0, 100),
socStopMin: clamp(num(input.soc_stop_min, 35), 0, 100),
currentMinA: clamp(num(input.current_min_a, 6), 6, 32),
currentMaxAutoA: clamp(
num(input.current_max_auto_a, 16),
clamp(num(input.current_min_a, 6), 6, 32),
32
),
currentStepUpA: clamp(num(input.current_step_up_a, 1), 1, 5),
currentStepDownA: clamp(num(input.current_step_down_a, 2), 1, 8),
currentChangeCooldownSeconds: Math.max(5, num(input.current_change_cooldown_seconds, 60)),
minRunMinutes: Math.max(0, num(input.min_run_minutes, 3)),
minStopMinutes: Math.max(0, num(input.min_stop_minutes, 10)),
requiredEnergyKwh: Math.max(0, num(input.required_energy_kwh, 0)),
socComfortTarget: clamp(num(input.soc_comfort_target, 85), 0, 100),
endOfDaySocGuard: clamp(num(input.end_of_day_soc_guard, 85), 0, 100),
baseLoadRemainingKwh: Math.max(0, num(input.base_load_remaining_kwh, 0)),
flexibleSafetyMarginKwh: Math.max(0, num(input.flexible_safety_margin_kwh, 0)),
endOfDaySocTarget: clamp(num(input.end_of_day_soc_target, 85), 0, 100),
acLoadsMaxSafeW: Math.max(0, num(input.ac_loads_max_safe_w, 8500)),
batteryFullSoc: clamp(num(input.battery_full_soc, 90), 0, 100),
batteryFullForecastMinKwh: Math.max(0, num(input.battery_full_forecast_min_kwh, 8)),
forecastCommitSocFloor: clamp(num(input.forecast_commit_soc_floor, 35), 0, 100),
forecastCommitMinKwh: Math.max(0, num(input.forecast_commit_min_kwh, 25)),
cloudDebtPrevKwh: Math.max(0, num(input.cloud_debt_kwh, 0)),
cloudDebtMaxKwh: Math.max(0.05, num(input.cloud_debt_max_kwh, 1.2)),
cloudToleranceMinutes: Math.max(1, num(input.cloud_tolerance_minutes, 20)),
departureTimeMin: parseTimeToMinutes(input.departure_time, null),
dayStartTimeMin: parseTimeToMinutes(input.day_start_time, 7 * 60 + 30),
dayStopTimeMin: parseTimeToMinutes(input.day_stop_time, 20 * 60 + 30),
hcNightStartMin: parseTimeToMinutes(input.hc_night_start_time, 23 * 60),
hcNightStopMin: parseTimeToMinutes(input.hc_night_stop_time, 6 * 60),
morningStartMin: parseTimeToMinutes(
input.morning_start_time,
parseTimeToMinutes(input.day_start_time, 7 * 60 + 30)
),
morningStopMin: parseTimeToMinutes(input.morning_stop_time, 11 * 60),
hcMiddayStartMin: parseTimeToMinutes(input.hc_midday_start_time, 12 * 60 + 21),
hcMiddayStopMin: parseTimeToMinutes(input.hc_midday_stop_time, 14 * 60 + 21),
eveningStartMin: parseTimeToMinutes(input.evening_start_time, 17 * 60),
eveningStopMin: parseTimeToMinutes(
input.evening_stop_time,
parseTimeToMinutes(input.day_stop_time, 20 * 60 + 30)
),
allowGridHcNight: low(input.allow_grid_hc_night || 'off') === 'on',
allowGridHcMidday: low(input.allow_grid_hc_midday || 'off') === 'on',
probeEnabled: low(input.probe_enabled || 'off') === 'on',
probeMinutes: Math.max(1, num(input.probe_minutes, 3)),
probeRetryMinutes: Math.max(5, num(input.probe_retry_minutes, 45)),
probeCurrentA: clamp(
num(input.probe_current_a, clamp(num(input.current_min_a, 6), 6, 32)),
clamp(num(input.current_min_a, 6), 6, 32),
clamp(num(input.current_max_auto_a, 16), clamp(num(input.current_min_a, 6), 6, 32), 32)
),
gridBatterySoftA: num(input.grid_battery_soft_a, -3),
gridBatteryHardA: num(input.grid_battery_hard_a, -6),
morningProbePvAvgMinW: Math.max(0, num(input.morning_probe_pv_avg_min_w, 1500)),
morningProbePvTrendMinW: num(input.morning_probe_pv_trend_min_w, 0),
middayProbePvAvgMinW: Math.max(0, num(input.midday_probe_pv_avg_min_w, 1200)),
afternoonProbePvAvgMinW: Math.max(0, num(input.afternoon_probe_pv_avg_min_w, 1000)),
afternoonProbeSocMin: clamp(num(input.afternoon_probe_soc_min, 85), 0, 100),
afternoonBatteryMaxDischargeA: num(input.afternoon_battery_max_discharge_a, -20),
morningSocMin: clamp(num(input.morning_soc_min, 35), 0, 100),
middaySocMin: clamp(num(input.midday_soc_min, 75), 0, 100),
eveningSocStartMin: clamp(num(input.evening_soc_start_min, 85), 0, 100),
eveningSocStopMin: clamp(num(input.evening_soc_stop_min, 80), 0, 100),
morningBatteryMaxDischargeA: num(input.morning_battery_max_discharge_a, -20),
middayBatteryMaxDischargeA: num(input.midday_battery_max_discharge_a, -40),
eveningBatteryMaxDischargeA: num(input.evening_battery_max_discharge_a, -30),
// =====================================================
// EVCS NIGHT ENGINE — NEW PARAMETERS
// =====================================================
evcsNightBatteryHardFloorPct: clamp(
num(input.evcs_night_battery_hard_floor_pct, 22),
0,
100
),
evcsNightStrongForecastKwh: Math.max(
0,
num(input.evcs_night_strong_forecast_kwh, 18)
),
evcsNightMediumForecastKwh: Math.max(
0,
num(input.evcs_night_medium_forecast_kwh, 10)
),
evcsNightTargetStrongForecastPct: clamp(
num(input.evcs_night_target_strong_forecast_pct, 35),
0,
100
),
evcsNightTargetMediumForecastPct: clamp(
num(input.evcs_night_target_medium_forecast_pct, 55),
0,
100
),
evcsNightTargetWeakForecastPct: clamp(
num(input.evcs_night_target_weak_forecast_pct, 85),
0,
100
),
evcsNightBatteryRecoveryPowerKw: Math.max(
0.1,
num(input.evcs_night_battery_recovery_power_kw, 2.5)
),
evcsNightDepartureSafetyMin: Math.max(
0,
num(input.evcs_night_departure_safety_min, 30)
),
};
// =====================================================
// HISTORY / PERSISTED HELPERS
// =====================================================
const nightSessionStartSocRaw = num(
input.evcs_night_session_start_soc ?? input.night_session_start_soc,
NaN
);
const nightSessionStartAtRaw = cleanText(
input.evcs_night_session_start_at ?? input.night_session_start_at
);
const history = {
lastBlockReason: cleanText(input.last_block_reason, 'none') || 'none',
blockedUntilAt: cleanText(input.blocked_until),
probeStartedAt: cleanText(input.probe_started_at),
lastStartAt: cleanText(input.last_start_at),
lastStopAt: cleanText(input.last_stop_at),
lastCurrentChangeAt: cleanText(input.last_current_change_at),
lastSignature: cleanText(input.last_signature),
cloudDebtStartedAt: cleanText(input.cloud_debt_started_at),
evcsPriorityManualUntilAt: cleanText(input.evcs_priority_manual_until),
blockedUntilMs: parseParisStampToMs(cleanText(input.blocked_until)),
probeStartedMs: parseParisStampToMs(cleanText(input.probe_started_at)),
lastStartMs: parseParisStampToMs(cleanText(input.last_start_at)),
lastStopMs: parseParisStampToMs(cleanText(input.last_stop_at)),
lastCurrentChangeMs: parseParisStampToMs(cleanText(input.last_current_change_at)),
cloudDebtStartedMsPrev: parseParisStampToMs(cleanText(input.cloud_debt_started_at)),
evcsPriorityManualUntilMs: parseParisStampToMs(cleanText(input.evcs_priority_manual_until)),
// =====================================================
// EVCS NIGHT SESSION MEMORY
// =====================================================
nightSessionStartSocPct: Number.isFinite(nightSessionStartSocRaw)
? clamp(nightSessionStartSocRaw, 0, 100)
: null,
nightSessionStartAt: nightSessionStartAtRaw || '',
nightSessionStartMs: parseParisStampToMs(nightSessionStartAtRaw),
};
// =====================================================
// BATTERY TUNING
// =====================================================
const batteryTune = {
global: {
houseSpikeGraceMinutes: Math.max(0, num(input.house_spike_grace_minutes, 10)),
solarPotentialGraceMinutes: Math.max(0, num(input.solar_potential_grace_minutes, 12)),
stepUpCooldownSeconds: Math.max(5, num(input.step_up_cooldown_seconds, params.currentChangeCooldownSeconds)),
stepDownCooldownSeconds: Math.max(5, num(input.step_down_cooldown_seconds, params.currentChangeCooldownSeconds)),
forceModeAutoRevertMinutes: Math.max(0, num(input.force_mode_auto_revert_minutes, 120)),
overloadStepDownA: Math.max(1, num(input.overload_step_down_a, 2)),
overloadHoldSeconds: Math.max(5, num(input.overload_hold_seconds, 30)),
overloadStopAfterSeconds: Math.max(10, num(input.overload_stop_after_seconds, 120)),
},
bands: {
gt90: {
dischargeSoftA: num(input.batt_discharge_soft_gt90_a, -50),
dischargeHardA: num(input.batt_discharge_hard_gt90_a, -100),
chargeReserveA: Math.max(0, num(input.batt_charge_reserve_gt90_a, 5)),
softToleranceMin: Math.max(0, num(input.batt_soft_tol_gt90_min, 12)),
hardToleranceSec: Math.max(0, num(input.batt_hard_tol_gt90_sec, 20)),
recoveryMin: Math.max(0, num(input.batt_recovery_gt90_min, 6)),
},
'80_90': {
dischargeSoftA: num(input.batt_discharge_soft_80_90_a, -40),
dischargeHardA: num(input.batt_discharge_hard_80_90_a, -80),
chargeReserveA: Math.max(0, num(input.batt_charge_reserve_80_90_a, 10)),
softToleranceMin: Math.max(0, num(input.batt_soft_tol_80_90_min, 10)),
hardToleranceSec: Math.max(0, num(input.batt_hard_tol_80_90_sec, 20)),
recoveryMin: Math.max(0, num(input.batt_recovery_80_90_min, 6)),
},
'60_80': {
dischargeSoftA: num(input.batt_discharge_soft_60_80_a, -20),
dischargeHardA: num(input.batt_discharge_hard_60_80_a, -50),
chargeReserveA: Math.max(0, num(input.batt_charge_reserve_60_80_a, 40)),
softToleranceMin: Math.max(0, num(input.batt_soft_tol_60_80_min, 6)),
hardToleranceSec: Math.max(0, num(input.batt_hard_tol_60_80_sec, 15)),
recoveryMin: Math.max(0, num(input.batt_recovery_60_80_min, 8)),
},
'40_60': {
dischargeSoftA: num(input.batt_discharge_soft_40_60_a, -12),
dischargeHardA: num(input.batt_discharge_hard_40_60_a, -30),
chargeReserveA: Math.max(0, num(input.batt_charge_reserve_40_60_a, 70)),
softToleranceMin: Math.max(0, num(input.batt_soft_tol_40_60_min, 4)),
hardToleranceSec: Math.max(0, num(input.batt_hard_tol_40_60_sec, 10)),
recoveryMin: Math.max(0, num(input.batt_recovery_40_60_min, 10)),
},
'25_40': {
dischargeSoftA: num(input.batt_discharge_soft_25_40_a, -5),
dischargeHardA: num(input.batt_discharge_hard_25_40_a, -15),
chargeReserveA: Math.max(0, num(input.batt_charge_reserve_25_40_a, 110)),
softToleranceMin: Math.max(0, num(input.batt_soft_tol_25_40_min, 2)),
hardToleranceSec: Math.max(0, num(input.batt_hard_tol_25_40_sec, 5)),
recoveryMin: Math.max(0, num(input.batt_recovery_25_40_min, 12)),
},
lt25: {
dischargeSoftA: num(input.batt_discharge_soft_lt25_a, -3),
dischargeHardA: num(input.batt_discharge_hard_lt25_a, -8),
chargeReserveA: Math.max(0, num(input.batt_charge_reserve_lt25_a, 150)),
softToleranceMin: Math.max(0, num(input.batt_soft_tol_lt25_min, 1)),
hardToleranceSec: Math.max(0, num(input.batt_hard_tol_lt25_sec, 2)),
recoveryMin: Math.max(0, num(input.batt_recovery_lt25_min, 15)),
},
},
};
// =====================================================
// OPTIONAL DEBUG / SUMMARY
// =====================================================
const evcsNightConfig = {
batteryHardFloorPct: params.evcsNightBatteryHardFloorPct,
strongForecastKwh: params.evcsNightStrongForecastKwh,
mediumForecastKwh: params.evcsNightMediumForecastKwh,
targetStrongForecastPct: params.evcsNightTargetStrongForecastPct,
targetMediumForecastPct: params.evcsNightTargetMediumForecastPct,
targetWeakForecastPct: params.evcsNightTargetWeakForecastPct,
batteryRecoveryPowerKw: params.evcsNightBatteryRecoveryPowerKw,
departureSafetyMin: params.evcsNightDepartureSafetyMin,
sessionStartSocPct: history.nightSessionStartSocPct,
sessionStartAt: history.nightSessionStartAt || null,
};
// =====================================================
// OUTPUT
// =====================================================
return [{
json: {
raw,
input,
nowMs,
now,
cfg: CFG,
parsed,
params,
history,
batteryTune,
evcsNightConfig,
}
}];

View File

@@ -0,0 +1,260 @@
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 cleanText(v, fallback = '') {
const s = (v ?? '').toString().trim();
const x = s.toLowerCase();
if (['unknown', 'unavailable', 'none', 'null', 'undefined'].includes(x)) return fallback;
return s;
}
function finiteOrNull(v) {
return Number.isFinite(v) ? v : null;
}
const raw = $input.first().json;
const input = extractSnapshot(raw);
const nowMs = Date.now();
const now = getParisParts(nowMs);
const CFG = {
mobileNotifyDomain: 'notify',
mobileNotifyService: 'mobile_app_iphone_16_de_tt',
batteryUsableCapacityKwh: 17.5,
nominalAcVoltageV: 230,
evcsDedicatedMaxW: 4900,
systemReserveW: 300,
vebusDcVoltageSoftFloorV: 49.0,
vebusDcVoltageHardFloorV: 48.6,
departurePriorityWindowMinutes: 240,
sampleSeconds: 30,
cloudDebtRecoveryThresholdKwh: 0.05,
cloudDebtMeaningfulThresholdKwh: 0.01,
};
// =====================================================
// PARSED SNAPSHOT
// =====================================================
const parsed = {
soc: clamp(num(input.soc_percent, 0), 0, 100),
batteryCurrentA: num(input.battery_current_a, 0),
pvPowerW: Math.max(0, num(input.pv_power_w, 0)),
pvPowerAvg10mW: Math.max(0, num(input.pv_power_10m_avg_w, num(input.pv_power_w, 0))),
pvTrendW: num(input.pv_trend_w, 0),
houseAcPowerW: Math.max(0, num(input.house_ac_power_w, 0)),
houseAcPowerPrevW: Math.max(0, num(input.house_ac_power_prev_w, num(input.house_ac_power_w, 0))),
gridPowerW: num(input.grid_power_w, 0),
gridIsOn: low(input.grid_state || 'off') === 'on',
acInAvailable: num(input.victron_acin1_available ?? input.ac_in1_available, 0) === 1,
gridAvailable:
(low(input.grid_state || 'off') === 'on') ||
(num(input.victron_acin1_available ?? input.ac_in1_available, 0) === 1),
todayRemainingKwh: Math.max(0, num(input.today_remaining_kwh, 0)),
evcsDeviceConnected: low(input.evcs_device_connected || 'off') === 'on',
evcsVehicleConnected: low(input.evcs_vehicle_connected || 'off') === 'on',
evcsStatusRaw: cleanText(input.evcs_status_raw),
evcsModeRaw: [
input.evcs_mode_sensor,
input.evcs_mode_fallback,
input.evcs_mode
]
.map(v => cleanText(v))
.find(v => v) || 'unknown',
evcsSetCurrentA: num(input.evcs_set_current_a, NaN),
evcsCommandCurrentA: num(input.evcs_command_current_a, NaN),
evcsCurrentA: Math.max(0, num(input.evcs_current_a, 0)),
evcsAcPowerW: Math.max(0, num(input.evcs_ac_power_w, 0)),
evcsChargingSwitchOn: low(input.evcs_charging_switch || 'off') === 'on',
evcsAutoStartSwitchOn: low(input.evcs_auto_start_switch || 'off') === 'on',
controllerEnabled: low(input.controller_enabled || 'off') === 'on',
notificationsEnabled: low(input.notifications_enabled || 'off') === 'on',
hardStopEnabledLegacy: low(input.hard_stop_enabled || 'off') === 'on',
chargePermissionNow: low(input.charge_permission_now || 'off') === 'on',
controlLatch: low(input.control_latch || 'off') === 'on',
departurePriorityEnabled: low(input.departure_priority_enabled || 'off') === 'on',
forecastModeEnabled: low(input.forecast_mode_enabled || 'off') === 'on',
evcsPriorityManualOverride: low(input.evcs_priority_manual_override || 'off') === 'on',
evcsPriorityManualMode: cleanText(input.evcs_priority_manual_mode || 'normal', 'normal').toLowerCase(),
evcsPriorityManualReason: cleanText(input.evcs_priority_manual_reason),
vebusOverload: low(input.vebus_overload || 'off') === 'on',
vebusL1Overload: low(input.vebus_l1_overload || 'off') === 'on',
vebusLowBattery: low(input.vebus_low_battery || 'off') === 'on',
vebusL1LowBattery: low(input.vebus_l1_low_battery || 'off') === 'on',
vebusAcOutPowerW: Math.max(0, num(input.vebus_ac_out_l1_power_w, 0)),
vebusAcOutCurrentA: Math.max(0, num(input.vebus_ac_out_l1_current_a, 0)),
vebusAcOutVoltageV: num(input.vebus_ac_out_l1_voltage_v, CFG.nominalAcVoltageV),
vebusDcVoltageV: num(input.vebus_dc_voltage_v, NaN),
vebusDcCurrentA: num(input.vebus_dc_current_a, NaN),
vebusDcPowerW: num(input.vebus_dc_power_w, NaN),
vebusState: num(input.vebus_state, NaN),
vebusMainState: num(input.vebus_main_state, NaN),
};
parsed.evcsModeIsAuto = low(parsed.evcsModeRaw).includes('auto');

View File

@@ -0,0 +1,296 @@
// =====================================================
// PARAMETERS
// =====================================================
const params = {
userStrategyRaw: input.victron_evcs_strategy || 'auto_solar',
targetCurrentAutoA: clamp(num(input.target_current_auto_a, 6), 0, 32),
pvStartMinW: Math.max(0, num(input.pv_start_min_w, 1800)),
pvHoldMinW: Math.max(0, num(input.pv_hold_min_w, 1200)),
houseAcHeadroomW: Math.max(0, num(input.house_ac_headroom_w, 1200)),
batteryDischargeSoftA: num(input.battery_discharge_soft_a, -5),
batteryDischargeHardA: num(input.battery_discharge_hard_a, -15),
batteryReserveUpA: Math.max(0, num(input.battery_reserve_up_a, 8)),
batteryNeutralBandA: Math.max(0, num(input.battery_neutral_band_a, 3)),
socStartMin: clamp(num(input.soc_start_min, 60), 0, 100),
socStopMin: clamp(num(input.soc_stop_min, 35), 0, 100),
currentMinA: clamp(num(input.current_min_a, 6), 6, 32),
currentMaxAutoA: clamp(
num(input.current_max_auto_a, 16),
clamp(num(input.current_min_a, 6), 6, 32),
32
),
currentStepUpA: clamp(num(input.current_step_up_a, 1), 1, 5),
currentStepDownA: clamp(num(input.current_step_down_a, 2), 1, 8),
currentChangeCooldownSeconds: Math.max(5, num(input.current_change_cooldown_seconds, 60)),
minRunMinutes: Math.max(0, num(input.min_run_minutes, 3)),
minStopMinutes: Math.max(0, num(input.min_stop_minutes, 10)),
requiredEnergyKwh: Math.max(0, num(input.required_energy_kwh, 0)),
socComfortTarget: clamp(num(input.soc_comfort_target, 85), 0, 100),
endOfDaySocGuard: clamp(num(input.end_of_day_soc_guard, 85), 0, 100),
baseLoadRemainingKwh: Math.max(0, num(input.base_load_remaining_kwh, 0)),
flexibleSafetyMarginKwh: Math.max(0, num(input.flexible_safety_margin_kwh, 0)),
endOfDaySocTarget: clamp(num(input.end_of_day_soc_target, 85), 0, 100),
acLoadsMaxSafeW: Math.max(0, num(input.ac_loads_max_safe_w, 8500)),
batteryFullSoc: clamp(num(input.battery_full_soc, 90), 0, 100),
batteryFullForecastMinKwh: Math.max(0, num(input.battery_full_forecast_min_kwh, 8)),
forecastCommitSocFloor: clamp(num(input.forecast_commit_soc_floor, 35), 0, 100),
forecastCommitMinKwh: Math.max(0, num(input.forecast_commit_min_kwh, 25)),
cloudDebtPrevKwh: Math.max(0, num(input.cloud_debt_kwh, 0)),
cloudDebtMaxKwh: Math.max(0.05, num(input.cloud_debt_max_kwh, 1.2)),
cloudToleranceMinutes: Math.max(1, num(input.cloud_tolerance_minutes, 20)),
departureTimeMin: parseTimeToMinutes(input.departure_time, null),
dayStartTimeMin: parseTimeToMinutes(input.day_start_time, 7 * 60 + 30),
dayStopTimeMin: parseTimeToMinutes(input.day_stop_time, 20 * 60 + 30),
hcNightStartMin: parseTimeToMinutes(input.hc_night_start_time, 23 * 60),
hcNightStopMin: parseTimeToMinutes(input.hc_night_stop_time, 6 * 60),
morningStartMin: parseTimeToMinutes(
input.morning_start_time,
parseTimeToMinutes(input.day_start_time, 7 * 60 + 30)
),
morningStopMin: parseTimeToMinutes(input.morning_stop_time, 11 * 60),
hcMiddayStartMin: parseTimeToMinutes(input.hc_midday_start_time, 12 * 60 + 21),
hcMiddayStopMin: parseTimeToMinutes(input.hc_midday_stop_time, 14 * 60 + 21),
eveningStartMin: parseTimeToMinutes(input.evening_start_time, 17 * 60),
eveningStopMin: parseTimeToMinutes(
input.evening_stop_time,
parseTimeToMinutes(input.day_stop_time, 20 * 60 + 30)
),
allowGridHcNight: low(input.allow_grid_hc_night || 'off') === 'on',
allowGridHcMidday: low(input.allow_grid_hc_midday || 'off') === 'on',
probeEnabled: low(input.probe_enabled || 'off') === 'on',
probeMinutes: Math.max(1, num(input.probe_minutes, 3)),
probeRetryMinutes: Math.max(5, num(input.probe_retry_minutes, 45)),
probeCurrentA: clamp(
num(input.probe_current_a, clamp(num(input.current_min_a, 6), 6, 32)),
clamp(num(input.current_min_a, 6), 6, 32),
clamp(num(input.current_max_auto_a, 16), clamp(num(input.current_min_a, 6), 6, 32), 32)
),
gridBatterySoftA: num(input.grid_battery_soft_a, -3),
gridBatteryHardA: num(input.grid_battery_hard_a, -6),
morningProbePvAvgMinW: Math.max(0, num(input.morning_probe_pv_avg_min_w, 1500)),
morningProbePvTrendMinW: num(input.morning_probe_pv_trend_min_w, 0),
middayProbePvAvgMinW: Math.max(0, num(input.midday_probe_pv_avg_min_w, 1200)),
afternoonProbePvAvgMinW: Math.max(0, num(input.afternoon_probe_pv_avg_min_w, 1000)),
afternoonProbeSocMin: clamp(num(input.afternoon_probe_soc_min, 85), 0, 100),
afternoonBatteryMaxDischargeA: num(input.afternoon_battery_max_discharge_a, -20),
morningSocMin: clamp(num(input.morning_soc_min, 35), 0, 100),
middaySocMin: clamp(num(input.midday_soc_min, 75), 0, 100),
eveningSocStartMin: clamp(num(input.evening_soc_start_min, 85), 0, 100),
eveningSocStopMin: clamp(num(input.evening_soc_stop_min, 80), 0, 100),
morningBatteryMaxDischargeA: num(input.morning_battery_max_discharge_a, -20),
middayBatteryMaxDischargeA: num(input.midday_battery_max_discharge_a, -40),
eveningBatteryMaxDischargeA: num(input.evening_battery_max_discharge_a, -30),
// =====================================================
// EVCS NIGHT ENGINE — NEW PARAMETERS
// =====================================================
evcsNightBatteryHardFloorPct: clamp(
num(input.evcs_night_battery_hard_floor_pct, 22),
0,
100
),
evcsNightStrongForecastKwh: Math.max(
0,
num(input.evcs_night_strong_forecast_kwh, 18)
),
evcsNightMediumForecastKwh: Math.max(
0,
num(input.evcs_night_medium_forecast_kwh, 10)
),
evcsNightTargetStrongForecastPct: clamp(
num(input.evcs_night_target_strong_forecast_pct, 35),
0,
100
),
evcsNightTargetMediumForecastPct: clamp(
num(input.evcs_night_target_medium_forecast_pct, 55),
0,
100
),
evcsNightTargetWeakForecastPct: clamp(
num(input.evcs_night_target_weak_forecast_pct, 85),
0,
100
),
evcsNightBatteryRecoveryPowerKw: Math.max(
0.1,
num(input.evcs_night_battery_recovery_power_kw, 2.5)
),
evcsNightDepartureSafetyMin: Math.max(
0,
num(input.evcs_night_departure_safety_min, 30)
),
};
// =====================================================
// HISTORY / PERSISTED HELPERS
// =====================================================
const nightSessionStartSocRaw = num(
input.evcs_night_session_start_soc ?? input.night_session_start_soc,
NaN
);
const nightSessionStartAtRaw = cleanText(
input.evcs_night_session_start_at ?? input.night_session_start_at
);
const history = {
lastBlockReason: cleanText(input.last_block_reason, 'none') || 'none',
blockedUntilAt: cleanText(input.blocked_until),
probeStartedAt: cleanText(input.probe_started_at),
lastStartAt: cleanText(input.last_start_at),
lastStopAt: cleanText(input.last_stop_at),
lastCurrentChangeAt: cleanText(input.last_current_change_at),
lastSignature: cleanText(input.last_signature),
cloudDebtStartedAt: cleanText(input.cloud_debt_started_at),
evcsPriorityManualUntilAt: cleanText(input.evcs_priority_manual_until),
blockedUntilMs: parseParisStampToMs(cleanText(input.blocked_until)),
probeStartedMs: parseParisStampToMs(cleanText(input.probe_started_at)),
lastStartMs: parseParisStampToMs(cleanText(input.last_start_at)),
lastStopMs: parseParisStampToMs(cleanText(input.last_stop_at)),
lastCurrentChangeMs: parseParisStampToMs(cleanText(input.last_current_change_at)),
cloudDebtStartedMsPrev: parseParisStampToMs(cleanText(input.cloud_debt_started_at)),
evcsPriorityManualUntilMs: parseParisStampToMs(cleanText(input.evcs_priority_manual_until)),
// =====================================================
// EVCS NIGHT SESSION MEMORY
// =====================================================
nightSessionStartSocPct: Number.isFinite(nightSessionStartSocRaw)
? clamp(nightSessionStartSocRaw, 0, 100)
: null,
nightSessionStartAt: nightSessionStartAtRaw || '',
nightSessionStartMs: parseParisStampToMs(nightSessionStartAtRaw),
};
// =====================================================
// BATTERY TUNING
// =====================================================
const batteryTune = {
global: {
houseSpikeGraceMinutes: Math.max(0, num(input.house_spike_grace_minutes, 10)),
solarPotentialGraceMinutes: Math.max(0, num(input.solar_potential_grace_minutes, 12)),
stepUpCooldownSeconds: Math.max(5, num(input.step_up_cooldown_seconds, params.currentChangeCooldownSeconds)),
stepDownCooldownSeconds: Math.max(5, num(input.step_down_cooldown_seconds, params.currentChangeCooldownSeconds)),
forceModeAutoRevertMinutes: Math.max(0, num(input.force_mode_auto_revert_minutes, 120)),
overloadStepDownA: Math.max(1, num(input.overload_step_down_a, 2)),
overloadHoldSeconds: Math.max(5, num(input.overload_hold_seconds, 30)),
overloadStopAfterSeconds: Math.max(10, num(input.overload_stop_after_seconds, 120)),
},
bands: {
gt90: {
dischargeSoftA: num(input.batt_discharge_soft_gt90_a, -50),
dischargeHardA: num(input.batt_discharge_hard_gt90_a, -100),
chargeReserveA: Math.max(0, num(input.batt_charge_reserve_gt90_a, 5)),
softToleranceMin: Math.max(0, num(input.batt_soft_tol_gt90_min, 12)),
hardToleranceSec: Math.max(0, num(input.batt_hard_tol_gt90_sec, 20)),
recoveryMin: Math.max(0, num(input.batt_recovery_gt90_min, 6)),
},
'80_90': {
dischargeSoftA: num(input.batt_discharge_soft_80_90_a, -40),
dischargeHardA: num(input.batt_discharge_hard_80_90_a, -80),
chargeReserveA: Math.max(0, num(input.batt_charge_reserve_80_90_a, 10)),
softToleranceMin: Math.max(0, num(input.batt_soft_tol_80_90_min, 10)),
hardToleranceSec: Math.max(0, num(input.batt_hard_tol_80_90_sec, 20)),
recoveryMin: Math.max(0, num(input.batt_recovery_80_90_min, 6)),
},
'60_80': {
dischargeSoftA: num(input.batt_discharge_soft_60_80_a, -20),
dischargeHardA: num(input.batt_discharge_hard_60_80_a, -50),
chargeReserveA: Math.max(0, num(input.batt_charge_reserve_60_80_a, 40)),
softToleranceMin: Math.max(0, num(input.batt_soft_tol_60_80_min, 6)),
hardToleranceSec: Math.max(0, num(input.batt_hard_tol_60_80_sec, 15)),
recoveryMin: Math.max(0, num(input.batt_recovery_60_80_min, 8)),
},
'40_60': {
dischargeSoftA: num(input.batt_discharge_soft_40_60_a, -12),
dischargeHardA: num(input.batt_discharge_hard_40_60_a, -30),
chargeReserveA: Math.max(0, num(input.batt_charge_reserve_40_60_a, 70)),
softToleranceMin: Math.max(0, num(input.batt_soft_tol_40_60_min, 4)),
hardToleranceSec: Math.max(0, num(input.batt_hard_tol_40_60_sec, 10)),
recoveryMin: Math.max(0, num(input.batt_recovery_40_60_min, 10)),
},
'25_40': {
dischargeSoftA: num(input.batt_discharge_soft_25_40_a, -5),
dischargeHardA: num(input.batt_discharge_hard_25_40_a, -15),
chargeReserveA: Math.max(0, num(input.batt_charge_reserve_25_40_a, 110)),
softToleranceMin: Math.max(0, num(input.batt_soft_tol_25_40_min, 2)),
hardToleranceSec: Math.max(0, num(input.batt_hard_tol_25_40_sec, 5)),
recoveryMin: Math.max(0, num(input.batt_recovery_25_40_min, 12)),
},
lt25: {
dischargeSoftA: num(input.batt_discharge_soft_lt25_a, -3),
dischargeHardA: num(input.batt_discharge_hard_lt25_a, -8),
chargeReserveA: Math.max(0, num(input.batt_charge_reserve_lt25_a, 150)),
softToleranceMin: Math.max(0, num(input.batt_soft_tol_lt25_min, 1)),
hardToleranceSec: Math.max(0, num(input.batt_hard_tol_lt25_sec, 2)),
recoveryMin: Math.max(0, num(input.batt_recovery_lt25_min, 15)),
},
},
};
// =====================================================
// OPTIONAL DEBUG / SUMMARY
// =====================================================
const evcsNightConfig = {
batteryHardFloorPct: params.evcsNightBatteryHardFloorPct,
strongForecastKwh: params.evcsNightStrongForecastKwh,
mediumForecastKwh: params.evcsNightMediumForecastKwh,
targetStrongForecastPct: params.evcsNightTargetStrongForecastPct,
targetMediumForecastPct: params.evcsNightTargetMediumForecastPct,
targetWeakForecastPct: params.evcsNightTargetWeakForecastPct,
batteryRecoveryPowerKw: params.evcsNightBatteryRecoveryPowerKw,
departureSafetyMin: params.evcsNightDepartureSafetyMin,
sessionStartSocPct: history.nightSessionStartSocPct,
sessionStartAt: history.nightSessionStartAt || null,
};
// =====================================================
// OUTPUT
// =====================================================
return [{
json: {
raw,
input,
nowMs,
now,
cfg: CFG,
parsed,
params,
history,
batteryTune,
evcsNightConfig,
}
}];

View File

@@ -0,0 +1,667 @@
function clamp(v, min, max) {
return Math.min(Math.max(v, min), max);
}
function to2(n) {
return String(n).padStart(2, '0');
}
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}`;
return {
date,
hour,
minute: Number(parts.minute),
second: Number(parts.second),
};
}
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]);
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 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 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 prev = $input.first().json;
const {
nowMs,
cfg,
parsed,
params,
history,
batteryTune,
strategy,
derived,
context,
lifecycle,
} = prev;
// =====================================================
// SESSION / SOURCE
// =====================================================
const sessionObservedActive =
parsed.evcsChargingSwitchOn ||
parsed.controlLatch ||
parsed.chargePermissionNow;
const isGridSource =
context.sourceMode === 'grid_hc_allowed' ||
context.sourceMode === 'grid_forced';
// =====================================================
// SOC BAND / BATTERY DYNAMIC POLICY
// =====================================================
const socBand =
parsed.soc > 90 ? 'gt90' :
parsed.soc > 80 ? '80_90' :
parsed.soc > 60 ? '60_80' :
parsed.soc > 40 ? '40_60' :
parsed.soc > 25 ? '25_40' :
'lt25';
const band = batteryTune.bands[socBand];
const dischargeModeFactor =
strategy.forceChargeRequested ? 1.2 :
strategy.forceGridRequested ? 0.7 :
strategy.userStrategy === 'auto_solar' ? 0.8 :
1.0;
const chargeReserveModeFactor =
strategy.forceChargeRequested ? 0.8 :
strategy.forceGridRequested ? 1.1 :
strategy.userStrategy === 'auto_solar' ? 1.1 :
1.0;
const toleranceModeFactor =
strategy.forceChargeRequested ? 1.25 :
strategy.forceGridRequested ? 0.8 :
strategy.userStrategy === 'auto_solar' ? 0.8 :
1.0;
const batteryDischargeSoftDynamicA = band.dischargeSoftA * dischargeModeFactor;
const batteryDischargeHardDynamicA = band.dischargeHardA * dischargeModeFactor;
const batteryChargeReserveDynamicA = Math.max(0, band.chargeReserveA * chargeReserveModeFactor);
const batterySoftToleranceMinutes = band.softToleranceMin * toleranceModeFactor;
const batteryHardToleranceSeconds = band.hardToleranceSec;
const batteryRecoveryMinutes = band.recoveryMin;
// =====================================================
// CONTEXT HELPERS
// =====================================================
const houseSpikeThresholdW = 1500;
const houseSpikeDetected =
(parsed.houseAcPowerW - parsed.houseAcPowerPrevW) >= houseSpikeThresholdW;
const solarPotentialContext =
context.sourceMode === 'solar_potential' ||
(
parsed.soc >= 80 &&
derived.forecastBufferKwh > 0 &&
parsed.pvPowerW < context.effectivePvStartMinW &&
derived.houseNonEvcsW < 1200
);
// =====================================================
// REASON-BASED LOCKS
// =====================================================
const nextWindowCandidates = [
nextDailyOccurrenceMs(params.morningStartMin, nowMs),
nextDailyOccurrenceMs(params.hcMiddayStartMin, nowMs),
nextDailyOccurrenceMs(params.eveningStartMin, nowMs),
nextDailyOccurrenceMs(params.hcNightStartMin, nowMs),
].filter(v => Number.isFinite(v));
const nextAllowedWindowStartMs =
nextWindowCandidates.length > 0 ? Math.min(...nextWindowCandidates) : null;
const blockActive =
history.blockedUntilMs !== null &&
history.blockedUntilMs > nowMs + 1000;
const blockStillRelevant =
history.lastBlockReason === 'outside_window'
? context.profile === 'outside_window'
: history.lastBlockReason === 'probe_failed'
? context.sourceMode === 'hybrid_probe'
: history.lastBlockReason === 'battery_protection_stop'
? (
parsed.batteryCurrentA < -params.batteryNeutralBandA ||
derived.anyVebusLowBattery ||
derived.vebusDcVoltageSoftLow
)
: history.lastBlockReason === 'grid_battery_guard'
? isGridSource
: history.lastBlockReason === 'vebus_overload_persistent'
? derived.anyVebusOverload
: false;
const startBlockedByReason = blockActive && blockStillRelevant;
// =====================================================
// CLOUD DEBT
// =====================================================
const batteryNetPowerW = parsed.batteryCurrentA * derived.batteryVoltageRef;
let cloudDebtNextKwh = params.cloudDebtPrevKwh;
if (sessionObservedActive) {
if (batteryNetPowerW < 0 && !isGridSource) {
cloudDebtNextKwh += (Math.abs(batteryNetPowerW) * cfg.sampleSeconds) / 3600000;
} else if (batteryNetPowerW > 0) {
cloudDebtNextKwh -= (batteryNetPowerW * cfg.sampleSeconds) / 3600000;
}
} else if (batteryNetPowerW > 0) {
cloudDebtNextKwh -= (batteryNetPowerW * cfg.sampleSeconds) / 3600000;
}
cloudDebtNextKwh = clamp(cloudDebtNextKwh, 0, params.cloudDebtMaxKwh * 4);
const cloudDebtMeaningful =
cloudDebtNextKwh > cfg.cloudDebtMeaningfulThresholdKwh;
const cloudEventDischargingNow =
sessionObservedActive &&
!isGridSource &&
parsed.batteryCurrentA < -Math.max(1, params.batteryNeutralBandA) &&
cloudDebtMeaningful;
const continuingCloudEvent =
cloudEventDischargingNow &&
history.cloudDebtStartedMsPrev !== null &&
params.cloudDebtPrevKwh > cfg.cloudDebtRecoveryThresholdKwh;
const enteringCloudDebt =
cloudEventDischargingNow && !continuingCloudEvent;
const cloudDebtActive = cloudEventDischargingNow;
const cloudDebtStartedMs =
cloudEventDischargingNow
? (continuingCloudEvent ? history.cloudDebtStartedMsPrev : nowMs)
: null;
const cloudDebtDurationMinutes =
cloudDebtActive && cloudDebtStartedMs !== null
? Math.max(0, (nowMs - cloudDebtStartedMs) / 60000)
: 0;
const effectiveCloudToleranceMinutes =
params.cloudToleranceMinutes +
(solarPotentialContext ? batteryTune.global.solarPotentialGraceMinutes : 0) +
(houseSpikeDetected ? batteryTune.global.houseSpikeGraceMinutes : 0);
const cloudToleranceOk =
cloudDebtNextKwh <= params.cloudDebtMaxKwh &&
(
!cloudDebtActive ||
cloudDebtDurationMinutes <= effectiveCloudToleranceMinutes
);
// =====================================================
// LEGACY / FORECAST MODES
// =====================================================
const batteryFullHarvestMode =
parsed.forecastModeEnabled &&
context.profile !== 'night_hc' &&
context.profile !== 'outside_window' &&
parsed.soc >= params.batteryFullSoc &&
parsed.todayRemainingKwh >= params.batteryFullForecastMinKwh &&
derived.projectedEndSocAfterCharge >= (derived.endDayGuardSoc - 2);
const forecastCommitMode =
parsed.forecastModeEnabled &&
context.profile !== 'night_hc' &&
context.profile !== 'outside_window' &&
parsed.soc >= params.forecastCommitSocFloor &&
parsed.todayRemainingKwh >= params.forecastCommitMinKwh &&
derived.projectedEndSocAfterCharge >= derived.endDayGuardSoc;
const batteryPriorityMode =
!batteryFullHarvestMode && !forecastCommitMode;
// =====================================================
// PROFILE-BASED THRESHOLDS
// =====================================================
const profileSocStartFloor =
context.profile === 'morning_solar' ? params.morningSocMin :
context.profile === 'midday_hc_or_solar' ? params.middaySocMin :
context.profile === 'evening_high_soc' ? params.eveningSocStartMin :
params.socStartMin;
const profileSocHoldFloor =
context.profile === 'morning_solar' ? Math.max(params.socStopMin, params.morningSocMin - 10) :
context.profile === 'midday_hc_or_solar' ? Math.max(params.socStopMin, params.middaySocMin - 15) :
context.profile === 'evening_high_soc' ? params.eveningSocStopMin :
params.socStopMin;
const profileSpecificDischargeFloorA =
context.profile === 'morning_solar' ? params.morningBatteryMaxDischargeA :
context.profile === 'midday_hc_or_solar' ? params.middayBatteryMaxDischargeA :
context.profile === 'evening_high_soc' ? params.eveningBatteryMaxDischargeA :
params.batteryDischargeSoftA;
const profileBatteryDischargeFloorA =
Math.max(profileSpecificDischargeFloorA, batteryDischargeSoftDynamicA);
// =====================================================
// GRID RELAX IN force_charge_now
// =====================================================
// Assouplissement uniquement pour la garde réseau,
// sans toucher aux sécurités dures globales.
const gridForceChargeRelaxSoftA =
strategy.forceChargeRequested && isGridSource
? (
parsed.soc >= 90 ? 28 :
parsed.soc >= 80 ? 16 :
parsed.soc >= 60 ? 8 :
parsed.soc >= 40 ? 4 :
0
)
: 0;
const gridForceChargeRelaxHardA =
strategy.forceChargeRequested && isGridSource
? (
parsed.soc >= 90 ? 39 :
parsed.soc >= 80 ? 29 :
parsed.soc >= 60 ? 18 :
parsed.soc >= 40 ? 8 :
0
)
: 0;
const gridBatterySoftDynamicABase =
Math.max(params.gridBatterySoftA, batteryDischargeSoftDynamicA);
const gridBatteryHardDynamicABase =
Math.max(params.gridBatteryHardA, batteryDischargeHardDynamicA);
let gridBatterySoftDynamicA =
gridBatterySoftDynamicABase - gridForceChargeRelaxSoftA;
let gridBatteryHardDynamicA =
gridBatteryHardDynamicABase - gridForceChargeRelaxHardA;
// Garantit que le hard reste plus permissif que le soft d'au moins 3 A
if (gridBatteryHardDynamicA > (gridBatterySoftDynamicA - 3)) {
gridBatteryHardDynamicA = gridBatterySoftDynamicA - 3;
}
// =====================================================
// BASE SAFETY
// =====================================================
const commonSafetyStartOk =
derived.canRunAtMinCurrent &&
!derived.anyVebusOverload &&
!derived.anyVebusLowBattery &&
!derived.vebusDcVoltageSoftLow &&
!derived.vebusAcOutSoftReached &&
parsed.evcsDeviceConnected &&
parsed.evcsVehicleConnected &&
!parsed.evcsModeIsAuto;
const commonSafetyHoldOk =
derived.canRunAtMinCurrent &&
!derived.anyVebusLowBattery &&
!derived.vebusDcVoltageHardLow &&
parsed.evcsDeviceConnected &&
parsed.evcsVehicleConnected &&
!parsed.evcsModeIsAuto;
// =====================================================
// START / HOLD CONDITIONS
// =====================================================
const solarStartOk =
(context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') &&
commonSafetyStartOk &&
parsed.soc >= profileSocStartFloor &&
parsed.batteryCurrentA >= profileBatteryDischargeFloorA &&
(
context.sourceMode === 'solar_potential' ||
parsed.pvPowerW >= context.effectivePvStartMinW
);
const solarHoldOk =
(context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') &&
commonSafetyHoldOk &&
parsed.soc >= profileSocHoldFloor &&
parsed.batteryCurrentA >= profileBatteryDischargeFloorA &&
(
parsed.pvPowerW >= params.pvHoldMinW ||
cloudToleranceOk
);
const gridStartOk =
isGridSource &&
commonSafetyStartOk &&
parsed.gridAvailable &&
parsed.batteryCurrentA >= gridBatterySoftDynamicA;
const gridHoldOk =
isGridSource &&
commonSafetyHoldOk &&
parsed.gridAvailable &&
parsed.batteryCurrentA >= gridBatteryHardDynamicA;
// =====================================================
// PROBE LOGIC — STRICTEMENT LIMITÉE AU MODE PROBE
// =====================================================
const probeModeActive = context.sourceMode === 'hybrid_probe';
const probeStartOk =
probeModeActive &&
commonSafetyStartOk &&
(context.morningProbeEligible || context.middayProbeEligible);
const probeHoldGuardOk =
probeModeActive &&
commonSafetyHoldOk &&
parsed.batteryCurrentA >= profileBatteryDischargeFloorA;
const probeWindowActive =
probeModeActive &&
sessionObservedActive &&
history.probeStartedMs !== null &&
lifecycle.minutesSinceProbeStart !== null &&
lifecycle.minutesSinceProbeStart < params.probeMinutes;
const probeElapsedReached =
probeModeActive &&
sessionObservedActive &&
history.probeStartedMs !== null &&
lifecycle.minutesSinceProbeStart !== null &&
lifecycle.minutesSinceProbeStart >= params.probeMinutes;
const probeSuccessNow =
probeModeActive &&
sessionObservedActive &&
history.probeStartedMs !== null &&
(
parsed.pvPowerW >= params.pvHoldMinW &&
parsed.batteryCurrentA >= Math.min(0, batteryDischargeSoftDynamicA)
);
const probeFailedNow =
probeModeActive &&
probeElapsedReached &&
!probeSuccessNow;
// =====================================================
// STEP-UP LOGIC
// =====================================================
const stepUpByBatteryReserve =
batteryPriorityMode &&
parsed.batteryCurrentA >= batteryChargeReserveDynamicA;
const stepUpByForecastMode =
!batteryPriorityMode &&
(
parsed.pvPowerW >= params.pvHoldMinW ||
parsed.pvPowerAvg10mW >= params.pvHoldMinW
) &&
derived.forecastBufferKwh >= -1 &&
derived.projectedEndSocAfterCharge >= (derived.endDayGuardSoc - 5) &&
cloudToleranceOk;
const solarShouldStepUp =
lifecycle.currentCooldownElapsed &&
derived.currentReferenceA < derived.physicalCapA &&
derived.physicalCapA >= params.currentMinA &&
!derived.vebusAcOutSoftReached &&
!derived.vebusDcVoltageSoftLow &&
!derived.houseLoadHighVsHeadroom &&
(
stepUpByBatteryReserve ||
stepUpByForecastMode ||
parsed.pvPowerW >= (context.effectivePvStartMinW + 400)
);
const gridShouldStepUp =
lifecycle.currentCooldownElapsed &&
derived.currentReferenceA < derived.physicalCapA &&
derived.physicalCapA >= params.currentMinA &&
!derived.vebusAcOutSoftReached &&
!derived.vebusDcVoltageSoftLow &&
parsed.batteryCurrentA >= gridBatterySoftDynamicA &&
!derived.houseLoadHighVsHeadroom;
// =====================================================
// STEP-DOWN LOGIC
// =====================================================
const solarShouldStepDownBattery =
parsed.batteryCurrentA <= batteryDischargeSoftDynamicA &&
lifecycle.currentCooldownElapsed &&
derived.currentReferenceA > params.currentMinA;
const solarShouldStepDownCloudDebt =
!cloudToleranceOk &&
lifecycle.currentCooldownElapsed &&
derived.currentReferenceA > params.currentMinA;
const gridShouldStepDownBattery =
parsed.batteryCurrentA < gridBatterySoftDynamicA &&
lifecycle.currentCooldownElapsed &&
derived.currentReferenceA > params.currentMinA;
const shouldStepDownVebusPower =
derived.vebusAcOutSoftReached &&
lifecycle.currentCooldownElapsed &&
derived.currentReferenceA > params.currentMinA;
const shouldStepDownVebusVoltage =
derived.vebusDcVoltageSoftLow &&
lifecycle.currentCooldownElapsed &&
derived.currentReferenceA > params.currentMinA;
const shouldStepDownOverload =
derived.anyVebusOverload &&
lifecycle.currentCooldownElapsed &&
derived.currentReferenceA > params.currentMinA;
const shouldStopOverloadAtMin =
derived.anyVebusOverload &&
derived.currentReferenceA <= params.currentMinA &&
sessionObservedActive;
// =====================================================
// HARD STOPS / PROTECTIONS
// =====================================================
const batteryCurrentHardExceeded =
parsed.batteryCurrentA <= batteryDischargeHardDynamicA;
// overload volontairement exclu du hard stop immédiat
const mustStopHard =
derived.anyVebusLowBattery ||
derived.vebusDcVoltageHardLow ||
derived.vebusAcOutHardReached ||
batteryCurrentHardExceeded;
const gridBatteryProtectionStop =
sessionObservedActive &&
isGridSource &&
parsed.batteryCurrentA < gridBatteryHardDynamicA;
// =====================================================
// OUTPUT
// =====================================================
return [{
json: {
...prev,
batteryPolicy: {
socBand,
batteryDischargeSoftDynamicA,
batteryDischargeHardDynamicA,
batteryChargeReserveDynamicA,
batterySoftToleranceMinutes,
batteryHardToleranceSeconds,
batteryRecoveryMinutes,
gridBatterySoftDynamicA,
gridBatteryHardDynamicA,
gridForceChargeRelaxSoftA,
gridForceChargeRelaxHardA,
houseSpikeDetected,
solarPotentialContext,
},
locks: {
nextAllowedWindowStartMs,
blockActive,
blockStillRelevant,
startBlockedByReason,
},
cloud: {
sessionObservedActive,
batteryNetPowerW,
cloudDebtNextKwh,
cloudDebtMeaningful,
cloudEventDischargingNow,
continuingCloudEvent,
enteringCloudDebt,
cloudDebtActive,
cloudDebtStartedMs,
cloudDebtDurationMinutes,
effectiveCloudToleranceMinutes,
cloudToleranceOk,
},
modes: {
batteryFullHarvestMode,
forecastCommitMode,
batteryPriorityMode,
},
safeguards: {
isGridSource,
commonSafetyStartOk,
commonSafetyHoldOk,
profileSocStartFloor,
profileSocHoldFloor,
profileBatteryDischargeFloorA,
batteryCurrentHardExceeded,
mustStopHard,
gridBatteryProtectionStop,
},
conditions: {
solarStartOk,
solarHoldOk,
gridStartOk,
gridHoldOk,
probeStartOk,
probeHoldGuardOk,
probeWindowActive,
probeElapsedReached,
probeSuccessNow,
probeFailedNow,
solarShouldStepUp,
gridShouldStepUp,
solarShouldStepDownBattery,
solarShouldStepDownCloudDebt,
gridShouldStepDownBattery,
shouldStepDownVebusPower,
shouldStepDownVebusVoltage,
shouldStepDownOverload,
shouldStopOverloadAtMin,
}
}
}];

View File

@@ -0,0 +1,293 @@
function clamp(v, min, max) {
return Math.min(Math.max(v, min), max);
}
function to2(n) {
return String(n).padStart(2, '0');
}
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}`;
return {
date,
hour,
minute: Number(parts.minute),
second: Number(parts.second),
};
}
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]);
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 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 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 prev = $input.first().json;
const {
nowMs,
cfg,
parsed,
params,
history,
batteryTune,
strategy,
derived,
context,
lifecycle,
} = prev;
// =====================================================
// SESSION / SOURCE
// =====================================================
const sessionObservedActive =
parsed.evcsChargingSwitchOn ||
parsed.controlLatch ||
parsed.chargePermissionNow;
const isGridSource =
context.sourceMode === 'grid_hc_allowed' ||
context.sourceMode === 'grid_forced';
// =====================================================
// SOC BAND / BATTERY DYNAMIC POLICY
// =====================================================
const socBand =
parsed.soc > 90 ? 'gt90' :
parsed.soc > 80 ? '80_90' :
parsed.soc > 60 ? '60_80' :
parsed.soc > 40 ? '40_60' :
parsed.soc > 25 ? '25_40' :
'lt25';
const band = batteryTune.bands[socBand];
const dischargeModeFactor =
strategy.forceChargeRequested ? 1.2 :
strategy.forceGridRequested ? 0.7 :
strategy.userStrategy === 'auto_solar' ? 0.8 :
1.0;
const chargeReserveModeFactor =
strategy.forceChargeRequested ? 0.8 :
strategy.forceGridRequested ? 1.1 :
strategy.userStrategy === 'auto_solar' ? 1.1 :
1.0;
const toleranceModeFactor =
strategy.forceChargeRequested ? 1.25 :
strategy.forceGridRequested ? 0.8 :
strategy.userStrategy === 'auto_solar' ? 0.8 :
1.0;
const batteryDischargeSoftDynamicA = band.dischargeSoftA * dischargeModeFactor;
const batteryDischargeHardDynamicA = band.dischargeHardA * dischargeModeFactor;
const batteryChargeReserveDynamicA = Math.max(0, band.chargeReserveA * chargeReserveModeFactor);
const batterySoftToleranceMinutes = band.softToleranceMin * toleranceModeFactor;
const batteryHardToleranceSeconds = band.hardToleranceSec;
const batteryRecoveryMinutes = band.recoveryMin;
// =====================================================
// CONTEXT HELPERS
// =====================================================
const houseSpikeThresholdW = 1500;
const houseSpikeDetected =
(parsed.houseAcPowerW - parsed.houseAcPowerPrevW) >= houseSpikeThresholdW;
const solarPotentialContext =
context.sourceMode === 'solar_potential' ||
(
parsed.soc >= 80 &&
derived.forecastBufferKwh > 0 &&
parsed.pvPowerW < context.effectivePvStartMinW &&
derived.houseNonEvcsW < 1200
);
// =====================================================
// REASON-BASED LOCKS
// =====================================================
const nextWindowCandidates = [
nextDailyOccurrenceMs(params.morningStartMin, nowMs),
nextDailyOccurrenceMs(params.hcMiddayStartMin, nowMs),
nextDailyOccurrenceMs(params.eveningStartMin, nowMs),
nextDailyOccurrenceMs(params.hcNightStartMin, nowMs),
].filter(v => Number.isFinite(v));
const nextAllowedWindowStartMs =
nextWindowCandidates.length > 0 ? Math.min(...nextWindowCandidates) : null;
const blockActive =
history.blockedUntilMs !== null &&
history.blockedUntilMs > nowMs + 1000;
const blockStillRelevant =
history.lastBlockReason === 'outside_window'
? context.profile === 'outside_window'
: history.lastBlockReason === 'probe_failed'
? context.sourceMode === 'hybrid_probe'
: history.lastBlockReason === 'battery_protection_stop'
? (
parsed.batteryCurrentA < -params.batteryNeutralBandA ||
derived.anyVebusLowBattery ||
derived.vebusDcVoltageSoftLow
)
: history.lastBlockReason === 'grid_battery_guard'
? isGridSource
: history.lastBlockReason === 'vebus_overload_persistent'
? derived.anyVebusOverload
: false;
const startBlockedByReason = blockActive && blockStillRelevant;
// =====================================================
// CLOUD DEBT
// =====================================================
const batteryNetPowerW = parsed.batteryCurrentA * derived.batteryVoltageRef;
let cloudDebtNextKwh = params.cloudDebtPrevKwh;
if (sessionObservedActive) {
if (batteryNetPowerW < 0 && !isGridSource) {
cloudDebtNextKwh += (Math.abs(batteryNetPowerW) * cfg.sampleSeconds) / 3600000;
} else if (batteryNetPowerW > 0) {
cloudDebtNextKwh -= (batteryNetPowerW * cfg.sampleSeconds) / 3600000;
}
} else if (batteryNetPowerW > 0) {
cloudDebtNextKwh -= (batteryNetPowerW * cfg.sampleSeconds) / 3600000;
}
cloudDebtNextKwh = clamp(cloudDebtNextKwh, 0, params.cloudDebtMaxKwh * 4);
const cloudDebtMeaningful =
cloudDebtNextKwh > cfg.cloudDebtMeaningfulThresholdKwh;
const cloudEventDischargingNow =
sessionObservedActive &&
!isGridSource &&
parsed.batteryCurrentA < -Math.max(1, params.batteryNeutralBandA) &&
cloudDebtMeaningful;
const continuingCloudEvent =
cloudEventDischargingNow &&
history.cloudDebtStartedMsPrev !== null &&
params.cloudDebtPrevKwh > cfg.cloudDebtRecoveryThresholdKwh;
const enteringCloudDebt =
cloudEventDischargingNow && !continuingCloudEvent;
const cloudDebtActive = cloudEventDischargingNow;
const cloudDebtStartedMs =
cloudEventDischargingNow
? (continuingCloudEvent ? history.cloudDebtStartedMsPrev : nowMs)
: null;
const cloudDebtDurationMinutes =
cloudDebtActive && cloudDebtStartedMs !== null
? Math.max(0, (nowMs - cloudDebtStartedMs) / 60000)
: 0;
const effectiveCloudToleranceMinutes =
params.cloudToleranceMinutes +
(solarPotentialContext ? batteryTune.global.solarPotentialGraceMinutes : 0) +
(houseSpikeDetected ? batteryTune.global.houseSpikeGraceMinutes : 0);
const cloudToleranceOk =
cloudDebtNextKwh <= params.cloudDebtMaxKwh &&
(
!cloudDebtActive ||
cloudDebtDurationMinutes <= effectiveCloudToleranceMinutes
);

View File

@@ -0,0 +1,182 @@
function clamp(v, min, max) {
return Math.min(Math.max(v, min), max);
}
function to2(n) {
return String(n).padStart(2, '0');
}
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}`;
return {
date,
hour,
minute: Number(parts.minute),
second: Number(parts.second),
};
}
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]);
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 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 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 prev = $input.first().json;
const {
nowMs,
cfg,
parsed,
params,
history,
batteryTune,
strategy,
derived,
context,
lifecycle,
} = prev;
// =====================================================
// SESSION / SOURCE
// =====================================================
const sessionObservedActive =
parsed.evcsChargingSwitchOn ||
parsed.controlLatch ||
parsed.chargePermissionNow;
const isGridSource =
context.sourceMode === 'grid_hc_allowed' ||
context.sourceMode === 'grid_forced';
// =====================================================
// SOC BAND / BATTERY DYNAMIC POLICY
// =====================================================
const socBand =
parsed.soc > 90 ? 'gt90' :
parsed.soc > 80 ? '80_90' :
parsed.soc > 60 ? '60_80' :
parsed.soc > 40 ? '40_60' :
parsed.soc > 25 ? '25_40' :
'lt25';
const band = batteryTune.bands[socBand];
const dischargeModeFactor =
strategy.forceChargeRequested ? 1.2 :
strategy.forceGridRequested ? 0.7 :
strategy.userStrategy === 'auto_solar' ? 0.8 :
1.0;
const chargeReserveModeFactor =
strategy.forceChargeRequested ? 0.8 :
strategy.forceGridRequested ? 1.1 :
strategy.userStrategy === 'auto_solar' ? 1.1 :
1.0;
const toleranceModeFactor =
strategy.forceChargeRequested ? 1.25 :
strategy.forceGridRequested ? 0.8 :
strategy.userStrategy === 'auto_solar' ? 0.8 :
1.0;
const batteryDischargeSoftDynamicA = band.dischargeSoftA * dischargeModeFactor;
const batteryDischargeHardDynamicA = band.dischargeHardA * dischargeModeFactor;
const batteryChargeReserveDynamicA = Math.max(0, band.chargeReserveA * chargeReserveModeFactor);
const batterySoftToleranceMinutes = band.softToleranceMin * toleranceModeFactor;
const batteryHardToleranceSeconds = band.hardToleranceSec;
const batteryRecoveryMinutes = band.recoveryMin;

View File

@@ -0,0 +1,110 @@
// =====================================================
// CONTEXT HELPERS
// =====================================================
const houseSpikeThresholdW = 1500;
const houseSpikeDetected =
(parsed.houseAcPowerW - parsed.houseAcPowerPrevW) >= houseSpikeThresholdW;
const solarPotentialContext =
context.sourceMode === 'solar_potential' ||
(
parsed.soc >= 80 &&
derived.forecastBufferKwh > 0 &&
parsed.pvPowerW < context.effectivePvStartMinW &&
derived.houseNonEvcsW < 1200
);
// =====================================================
// REASON-BASED LOCKS
// =====================================================
const nextWindowCandidates = [
nextDailyOccurrenceMs(params.morningStartMin, nowMs),
nextDailyOccurrenceMs(params.hcMiddayStartMin, nowMs),
nextDailyOccurrenceMs(params.eveningStartMin, nowMs),
nextDailyOccurrenceMs(params.hcNightStartMin, nowMs),
].filter(v => Number.isFinite(v));
const nextAllowedWindowStartMs =
nextWindowCandidates.length > 0 ? Math.min(...nextWindowCandidates) : null;
const blockActive =
history.blockedUntilMs !== null &&
history.blockedUntilMs > nowMs + 1000;
const blockStillRelevant =
history.lastBlockReason === 'outside_window'
? context.profile === 'outside_window'
: history.lastBlockReason === 'probe_failed'
? context.sourceMode === 'hybrid_probe'
: history.lastBlockReason === 'battery_protection_stop'
? (
parsed.batteryCurrentA < -params.batteryNeutralBandA ||
derived.anyVebusLowBattery ||
derived.vebusDcVoltageSoftLow
)
: history.lastBlockReason === 'grid_battery_guard'
? isGridSource
: history.lastBlockReason === 'vebus_overload_persistent'
? derived.anyVebusOverload
: false;
const startBlockedByReason = blockActive && blockStillRelevant;
// =====================================================
// CLOUD DEBT
// =====================================================
const batteryNetPowerW = parsed.batteryCurrentA * derived.batteryVoltageRef;
let cloudDebtNextKwh = params.cloudDebtPrevKwh;
if (sessionObservedActive) {
if (batteryNetPowerW < 0 && !isGridSource) {
cloudDebtNextKwh += (Math.abs(batteryNetPowerW) * cfg.sampleSeconds) / 3600000;
} else if (batteryNetPowerW > 0) {
cloudDebtNextKwh -= (batteryNetPowerW * cfg.sampleSeconds) / 3600000;
}
} else if (batteryNetPowerW > 0) {
cloudDebtNextKwh -= (batteryNetPowerW * cfg.sampleSeconds) / 3600000;
}
cloudDebtNextKwh = clamp(cloudDebtNextKwh, 0, params.cloudDebtMaxKwh * 4);
const cloudDebtMeaningful =
cloudDebtNextKwh > cfg.cloudDebtMeaningfulThresholdKwh;
const cloudEventDischargingNow =
sessionObservedActive &&
!isGridSource &&
parsed.batteryCurrentA < -Math.max(1, params.batteryNeutralBandA) &&
cloudDebtMeaningful;
const continuingCloudEvent =
cloudEventDischargingNow &&
history.cloudDebtStartedMsPrev !== null &&
params.cloudDebtPrevKwh > cfg.cloudDebtRecoveryThresholdKwh;
const enteringCloudDebt =
cloudEventDischargingNow && !continuingCloudEvent;
const cloudDebtActive = cloudEventDischargingNow;
const cloudDebtStartedMs =
cloudEventDischargingNow
? (continuingCloudEvent ? history.cloudDebtStartedMsPrev : nowMs)
: null;
const cloudDebtDurationMinutes =
cloudDebtActive && cloudDebtStartedMs !== null
? Math.max(0, (nowMs - cloudDebtStartedMs) / 60000)
: 0;
const effectiveCloudToleranceMinutes =
params.cloudToleranceMinutes +
(solarPotentialContext ? batteryTune.global.solarPotentialGraceMinutes : 0) +
(houseSpikeDetected ? batteryTune.global.houseSpikeGraceMinutes : 0);
const cloudToleranceOk =
cloudDebtNextKwh <= params.cloudDebtMaxKwh &&
(
!cloudDebtActive ||
cloudDebtDurationMinutes <= effectiveCloudToleranceMinutes
);

View File

@@ -0,0 +1,188 @@
// =====================================================
// LEGACY / FORECAST MODES
// =====================================================
const batteryFullHarvestMode =
parsed.forecastModeEnabled &&
context.profile !== 'night_hc' &&
context.profile !== 'outside_window' &&
parsed.soc >= params.batteryFullSoc &&
parsed.todayRemainingKwh >= params.batteryFullForecastMinKwh &&
derived.projectedEndSocAfterCharge >= (derived.endDayGuardSoc - 2);
const forecastCommitMode =
parsed.forecastModeEnabled &&
context.profile !== 'night_hc' &&
context.profile !== 'outside_window' &&
parsed.soc >= params.forecastCommitSocFloor &&
parsed.todayRemainingKwh >= params.forecastCommitMinKwh &&
derived.projectedEndSocAfterCharge >= derived.endDayGuardSoc;
const batteryPriorityMode =
!batteryFullHarvestMode && !forecastCommitMode;
// =====================================================
// PROFILE-BASED THRESHOLDS
// =====================================================
const profileSocStartFloor =
context.profile === 'morning_solar' ? params.morningSocMin :
context.profile === 'midday_hc_or_solar' ? params.middaySocMin :
context.profile === 'evening_high_soc' ? params.eveningSocStartMin :
params.socStartMin;
const profileSocHoldFloor =
context.profile === 'morning_solar' ? Math.max(params.socStopMin, params.morningSocMin - 10) :
context.profile === 'midday_hc_or_solar' ? Math.max(params.socStopMin, params.middaySocMin - 15) :
context.profile === 'evening_high_soc' ? params.eveningSocStopMin :
params.socStopMin;
const profileSpecificDischargeFloorA =
context.profile === 'morning_solar' ? params.morningBatteryMaxDischargeA :
context.profile === 'midday_hc_or_solar' ? params.middayBatteryMaxDischargeA :
context.profile === 'evening_high_soc' ? params.eveningBatteryMaxDischargeA :
params.batteryDischargeSoftA;
const profileBatteryDischargeFloorA =
Math.max(profileSpecificDischargeFloorA, batteryDischargeSoftDynamicA);
// =====================================================
// GRID RELAX IN force_charge_now
// =====================================================
// Assouplissement uniquement pour la garde réseau,
// sans toucher aux sécurités dures globales.
const gridForceChargeRelaxSoftA =
strategy.forceChargeRequested && isGridSource
? (
parsed.soc >= 90 ? 28 :
parsed.soc >= 80 ? 16 :
parsed.soc >= 60 ? 8 :
parsed.soc >= 40 ? 4 :
0
)
: 0;
const gridForceChargeRelaxHardA =
strategy.forceChargeRequested && isGridSource
? (
parsed.soc >= 90 ? 39 :
parsed.soc >= 80 ? 29 :
parsed.soc >= 60 ? 18 :
parsed.soc >= 40 ? 8 :
0
)
: 0;
const gridBatterySoftDynamicABase =
Math.max(params.gridBatterySoftA, batteryDischargeSoftDynamicA);
const gridBatteryHardDynamicABase =
Math.max(params.gridBatteryHardA, batteryDischargeHardDynamicA);
let gridBatterySoftDynamicA =
gridBatterySoftDynamicABase - gridForceChargeRelaxSoftA;
let gridBatteryHardDynamicA =
gridBatteryHardDynamicABase - gridForceChargeRelaxHardA;
// Garantit que le hard reste plus permissif que le soft d'au moins 3 A
if (gridBatteryHardDynamicA > (gridBatterySoftDynamicA - 3)) {
gridBatteryHardDynamicA = gridBatterySoftDynamicA - 3;
}
// =====================================================
// BASE SAFETY
// =====================================================
const commonSafetyStartOk =
derived.canRunAtMinCurrent &&
!derived.anyVebusOverload &&
!derived.anyVebusLowBattery &&
!derived.vebusDcVoltageSoftLow &&
!derived.vebusAcOutSoftReached &&
parsed.evcsDeviceConnected &&
parsed.evcsVehicleConnected &&
!parsed.evcsModeIsAuto;
const commonSafetyHoldOk =
derived.canRunAtMinCurrent &&
!derived.anyVebusLowBattery &&
!derived.vebusDcVoltageHardLow &&
parsed.evcsDeviceConnected &&
parsed.evcsVehicleConnected &&
!parsed.evcsModeIsAuto;
// =====================================================
// START / HOLD CONDITIONS
// =====================================================
const solarStartOk =
(context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') &&
commonSafetyStartOk &&
parsed.soc >= profileSocStartFloor &&
parsed.batteryCurrentA >= profileBatteryDischargeFloorA &&
(
context.sourceMode === 'solar_potential' ||
parsed.pvPowerW >= context.effectivePvStartMinW
);
const solarHoldOk =
(context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') &&
commonSafetyHoldOk &&
parsed.soc >= profileSocHoldFloor &&
parsed.batteryCurrentA >= profileBatteryDischargeFloorA &&
(
parsed.pvPowerW >= params.pvHoldMinW ||
cloudToleranceOk
);
const gridStartOk =
isGridSource &&
commonSafetyStartOk &&
parsed.gridAvailable &&
parsed.batteryCurrentA >= gridBatterySoftDynamicA;
const gridHoldOk =
isGridSource &&
commonSafetyHoldOk &&
parsed.gridAvailable &&
parsed.batteryCurrentA >= gridBatteryHardDynamicA;
// =====================================================
// PROBE LOGIC — STRICTEMENT LIMITÉE AU MODE PROBE
// =====================================================
const probeModeActive = context.sourceMode === 'hybrid_probe';
const probeStartOk =
probeModeActive &&
commonSafetyStartOk &&
(context.morningProbeEligible || context.middayProbeEligible);
const probeHoldGuardOk =
probeModeActive &&
commonSafetyHoldOk &&
parsed.batteryCurrentA >= profileBatteryDischargeFloorA;
const probeWindowActive =
probeModeActive &&
sessionObservedActive &&
history.probeStartedMs !== null &&
lifecycle.minutesSinceProbeStart !== null &&
lifecycle.minutesSinceProbeStart < params.probeMinutes;
const probeElapsedReached =
probeModeActive &&
sessionObservedActive &&
history.probeStartedMs !== null &&
lifecycle.minutesSinceProbeStart !== null &&
lifecycle.minutesSinceProbeStart >= params.probeMinutes;
const probeSuccessNow =
probeModeActive &&
sessionObservedActive &&
history.probeStartedMs !== null &&
(
parsed.pvPowerW >= params.pvHoldMinW &&
parsed.batteryCurrentA >= Math.min(0, batteryDischargeSoftDynamicA)
);
const probeFailedNow =
probeModeActive &&
probeElapsedReached &&
!probeSuccessNow;

View File

@@ -0,0 +1,184 @@
// =====================================================
// STEP-UP LOGIC
// =====================================================
const stepUpByBatteryReserve =
batteryPriorityMode &&
parsed.batteryCurrentA >= batteryChargeReserveDynamicA;
const stepUpByForecastMode =
!batteryPriorityMode &&
(
parsed.pvPowerW >= params.pvHoldMinW ||
parsed.pvPowerAvg10mW >= params.pvHoldMinW
) &&
derived.forecastBufferKwh >= -1 &&
derived.projectedEndSocAfterCharge >= (derived.endDayGuardSoc - 5) &&
cloudToleranceOk;
const solarShouldStepUp =
lifecycle.currentCooldownElapsed &&
derived.currentReferenceA < derived.physicalCapA &&
derived.physicalCapA >= params.currentMinA &&
!derived.vebusAcOutSoftReached &&
!derived.vebusDcVoltageSoftLow &&
!derived.houseLoadHighVsHeadroom &&
(
stepUpByBatteryReserve ||
stepUpByForecastMode ||
parsed.pvPowerW >= (context.effectivePvStartMinW + 400)
);
const gridShouldStepUp =
lifecycle.currentCooldownElapsed &&
derived.currentReferenceA < derived.physicalCapA &&
derived.physicalCapA >= params.currentMinA &&
!derived.vebusAcOutSoftReached &&
!derived.vebusDcVoltageSoftLow &&
parsed.batteryCurrentA >= gridBatterySoftDynamicA &&
!derived.houseLoadHighVsHeadroom;
// =====================================================
// STEP-DOWN LOGIC
// =====================================================
const solarShouldStepDownBattery =
parsed.batteryCurrentA <= batteryDischargeSoftDynamicA &&
lifecycle.currentCooldownElapsed &&
derived.currentReferenceA > params.currentMinA;
const solarShouldStepDownCloudDebt =
!cloudToleranceOk &&
lifecycle.currentCooldownElapsed &&
derived.currentReferenceA > params.currentMinA;
const gridShouldStepDownBattery =
parsed.batteryCurrentA < gridBatterySoftDynamicA &&
lifecycle.currentCooldownElapsed &&
derived.currentReferenceA > params.currentMinA;
const shouldStepDownVebusPower =
derived.vebusAcOutSoftReached &&
lifecycle.currentCooldownElapsed &&
derived.currentReferenceA > params.currentMinA;
const shouldStepDownVebusVoltage =
derived.vebusDcVoltageSoftLow &&
lifecycle.currentCooldownElapsed &&
derived.currentReferenceA > params.currentMinA;
const shouldStepDownOverload =
derived.anyVebusOverload &&
lifecycle.currentCooldownElapsed &&
derived.currentReferenceA > params.currentMinA;
const shouldStopOverloadAtMin =
derived.anyVebusOverload &&
derived.currentReferenceA <= params.currentMinA &&
sessionObservedActive;
// =====================================================
// HARD STOPS / PROTECTIONS
// =====================================================
const batteryCurrentHardExceeded =
parsed.batteryCurrentA <= batteryDischargeHardDynamicA;
// overload volontairement exclu du hard stop immédiat
const mustStopHard =
derived.anyVebusLowBattery ||
derived.vebusDcVoltageHardLow ||
derived.vebusAcOutHardReached ||
batteryCurrentHardExceeded;
const gridBatteryProtectionStop =
sessionObservedActive &&
isGridSource &&
parsed.batteryCurrentA < gridBatteryHardDynamicA;
// =====================================================
// OUTPUT
// =====================================================
return [{
json: {
...prev,
batteryPolicy: {
socBand,
batteryDischargeSoftDynamicA,
batteryDischargeHardDynamicA,
batteryChargeReserveDynamicA,
batterySoftToleranceMinutes,
batteryHardToleranceSeconds,
batteryRecoveryMinutes,
gridBatterySoftDynamicA,
gridBatteryHardDynamicA,
gridForceChargeRelaxSoftA,
gridForceChargeRelaxHardA,
houseSpikeDetected,
solarPotentialContext,
},
locks: {
nextAllowedWindowStartMs,
blockActive,
blockStillRelevant,
startBlockedByReason,
},
cloud: {
sessionObservedActive,
batteryNetPowerW,
cloudDebtNextKwh,
cloudDebtMeaningful,
cloudEventDischargingNow,
continuingCloudEvent,
enteringCloudDebt,
cloudDebtActive,
cloudDebtStartedMs,
cloudDebtDurationMinutes,
effectiveCloudToleranceMinutes,
cloudToleranceOk,
},
modes: {
batteryFullHarvestMode,
forecastCommitMode,
batteryPriorityMode,
},
safeguards: {
isGridSource,
commonSafetyStartOk,
commonSafetyHoldOk,
profileSocStartFloor,
profileSocHoldFloor,
profileBatteryDischargeFloorA,
batteryCurrentHardExceeded,
mustStopHard,
gridBatteryProtectionStop,
},
conditions: {
solarStartOk,
solarHoldOk,
gridStartOk,
gridHoldOk,
probeStartOk,
probeHoldGuardOk,
probeWindowActive,
probeElapsedReached,
probeSuccessNow,
probeFailedNow,
solarShouldStepUp,
gridShouldStepUp,
solarShouldStepDownBattery,
solarShouldStepDownCloudDebt,
gridShouldStepDownBattery,
shouldStepDownVebusPower,
shouldStepDownVebusVoltage,
shouldStepDownOverload,
shouldStopOverloadAtMin,
}
}
}];

View File

@@ -0,0 +1,373 @@
// =====================================================
// LEGACY / FORECAST MODES
// =====================================================
const batteryFullHarvestMode =
parsed.forecastModeEnabled &&
context.profile !== 'night_hc' &&
context.profile !== 'outside_window' &&
parsed.soc >= params.batteryFullSoc &&
parsed.todayRemainingKwh >= params.batteryFullForecastMinKwh &&
derived.projectedEndSocAfterCharge >= (derived.endDayGuardSoc - 2);
const forecastCommitMode =
parsed.forecastModeEnabled &&
context.profile !== 'night_hc' &&
context.profile !== 'outside_window' &&
parsed.soc >= params.forecastCommitSocFloor &&
parsed.todayRemainingKwh >= params.forecastCommitMinKwh &&
derived.projectedEndSocAfterCharge >= derived.endDayGuardSoc;
const batteryPriorityMode =
!batteryFullHarvestMode && !forecastCommitMode;
// =====================================================
// PROFILE-BASED THRESHOLDS
// =====================================================
const profileSocStartFloor =
context.profile === 'morning_solar' ? params.morningSocMin :
context.profile === 'midday_hc_or_solar' ? params.middaySocMin :
context.profile === 'evening_high_soc' ? params.eveningSocStartMin :
params.socStartMin;
const profileSocHoldFloor =
context.profile === 'morning_solar' ? Math.max(params.socStopMin, params.morningSocMin - 10) :
context.profile === 'midday_hc_or_solar' ? Math.max(params.socStopMin, params.middaySocMin - 15) :
context.profile === 'evening_high_soc' ? params.eveningSocStopMin :
params.socStopMin;
const profileSpecificDischargeFloorA =
context.profile === 'morning_solar' ? params.morningBatteryMaxDischargeA :
context.profile === 'midday_hc_or_solar' ? params.middayBatteryMaxDischargeA :
context.profile === 'evening_high_soc' ? params.eveningBatteryMaxDischargeA :
params.batteryDischargeSoftA;
const profileBatteryDischargeFloorA =
Math.max(profileSpecificDischargeFloorA, batteryDischargeSoftDynamicA);
// =====================================================
// GRID RELAX IN force_charge_now
// =====================================================
// Assouplissement uniquement pour la garde réseau,
// sans toucher aux sécurités dures globales.
const gridForceChargeRelaxSoftA =
strategy.forceChargeRequested && isGridSource
? (
parsed.soc >= 90 ? 28 :
parsed.soc >= 80 ? 16 :
parsed.soc >= 60 ? 8 :
parsed.soc >= 40 ? 4 :
0
)
: 0;
const gridForceChargeRelaxHardA =
strategy.forceChargeRequested && isGridSource
? (
parsed.soc >= 90 ? 39 :
parsed.soc >= 80 ? 29 :
parsed.soc >= 60 ? 18 :
parsed.soc >= 40 ? 8 :
0
)
: 0;
const gridBatterySoftDynamicABase =
Math.max(params.gridBatterySoftA, batteryDischargeSoftDynamicA);
const gridBatteryHardDynamicABase =
Math.max(params.gridBatteryHardA, batteryDischargeHardDynamicA);
let gridBatterySoftDynamicA =
gridBatterySoftDynamicABase - gridForceChargeRelaxSoftA;
let gridBatteryHardDynamicA =
gridBatteryHardDynamicABase - gridForceChargeRelaxHardA;
// Garantit que le hard reste plus permissif que le soft d'au moins 3 A
if (gridBatteryHardDynamicA > (gridBatterySoftDynamicA - 3)) {
gridBatteryHardDynamicA = gridBatterySoftDynamicA - 3;
}
// =====================================================
// BASE SAFETY
// =====================================================
const commonSafetyStartOk =
derived.canRunAtMinCurrent &&
!derived.anyVebusOverload &&
!derived.anyVebusLowBattery &&
!derived.vebusDcVoltageSoftLow &&
!derived.vebusAcOutSoftReached &&
parsed.evcsDeviceConnected &&
parsed.evcsVehicleConnected &&
!parsed.evcsModeIsAuto;
const commonSafetyHoldOk =
derived.canRunAtMinCurrent &&
!derived.anyVebusLowBattery &&
!derived.vebusDcVoltageHardLow &&
parsed.evcsDeviceConnected &&
parsed.evcsVehicleConnected &&
!parsed.evcsModeIsAuto;
// =====================================================
// START / HOLD CONDITIONS
// =====================================================
const solarStartOk =
(context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') &&
commonSafetyStartOk &&
parsed.soc >= profileSocStartFloor &&
parsed.batteryCurrentA >= profileBatteryDischargeFloorA &&
(
context.sourceMode === 'solar_potential' ||
parsed.pvPowerW >= context.effectivePvStartMinW
);
const solarHoldOk =
(context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') &&
commonSafetyHoldOk &&
parsed.soc >= profileSocHoldFloor &&
parsed.batteryCurrentA >= profileBatteryDischargeFloorA &&
(
parsed.pvPowerW >= params.pvHoldMinW ||
cloudToleranceOk
);
const gridStartOk =
isGridSource &&
commonSafetyStartOk &&
parsed.gridAvailable &&
parsed.batteryCurrentA >= gridBatterySoftDynamicA;
const gridHoldOk =
isGridSource &&
commonSafetyHoldOk &&
parsed.gridAvailable &&
parsed.batteryCurrentA >= gridBatteryHardDynamicA;
// =====================================================
// PROBE LOGIC — STRICTEMENT LIMITÉE AU MODE PROBE
// =====================================================
const probeModeActive = context.sourceMode === 'hybrid_probe';
const probeStartOk =
probeModeActive &&
commonSafetyStartOk &&
(context.morningProbeEligible || context.middayProbeEligible);
const probeHoldGuardOk =
probeModeActive &&
commonSafetyHoldOk &&
parsed.batteryCurrentA >= profileBatteryDischargeFloorA;
const probeWindowActive =
probeModeActive &&
sessionObservedActive &&
history.probeStartedMs !== null &&
lifecycle.minutesSinceProbeStart !== null &&
lifecycle.minutesSinceProbeStart < params.probeMinutes;
const probeElapsedReached =
probeModeActive &&
sessionObservedActive &&
history.probeStartedMs !== null &&
lifecycle.minutesSinceProbeStart !== null &&
lifecycle.minutesSinceProbeStart >= params.probeMinutes;
const probeSuccessNow =
probeModeActive &&
sessionObservedActive &&
history.probeStartedMs !== null &&
(
parsed.pvPowerW >= params.pvHoldMinW &&
parsed.batteryCurrentA >= Math.min(0, batteryDischargeSoftDynamicA)
);
const probeFailedNow =
probeModeActive &&
probeElapsedReached &&
!probeSuccessNow;
// =====================================================
// STEP-UP LOGIC
// =====================================================
const stepUpByBatteryReserve =
batteryPriorityMode &&
parsed.batteryCurrentA >= batteryChargeReserveDynamicA;
const stepUpByForecastMode =
!batteryPriorityMode &&
(
parsed.pvPowerW >= params.pvHoldMinW ||
parsed.pvPowerAvg10mW >= params.pvHoldMinW
) &&
derived.forecastBufferKwh >= -1 &&
derived.projectedEndSocAfterCharge >= (derived.endDayGuardSoc - 5) &&
cloudToleranceOk;
const solarShouldStepUp =
lifecycle.currentCooldownElapsed &&
derived.currentReferenceA < derived.physicalCapA &&
derived.physicalCapA >= params.currentMinA &&
!derived.vebusAcOutSoftReached &&
!derived.vebusDcVoltageSoftLow &&
!derived.houseLoadHighVsHeadroom &&
(
stepUpByBatteryReserve ||
stepUpByForecastMode ||
parsed.pvPowerW >= (context.effectivePvStartMinW + 400)
);
const gridShouldStepUp =
lifecycle.currentCooldownElapsed &&
derived.currentReferenceA < derived.physicalCapA &&
derived.physicalCapA >= params.currentMinA &&
!derived.vebusAcOutSoftReached &&
!derived.vebusDcVoltageSoftLow &&
parsed.batteryCurrentA >= gridBatterySoftDynamicA &&
!derived.houseLoadHighVsHeadroom;
// =====================================================
// STEP-DOWN LOGIC
// =====================================================
const solarShouldStepDownBattery =
parsed.batteryCurrentA <= batteryDischargeSoftDynamicA &&
lifecycle.currentCooldownElapsed &&
derived.currentReferenceA > params.currentMinA;
const solarShouldStepDownCloudDebt =
!cloudToleranceOk &&
lifecycle.currentCooldownElapsed &&
derived.currentReferenceA > params.currentMinA;
const gridShouldStepDownBattery =
parsed.batteryCurrentA < gridBatterySoftDynamicA &&
lifecycle.currentCooldownElapsed &&
derived.currentReferenceA > params.currentMinA;
const shouldStepDownVebusPower =
derived.vebusAcOutSoftReached &&
lifecycle.currentCooldownElapsed &&
derived.currentReferenceA > params.currentMinA;
const shouldStepDownVebusVoltage =
derived.vebusDcVoltageSoftLow &&
lifecycle.currentCooldownElapsed &&
derived.currentReferenceA > params.currentMinA;
const shouldStepDownOverload =
derived.anyVebusOverload &&
lifecycle.currentCooldownElapsed &&
derived.currentReferenceA > params.currentMinA;
const shouldStopOverloadAtMin =
derived.anyVebusOverload &&
derived.currentReferenceA <= params.currentMinA &&
sessionObservedActive;
// =====================================================
// HARD STOPS / PROTECTIONS
// =====================================================
const batteryCurrentHardExceeded =
parsed.batteryCurrentA <= batteryDischargeHardDynamicA;
// overload volontairement exclu du hard stop immédiat
const mustStopHard =
derived.anyVebusLowBattery ||
derived.vebusDcVoltageHardLow ||
derived.vebusAcOutHardReached ||
batteryCurrentHardExceeded;
const gridBatteryProtectionStop =
sessionObservedActive &&
isGridSource &&
parsed.batteryCurrentA < gridBatteryHardDynamicA;
// =====================================================
// OUTPUT
// =====================================================
return [{
json: {
...prev,
batteryPolicy: {
socBand,
batteryDischargeSoftDynamicA,
batteryDischargeHardDynamicA,
batteryChargeReserveDynamicA,
batterySoftToleranceMinutes,
batteryHardToleranceSeconds,
batteryRecoveryMinutes,
gridBatterySoftDynamicA,
gridBatteryHardDynamicA,
gridForceChargeRelaxSoftA,
gridForceChargeRelaxHardA,
houseSpikeDetected,
solarPotentialContext,
},
locks: {
nextAllowedWindowStartMs,
blockActive,
blockStillRelevant,
startBlockedByReason,
},
cloud: {
sessionObservedActive,
batteryNetPowerW,
cloudDebtNextKwh,
cloudDebtMeaningful,
cloudEventDischargingNow,
continuingCloudEvent,
enteringCloudDebt,
cloudDebtActive,
cloudDebtStartedMs,
cloudDebtDurationMinutes,
effectiveCloudToleranceMinutes,
cloudToleranceOk,
},
modes: {
batteryFullHarvestMode,
forecastCommitMode,
batteryPriorityMode,
},
safeguards: {
isGridSource,
commonSafetyStartOk,
commonSafetyHoldOk,
profileSocStartFloor,
profileSocHoldFloor,
profileBatteryDischargeFloorA,
batteryCurrentHardExceeded,
mustStopHard,
gridBatteryProtectionStop,
},
conditions: {
solarStartOk,
solarHoldOk,
gridStartOk,
gridHoldOk,
probeStartOk,
probeHoldGuardOk,
probeWindowActive,
probeElapsedReached,
probeSuccessNow,
probeFailedNow,
solarShouldStepUp,
gridShouldStepUp,
solarShouldStepDownBattery,
solarShouldStepDownCloudDebt,
gridShouldStepDownBattery,
shouldStepDownVebusPower,
shouldStepDownVebusVoltage,
shouldStepDownOverload,
shouldStopOverloadAtMin,
}
}
}];

View File

@@ -0,0 +1,464 @@
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 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);
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;
let state = 'observe_only';
let reason = [];
let desiredGrid = gridIsOn ? 'on' : 'off';
let desiredLatch = latchIsOn ? 'on' : 'off';
// ======================================================
// Core hysteresis logic V1.1
// ======================================================
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) {
// Seuil bas atteint => prise de main immédiate
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) {
// NOUVEAU CAS V1.1 :
// Le réseau est déjà ON mais ce workflow n'a pas la main.
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) {
// Tant que le latch est actif, on garde la main
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
// ======================================================
// On pose un nouveau "last_on_at" si :
// - on allume réellement EDF
// - ou on adopte un EDF déjà ON avec latch OFF
const setLastOnAt =
gridAction === 'turn_on' ||
state === 'adopt_existing_grid_on' ||
state === 'adopt_existing_grid_on_low_soc';
// On pose "last_off_at" si on coupe réellement EDF
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`);
}
// ======================================================
// Notifications
// ======================================================
let notifyTitle = 'Victron Grid Hysteresis';
let notifyMessage = '';
let notifyWanted = false;
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'}`
].join('|');
const signatureChanged = signature !== lastSignature;
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
},
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
},
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 }];

View File

@@ -0,0 +1,233 @@
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 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);
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;
let state = 'observe_only';
let reason = [];
let desiredGrid = gridIsOn ? 'on' : 'off';
let desiredLatch = latchIsOn ? 'on' : 'off';
// ======================================================
// Core hysteresis logic V1.1
// ======================================================
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) {
// Seuil bas atteint => prise de main immédiate
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) {
// NOUVEAU CAS V1.1 :
// Le réseau est déjà ON mais ce workflow n'a pas la main.
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) {
// Tant que le latch est actif, on garde la main
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';

View File

@@ -0,0 +1,230 @@
// ======================================================
// Timestamps
// ======================================================
// On pose un nouveau "last_on_at" si :
// - on allume réellement EDF
// - ou on adopte un EDF déjà ON avec latch OFF
const setLastOnAt =
gridAction === 'turn_on' ||
state === 'adopt_existing_grid_on' ||
state === 'adopt_existing_grid_on_low_soc';
// On pose "last_off_at" si on coupe réellement EDF
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`);
}
// ======================================================
// Notifications
// ======================================================
let notifyTitle = 'Victron Grid Hysteresis';
let notifyMessage = '';
let notifyWanted = false;
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'}`
].join('|');
const signatureChanged = signature !== lastSignature;
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
},
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
},
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 }];

View File

@@ -0,0 +1,86 @@
const root = $input.first().json;
const actions = [];
// Toujours mettre à jour état + raison
actions.push({
json: {
action: 'helper_state',
...root.ha_service_calls.helper_state
}
});
actions.push({
json: {
action: 'helper_reason',
...root.ha_service_calls.helper_reason
}
});
// Action réseau si nécessaire
if (root.decision.grid_action !== 'none') {
actions.push({
json: {
action: 'grid',
...root.ha_service_calls.grid
}
});
}
// Action latch si nécessaire
if (root.decision.latch_action !== 'none') {
actions.push({
json: {
action: 'helper_latch',
...root.ha_service_calls.helper_latch
}
});
}
// Timestamps si nécessaire
if (root.decision.set_last_on_at) {
actions.push({
json: {
action: 'helper_last_on_at',
...root.ha_service_calls.helper_last_on_at
}
});
}
if (root.decision.set_last_off_at) {
actions.push({
json: {
action: 'helper_last_off_at',
...root.ha_service_calls.helper_last_off_at
}
});
}
// Signature + logbook si changement
if (root.decision.signature_changed) {
actions.push({
json: {
action: 'helper_signature',
...root.ha_service_calls.helper_signature
}
});
actions.push({
json: {
action: 'logbook',
...root.ha_service_calls.logbook
}
});
}
// Notification si utile
if (root.decision.should_notify) {
actions.push({
json: {
action: 'mobile_notification',
...root.ha_service_calls.mobile_notification
}
});
}
return actions;

View File

@@ -0,0 +1,216 @@
// === STRATÉGIE UTILISATEUR (V2.0 - NEW) ===
const userStrategyRaw = input.victron_evcs_strategy || 'auto_solar';
const userStrategy = low(userStrategyRaw); // auto_solar | auto_solar_plus | force_grid_now | force_charge_now | stop
// Mode Force (Ignore HC strict & PV minimaux)
const isForceMode = (userStrategy === 'force_grid_now' || userStrategy === 'force_charge_now');
const isStopMode = (userStrategy === 'stop');
// Override for Force modes
let effectivePvStartMinW = pvStartMinW;
let effectiveAllowGridMidday = low(input.allow_grid_hc_midday || 'off') === 'on';
if (isForceMode) {
effectivePvStartMinW = 0;
effectiveAllowGridMidday = true;
}
// === OPTIONAL V1.4 INPUTS ===
const hcNightStartMin = parseTimeToMinutes(input.hc_night_start_time, 23 * 60);
const hcNightStopMin = parseTimeToMinutes(input.hc_night_stop_time, 6 * 60);
const morningStartMin = parseTimeToMinutes(input.morning_start_time, dayStartTimeMin);
const morningStopMin = parseTimeToMinutes(input.morning_stop_time, 11 * 60);
const hcMiddayStartMin = parseTimeToMinutes(input.hc_midday_start_time, 12 * 60 + 24);
const hcMiddayStopMin = parseTimeToMinutes(input.hc_midday_stop_time, 14 * 60 + 24);
const eveningStartMin = parseTimeToMinutes(input.evening_start_time, 17 * 60);
const eveningStopMin = parseTimeToMinutes(input.evening_stop_time, dayStopTimeMin);
const allowGridHcNight = low(input.allow_grid_hc_night || 'off') === 'on';
const allowGridHcMidday = effectiveAllowGridMidday; // Utilise le override si force mode
const probeEnabled = low(input.probe_enabled || 'off') === 'on';
const probeMinutes = Math.max(1, num(input.probe_minutes, 3));
const probeRetryMinutes = Math.max(5, num(input.probe_retry_minutes, 45));
const probeCurrentA = clamp(num(input.probe_current_a, currentMinA), currentMinA, currentMaxAutoA);
const gridBatterySoftA = num(input.grid_battery_soft_a, -3);
const gridBatteryHardA = num(input.grid_battery_hard_a, -6);
const morningProbePvAvgMinW = Math.max(0, num(input.morning_probe_pv_avg_min_w, 1500));
const morningProbePvTrendMinW = num(input.morning_probe_pv_trend_min_w, 0);
const middayProbePvAvgMinW = Math.max(0, num(input.midday_probe_pv_avg_min_w, 1200));
const morningSocMin = clamp(num(input.morning_soc_min, 35), 0, 100);
const middaySocMin = clamp(num(input.midday_soc_min, 75), 0, 100);
const eveningSocStartMin = clamp(num(input.evening_soc_start_min, 85), 0, 100);
const eveningSocStopMin = clamp(num(input.evening_soc_stop_min, 80), 0, 100);
const morningBatteryMaxDischargeA = num(input.morning_battery_max_discharge_a, -20);
const middayBatteryMaxDischargeA = num(input.midday_battery_max_discharge_a, -40);
const eveningBatteryMaxDischargeA = num(input.evening_battery_max_discharge_a, -30);
const lastBlockReason = (input.last_block_reason ?? '').toString().trim() || 'none';
const blockedUntilAt = (input.blocked_until ?? '').toString().trim();
const probeStartedAt = (input.probe_started_at ?? '').toString().trim();
const lastStartAt = (input.last_start_at ?? '').toString().trim();
const lastStopAt = (input.last_stop_at ?? '').toString().trim();
const lastCurrentChangeAt = (input.last_current_change_at ?? '').toString().trim();
const lastSignature = (input.last_signature ?? '').toString().trim();
const cloudDebtStartedAt = (input.cloudDebtStartedAt ?? '').toString().trim();
const lastStartMs = parseParisStampToMs(lastStartAt);
const lastStopMs = parseParisStampToMs(lastStopAt);
const lastCurrentChangeMs = parseParisStampToMs(lastCurrentChangeAt);
const blockedUntilMs = parseParisStampToMs(blockedUntilAt);
const probeStartedMs = parseParisStampToMs(probeStartedAt);
const cloudDebtStartedMsPrev = parseParisStampToMs(cloudDebtStartedAt);
const minutesSinceStart = minutesSince(lastStartMs, nowMs);
const minutesSinceStop = minutesSince(lastStopMs, nowMs);
const secondsSinceCurrentChange = secondsSince(lastCurrentChangeMs, nowMs);
const minutesSinceProbeStart = minutesSince(probeStartedMs, nowMs);
const minRunElapsed = minutesSinceStart === null ? true : minutesSinceStart >= minRunMinutes;
const minStopElapsed = minutesSinceStop === null ? true : minutesSinceStop >= minStopMinutes;
const currentCooldownElapsed = secondsSinceCurrentChange === null ? true : secondsSinceCurrentChange >= currentChangeCooldownSeconds;
const minutesUntilDeparture =
departureTimeMin === null
? null
: (() => {
const rawDiff = departureTimeMin - now.minNow;
return rawDiff >= 0 ? rawDiff : (1440 - now.minNow + departureTimeMin);
})();
const departurePriorityActive =
departurePriorityEnabled &&
Number.isFinite(minutesUntilDeparture) &&
minutesUntilDeparture >= 0 &&
minutesUntilDeparture <= CFG.departurePriorityWindowMinutes &&
evcsVehicleConnected;
// === VE.BUS INPUTS ===
const vebusOverload = low(input.vebus_overload || 'off') === 'on';
const vebusL1Overload = low(input.vebus_l1_overload || 'off') === 'on';
const vebusLowBattery = low(input.vebus_low_battery || 'off') === 'on';
const vebusL1LowBattery = low(input.vebus_l1_low_battery || 'off') === 'on';
const vebusAcOutPowerW = Math.max(0, num(input.vebus_ac_out_l1_power_w, 0));
const vebusAcOutCurrentA = Math.max(0, num(input.vebus_ac_out_l1_current_a, 0));
const vebusAcOutVoltageV = num(input.vebus_ac_out_l1_voltage_v, CFG.nominalAcVoltageV);
const vebusDcVoltageV = num(input.vebus_dc_voltage_v, NaN);
const vebusDcCurrentA = num(input.vebus_dc_current_a, NaN);
const vebusDcPowerW = num(input.vebus_dc_power_w, NaN);
const vebusState = num(input.vebus_state, NaN);
const vebusMainState = num(input.vebus_main_state, NaN);
// === DERIVED ===
const acVoltageRef = Number.isFinite(vebusAcOutVoltageV) && vebusAcOutVoltageV > 180
? vebusAcOutVoltageV
: CFG.nominalAcVoltageV;
const batteryVoltageRef = Number.isFinite(vebusDcVoltageV) && vebusDcVoltageV > 0
? vebusDcVoltageV
: 51.2;
const houseNonEvcsW = Math.max(0, houseAcPowerW - evcsAcPowerW);
const vebusNonEvcsW = Math.max(0, vebusAcOutPowerW - evcsAcPowerW);
const totalVebusSoftCeilingW = Math.max(6500, acLoadsMaxSafeW || 8500);
const totalVebusHardCeilingW = totalVebusSoftCeilingW + 400;
const safeEvcsByDedicatedCapW = Math.max(0, CFG.evcsDedicatedMaxW - CFG.systemReserveW);
const safeEvcsByVebusW = Math.max(0, totalVebusSoftCeilingW - vebusNonEvcsW - CFG.systemReserveW);
const safeEvcsCombinedW = Math.max(0, Math.min(safeEvcsByDedicatedCapW, safeEvcsByVebusW));
const systemCapCurrentA = Math.max(0, Math.floor(safeEvcsCombinedW / acVoltageRef));
const physicalCapA = clamp(systemCapCurrentA, 0, currentMaxAutoA);
const canRunAtMinCurrent = physicalCapA >= currentMinA;
const endDayGuardSoc = Math.max(endOfDaySocGuard, endOfDaySocTarget);
const energyNeededForEndDayGuardKwh = Math.max(0, ((endDayGuardSoc - soc) / 100) * CFG.batteryUsableCapacityKwh);
const forecastBufferKwh =
todayRemainingKwh -
baseLoadRemainingKwh -
flexibleSafetyMarginKwh -
requiredEnergyKwh -
energyNeededForEndDayGuardKwh;
const projectedEndSocAfterCharge = clamp(
soc + (
(
todayRemainingKwh -
baseLoadRemainingKwh -
flexibleSafetyMarginKwh -
requiredEnergyKwh
) / CFG.batteryUsableCapacityKwh
) * 100,
0,
100
);
const currentReferenceA = clamp(
Number.isFinite(evcsCommandCurrentA)
? evcsCommandCurrentA
: Number.isFinite(evcsSetCurrentA)
? evcsSetCurrentA
: targetCurrentAutoA,
0,
32
);
const anyVebusOverload = vebusOverload || vebusL1Overload;
const anyVebusLowBattery = vebusLowBattery || vebusL1LowBattery;
const vebusAcOutSoftReached = vebusAcOutPowerW >= totalVebusSoftCeilingW;
const vebusAcOutHardReached = vebusAcOutPowerW >= totalVebusHardCeilingW;
const vebusDcVoltageSoftLow =
Number.isFinite(vebusDcVoltageV) &&
vebusDcVoltageV > 0 &&
vebusDcVoltageV <= CFG.vebusDcVoltageSoftFloorV;
const vebusDcVoltageHardLow =
Number.isFinite(vebusDcVoltageV) &&
vebusDcVoltageV > 0 &&
vebusDcVoltageV <= CFG.vebusDcVoltageHardFloorV;
const houseLoadHighVsHeadroom = houseNonEvcsW > houseAcHeadroomW;
// === WINDOWS / PROFILE / SOURCE MODE (With Solar Potential Logic) ===
const inNightHcWindow = isWithinWindow(now.minNow, hcNightStartMin, hcNightStopMin);
const inMorningWindow = isWithinWindow(now.minNow, morningStartMin, morningStopMin);
const inMiddayWindow = isWithinWindow(now.minNow, hcMiddayStartMin, hcMiddayStopMin);
const inEveningWindow = isWithinWindow(now.minNow, eveningStartMin, eveningStopMin);
const profile =
inNightHcWindow ? 'night_hc' :
inMorningWindow ? 'morning_solar' :
inMiddayWindow ? 'midday_hc_or_solar' :
inEveningWindow ? 'evening_high_soc' :
'outside_window';
const morningProbeEligible =
profile === 'morning_solar' &&
probeEnabled &&
pvPowerAvg10mW >= morningProbePvAvgMinW &&
pvTrendW >= morningProbePvTrendMinW &&
soc >= morningSocMin &&
batteryCurrentA >= morningBatteryMaxDischargeA;
const middayProbeEligible =
profile === 'midday_hc_or_solar' &&
probeEnabled &&
pvPowerAvg10mW >= middayProbePvAvgMinW &&
soc >= middaySocMin &&
batteryCurrentA >= middayBatteryMaxDischargeA;
// Solar Potential Condition (High Battery + Forecast)
const cloudDebtOkForPotential = cloudDebtPrevKwh <= cloudDebtMaxKwh;
const batteryOkForPotential = batteryCurrentA >= -Math.max(1, batteryNeutralBandA);
const isHighBatteryForPotential =
soc >= 85 &&
forecastBufferKwh > 0 &&
cloudDebtOkForPotential &&
batteryOkForPotential;
const potentialAllowed = userStrategy !== 'auto_solar'; // auto_solar_plus autorise aussi
const isForcePotential = userStrategy === 'force_charge_now' || userStrategy === 'force_grid_now';
const strongSolarSignal =
pvPowerW >= effectivePvStartMinW ||
pvPowerAvg10mW >= pvHoldMinW ||
(departurePriorityActive && pvPowerAvg10mW >= pvHoldMinW);
let sourceMode = 'blocked';
if (isForcePotential) {
sourceMode = 'grid_forced'; // Priorité à l'utilisateur
} else if (profile === 'midday_hc_or_solar' || profile === 'morning_solar') {
if (strongSolarSignal) {
sourceMode = 'solar_only';
} else if (isHighBatteryForPotential && potentialAllowed) {
sourceMode = 'solar_potential'; // Le nouveau comportement clé !
} else if (profile === 'midday_hc_or_solar' && middayProbeEligible) {
sourceMode = 'hybrid_probe';
} else if (allowGridHcMidday && gridIsOn) {
sourceMode = 'grid_hc_allowed';
} else {
sourceMode = 'blocked'; // Fallback strict si PV bridé et pas de potentiel clair
}
} else if (profile === 'night_hc') {
sourceMode = allowGridHcNight && gridIsOn ? 'grid_hc_allowed' : 'blocked';
} else if (profile === 'evening_high_soc') {
sourceMode = strongSolarSignal ? 'solar_only' : 'blocked';
} else if (profile === 'outside_window' && userStrategy !== 'auto_solar') {
// Nouvelle condition : autorise charge en dehors fenêtre si stratégie auto (pas strict auto_solar)
if (strongSolarSignal) {
sourceMode = 'solar_only';
} else if (isHighBatteryForPotential && potentialAllowed) {
sourceMode = 'solar_potential';
}
}
const operatingMode = profile;