Nouvelle table cellule, indexee par CODE-BARRES et non par adresse : chaque chaine renumerote ses modules a partir de 1, il existe donc deux « module 1 » dans ce parc. L'importeur lit les exports d'historique et les CSV de suivi. Deux pieges corriges en le construisant : le tampon du BMS est circulaire, donc le plus grand index n'est pas le plus recent - trier par index donnait un decalage deduit de -865 h ; et les fichiers d'evenements portent les memes colonnes sans decrire un echantillonnage regulier, ils sont ecartes. La deduction du decalage d'horloge reste approximative : sur BAT3 elle donne huit heures la ou le chronometre en donne six. La console inscrit desormais le decalage en tete de chaque export, mesure a la seconde ou il commence ; l'importeur le prefere toujours a sa propre deduction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
97 lines
4.2 KiB
JavaScript
97 lines
4.2 KiB
JavaScript
#!/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'));
|
|
})();
|