Files
EVCS/tools/pylontech-console.mjs
Tallagrand Thierry 5ed85a5550 Suivi en direct de plusieurs modules d'une chaine pendant l'alarme
--modules 1,2,3 interroge chaque adresse avec soh, qui donne les quinze
tensions et le compteur de sante par cellule. Sans cet argument on ne
lisait que le module portant le cable, alors que l'adressage a distance
permet de couvrir toute la chaine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
2026-09-05 13:01:51 +02:00

307 lines
12 KiB
JavaScript

#!/usr/bin/env node
// Console serie des modules Pylontech US2000C.
//
// Le protocole n'est pas devine : il est repris tel quel des exports
// BatteryView du 05/09/2026 (docs/mesures/batteries/). Le module declare
// lui-meme sa vitesse dans la reponse a `info` :
// EPONPort rate : 1200
// Console Port rate : 115200
// L'invite est `pylon_debug>` et la fin de reponse est marquee par `$$`.
//
// Aucune dependance : stty pour regler la ligne, fs pour lire et ecrire.
//
// node tools/pylontech-console.mjs --detecter
// node tools/pylontech-console.mjs --port /dev/ttyUSB0
// node tools/pylontech-console.mjs --port /dev/ttyUSB0 --commande help
// node tools/pylontech-console.mjs --port /dev/ttyUSB0 --historique
// node tools/pylontech-console.mjs --port /dev/ttyUSB0 --suivi 30
import fs from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
const arg = (nom, defaut = null) => {
const i = process.argv.indexOf('--' + nom);
if (i < 0) return defaut;
const v = process.argv[i + 1];
return v && !v.startsWith('--') ? v : true;
};
const DOSSIER = 'docs/mesures/batteries';
const VITESSE = arg('vitesse', '115200');
const BAVARD = !!arg('bavard');
// ---------------------------------------------------------------- detection
function portsCandidats() {
const base = '/dev/serial/by-id';
const out = [];
if (fs.existsSync(base)) {
for (const n of fs.readdirSync(base)) {
out.push({ lien: path.join(base, n), dev: fs.realpathSync(path.join(base, n)), nom: n });
}
}
for (const n of fs.readdirSync('/dev')) {
if (/^(ttyUSB|ttyACM)\d+$/.test(n) && !out.some((p) => p.dev === '/dev/' + n)) {
out.push({ lien: '/dev/' + n, dev: '/dev/' + n, nom: n });
}
}
return out;
}
// ------------------------------------------------------------------- ligne
class Ligne {
constructor(dev, vitesse) {
this.dev = dev;
// clocal : ne pas attendre la porteuse (DCD), sinon l'ouverture bloque
// sur un cable qui ne cable pas ce signal - le cas de la plupart des
// adaptateurs console.
const reglage = [
'-F', dev, vitesse, 'cs8', '-cstopb', '-parenb', '-crtscts',
'clocal', 'raw', '-echo', '-echoe', '-echok', '-ixon', '-ixoff',
'min', '0', 'time', '0',
];
execFileSync('stty', reglage);
this.fd = fs.openSync(dev, fs.constants.O_RDWR | fs.constants.O_NOCTTY | fs.constants.O_NONBLOCK);
execFileSync('stty', reglage); // l'ouverture peut remettre des defauts
this.tampon = '';
}
ecrire(txt) {
if (BAVARD) process.stderr.write(` -> ${JSON.stringify(txt)}\n`);
fs.writeSync(this.fd, Buffer.from(txt, 'ascii'));
}
vider() { this.tampon = ''; }
// Lit jusqu'a voir l'un des motifs, ou jusqu'au silence.
async lireJusqua(motifs, msMax = 8000, msSilence = 700) {
const buf = Buffer.alloc(4096);
const debut = Date.now();
let dernier = Date.now();
for (;;) {
let n = 0;
try { n = fs.readSync(this.fd, buf, 0, buf.length, null); }
catch (e) { if (e.code !== 'EAGAIN') throw e; }
if (n > 0) {
this.tampon += buf.slice(0, n).toString('ascii');
dernier = Date.now();
for (const m of motifs) if (this.tampon.includes(m)) return { texte: this.tampon, motif: m };
} else {
if (this.tampon && Date.now() - dernier > msSilence) return { texte: this.tampon, motif: null };
if (Date.now() - debut > msMax) return { texte: this.tampon, motif: null };
await new Promise((r) => setTimeout(r, 30));
}
}
}
fermer() { try { fs.closeSync(this.fd); } catch {} }
}
const FINS = ['pylon_debug>', '$$', 'Press [Enter] to be continued'];
async function commande(l, cmd, msMax = 12000) {
l.vider();
l.ecrire(cmd + '\r');
let tout = '';
for (let page = 0; page < 200; page++) {
const { texte, motif } = await l.lireJusqua(FINS, msMax);
tout += texte;
l.vider();
if (motif !== 'Press [Enter] to be continued') break;
l.ecrire('\r'); // page suivante
}
return tout;
}
// ------------------------------------------------------------------ lecture
function cellules(txt) {
// Table produite par `data` / `data history` :
// Battery Volt Curr Tempr Base State ...
// 0 3303 -7623 23300 Dischg ...
const out = [];
for (const ligne of txt.split('\n')) {
const m = ligne.trim().match(/^(\d{1,2})\s+(\d{4})\s+(-?\d+)\s+(-?\d+)\s+(\w+)/);
// `data` numerote les cellules a partir de 0, `datalist history dtls` a
// partir de 1. On ramene tout sur 1..15, la numerotation de l'historique,
// pour que les deux sources se comparent sans piege.
if (m) out.push({ n: +m[1] + 1, mV: +m[2], mA: +m[3], mC: +m[4], etat: m[5] });
}
return out.length >= 10 ? out : null;
}
function tableau(cel) {
const v = cel.map((c) => c.mV);
const hi = Math.max(...v), lo = Math.min(...v);
const nHi = cel.find((c) => c.mV === hi).n, nLo = cel.find((c) => c.mV === lo).n;
let s = '';
for (const c of cel) {
const marque = c.mV === hi ? ' <-- la plus haute' : c.mV === lo ? ' <-- la plus basse' : '';
const barre = '#'.repeat(Math.max(0, Math.round((c.mV - lo) / Math.max(1, hi - lo) * 30)));
s += ` cellule ${String(c.n).padStart(2)} : ${c.mV} mV ${barre.padEnd(30)}${marque}\n`;
}
s += `\n ecart ${hi - lo} mV entre la cellule ${nHi} (${hi} mV) et la cellule ${nLo} (${lo} mV)\n`;
if (hi >= 3600) s += ` ATTENTION : ${hi} mV, la protection de surtension est proche.\n`;
else if (hi >= 3500) s += ` la cellule ${nHi} entre dans la zone haute.\n`;
return s;
}
function journal(nom, texte) {
fs.mkdirSync(DOSSIER, { recursive: true });
const f = path.join(DOSSIER, nom);
fs.appendFileSync(f, texte);
return f;
}
const horodate = () => new Date().toLocaleString('fr-FR', { timeZone: 'Europe/Paris' });
const tampon = () => new Date().toLocaleString('sv-SE', { timeZone: 'Europe/Paris' }).replace(/[-: ]/g, '').slice(0, 12);
// -------------------------------------------------------------------- main
async function main() {
if (arg('detecter')) {
const p = portsCandidats();
if (!p.length) {
console.log('Aucun port serie. Le cable n\'est pas branche, ou son pilote ne charge pas.');
console.log('Apres branchement : dmesg | tail -20 doit nommer ftdi_sio, ch341, cp210x ou pl2303.');
process.exit(1);
}
console.log('Ports serie visibles :');
for (const x of p) console.log(` ${x.dev.padEnd(14)} ${x.nom}`);
return;
}
let dev = arg('port');
if (!dev) {
const p = portsCandidats();
if (p.length !== 1) {
console.log(p.length ? 'Plusieurs ports, precisez --port :' : 'Aucun port serie detecte.');
for (const x of p) console.log(` ${x.dev} ${x.nom}`);
process.exit(1);
}
dev = p[0].dev;
console.log(`Port retenu : ${dev}`);
}
const l = new Ligne(dev, VITESSE);
process.on('exit', () => l.fermer());
// Reveil : on envoie une entree et on regarde ce qui revient.
// On ne suppose rien - si l'invite n'apparait pas, on montre l'octet brut.
l.ecrire('\r');
let r = await l.lireJusqua(FINS, 3000);
if (!r.motif) {
l.vider(); l.ecrire('~\r'); // certains firmwares veulent ~
r = await l.lireJusqua(FINS, 3000);
}
if (!r.motif) {
console.log(`Pas d'invite a ${VITESSE} bauds. Recu : ${JSON.stringify(r.texte.slice(-200))}`);
console.log('Essayez --vitesse 1200 (port EPON) ou verifiez le cable.');
process.exit(1);
}
l.vider();
if (arg('commande')) {
const cmd = arg('commande');
const txt = await commande(l, String(cmd));
console.log(txt);
console.log('--- journalise dans ' + journal(`${tampon()}-console.txt`,
`\n===== ${horodate()} =====\n${cmd}\n${txt}\n`));
return;
}
if (arg('historique')) {
const depuis = +(arg('depuis', '1')) || 1;
const nom = `${tampon()}-historique.txt`;
console.log(`Extraction de l'historique a partir de l'enregistrement ${depuis}.`);
console.log('Le module en garde 1798, un toutes les 30 minutes : comptez plusieurs minutes.');
const f = journal(nom, `# releve du ${horodate()} sur ${dev}\n`);
journal(nom, await commande(l, 'info', 8000));
// UNE seule commande suffit : le module pagine lui-meme jusqu'au dernier
// enregistrement, `commande` tourne les pages. Boucler sur des index
// croissants relancait le meme vidage complet a chaque fois.
const txt = await commande(l, `datalist history dtls ${depuis}`, 30000);
journal(nom, txt);
const n = (txt.match(/^\d+ +\d\d-\d\d-\d\d /gm) || []).length;
console.log(`${n} enregistrements ecrits dans ${f}`);
return;
}
if (arg('suivi')) {
const pas = Math.max(10, +(arg('suivi', '30')) || 30);
const nom = `${tampon()}-suivi.csv`;
const f = path.join(DOSSIER, nom);
fs.mkdirSync(DOSSIER, { recursive: true });
if (!fs.existsSync(f)) {
fs.writeFileSync(f, 'heure_reelle,etat,courant_mA,' +
Array.from({ length: 15 }, (_, i) => `c${i + 1}_mV`).join(',') + ',ecart_mV\n');
}
// --modules 1,2,3 : interroge chaque adresse de la chaine avec `soh <n>`,
// qui donne les quinze tensions ET le compteur de sante par cellule.
// Sans cet argument on ne lit que le module sur lequel le cable est pose.
const adresses = String(arg('modules', '')).split(',')
.map((x) => parseInt(x, 10)).filter((n) => n >= 1 && n <= 16);
console.log(`Suivi toutes les ${pas} s, cellule par cellule -> ${f}`);
if (adresses.length) console.log(`Adresses interrogees : ${adresses.join(', ')}`);
console.log('L\'heure enregistree est l\'heure REELLE de cette machine, pas celle du module');
console.log('(l\'horloge interne du module derive de plusieurs heures).');
console.log('Ctrl-C pour arreter.\n');
for (;;) {
if (adresses.length) {
for (const ad of adresses) {
const t = await commande(l, `soh ${ad}`, 10000);
// `soh` : "<n0based> <mV> <SOHCount> <statut>"
const c = [];
for (const ligne of t.split('\n')) {
const m = ligne.trim().match(/^(\d{1,2})\s+(\d{4})\s+(\d+)\s+(\w+)/);
if (m) c.push({ n: +m[1] + 1, mV: +m[2] });
}
if (c.length < 10) { console.log(`${horodate()} module ${ad} : illisible`); continue; }
const v = c.map((x) => x.mV);
const hi = Math.max(...v), lo = Math.min(...v);
fs.appendFileSync(f, [new Date().toISOString(), `module${ad}`, '', ...v, hi - lo].join(',') + '\n');
console.log(`${horodate()} module ${ad} haute ${hi} mV (cellule ${c.find((x) => x.mV === hi).n})` +
` basse ${lo} mV ecart ${String(hi - lo).padStart(3)} mV` +
(hi >= 3600 ? ' NIVEAU 4' : hi >= 3550 ? ' NIVEAU 3, le courant se reduit' : ''));
}
await new Promise((r) => setTimeout(r, pas * 1000));
continue;
}
const txt = await commande(l, 'data', 10000);
const cel = cellules(txt);
if (cel) {
const v = cel.map((c) => c.mV);
const hi = Math.max(...v), lo = Math.min(...v);
fs.appendFileSync(f, [
new Date().toISOString(), cel[0].etat, cel[0].mA, ...v, hi - lo,
].join(',') + '\n');
const nHi = cel.find((c) => c.mV === hi).n;
console.log(`${horodate()} ${String(cel[0].etat).padEnd(7)} ` +
`${String((cel[0].mA / 1000).toFixed(2)).padStart(8)} A ` +
`haute ${hi} mV (cellule ${nHi}) ecart ${String(hi - lo).padStart(3)} mV` +
(hi >= 3600 ? ' ATTENTION zone de protection' : ''));
} else {
console.log(`${horodate()} reponse non exploitable`);
}
await new Promise((r) => setTimeout(r, pas * 1000));
}
}
// Par defaut : un instantane complet.
const nom = `${tampon()}-instantane.txt`;
let tout = `# releve du ${horodate()} sur ${dev} a ${VITESSE} bauds\n`;
for (const c of ['info', 'stat', 'data']) {
const txt = await commande(l, c, 12000);
tout += `\n===== ${c} =====\n${txt}\n`;
if (c === 'info') {
const m = txt.match(/Barcode\s*:\s*(\S+)/); const a = txt.match(/Device address\s*:\s*(\d+)/);
console.log(`Module ${m ? m[1] : '?'}, adresse ${a ? a[1] : '?'}`);
}
if (c === 'stat') {
for (const k of ['Bat HV Times', 'Bat OV Times', 'SOH Times', 'SOH ', 'CYCLE Times']) {
const m = txt.match(new RegExp(k.trim() + '\\s*:\\s*(\\S+)'));
if (m) console.log(` ${k.trim().padEnd(14)} ${m[1]}`);
}
}
if (c === 'data') {
const cel = cellules(txt);
if (cel) { console.log(); console.log(tableau(cel)); }
}
}
console.log('Journalise dans ' + journal(nom, tout));
}
main().catch((e) => { console.error('Echec : ' + e.message); process.exit(1); });