1561 lines
58 KiB
JavaScript
1561 lines
58 KiB
JavaScript
// Bornes de la consigne de puissance reseau. Le maximum est celui declare par
|
|
// l'entite elle-meme (number.airtria_mpii_v1_ac_power_setpoint_l1, max 10000 W),
|
|
// pas un chiffre choisi ici. La bande morte evite de reecrire la meme valeur
|
|
// toutes les 30 s sans empecher la correction d'une derive externe.
|
|
const GRID_SETPOINT_MAX_W = 10000;
|
|
const GRID_SETPOINT_DEADBAND_W = 100;
|
|
|
|
function round1(v) {
|
|
return Math.round(v * 10) / 10;
|
|
}
|
|
|
|
function round2(v) {
|
|
return Math.round(v * 100) / 100;
|
|
}
|
|
|
|
function round3(v) {
|
|
return Math.round(v * 1000) / 1000;
|
|
}
|
|
|
|
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,
|
|
lifecycle,
|
|
context,
|
|
derived,
|
|
batteryTune,
|
|
batteryPolicy,
|
|
locks,
|
|
cloud,
|
|
modes,
|
|
safeguards,
|
|
conditions,
|
|
decision,
|
|
nightEvcs,
|
|
vehicleDemandFilter,
|
|
} = prev;
|
|
|
|
// =====================================================
|
|
// SAFE ACCESS NIGHT EVCS
|
|
// =====================================================
|
|
const night = nightEvcs ?? {
|
|
nightSessionActive: false,
|
|
nightSessionNeedsCapture: false,
|
|
nightSessionNeedsClear: false,
|
|
nightSessionStartSocPct: null,
|
|
nightSessionStartAt: '',
|
|
batteryHardFloorPct: null,
|
|
batteryMorningTargetPct: null,
|
|
maxBatteryDischargePoints: null,
|
|
gridTakeoverTriggerPct: null,
|
|
batteryRecoveryUrgent: false,
|
|
nightGridTakeoverNeeded: false,
|
|
nightPreferredSourceMode: 'blocked',
|
|
};
|
|
|
|
// =====================================================
|
|
// SAFE ACCESS VEHICLE DEMAND FILTER
|
|
// =====================================================
|
|
const vdf = vehicleDemandFilter ?? {
|
|
dtSec: null,
|
|
commandCurrentA: null,
|
|
realCurrentA: null,
|
|
expectedPowerW: null,
|
|
realPowerW: null,
|
|
currentDeliveryRatioRaw: null,
|
|
powerDeliveryRatioRaw: null,
|
|
deliveryRatioRaw: null,
|
|
deliveryRatioEma: null,
|
|
deliveryNoiseEma: null,
|
|
adaptiveQuietBand: null,
|
|
noDemandEvidenceRaw: null,
|
|
sessionElapsedSec: null,
|
|
sessionArmFactor: null,
|
|
commandFactor: null,
|
|
sourceDeliverableForNoDemand: false,
|
|
evcsSessionArmedForNoDemand: false,
|
|
noDemandScoreRaw: null,
|
|
noDemandScoreEma: null,
|
|
noDemandScoreOn: null,
|
|
noDemandScoreOff: null,
|
|
evcsVehicleNoDemandSemantic: false,
|
|
evcsNoDemandSinceMs: null,
|
|
evcsNoDemandSinceMinutes: null,
|
|
nextHistory: {
|
|
evcsDeliveryRatioEma: null,
|
|
evcsDeliveryNoiseEma: null,
|
|
evcsNoDemandScoreEma: null,
|
|
evcsNoDemandSemanticActive: false,
|
|
evcsNoDemandSinceMs: null,
|
|
evcsNoDemandFilterEvalMs: null
|
|
}
|
|
};
|
|
|
|
// =====================================================
|
|
// 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);
|
|
|
|
const evcsVehicleNotRequestingPowerState =
|
|
decision.state === 'vehicle_full_or_paused' ||
|
|
decision.currentDecision === 'stop_vehicle_full_or_paused';
|
|
|
|
// -------------------------
|
|
// PRIORITÉ AUTO
|
|
// -------------------------
|
|
let autoEvcsPriorityActive = false;
|
|
let autoEnergyPriorityMode = 'normal';
|
|
let autoEnergyPriorityReason = 'Aucune priorité EVCS';
|
|
let autoEvcsPriorityUntil = now.stamp;
|
|
|
|
if (
|
|
parsed.evcsVehicleConnected &&
|
|
!strategy.isStopMode &&
|
|
!evcsVehicleNotRequestingPowerState
|
|
) {
|
|
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 (night.nightSessionActive) {
|
|
autoEvcsPriorityActive = true;
|
|
autoEnergyPriorityMode = 'evcs_priority';
|
|
autoEnergyPriorityReason =
|
|
night.batteryRecoveryUrgent
|
|
? 'EVCS prioritaire : session nuit + urgence départ'
|
|
: night.nightGridTakeoverNeeded
|
|
? 'EVCS prioritaire : session nuit + takeover réseau'
|
|
: 'EVCS prioritaire : session nuit active';
|
|
|
|
autoEvcsPriorityUntil =
|
|
Number.isFinite(lifecycle.minutesUntilDeparture)
|
|
? formatParisStampFromMs(
|
|
nowMs + Math.max(1, lifecycle.minutesUntilDeparture) * 60000
|
|
)
|
|
: formatParisStampFromMs(nowMs + forceWindowMinutes * 60000);
|
|
}
|
|
|
|
else if (context.departurePriorityActive && Number.isFinite(lifecycle.minutesUntilDeparture)) {
|
|
autoEvcsPriorityActive = true;
|
|
autoEnergyPriorityMode = 'evcs_priority';
|
|
autoEnergyPriorityReason = `EVCS prioritaire : départ dans ${Math.round(lifecycle.minutesUntilDeparture)} min`;
|
|
autoEvcsPriorityUntil = formatParisStampFromMs(
|
|
nowMs + Math.max(1, 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 && !evcsVehicleNotRequestingPowerState) {
|
|
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];
|
|
|
|
// Budget voiture de la nuit (30/09) : dire pourquoi la voiture s'arrete.
|
|
if (night.nightBudgetKwh !== null && night.nightBudgetKwh !== undefined) {
|
|
const f = (x) => String(Math.round(x * 100) / 100).replace('.', ',');
|
|
reason.push(`Budget batterie nuit ${f(night.nightBudgetDeliveredKwh ?? 0)}/${f(night.nightBudgetKwh)} kWh` +
|
|
(night.nightCarWaitsNoGrid ? ' — atteint, sans EDF : la voiture attend le soleil'
|
|
: night.nightBudgetReached ? ' — atteint, EDF finit la charge' : ''));
|
|
} else if (night.nightCarWaitsNoGrid) {
|
|
reason.push('Pas de budget batterie exploitable, sans EDF : la voiture attend le soleil');
|
|
}
|
|
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`);
|
|
// =====================================================
|
|
// CONSIGNE DE PUISSANCE RESEAU (01/09/2026)
|
|
// =====================================================
|
|
// Fermer le contacteur ne suffit PAS. Au-dessus du SOC minimal, l'ESS en
|
|
// autoconsommation ne preleve que `ac_power_setpoint` et laisse la batterie
|
|
// porter tout le reste : nuit du 01/09, contacteur ferme, consigne 50 W,
|
|
// batterie a -803 W pour 761 W de maison, voiture arretee par le garde
|
|
// "batterie trop sollicitee". La consigne est donc le levier qui rend une
|
|
// session reseau REELLE, et il doit appartenir au controleur, pas rester un
|
|
// reglage manuel permanent (celui-ci provoquait la substitution du solaire
|
|
// par le reseau en journee, cf. HANDOFF 27/28).
|
|
//
|
|
// La valeur n'est pas une constante : c'est le besoin mesure du moment, la
|
|
// maison telle qu'elle consomme plus la voiture au courant que le controleur
|
|
// s'autorise. Le plafond joue le role de marge : quand la voiture n'est pas a
|
|
// son plafond, la consigne est deja genereuse, et l'exces ne peut pas partir
|
|
// dans la batterie (DVCC tenue a 2 A par l'Auto Charge Manager pendant un
|
|
// takeover). Hors session, retour a la valeur de repos.
|
|
// ⚠️ LA SESSION ENGAGEE COMPTE AUSSI (04/09/2026).
|
|
// Nuit du 03-04/09 : la session reseau unique a tenu le contacteur toute la
|
|
// nuit — une seule bascule, comme voulu. Mais a 02:50, la voiture finie, le
|
|
// sourceMode est passe a 'blocked' et la consigne est retombee de 5078 W a
|
|
// 50 W. Contacteur ferme, reseau disponible, heures creuses, et l'ESS ne
|
|
// demandait que 50 W pendant que la maison tirait 770 W SUR LA BATTERIE.
|
|
// Trois heures et demie, SOC 44 % -> 17 %, 2,60 kWh pris a la batterie quand
|
|
// le reseau etait la et bon marche.
|
|
//
|
|
// C'est le defaut du 01/09 revenu ailleurs : fermer le contacteur ne suffit
|
|
// pas, la consigne doit demander la puissance. J'avais conditionne la consigne
|
|
// au sourceMode, puis cree la veille une NOUVELLE raison de garder le
|
|
// contacteur ferme — l'engagement de session — sans la brancher ici. La
|
|
// session tenait le contacteur, pas l'energie.
|
|
//
|
|
// Regle : tant que le contacteur est ferme A NOTRE DEMANDE, la consigne doit
|
|
// couvrir la maison. Peu importe laquelle des deux raisons le tient.
|
|
const gridSessionActive =
|
|
parsed.acInAvailable &&
|
|
(
|
|
context.sourceMode === 'grid_forced' ||
|
|
context.sourceMode === 'grid_hc_allowed' ||
|
|
night.nightGridSessionCommitted === true
|
|
);
|
|
|
|
// =====================================================
|
|
// DIMENSIONNEMENT SUR LE DEFICIT DU BILAN (18/09/2026)
|
|
// =====================================================
|
|
// Ce que la consigne remplace : `maison + plafond de la borne`, ou le plafond
|
|
// de la borne n'a AUCUNE relation physique avec ce dont la batterie a besoin.
|
|
// Qu'il tombe juste est un accident — releve du 17/09 a 13:51, voiture partie
|
|
// depuis cinq minutes, consigne a 7032 W pour une maison a 933 W.
|
|
//
|
|
// La consigne additionne desormais des besoins NOMMES :
|
|
// maison toujours ;
|
|
// voiture seulement si un vehicule est branche ;
|
|
// batterie seulement dans une fenetre d'heures creuses.
|
|
//
|
|
// ⚠️ MAX ET NON ADDITION, et ce n'est pas de la prudence. La consigne est une
|
|
// AUTORISATION, pas une prediction de consommation — le commentaire de la bande
|
|
// asymetrique le dit plus bas : « la consigne dit ce que le reseau a le DROIT
|
|
// de servir, DVCC dit ce que la batterie a le droit de prendre ». Additionner
|
|
// deux autorisations independantes n'est pas plus honnete, seulement plus
|
|
// permissif. Mesure a l'appui : avec l'addition,
|
|
// `consigne-session-engagee-couvre-la-maison` passe de 5595 a 6341 W, soit
|
|
// 746 W d'autorisation en plus sur une nuit engagee qui fonctionne deja — une
|
|
// regression sur le chemin DOMINANT pour corriger un chemin emprunte zero fois
|
|
// en treize jours.
|
|
//
|
|
// ⚠️ ET LE PLAFOND DU TERME BATTERIE EST DVCC, PAS LE BMS. Le BMS declare
|
|
// 250 A, soit 12,2 kW a 49 V : au-dessus de GRID_SETPOINT_MAX_W, donc il ne
|
|
// bornerait rien. Mesure : en fin de fenetre (14 min restantes, manque
|
|
// 5,672 kWh) la division rendait 17 kW, ecretes a 10 000 — PIRE que les 5437
|
|
// d'aujourd'hui. Avec DVCC le terme vaut 1538 W et la consigne 2380 W.
|
|
// Composer les deux leviers, ce n'est pas les confondre : le reseau n'est
|
|
// autorise a servir la batterie qu'a hauteur de ce que la batterie est
|
|
// autorisee a prendre.
|
|
//
|
|
// Corollaire : aucun plancher d'horizon n'est necessaire. Un horizon qui tend
|
|
// vers zero donne `min(enorme, DVCC) = DVCC`. Il ne reste qu'une garde d'une
|
|
// minute, qui est de l'arithmetique defensive et non une constante calibree.
|
|
//
|
|
// INVARIANT TENU PAR LA FORME : `maison + max(...)` ne peut jamais descendre
|
|
// sous la maison, quel que soit DVCC. La regle du 01/09 et du 04/09 — « tant
|
|
// que le contacteur est ferme A NOTRE DEMANDE, la consigne doit couvrir la
|
|
// maison » — est donc garantie par construction. A DVCC nul, le plafond vaut
|
|
// exactement la maison.
|
|
const SETPOINT_BALANCE_MAX_AGE_MIN = 30;
|
|
const setpointBalanceAgeMin = Number.isFinite(params.balanceLastRunMs)
|
|
? (nowMs - params.balanceLastRunMs) / 60000
|
|
: null;
|
|
|
|
// Le manque, pas l'energie a couvrir : voir le commentaire d'INPUT_PARSE.
|
|
const balanceUsableForSetpoint =
|
|
params.balanceStatusOk &&
|
|
Number.isFinite(params.balanceGridEnergyNeededKwh) &&
|
|
setpointBalanceAgeMin !== null &&
|
|
setpointBalanceAgeMin >= -5 &&
|
|
setpointBalanceAgeMin <= SETPOINT_BALANCE_MAX_AGE_MIN;
|
|
|
|
const dvccChargeLimitW =
|
|
Number.isFinite(params.dvccChargeLimitA) && params.dvccChargeLimitA >= 0
|
|
? params.dvccChargeLimitA * derived.batteryVoltageRef
|
|
: null;
|
|
|
|
// Toute lecture manquante retombe sur la formule d'avant : degradation vers
|
|
// l'existant, jamais pire.
|
|
const setpointFromBalance = balanceUsableForSetpoint && dvccChargeLimitW !== null;
|
|
|
|
const evcsSetpointShareW = parsed.evcsVehicleConnected
|
|
? derived.physicalCapA * derived.acVoltageRef
|
|
: 0;
|
|
|
|
// Recharger la batterie sur le reseau n'a de sens economique QUE dans une
|
|
// fenetre creuse. Hors fenetre le terme vaut zero — ce n'est pas un repli
|
|
// faute d'horizon, c'est le comportement voulu, et le probleme de la division
|
|
// par un temps qui tend vers zero disparait par construction.
|
|
const hcMinutesLeft = Number.isFinite(windows.minutesRemainingInHcWindow)
|
|
? windows.minutesRemainingInHcWindow
|
|
: null;
|
|
|
|
const batterySetpointShareW =
|
|
setpointFromBalance && hcMinutesLeft !== null && params.balanceGridEnergyNeededKwh > 0
|
|
? Math.min(
|
|
(params.balanceGridEnergyNeededKwh * 1000) / Math.max(hcMinutesLeft / 60, 1 / 60),
|
|
dvccChargeLimitW
|
|
)
|
|
: 0;
|
|
|
|
// =====================================================
|
|
// LE REPLI NE DOIT PAS RESSUSCITER LE DEFAUT (18/09/2026)
|
|
// =====================================================
|
|
// Quand la garde du bilan tombe -- statut degrade, mesure perimee, champ
|
|
// illisible -- la consigne revenait a `maison + plafond de borne`, y compris
|
|
// sans voiture. C'est-a-dire au defaut d'origine, et precisement au moment ou
|
|
// le systeme est deja degrade. Mesure sur le chemin concerne (midi, fenetre HC
|
|
// autorisee, aucun vehicule, bilan indisponible) : 5725 W autorises pour une
|
|
// maison a 900 W. Avec la part voiture : 1130 W.
|
|
//
|
|
// La branche de repli emploie donc la meme part voiture que la branche
|
|
// nominale. L'invariant du 01/09 et du 04/09 reste tenu PAR LA FORME : le
|
|
// plafond vaut `maison + un terme positif ou nul`, il ne descend donc jamais
|
|
// sous la maison, quelle que soit la branche empruntee.
|
|
//
|
|
// ⚠️ NE PAS REINTRODUIRE UN DISJOINT `nightGridSessionCommitted ||
|
|
// forceGridRequested` SUR LA PART VOITURE. Il a ete ecrit, mesure, puis
|
|
// retire : il ECRASE le terme batterie du bilan, puisque `max(4825 emprunte,
|
|
// 1538 mesure)` retient l'emprunt. Cout mesure, trois attendus figes d'un coup :
|
|
// nuit-sans-vehicule-session-engage 2380 -> 5437
|
|
// consigne-bilan-fin-de-fenetre-bornee-par-dvcc 2380 -> 5437
|
|
// consigne-bilan-plancher-nuage-exclut-la-batterie 4219 -> 6301
|
|
// Ce disjoint avait un sens tant que la consigne n'avait AUCUN terme batterie.
|
|
// Depuis qu'elle en a un, dimensionne sur le manque reel, il n'est plus une
|
|
// protection mais un court-circuit.
|
|
//
|
|
// Et si l'idee revient malgre tout : pendant une panne de bilan,
|
|
// `nightGridSessionCommitted` ne peut engager aucune session NOUVELLE, il ne
|
|
// survit que par `committedPrev`. Dans le repli, ce sont donc
|
|
// `forceGridRequested` et le terme vehicule qui portent reellement.
|
|
const gridSetpointCeilingW = clamp(
|
|
Math.round(
|
|
setpointFromBalance
|
|
? derived.houseNonEvcsW + Math.max(evcsSetpointShareW, batterySetpointShareW)
|
|
: derived.houseNonEvcsW + evcsSetpointShareW
|
|
),
|
|
params.gridSetpointIdleW,
|
|
GRID_SETPOINT_MAX_W
|
|
);
|
|
|
|
const gridSetpointCurrentW = Number.isFinite(parsed.gridSetpointW)
|
|
? parsed.gridSetpointW
|
|
: null;
|
|
|
|
// =====================================================
|
|
// CREDIT SOLAIRE SUR LA CONSIGNE (11/09/2026)
|
|
// =====================================================
|
|
// Defaut mesure ce jour-la : BOOST 28 A en plein soleil, consigne calculee a
|
|
// 8242 W (maison 1802 + 28 A x 230). L'ESS n'a qu'un moyen de prelever 8,2 kW
|
|
// quand le soleil en donne 4 : brider les MPPT. Releve 17:02 -- PV 121 W,
|
|
// reseau 7697 W, batterie 0 A, SOC 96 %. La production etait refusee, et par
|
|
// NOTRE consigne. Le commentaire ci-dessus ("une consigne trop HAUTE ne coute
|
|
// rien") a ete ecrit la nuit du 01/09, ou la PV valait zero : il n'y avait
|
|
// alors rien a evincer. En plein jour il est faux.
|
|
//
|
|
// ⚠️ ON NE PEUT PAS SOUSTRAIRE LA PV MESUREE. Elle vaut 121 W *a cause* de
|
|
// cette consigne : mesure censuree, la soustraire ne bouge rien et le systeme
|
|
// reste sur son mauvais equilibre. Preuve le meme jour a 17:22 -- consigne
|
|
// descendue A LA MAIN a 2280 W : PV 4758 W en 50 secondes, batterie +9 A. La
|
|
// ressource etait la, seule la consigne l'evincait.
|
|
//
|
|
// On DESCEND donc, et on laisse la physique repondre. Deux signaux, tous deux
|
|
// deja lus et tous deux INCENSURABLES :
|
|
// - les chargeurs declarent eux-memes s'ils se BRIDENT (mpptLimitedCount).
|
|
// `off` ne compte pas : la nuit ils sont tous `off` et il n'y a rien a
|
|
// liberer -- c'est ce qui garantit que le comportement de nuit (§31, §57)
|
|
// est strictement inchange ;
|
|
// - le courant batterie dit si on est descendu trop bas, via la politique
|
|
// par bande de SOC deja calibree (batterySoftBreachNow, §35).
|
|
// La valeur courante vit dans le GX : l'etat est deja persiste, aucun helper
|
|
// nouveau n'est necessaire.
|
|
//
|
|
// PLANCHER : la consigne ne descend JAMAIS sous la maison hors voiture. C'est
|
|
// la garantie du 01/09 et du 04/09 -- le reseau, quand le contacteur est ferme
|
|
// a notre demande, couvre la maison. On ne libere que la part VOITURE, qui est
|
|
// exactement celle qui evince le solaire. Le solaire pourrait souvent couvrir
|
|
// la maison en plus, mais l'affirmer demanderait ses propres scenarios.
|
|
// ⚠️ SECOND PLANCHER : LE NUAGE (ajoute 11/09 18:05, apres incident).
|
|
// Premiere version de la descente : consigne tombee a 1281 W alors que le
|
|
// solaire portait tout (PV 6058 W, reseau 1163 W, voiture a 28 A). Un nuage a
|
|
// fait chuter la PV de 4,6 kW en une minute ; le reseau etant plafonne a
|
|
// 1281 W, la difference est tombee entierement sur la batterie -- 87 A pour
|
|
// une limite dure de 70 A -> `battery_current_hard_stop`, charge coupee.
|
|
//
|
|
// Sans injection, la production peut s'effondrer de plusieurs kW en quelques
|
|
// secondes. Une boucle a 30 s ne peut pas rattraper cela : il faut que le
|
|
// reseau soit DEJA autorise a prendre le relais. La consigne ne descend donc
|
|
// jamais au point ou la perte totale du solaire depasserait ce que la batterie
|
|
// sait encaisser.
|
|
//
|
|
// Le budget vient de la politique batterie deja calibree par bande de SOC
|
|
// (§35), pas d'une constante nouvelle, et on garde 20 % de marge sous la
|
|
// limite DURE -- c'est elle qui coupe la charge, et une coupure est plus
|
|
// couteuse que quelques centaines de watts de solaire non captes.
|
|
//
|
|
// Contrepartie assumee, et elle est reelle : voiture a 28 A, la descente est
|
|
// nettement moins profonde qu'a 10 A. C'est proportionne -- c'est le courant
|
|
// de la voiture qui cree l'exposition. Et c'est la regle du proprietaire :
|
|
// protéger le materiel prime sur l'economie de kilowattheures.
|
|
// `derived.batteryVoltageRef` est deja la tension retenue en amont (mesure
|
|
// vebus, repli 51,2 V) : meme grandeur que celle qui sert a convertir le
|
|
// courant batterie en puissance dans BATTERY_CLOUD_LOCKS.
|
|
const batterySurgeBudgetW = Math.max(
|
|
0,
|
|
Math.abs(batteryPolicy.batteryDischargeHardDynamicA) * derived.batteryVoltageRef * 0.8
|
|
);
|
|
// ⚠️ LE PLANCHER NUAGE SUIT LA VOITURE, PAS LA BATTERIE. Il existe pour que la
|
|
// consigne ne descende pas au point ou la perte totale du solaire depasserait
|
|
// ce que la batterie sait encaisser — et le commentaire ci-dessus dit pourquoi :
|
|
// « c'est le courant de la voiture qui cree l'exposition ». Une batterie qu'on
|
|
// recharge depuis le reseau n'est PAS une exposition : si le solaire s'effondre,
|
|
// la recharge ralentit et personne ne se fait couper. L'y inclure remonterait le
|
|
// plancher sans aucune contrepartie de securite, et contre l'objectif.
|
|
// Une seule branche depuis le 18/09/2026 : le plancher nuage se construit sur
|
|
// la maison et la voiture dans les deux cas. La distinction n'avait de sens que
|
|
// tant que le repli gardait le plafond de borne emprunte.
|
|
const gridSetpointLoadW = derived.houseNonEvcsW + evcsSetpointShareW;
|
|
const gridSetpointCloudFloorW = Math.round(
|
|
Math.max(0, gridSetpointLoadW - batterySurgeBudgetW)
|
|
);
|
|
|
|
const gridSetpointHouseFloorW = clamp(
|
|
Math.max(Math.round(derived.houseNonEvcsW), gridSetpointCloudFloorW),
|
|
params.gridSetpointIdleW,
|
|
GRID_SETPOINT_MAX_W
|
|
);
|
|
|
|
const solarResourceRefused =
|
|
parsed.mpptReadableCount > 0 && parsed.mpptLimitedCount > 0;
|
|
|
|
// Pendant la fenetre d'etablissement (§29) les mesures PV et batterie ne
|
|
// decrivent aucun regime permanent : on ne descend pas sur elles.
|
|
const gridSetpointDescendOk =
|
|
gridSessionActive &&
|
|
solarResourceRefused &&
|
|
!batteryPolicy.batterySoftBreachNow &&
|
|
!derived.gridSettling &&
|
|
gridSetpointCurrentW !== null;
|
|
|
|
// ⚠️ LE PLAFOND EST UN PLAFOND, PAS UNE CIBLE (corrige 11/09 17:52).
|
|
// Premiere version de ce correctif, en production 12 minutes : la consigne
|
|
// descendait bien a 2241 W et la PV remontait a 7074 W -- puis, tous les
|
|
// chargeurs repassant `mppt_active`, la condition de descente devenait fausse,
|
|
// le besoin revenait au plafond et la consigne etait REECRITE a 7100 W. La PV
|
|
// s'effondrait a 1785 W, les chargeurs se bridaient, on redescendait.
|
|
// Oscillateur de periode ~5 min. Releve : 17:40:30 -> 2241 / 17:47:19 -> 7100
|
|
// / 17:47:28 PV 1785 / 17:48:30 -> 7368.
|
|
//
|
|
// Une fois la consigne descendue, on la TIENT tant que le solaire produit. Ce
|
|
// n'est pas un etat a memoriser : la valeur courante est dans le GX, et le
|
|
// plancher maison la fait remonter d'elle-meme si la maison grossit.
|
|
//
|
|
// La nuit, les chargeurs sont `off` (ils ne produisent pas) : on retombe sur le
|
|
// comportement historique -- la consigne monte au plafond pour que le reseau
|
|
// porte maison et voiture (§31, §57). C'est cette distinction qui empeche le
|
|
// « on tient » de casser les nuits HC.
|
|
const solarProducing =
|
|
parsed.mpptReadableCount > 0 && parsed.mpptAtMaxPowerCount > 0;
|
|
|
|
let gridSetpointNeedW;
|
|
let gridSetpointDescending = false;
|
|
|
|
if (!gridSessionActive) {
|
|
gridSetpointNeedW = params.gridSetpointIdleW;
|
|
} else if (solarProducing && !batteryPolicy.batterySoftBreachNow && gridSetpointCurrentW !== null) {
|
|
// On tient la valeur acquise, bornee par la maison et par le plafond.
|
|
gridSetpointNeedW = clamp(
|
|
Math.min(gridSetpointCurrentW, gridSetpointCeilingW),
|
|
gridSetpointHouseFloorW,
|
|
gridSetpointCeilingW
|
|
);
|
|
} else {
|
|
gridSetpointNeedW = gridSetpointCeilingW;
|
|
}
|
|
|
|
if (gridSetpointDescendOk) {
|
|
// Un chargeur bride PROUVE qu'il reste de la ressource : on peut descendre
|
|
// franchement. Une descente trop forte est rattrapee au cycle suivant (30 s
|
|
// ou la batterie porte l'ecart, soit quelques dizaines de Wh), alors qu'une
|
|
// descente trop timide laisse des kW de solaire refuses pendant dix minutes.
|
|
const minStepW = Math.max(
|
|
GRID_SETPOINT_DEADBAND_W,
|
|
Math.round(params.currentStepUpA * derived.acVoltageRef)
|
|
);
|
|
const baseW = Math.min(gridSetpointCurrentW, gridSetpointCeilingW);
|
|
const gapW = baseW - gridSetpointHouseFloorW;
|
|
if (gapW > 0) {
|
|
const stepW = Math.max(minStepW, Math.round(gapW / 2));
|
|
gridSetpointNeedW = Math.max(gridSetpointHouseFloorW, baseW - stepW);
|
|
gridSetpointDescending = true;
|
|
}
|
|
}
|
|
|
|
// La bande est ASYMETRIQUE, et c'est le point. Une consigne trop BASSE fait
|
|
// porter la difference par la batterie : c'est le defaut de la nuit du 01/09,
|
|
// il se corrige immediatement. Une consigne trop HAUTE ne coute rien : l'ESS
|
|
// ne preleve que ce qu'il peut placer, et le courant de charge de la batterie
|
|
// est tenu par ailleurs (DVCC, Auto Charge Manager). Deux leviers, deux roles
|
|
// -- la consigne dit ce que le reseau a le DROIT de servir, DVCC dit ce que la
|
|
// batterie a le droit de prendre. Ne pas confondre les deux.
|
|
//
|
|
// On ecrit donc avec une marge d'un pas de courant EVCS (grandeur du systeme,
|
|
// pas une constante inventee) pour que le bruit de la maison ne rende pas la
|
|
// consigne insuffisante au cycle suivant, et on ne redescend que si elle est
|
|
// devenue franchement inutile. Sans cela la boucle 30 s reecrivait a chaque
|
|
// cycle en poursuivant le bruit.
|
|
const gridSetpointSlackW = gridSessionActive && !gridSetpointDescending
|
|
? Math.max(GRID_SETPOINT_DEADBAND_W, Math.round(params.currentStepUpA * derived.acVoltageRef))
|
|
: 0;
|
|
|
|
const gridSetpointTargetW = clamp(
|
|
gridSetpointNeedW + gridSetpointSlackW,
|
|
0,
|
|
GRID_SETPOINT_MAX_W
|
|
);
|
|
|
|
const gridSetpointNeedsWrite =
|
|
gridSetpointCurrentW === null ||
|
|
gridSetpointCurrentW < gridSetpointNeedW ||
|
|
gridSetpointCurrentW > gridSetpointNeedW + 2 * gridSetpointSlackW + GRID_SETPOINT_DEADBAND_W;
|
|
|
|
reason.push(`Grid=${parsed.gridAvailable ? 'on' : 'off'} (${Math.round(parsed.gridPowerW)}W)`);
|
|
// Trace de la fenetre d'etablissement : sans elle, une lecture du logbook
|
|
// pendant le transitoire donne l'impression que le systeme decide n'importe
|
|
// quoi. Il decide, au contraire, de ne PAS decider sur ces mesures.
|
|
if (derived.gridSettling) {
|
|
reason.push(`ETABLISSEMENT reseau ${Math.round(parsed.gridTransitionAgeSec)}s/${params.gridSettleSeconds}s (mesures PV/batterie non fiables)`);
|
|
}
|
|
if (gridSessionActive) {
|
|
reason.push(
|
|
gridSetpointDescending
|
|
? `Consigne reseau=${gridSetpointTargetW}W (CREDIT SOLAIRE, ${parsed.mpptLimitedCount}/${parsed.mpptReadableCount} brides)`
|
|
: `Consigne reseau=${gridSetpointTargetW}W`
|
|
);
|
|
}
|
|
// Visible dans le logbook uniquement pendant un depassement souple : c'est le
|
|
// seul moment ou l'information sert, et la raison est tronquee a 255 caracteres.
|
|
if (batteryPolicy.batterySoftBreachNow) {
|
|
reason.push(
|
|
`Batt souple ${round1(batteryPolicy.batterySoftBreachMinutes)}/${round1(batteryPolicy.batterySoftToleranceMinutes)}min`
|
|
);
|
|
}
|
|
// Idem : visible seulement quand le bilan energetique DURCIT le plancher du
|
|
// soir. Sans cette trace, un blocage de charge un soir de SOC 94 % est
|
|
// incomprehensible a la lecture du logbook.
|
|
if (safeguards.eveningFloorFromBalance) {
|
|
reason.push(
|
|
`Plancher soir=${round1(safeguards.eveningSocStartFloorPct)}% (BILAN, ${round1(safeguards.balanceEnergyToCoverKwh)}kWh a couvrir)`
|
|
);
|
|
}
|
|
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})`);
|
|
reason.push(`VDF delivery=${Number.isFinite(vdf.deliveryRatioEma) ? round3(vdf.deliveryRatioEma) : 'na'}`);
|
|
reason.push(`VDF noise=${Number.isFinite(vdf.deliveryNoiseEma) ? round3(vdf.deliveryNoiseEma) : 'na'}`);
|
|
reason.push(`VDF score=${Number.isFinite(vdf.noDemandScoreEma) ? round3(vdf.noDemandScoreEma) : 'na'}`);
|
|
reason.push(`VDF noDemand=${vdf.evcsVehicleNoDemandSemantic ? 'on' : 'off'}`);
|
|
|
|
if (night.nightSessionActive) {
|
|
reason.push(`NightTarget=${round1(night.batteryMorningTargetPct ?? 0)}%`);
|
|
reason.push(`NightTrigger=${round1(night.gridTakeoverTriggerPct ?? 0)}%`);
|
|
|
|
if (Number.isFinite(night.nightSessionStartSocPct)) {
|
|
reason.push(`StartSOC=${round1(night.nightSessionStartSocPct)}%`);
|
|
reason.push(`AllowDrop=${round1(night.maxBatteryDischargePoints ?? 0)}pts`);
|
|
}
|
|
|
|
reason.push(`NightPreferred=${night.nightPreferredSourceMode ?? 'blocked'}`);
|
|
reason.push(`NightTakeover=${night.nightGridTakeoverNeeded ? 'on' : 'off'}`);
|
|
reason.push(`NightUrgent=${night.batteryRecoveryUrgent ? 'on' : 'off'}`);
|
|
}
|
|
|
|
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 === 'battery_assist_night' ? 'Assistance batterie nuit' :
|
|
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'}`,
|
|
`night=${night.nightSessionActive ? '1' : '0'}`,
|
|
`ntrg=${Number.isFinite(night.gridTakeoverTriggerPct) ? round1(night.gridTakeoverTriggerPct) : 'na'}`,
|
|
`nurg=${night.batteryRecoveryUrgent ? '1' : '0'}`,
|
|
`vdf=${vdf.evcsVehicleNoDemandSemantic ? '1' : '0'}`,
|
|
`vdfs=${Number.isFinite(vdf.noDemandScoreEma) ? round3(vdf.noDemandScoreEma) : 'na'}`
|
|
].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';
|
|
} else if (decision.state === 'vehicle_full_or_paused') {
|
|
notifyWanted = true;
|
|
notifyTitle = '🚗 EVCS véhicule plein ou en pause';
|
|
}
|
|
}
|
|
|
|
// =====================================================
|
|
// TIMESTAMPS TO WRITE
|
|
// =====================================================
|
|
const cloudDebtStartedAtToWrite =
|
|
cloud.cloudDebtStartedMs
|
|
? formatParisStampFromMs(cloud.cloudDebtStartedMs)
|
|
: now.stamp;
|
|
|
|
// DEPASSEMENT SOUPLE BATTERIE (01/09/2026) : chaine vide quand il n'y a pas
|
|
// de depassement en cours. Le helper n'est reecrit que sur changement reel
|
|
// (debut ou fin), pas a chaque cycle : la valeur du debut doit RESTER figee
|
|
// pendant toute la duree du depassement, sinon le compteur ne monte jamais.
|
|
const batterySoftBreachAtToWrite =
|
|
batteryPolicy.batterySoftBreachStartedMs !== null &&
|
|
batteryPolicy.batterySoftBreachStartedMs !== undefined
|
|
? formatParisStampFromMs(batteryPolicy.batterySoftBreachStartedMs)
|
|
: '';
|
|
|
|
const setBatterySoftBreachAt =
|
|
batterySoftBreachAtToWrite !== (history.batterySoftBreachAt || '');
|
|
|
|
const probeStartedAtToWrite = now.stamp;
|
|
const lastStartAtToWrite = now.stamp;
|
|
const lastStopAtToWrite = now.stamp;
|
|
const lastCurrentChangeAtToWrite = now.stamp;
|
|
|
|
// =====================================================
|
|
// NIGHT SESSION MEMORY
|
|
// =====================================================
|
|
const nightSessionStartSocToWrite =
|
|
decision.clearNightSessionStart
|
|
? ''
|
|
: decision.setNightSessionStart
|
|
? String(round1(parsed.soc))
|
|
: (
|
|
Number.isFinite(history.nightSessionStartSocPct)
|
|
? String(round1(history.nightSessionStartSocPct))
|
|
: ''
|
|
);
|
|
|
|
const nightSessionStartAtToWrite =
|
|
decision.clearNightSessionStart
|
|
? ''
|
|
: decision.setNightSessionStart
|
|
? now.stamp
|
|
: (history.nightSessionStartAt || '');
|
|
|
|
// =====================================================
|
|
// 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,
|
|
|
|
// D'ou vient la consigne reseau. Sans cette trace, un repli est
|
|
// SILENCIEUX : la consigne reprend la formule d'avant sans que rien ne le
|
|
// dise, et c'est precisement l'information qui manquera le jour ou elle se
|
|
// comportera mal. `bilan` = dimensionnee sur le manque, `heritee` = repli.
|
|
setpoint_source: setpointFromBalance ? 'bilan' : 'heritee',
|
|
setpoint_battery_share_w: Math.round(batterySetpointShareW),
|
|
setpoint_evcs_share_w: Math.round(evcsSetpointShareW),
|
|
dvcc_limit_w: dvccChargeLimitW === null ? null : Math.round(dvccChargeLimitW),
|
|
hc_minutes_left: hcMinutesLeft,
|
|
|
|
departure_priority_active: context.departurePriorityActive,
|
|
minutes_until_departure: 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,
|
|
|
|
batt_soft_breach_now: !!batteryPolicy.batterySoftBreachNow,
|
|
batt_soft_breach_minutes: round1(batteryPolicy.batterySoftBreachMinutes || 0),
|
|
batt_soft_discharge_hold_ok: batteryPolicy.batterySoftDischargeHoldOk !== false,
|
|
|
|
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,
|
|
|
|
night_session_active: night.nightSessionActive,
|
|
night_session_needs_capture: !!night.nightSessionNeedsCapture,
|
|
night_session_needs_clear: !!night.nightSessionNeedsClear,
|
|
night_session_start_soc_pct: Number.isFinite(night.nightSessionStartSocPct)
|
|
? round1(night.nightSessionStartSocPct)
|
|
: null,
|
|
night_session_start_at: night.nightSessionStartAt || null,
|
|
|
|
battery_hard_floor_pct: Number.isFinite(night.batteryHardFloorPct)
|
|
? round1(night.batteryHardFloorPct)
|
|
: null,
|
|
battery_morning_target_pct: Number.isFinite(night.batteryMorningTargetPct)
|
|
? round1(night.batteryMorningTargetPct)
|
|
: null,
|
|
max_battery_discharge_points: Number.isFinite(night.maxBatteryDischargePoints)
|
|
? round1(night.maxBatteryDischargePoints)
|
|
: null,
|
|
grid_takeover_trigger_pct: Number.isFinite(night.gridTakeoverTriggerPct)
|
|
? round1(night.gridTakeoverTriggerPct)
|
|
: null,
|
|
|
|
battery_recovery_urgent: !!night.batteryRecoveryUrgent,
|
|
night_grid_takeover_needed: !!night.nightGridTakeoverNeeded,
|
|
night_preferred_source_mode: night.nightPreferredSourceMode || 'blocked',
|
|
|
|
evcs_delivery_ratio_ema: Number.isFinite(vdf.deliveryRatioEma) ? round3(vdf.deliveryRatioEma) : null,
|
|
evcs_delivery_noise_ema: Number.isFinite(vdf.deliveryNoiseEma) ? round3(vdf.deliveryNoiseEma) : null,
|
|
evcs_no_demand_score_ema: Number.isFinite(vdf.noDemandScoreEma) ? round3(vdf.noDemandScoreEma) : null,
|
|
evcs_vehicle_no_demand_semantic: !!vdf.evcsVehicleNoDemandSemantic,
|
|
evcs_no_demand_since_minutes: Number.isFinite(vdf.evcsNoDemandSinceMinutes) ? round3(vdf.evcsNoDemandSinceMinutes) : null,
|
|
evcs_source_deliverable_for_no_demand: !!vdf.sourceDeliverableForNoDemand,
|
|
evcs_session_armed_for_no_demand: !!vdf.evcsSessionArmedForNoDemand,
|
|
evcs_no_demand_score_on: Number.isFinite(vdf.noDemandScoreOn) ? round3(vdf.noDemandScoreOn) : null,
|
|
evcs_no_demand_score_off: Number.isFinite(vdf.noDemandScoreOff) ? round3(vdf.noDemandScoreOff) : null
|
|
},
|
|
|
|
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_night_session_start: !!decision.setNightSessionStart,
|
|
clear_night_session_start: !!decision.clearNightSessionStart,
|
|
|
|
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'
|
|
}
|
|
},
|
|
|
|
// Bouton reset borne (19/09/2026) : momentane. Il a deja fait son effet
|
|
// dans WINDOWS (sessionFresh forcee ce cycle) ; on le rebascule off pour
|
|
// qu'il ne force pas un retest en boucle. N'ecrit que s'il etait ON.
|
|
helper_reset_borne_ack: {
|
|
execute: !!params.resetBorneRequested,
|
|
domain: 'input_boolean',
|
|
service: 'turn_off',
|
|
data: {
|
|
entity_id: 'input_boolean.evcs_reset_borne'
|
|
}
|
|
},
|
|
|
|
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_battery_soft_breach_at: {
|
|
execute: setBatterySoftBreachAt,
|
|
domain: 'input_text',
|
|
service: 'set_value',
|
|
data: {
|
|
entity_id: 'input_text.victron_evcs_battery_soft_breach_at',
|
|
value: batterySoftBreachAtToWrite
|
|
}
|
|
},
|
|
|
|
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
|
|
}
|
|
},
|
|
|
|
helper_night_target_soc: {
|
|
execute: true,
|
|
domain: 'input_number',
|
|
service: 'set_value',
|
|
data: {
|
|
entity_id: 'input_number.victron_evcs_night_target_soc',
|
|
value: round1(
|
|
Number.isFinite(night.batteryMorningTargetPct)
|
|
? night.batteryMorningTargetPct
|
|
: 0
|
|
)
|
|
}
|
|
},
|
|
|
|
helper_night_grid_trigger_soc: {
|
|
execute: true,
|
|
domain: 'input_number',
|
|
service: 'set_value',
|
|
data: {
|
|
entity_id: 'input_number.victron_evcs_night_grid_trigger_soc',
|
|
value: round1(
|
|
Number.isFinite(night.gridTakeoverTriggerPct)
|
|
? night.gridTakeoverTriggerPct
|
|
: 0
|
|
)
|
|
}
|
|
},
|
|
|
|
helper_night_preferred_source_mode: {
|
|
execute: true,
|
|
domain: 'input_text',
|
|
service: 'set_value',
|
|
data: {
|
|
entity_id: 'input_text.victron_evcs_night_preferred_source_mode',
|
|
value: night.nightPreferredSourceMode || 'blocked'
|
|
}
|
|
},
|
|
|
|
// SESSION RESEAU UNIQUE (03/09/2026) : ce bit est ce qui rend la session
|
|
// UNIQUE. Sans persistance, l'engagement serait reevalue a chaque cycle
|
|
// et le systeme retomberait dans une bande — donc dans un oscillateur.
|
|
// Ecrit uniquement sur changement reel.
|
|
helper_night_grid_session_committed: {
|
|
execute: night.nightGridSessionCommitted !== history.nightGridSessionCommittedPrev,
|
|
domain: 'input_boolean',
|
|
service: night.nightGridSessionCommitted ? 'turn_on' : 'turn_off',
|
|
data: {
|
|
entity_id: 'input_boolean.victron_evcs_night_grid_session_committed'
|
|
}
|
|
},
|
|
|
|
// P12 — LE VERROU ANTI-OSCILLATEUR.
|
|
//
|
|
// Le plancher de surplus (`required_soc_prudent`) DESCEND pendant la nuit,
|
|
// et le SOC aussi. Mesure du 15/09 : voiture en charge, le SOC descend de
|
|
// 7,1 pt/h et le plancher de 3,7 ; voiture ARRETEE, le SOC ne descend plus
|
|
// que de 2,6 pt/h quand le plancher continue a 3,7. Le surplus
|
|
// REAPPARAITRAIT donc tout seul et la charge repartirait — cinquieme
|
|
// oscillateur du projet, par construction.
|
|
//
|
|
// Une seule passe de surplus par session de nuit, comme la recovery
|
|
// batterie depuis le 27/08. Ecrit uniquement sur changement reel.
|
|
helper_night_battery_done: {
|
|
execute: !!night.nightBatterySurplusExhausted !== !!params.nightBatteryDonePrev,
|
|
domain: 'input_boolean',
|
|
service: night.nightBatterySurplusExhausted ? 'turn_on' : 'turn_off',
|
|
data: {
|
|
entity_id: 'input_boolean.victron_evcs_night_battery_done'
|
|
}
|
|
},
|
|
|
|
helper_night_grid_takeover_needed: {
|
|
execute: true,
|
|
domain: 'input_boolean',
|
|
// P9 : la fenêtre "départ imminent" (jour) tient aussi le helper
|
|
// takeover → override hystérésis + maintien batterie ACM pendant
|
|
// la charge réseau pré-départ.
|
|
service: (night.nightGridTakeoverNeeded || night.departureUrgentActive)
|
|
? 'turn_on'
|
|
: 'turn_off',
|
|
data: {
|
|
entity_id: 'input_boolean.victron_evcs_night_grid_takeover_needed'
|
|
}
|
|
},
|
|
|
|
helper_night_battery_recovery_urgent: {
|
|
execute: true,
|
|
domain: 'input_boolean',
|
|
service: night.batteryRecoveryUrgent ? 'turn_on' : 'turn_off',
|
|
data: {
|
|
entity_id: 'input_boolean.victron_evcs_night_battery_recovery_urgent'
|
|
}
|
|
},
|
|
|
|
helper_night_session_active: {
|
|
execute: true,
|
|
domain: 'input_boolean',
|
|
service: night.nightSessionActive ? 'turn_on' : 'turn_off',
|
|
data: {
|
|
entity_id: 'input_boolean.victron_evcs_night_session_active'
|
|
}
|
|
},
|
|
|
|
helper_night_session_start_soc: {
|
|
execute: decision.setNightSessionStart || decision.clearNightSessionStart,
|
|
domain: 'input_text',
|
|
service: 'set_value',
|
|
data: {
|
|
entity_id: 'input_text.victron_evcs_night_session_start_soc',
|
|
value: nightSessionStartSocToWrite
|
|
}
|
|
},
|
|
|
|
// Budget voiture de la nuit (30/09) : fige a l'ouverture, efface en fin.
|
|
helper_night_budget: {
|
|
execute: night.nightBudgetToWrite !== null && night.nightBudgetToWrite !== undefined,
|
|
domain: 'input_text',
|
|
service: 'set_value',
|
|
data: {
|
|
entity_id: 'input_text.victron_evcs_night_budget',
|
|
value: night.nightBudgetToWrite ?? ''
|
|
}
|
|
},
|
|
|
|
helper_night_session_start_at: {
|
|
execute: decision.setNightSessionStart || decision.clearNightSessionStart,
|
|
domain: 'input_text',
|
|
service: 'set_value',
|
|
data: {
|
|
entity_id: 'input_text.victron_evcs_night_session_start_at',
|
|
value: nightSessionStartAtToWrite
|
|
}
|
|
},
|
|
|
|
// =================================================
|
|
// VEHICLE DEMAND FILTER HISTORY PERSISTENCE
|
|
// =================================================
|
|
helper_evcs_delivery_ratio_ema: {
|
|
execute: Number.isFinite(vdf?.nextHistory?.evcsDeliveryRatioEma),
|
|
domain: 'input_number',
|
|
service: 'set_value',
|
|
data: {
|
|
entity_id: 'input_number.victron_evcs_delivery_ratio_ema',
|
|
value: round3(vdf.nextHistory.evcsDeliveryRatioEma)
|
|
}
|
|
},
|
|
|
|
helper_evcs_delivery_noise_ema: {
|
|
execute: Number.isFinite(vdf?.nextHistory?.evcsDeliveryNoiseEma),
|
|
domain: 'input_number',
|
|
service: 'set_value',
|
|
data: {
|
|
entity_id: 'input_number.victron_evcs_delivery_noise_ema',
|
|
value: round3(vdf.nextHistory.evcsDeliveryNoiseEma)
|
|
}
|
|
},
|
|
|
|
helper_evcs_no_demand_score_ema: {
|
|
execute: Number.isFinite(vdf?.nextHistory?.evcsNoDemandScoreEma),
|
|
domain: 'input_number',
|
|
service: 'set_value',
|
|
data: {
|
|
entity_id: 'input_number.victron_evcs_no_demand_score_ema',
|
|
value: round3(vdf.nextHistory.evcsNoDemandScoreEma)
|
|
}
|
|
},
|
|
|
|
helper_evcs_no_demand_semantic_active: {
|
|
execute: true,
|
|
domain: 'input_boolean',
|
|
service: vdf?.nextHistory?.evcsNoDemandSemanticActive ? 'turn_on' : 'turn_off',
|
|
data: {
|
|
entity_id: 'input_boolean.victron_evcs_no_demand_semantic_active'
|
|
}
|
|
},
|
|
|
|
helper_evcs_no_demand_since_at: {
|
|
execute: Number.isFinite(vdf?.nextHistory?.evcsNoDemandSinceMs),
|
|
domain: 'input_datetime',
|
|
service: 'set_datetime',
|
|
data: {
|
|
entity_id: 'input_datetime.victron_evcs_no_demand_since_at',
|
|
datetime: formatParisStampFromMs(vdf.nextHistory.evcsNoDemandSinceMs)
|
|
}
|
|
},
|
|
|
|
helper_evcs_no_demand_filter_eval_at: {
|
|
execute: Number.isFinite(vdf?.nextHistory?.evcsNoDemandFilterEvalMs),
|
|
domain: 'input_datetime',
|
|
service: 'set_datetime',
|
|
data: {
|
|
entity_id: 'input_datetime.victron_evcs_no_demand_filter_eval_at',
|
|
datetime: formatParisStampFromMs(vdf.nextHistory.evcsNoDemandFilterEvalMs)
|
|
}
|
|
},
|
|
|
|
// =================================================
|
|
// PHYSICAL EDF GRID TAKEOVER
|
|
// =================================================
|
|
edf_general_ok_on: {
|
|
execute:
|
|
['grid_forced', 'grid_hc_allowed'].includes(context.sourceMode) &&
|
|
!parsed.gridAvailable,
|
|
domain: 'switch',
|
|
service: 'turn_on',
|
|
data: {
|
|
entity_id: 'switch.edf_general_ok_switch'
|
|
}
|
|
},
|
|
|
|
grid_power_setpoint: {
|
|
execute: gridSetpointNeedsWrite,
|
|
domain: 'number',
|
|
service: 'set_value',
|
|
data: {
|
|
entity_id: 'number.gx_device_ac_power_setpoint',
|
|
value: gridSetpointTargetW
|
|
}
|
|
},
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}];
|