- 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.
199 lines
5.7 KiB
JavaScript
199 lines
5.7 KiB
JavaScript
function num(v, fallback = NaN) {
|
|
const n = Number.parseFloat(v);
|
|
return Number.isFinite(n) ? n : fallback;
|
|
}
|
|
|
|
function low(v) {
|
|
return (v ?? '').toString().trim().toLowerCase();
|
|
}
|
|
|
|
function parseMaybeJson(v) {
|
|
if (typeof v !== 'string') return v;
|
|
try {
|
|
return JSON.parse(v);
|
|
} catch {
|
|
return v;
|
|
}
|
|
}
|
|
|
|
function extractSnapshot(input) {
|
|
if (typeof input === 'string') return parseMaybeJson(input);
|
|
if (input?.data && typeof input.data === 'string') return parseMaybeJson(input.data);
|
|
if (input?.body && typeof input.body === 'string') return parseMaybeJson(input.body);
|
|
if (input?.data && typeof input.data === 'object') return input.data;
|
|
if (input?.body && typeof input.body === 'object') return input.body;
|
|
return input;
|
|
}
|
|
|
|
function round1(v) {
|
|
return Math.round(v * 10) / 10;
|
|
}
|
|
|
|
function clamp(v, min, max) {
|
|
return Math.min(Math.max(v, min), max);
|
|
}
|
|
|
|
function pickDefined(...values) {
|
|
for (const v of values) {
|
|
if (v === undefined || v === null) continue;
|
|
if (typeof v === 'string' && v.trim() === '') continue;
|
|
return v;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function isOnLike(v) {
|
|
const s = low(v);
|
|
return s === 'on' || s === 'true' || s === '1' || s === 'yes';
|
|
}
|
|
|
|
function getParisNowParts() {
|
|
const fmt = new Intl.DateTimeFormat('fr-FR', {
|
|
timeZone: 'Europe/Paris',
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit',
|
|
hour12: false,
|
|
});
|
|
|
|
const parts = Object.fromEntries(
|
|
fmt.formatToParts(new Date())
|
|
.filter(p => p.type !== 'literal')
|
|
.map(p => [p.type, p.value])
|
|
);
|
|
|
|
const date = `${parts.year}-${parts.month}-${parts.day}`;
|
|
const stamp = `${date} ${parts.hour}:${parts.minute}:${parts.second}`;
|
|
|
|
return {
|
|
year: Number(parts.year),
|
|
month: Number(parts.month),
|
|
day: Number(parts.day),
|
|
hour: Number(parts.hour),
|
|
minute: Number(parts.minute),
|
|
second: Number(parts.second),
|
|
date,
|
|
stamp,
|
|
};
|
|
}
|
|
|
|
function parseLocalStampToMs(s) {
|
|
const text = (s ?? '').toString().trim();
|
|
const m = text.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/);
|
|
if (!m) return null;
|
|
|
|
const y = Number(m[1]);
|
|
const mo = Number(m[2]);
|
|
const d = Number(m[3]);
|
|
const h = Number(m[4]);
|
|
const mi = Number(m[5]);
|
|
const se = Number(m[6]);
|
|
|
|
if (![y, mo, d, h, mi, se].every(Number.isFinite)) return null;
|
|
|
|
return new Date(y, mo - 1, d, h, mi, se).getTime();
|
|
}
|
|
|
|
const cfg = {
|
|
mobileNotifyDomain: 'notify',
|
|
mobileNotifyService: 'mobile_app_iphone_16_de_tt',
|
|
};
|
|
|
|
const raw = $input.first().json;
|
|
const input = extractSnapshot(raw);
|
|
const now = getParisNowParts();
|
|
const nowMs = parseLocalStampToMs(now.stamp);
|
|
|
|
// ======================================================
|
|
// INPUTS PRINCIPAUX
|
|
// ======================================================
|
|
const soc = clamp(num(input.soc_percent, 0), 0, 100);
|
|
const gridIsOn = low(input.grid_state || 'off') === 'on';
|
|
const pvPowerW = Math.max(0, num(input.pv_power_w, 0));
|
|
|
|
const houseAcPowerW = Math.max(0, num(input.house_ac_power_w, 0));
|
|
const gridPowerW = num(input.grid_power_w, 0);
|
|
|
|
const evcsAcPowerW = Math.max(0, num(input.evcs_ac_power_w, 0));
|
|
const evcsCharging = low(input.evcs_charging || 'off') === 'on';
|
|
const evcsStatusRaw = num(input.evcs_status_raw, NaN);
|
|
|
|
const hysteresisEnabled = low(input.hysteresis_enabled || 'off') === 'on';
|
|
const notificationsEnabled = low(input.notifications_enabled || 'off') === 'on';
|
|
const latchIsOn = low(input.grid_latch || 'off') === 'on';
|
|
|
|
const gridSocOn = clamp(num(input.grid_soc_on, 22), 0, 100);
|
|
const gridSocOff = clamp(num(input.grid_soc_off, 35), 0, 100);
|
|
const gridMinOnMinutes = Math.max(0, num(input.grid_min_on_minutes, 45));
|
|
const gridCooldownMinutes = Math.max(0, num(input.grid_cooldown_minutes, 15));
|
|
const acLoadsMaxSafeW = Math.max(0, num(input.ac_loads_max_safe_w, 7800));
|
|
|
|
const lastOnAt = (input.last_on_at ?? '').toString().trim();
|
|
const lastOffAt = (input.last_off_at ?? '').toString().trim();
|
|
const lastSignature = (input.last_signature ?? '').toString().trim();
|
|
|
|
const lastOnMs = parseLocalStampToMs(lastOnAt);
|
|
const lastOffMs = parseLocalStampToMs(lastOffAt);
|
|
|
|
const minutesSinceLastOn =
|
|
lastOnMs !== null && nowMs !== null ? Math.max(0, (nowMs - lastOnMs) / 60000) : null;
|
|
|
|
const minutesSinceLastOff =
|
|
lastOffMs !== null && nowMs !== null ? Math.max(0, (nowMs - lastOffMs) / 60000) : null;
|
|
|
|
const minOnElapsed =
|
|
minutesSinceLastOn === null ? true : minutesSinceLastOn >= gridMinOnMinutes;
|
|
|
|
const cooldownElapsed =
|
|
minutesSinceLastOff === null ? true : minutesSinceLastOff >= gridCooldownMinutes;
|
|
|
|
const socBand =
|
|
soc <= gridSocOn ? 'low' :
|
|
soc < gridSocOff ? 'mid' :
|
|
'high';
|
|
|
|
const evcsSeemsActive = evcsCharging || evcsAcPowerW > 300;
|
|
const acOverSafeLimit = houseAcPowerW >= acLoadsMaxSafeW;
|
|
|
|
// ======================================================
|
|
// EVCS OVERRIDE INPUTS
|
|
// ======================================================
|
|
const evcsPriorityActive = isOnLike(
|
|
pickDefined(
|
|
input.evcs_priority_active,
|
|
input.victron_evcs_priority_active,
|
|
input['input_boolean.victron_evcs_priority_active']
|
|
)
|
|
);
|
|
|
|
const evcsNightGridTakeoverNeeded = isOnLike(
|
|
pickDefined(
|
|
input.night_grid_takeover_needed,
|
|
input.victron_evcs_night_grid_takeover_needed,
|
|
input['input_boolean.victron_evcs_night_grid_takeover_needed']
|
|
)
|
|
);
|
|
|
|
const evcsSourceModeRaw = (
|
|
pickDefined(
|
|
input.evcs_source_mode,
|
|
input.victron_evcs_source_mode,
|
|
input['input_text.victron_evcs_source_mode']
|
|
) ?? ''
|
|
).toString().trim();
|
|
|
|
const evcsSourceMode = low(evcsSourceModeRaw);
|
|
|
|
const userStrategyRaw = (
|
|
pickDefined(
|
|
input.user_strategy,
|
|
input.victron_evcs_user_strategy,
|
|
input['input_select.victron_evcs_user_strategy']
|
|
) ?? ''
|
|
).toString().trim();
|
|
|
|
const userStrategy = low(userStrategyRaw);
|