#!/usr/bin/env node // Guette le plafond de courant de charge impose par le BMS et note chacun de // ses changements, en y accolant les tensions de cellule relevees au meme // moment par `pylontech-console.mjs --suivi` (le CSV le plus recent). // // Le point qui interesse : a quelle tension de cellule, et apres combien de // temps, le BMS RELACHE. La retombee de l'alarme ne le dit pas — elle survient // bien avant, le plafond restant bas plusieurs heures apres. // // node tools/guetter-plafond-bms.mjs [--minutes 55] [--pas 15] import fs from 'node:fs'; import path from 'node:path'; const arg = (n, d) => { const i = process.argv.indexOf('--' + n); return i < 0 ? d : process.argv[i + 1]; }; const PAS = Math.max(5, +arg('pas', 15)) * 1000; const FIN_MS = Date.now() + Math.max(1, +arg('minutes', 55)) * 60000; const DOSSIER = 'docs/mesures/batteries'; const env = Object.fromEntries( fs.readFileSync('.env', 'utf8').split('\n') .filter((l) => l.includes('=') && !l.startsWith('#')) .map((l) => [l.slice(0, l.indexOf('=')).trim(), l.slice(l.indexOf('=') + 1).trim()])); const etat = async (id) => { const r = await fetch('http://10.10.10.17:8123/api/states/' + id, { headers: { Authorization: 'Bearer ' + env.HA_TOKEN } }); return r.json(); }; // Dernier relevé console par module, depuis le CSV du suivi en cours. function cellulesRecentes() { let f; try { f = fs.readdirSync(DOSSIER).filter((n) => n.endsWith('-suivi.csv')) .map((n) => path.join(DOSSIER, n)) .sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs)[0]; } catch { return ''; } if (!f || Date.now() - fs.statSync(f).mtimeMs > 180000) return ''; // suivi arrete const lignes = fs.readFileSync(f, 'utf8').trim().split('\n').slice(1); const parModule = new Map(); for (const l of lignes) { const c = l.split(','); if (c.length > 17) parModule.set(c[1], c); } const out = []; for (const [nom, c] of parModule) { const v = c.slice(3, 18).map(Number).filter((x) => x > 0); if (!v.length) continue; const hi = Math.max(...v); out.push(`${nom} haute ${hi} mV (cellule ${v.indexOf(hi) + 1}, ecart ${hi - Math.min(...v)} mV)`); } return out.join(' · '); } const h = () => new Date().toLocaleTimeString('fr-FR'); (async () => { let plafond = null, depuis = Date.now(), plancher = Infinity; const journal = []; console.log(`Guet du plafond de charge, pas de ${PAS / 1000} s.`); while (Date.now() < FIN_MS) { let ccl, soc, alarme; try { [ccl, soc, alarme] = await Promise.all([ etat('sensor.airtria_8_us2000c_maximum_allowed_charge_current'), etat('sensor.victron_battery_soc'), etat('sensor.airtria_8_us2000c_tension_de_cellule_elevee'), ]); } catch { await new Promise((r) => setTimeout(r, PAS)); continue; } const v = parseFloat(ccl.state); if (!Number.isFinite(v)) { await new Promise((r) => setTimeout(r, PAS)); continue; } if (plafond === null) { plafond = v; plancher = v; console.log(`${h()} plafond initial ${v} A · SOC ${soc.state} % · alarme ${alarme.state}`); console.log(` ${cellulesRecentes()}`); } else if (v !== plafond) { const duree = Math.round((Date.now() - depuis) / 1000); const sens = v > plafond ? 'REMONTE' : 'baisse '; const ligne = `${h()} ${sens} ${plafond} -> ${v} A (apres ${Math.floor(duree / 60)} min ${duree % 60} s)` + ` SOC ${soc.state} % alarme ${alarme.state}\n ${cellulesRecentes()}`; console.log(ligne); journal.push(ligne); const remonte = v > plafond && plafond <= plancher; plafond = v; depuis = Date.now(); plancher = Math.min(plancher, v); // --complet : suivre tout l'escalier de remontee au lieu de s'arreter // a la premiere marche. La forme de la remontee est ce qui dit si le // BMS relache par paliers fixes ou proportionnellement. if (remonte && !arg('complet', false)) { console.log('\n=== LE PLAFOND EST REMONTE ==='); console.log(journal.join('\n')); process.exit(0); } } await new Promise((r) => setTimeout(r, PAS)); } console.log(`${h()} fin du guet, plafond toujours a ${plafond} A`); if (journal.length) console.log(journal.join('\n')); })();