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,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');