Quatre modules, ecart median de 2 mV chacun, et soixante cellules dont les amplitudes tiennent toutes entre 168 et 191 mV. Il n'y a rien a chercher ailleurs que dans les trois modules deja nommes. Trois des quatre extractions sont sorties sans horloge ni code-barres : une extraction precedente se deversait encore sur la ligne, et la premiere commande lisait ces restes - qui contiennent un 168143 - en croyant avoir sa reponse. La ligne est desormais purgee avant le reveil, et l'outil refuse de partir sans savoir de qui ni de quand il parle plutot que de rendre douze minutes inexploitables. Les trois fichiers ont ete repares : meme chaine, donc meme decalage de 373 minutes, verifie sur le module dont le dernier enregistrement tombe a la fin de son extraction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
396 lines
17 KiB
JavaScript
396 lines
17 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());
|
|
|
|
// Purge : une extraction precedente peut encore etre en train de se deverser
|
|
// sur la ligne. Sans cela, la premiere commande lit ces restes — qui
|
|
// contiennent un `$$` — croit avoir sa reponse, et rend n'importe quoi. C'est
|
|
// ce qui a fait perdre l'horloge ET le code-barres de trois modules sur
|
|
// quatre le 05/09 : seul le premier appel de la serie etait propre.
|
|
await l.lireJusqua([], 1200, 400);
|
|
l.vider();
|
|
|
|
// 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`);
|
|
// L'horloge du module derive de plusieurs heures et chaque module a la
|
|
// sienne. On la CAPTURE ici, a la seconde ou l'export commence, plutot que
|
|
// de la deduire ensuite : le tampon d'historique est circulaire et contient
|
|
// des enregistrements anterieurs a des remises a l'heure, ce qui rend toute
|
|
// deduction a posteriori fausse de plusieurs heures.
|
|
const rtc = await commande(l, 'time', 8000);
|
|
const m = rtc.match(/RTC\s+(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})/);
|
|
if (m) {
|
|
const reel = new Date();
|
|
const mod = new Date(m[1].replace(' ', 'T') + 'Z');
|
|
const decMin = Math.round((mod.getTime() -
|
|
(reel.getTime() - reel.getTimezoneOffset() * 60000)) / 60000);
|
|
journal(nom, `# RTC module : ${m[1]}\n# heure reelle : ${reel.toISOString()}\n`
|
|
+ `# decalage_minutes : ${decMin}\n`);
|
|
console.log(`Horloge du module : ${m[1]} — decalage ${decMin} min sur l'heure reelle.`);
|
|
} else {
|
|
journal(nom, '# RTC module : non lu\n');
|
|
console.log('Horloge du module non lue : le decalage devra etre donne a l\'import.');
|
|
}
|
|
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;
|
|
}
|
|
|
|
// --historique-distant <adresse> : l'historique d'un AUTRE module de la
|
|
// chaine. `datalist` est local, mais `data <adresse> history <n>` est
|
|
// adressable et rend l'enregistrement complet, quinze cellules comprises.
|
|
// Un seul branchement par chaine suffit donc pour tout son historique.
|
|
if (arg('historique-distant')) {
|
|
const ad = parseInt(String(arg('historique-distant')), 10);
|
|
if (!(ad >= 1 && ad <= 16)) { console.log('Adresse invalide.'); process.exit(1); }
|
|
|
|
// Le decalage se mesure AVANT, car les enregistrements stockes portent
|
|
// l'heure qu'avait le module quand il les a ecrits.
|
|
const rtc = await commande(l, 'time', 8000);
|
|
const mr = rtc.match(/RTC\s+(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})/);
|
|
let decMs = 0;
|
|
if (mr) {
|
|
const [d, h] = mr[1].split(' ');
|
|
const [A, M, J] = d.split('-').map(Number); const [H, N, S] = h.split(':').map(Number);
|
|
decMs = new Date(A, M - 1, J, H, N, S).getTime() - Date.now();
|
|
console.log(`Horloge du module : ${mr[1]} — ${Math.round(decMs / 60000)} min d'avance, corriges a l'ecriture.`);
|
|
} else {
|
|
console.log('Horloge non lue : les horodatages seront ceux du module, non corriges.');
|
|
}
|
|
|
|
const info = await commande(l, `info ${ad}`, 8000);
|
|
const bc = (info.match(/Barcode\s*:\s*(\S+)/) || [])[1];
|
|
// Sans code-barres on ne sait pas de QUI on parle, et sans horloge on ne
|
|
// sait pas QUAND. Douze minutes d'extraction inexploitable valent moins
|
|
// qu'un arret immediat.
|
|
if (!bc || !mr) {
|
|
console.log('Code-barres ou horloge illisible : on n\'extrait pas a l\'aveugle.');
|
|
console.log('Relancer la commande — la ligne aura eu le temps de se vider.');
|
|
process.exit(1);
|
|
}
|
|
const f = path.join(DOSSIER, `${tampon()}-histo-distant-${bc}.csv`);
|
|
fs.mkdirSync(DOSSIER, { recursive: true });
|
|
fs.writeFileSync(f, 'heure_reelle,module,etat,courant_mA,soc,' +
|
|
Array.from({ length: 15 }, (_, i) => `c${i + 1}_mV`).join(',') + ',ecart_mV\n');
|
|
console.log(`Module ${bc} (adresse ${ad}) -> ${f}`);
|
|
|
|
let ecrits = 0, vides = 0;
|
|
for (let n = 1; n <= 1800; n++) {
|
|
const t = await commande(l, `data ${ad} history ${n}`, 8000);
|
|
const mt = t.match(/Time\s*:\s*(\d\d-\d\d-\d\d \d\d:\d\d:\d\d)/);
|
|
const cel = cellules(t);
|
|
if (!mt || !cel || cel.length < 15) {
|
|
if (++vides > 20) break; // fin du tampon
|
|
continue;
|
|
}
|
|
vides = 0;
|
|
const [d, h] = mt[1].split(' ');
|
|
const [A, M, J] = d.split('-').map(Number); const [H, N, S] = h.split(':').map(Number);
|
|
const quand = new Date(new Date(2000 + A, M - 1, J, H, N, S).getTime() - decMs);
|
|
const v = cel.map((c) => c.mV);
|
|
const soc = (t.match(/Percent\s*:\s*(\d+)/) || [])[1] || '';
|
|
fs.appendFileSync(f, [quand.toISOString(), bc, cel[0].etat, cel[0].mA, soc,
|
|
...v, Math.max(...v) - Math.min(...v)].join(',') + '\n');
|
|
ecrits++;
|
|
if (n % 25 === 0) process.stdout.write(`\r ${n} interroges, ${ecrits} ecrits `);
|
|
}
|
|
console.log(`\n${ecrits} 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); });
|