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,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
}
}
}
}
}];