#!/usr/bin/env node // Campagne de test EDF <-> MPPT, protocole ALTERNE. // // Usage: node tools/test-edf-alterne.mjs [phaseSec] [sampleSec] // node tools/test-edf-alterne.mjs 180 15 (defaut) // // Protocole : OFF / ON / OFF / ON / OFF, phases de duree egale, echantillon // regulier. L'alternance est ce qui rend la mesure interpretable : une derive // meteo monotone ne peut pas produire un signal qui suit le contacteur quatre // fois de suite. // // Ce que le script mesure a chaque echantillon : PV totale, tension et courant // batterie, consommation maison, import reseau, SOC, limite DVCC, presence // physique AC-IN, et le mode de fonctionnement des 7 chargeurs solaires // (mppt_active = cherche son point de puissance maximale ; sinon il se retient). // // SECURITE : l'hysteresis reseau est desactivee pendant le test puis restauree, // ainsi que le latch et l'etat du contacteur. La restauration est faite aussi // sur Ctrl-C et sur erreur. import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const HA = process.env.HA_URL || 'http://10.10.10.17:8123'; function loadToken() { if (process.env.HA_TOKEN) return process.env.HA_TOKEN; const m = fs.readFileSync(path.join(ROOT, '.env'), 'utf8').match(/^\s*HA_TOKEN\s*=\s*(.+?)\s*$/m); if (!m) { console.error('HA_TOKEN absent de .env'); process.exit(1); } return m[1].replace(/^["']|["']$/g, ''); } const TOKEN = loadToken(); const H = { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' }; const get = async (id) => { const r = await fetch(`${HA}/api/states/${id}`, { headers: H }); if (!r.ok) return null; return (await r.json()).state; }; const call = async (svc, data) => { const [d, s] = svc.split('.'); const r = await fetch(`${HA}/api/services/${d}/${s}`, { method: 'POST', headers: H, body: JSON.stringify(data) }); if (!r.ok) throw new Error(`${svc} -> HTTP ${r.status} ${await r.text()}`); }; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // --- entites ----------------------------------------------------------------- const MPPT = [ ['mppt1', 'sensor.airtria_mppt1_mppt_operation_mode'], ['rs_1', 'sensor.mppt_450_200_hq2449yr9pt_pv_tracker_solar_1_operation_mode'], ['rs_2', 'sensor.mppt_450_200_hq2449yr9pt_pv_tracker_solar_2_operation_mode'], ['rs_3', 'sensor.mppt_450_200_hq2449yr9pt_pv_tracker_solar_3_operation_mode'], ['rs_4', 'sensor.mppt_450_200_hq2449yr9pt_pv_tracker_solar_4_operation_mode'], ['rs_g', 'sensor.mppt_450_200_hq2449yr9pt_mppt_operation_mode'], ['v1', 'sensor.v1_mppt_operation_mode'], ]; const M = { pv: 'sensor.victron_pv_power_system', u: 'sensor.victron_battery_voltage', i: 'sensor.victron_battery_current', conso: 'sensor.victron_consumption_power_l1', grid: 'sensor.victron_grid_power_l1', soc: 'sensor.victron_battery_soc', dvcc_a: 'sensor.victron_max_charge_current_setting', dvcc_v: 'sensor.victron_max_charge_voltage_setting', acin: 'sensor.victron_ac_in1_available', edf: 'switch.edf_general_ok_switch', mode: 'input_text.victron_charge_mode', }; const HYST = 'input_boolean.victron_grid_hysteresis_enabled'; const LATCH = 'input_boolean.victron_grid_hysteresis_latch'; const PHASE_SEC = Number(process.argv[2] || 180); const SAMPLE_SEC = Number(process.argv[3] || 15); const PHASES = ['OFF_1', 'ON_1', 'OFF_2', 'ON_2', 'FIN_OFF']; const stamp = () => new Date().toLocaleTimeString('fr-FR', { timeZone: 'Europe/Paris' }); const out = []; const say = (l) => { console.log(l); out.push(l); }; // --- restauration ------------------------------------------------------------ let initial = null; let restored = false; async function restore(why) { if (restored || !initial) return; restored = true; say(`>>> RESTAURATION (${why})`); try { await call('switch.turn_off', { entity_id: M.edf }); await call(`input_boolean.turn_${initial.latch}`, { entity_id: LATCH }); await call(`input_boolean.turn_${initial.hyst}`, { entity_id: HYST }); say(`>>> etat restaure : edf off | latch ${initial.latch} | hysteresis ${initial.hyst}`); } catch (e) { say(`!!! ECHEC RESTAURATION : ${e.message} — VERIFIER A LA MAIN ${M.edf}, ${LATCH}, ${HYST}`); } } process.on('SIGINT', async () => { await restore('Ctrl-C'); process.exit(130); }); // --- deroule ----------------------------------------------------------------- initial = { hyst: (await get(HYST)) === 'on' ? 'on' : 'off', latch: (await get(LATCH)) === 'on' ? 'on' : 'off', edf: await get(M.edf), }; say(`TEST ALTERNE EDF <-> MPPT — ${new Date().toLocaleString('fr-FR', { timeZone: 'Europe/Paris' })}`); say(`phases ${PHASE_SEC}s, echantillon ${SAMPLE_SEC}s, sequence ${PHASES.join(' / ')}`); say(`etat initial : edf ${initial.edf} | latch ${initial.latch} | hysteresis ${initial.hyst}`); say(`consigne reseau ${await get('number.gx_device_ac_power_setpoint')} W | SOC min ESS ${await get('number.gx_device_ess_min_soc_limit')} % | limite active ${await get('sensor.gx_device_active_soc_limit')} %`); say(`MPPT ordre: ${MPPT.map((m) => m[0]).join(' ')} (MPPT=cherche le max, lim_=se retient)`); say(''); try { await call('input_boolean.turn_off', { entity_id: HYST }); await call('switch.turn_off', { entity_id: M.edf }); await sleep(2000); for (const phase of PHASES) { const want = phase.startsWith('ON') ? 'on' : 'off'; if ((await get(M.edf)) !== want) { say(`>>> EDF ${want.toUpperCase()}`); await call(`switch.turn_${want}`, { entity_id: M.edf }); await sleep(3000); } const t0 = Date.now(); while (Date.now() - t0 < PHASE_SEC * 1000) { const v = {}; await Promise.all(Object.entries(M).map(async ([k, id]) => { v[k] = await get(id); })); const modes = await Promise.all(MPPT.map(async ([, id]) => { const s = await get(id); return s === 'mppt_active' ? 'MPPT' : (s || 'n/a').slice(0, 4).padEnd(4); })); const n = (x, d = 0) => (Number.isFinite(Number(x)) ? Number(x).toFixed(d) : String(x)); say( `${stamp()} | ${phase.padEnd(7)}| PV ${n(v.pv).padStart(5)} | U ${n(v.u, 2).padStart(5)}` + ` | I ${n(v.i, 1).padStart(6)} | conso ${n(v.conso).padStart(5)} | grid ${n(v.grid).padStart(5)}` + ` | SOC ${n(v.soc)} | DVCC ${n(v.dvcc_a)}A/${n(v.dvcc_v, 1)}V | acin ${v.acin} | edf ${v.edf}` + ` | ${modes.join(' ')}` ); await sleep(SAMPLE_SEC * 1000); } } } catch (e) { say(`!!! ERREUR : ${e.message}`); } finally { await restore('fin de campagne'); } // Le nom porte la date ET l'heure : deux campagnes le meme jour ne doivent pas // s'ecraser l'une l'autre (piege rencontre le 31/08). const now = new Date(); const hhmm = now.toLocaleTimeString('fr-FR', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit' }).replace(':', ''); const dest = path.join(ROOT, 'docs/mesures', `${now.toISOString().slice(0, 10).replace(/-/g, '')}-${hhmm}-test-edf-alterne.log`); fs.writeFileSync(dest, out.join('\n') + '\n'); console.log(`\nJournal : ${dest}`);