Files
EVCS/tools/pylontech-console.mjs
Tallagrand Thierry 8c35f8c2ca Le rang du module dans sa chaine accompagne desormais chaque releve de cellules
Le proprietaire demandait si le rang dans le pack etait accessible ou si seul
le code-barres l'etait. Il l'etait depuis le debut : l'outil de releve resout
adresse <-> code-barres au demarrage mais ne l'ecrivait pas. Desormais :
- pylontech-console.mjs --suivi ecrit une colonne 'adresse' (vide en mono-module,
  jamais devinee) ;
- historise-evcs.mjs l'ingere par son NOM dans cellule.rang, enregistre PAR
  RELEVE et non dans une table figee — un recablage du LVHUB renumerote, et
  l'historique de chaque ligne doit garder le rang vrai a son instant ;
- Grafana : colonne 'Rang' dans le tableau de sante, et la liste deroulante
  des modules affiche 'rang - code-barres' quand le rang est connu.
75/75 requetes valides.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GwpFpy2aQ2rJok6tNQSbVj
2026-09-18 12:12:12 +02:00

483 lines
22 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 horodateFichier = () => 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(`${horodateFichier()}-console.txt`,
`\n===== ${horodate()} =====\n${cmd}\n${txt}\n`));
return;
}
if (arg('historique')) {
const depuis = +(arg('depuis', '1')) || 1;
const nom = `${horodateFichier()}-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);
// Deux tampons distincts vivent dans le BMS. `history` echantillonne
// toutes les 30 minutes sur cinq semaines ; `event` n'enregistre QUE les
// declenchements, a la seconde, et remonte bien plus loin — jusqu'a mai
// 2025 sur ces modules. C'est le second que lit un fabricant.
const tampon = arg('tampon', 'history') === 'event' ? 'event' : 'history';
const MAX = tampon === 'event' ? 900 : 1800;
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;
// ⚠️ Si l'horloge du module a ete remise a l'heure APRES l'ecriture des
// enregistrements, la lire maintenant donne zero alors que les donnees
// stockees portent l'ancien decalage. --decalage-minutes impose la valeur
// qui avait cours quand ces enregistrements ont ete ecrits.
const impose = arg('decalage-minutes');
if (impose !== null && impose !== true) {
decMs = parseInt(String(impose), 10) * 60000;
console.log(`Decalage impose : ${impose} min (l'horloge lue vaut ` +
`${mr ? mr[1] : 'inconnue'}).`);
} else 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 bascule = arg('decalage-minutes') ? Date.now() : null;
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,
`${horodateFichier()}-${tampon === 'event' ? 'evenements' : '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,adresse\n');
console.log(`Module ${bc} (adresse ${ad}), tampon ${tampon} -> ${f}`);
let ecrits = 0, vides = 0, apresBascule = 0;
for (let n = 1; n <= MAX; n++) {
const t = await commande(l, `data ${ad} ${tampon} ${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');
// Les enregistrements posterieurs a la remise a l'heure ne doivent PAS
// recevoir la correction : on les signale plutot que de les fausser.
if (bascule && quand.getTime() > bascule) apresBascule++;
ecrits++;
if (n % 25 === 0) process.stdout.write(`\r ${n} interroges, ${ecrits} ecrits `);
}
console.log(`\n${ecrits} enregistrements ecrits dans ${f}`);
if (apresBascule) {
console.log(`⚠️ ${apresBascule} enregistrement(s) tombent apres l'instant present : `
+ `ils ont ete ecrits APRES la remise a l'heure et ne devaient pas etre corriges.`);
}
return;
}
// ---------------------------------------------------------------------
// COURANT PAR MODULE — un seul `pwr` pour toute la chaine
// ---------------------------------------------------------------------
// `soh <n>` donne les quinze tensions mais AUCUN courant : c'est pourquoi la
// colonne courant_mA du CSV est restee vide depuis la mise en service.
// `pwr` donne courant, tension et SOC pour TOUS les modules en un appel —
// une commande serie par cycle au lieu d'une par module.
//
// ⚠️ LE TABLEAU N'A PAS LES MEMES COLONNES SELON LA FAMILLE DE MODULES.
// Mesure du 16/09 : les US5000 (firmware B69.26) intercalent Tlow.Id,
// Thigh.Id, Vlow.Id et Vhigh.Id que les US2000C (B69.13) n'ont pas. Un
// parseur a index figes lit donc n'importe quoi des qu'on change de pack.
// On resout les colonnes PAR LEUR NOM, dans l'en-tete.
//
// Les colonnes utiles (Volt, Curr, Coulomb) precedent toutes `Time`, dont la
// valeur contient une espace — le decoupage positionnel reste donc valable.
function courantsParModule(txt) {
const lignes = txt.split('\n').map((l) => l.replace(/\r/g, '').trimEnd());
const iEntete = lignes.findIndex((l) => /^\s*Power\s+Volt\s+Curr\b/.test(l));
if (iEntete === -1) return new Map();
const cols = lignes[iEntete].trim().split(/\s+/);
const iCurr = cols.indexOf('Curr');
const iVolt = cols.indexOf('Volt');
const iSoc = cols.indexOf('Coulomb');
if (iCurr === -1) return new Map();
const m = new Map();
for (const l of lignes.slice(iEntete + 1)) {
const c = l.trim().split(/\s+/);
const ad = parseInt(c[0], 10);
if (!Number.isFinite(ad) || c[iCurr] === undefined || c[iCurr] === '-') continue;
const mA = parseInt(c[iCurr], 10);
if (!Number.isFinite(mA)) continue;
m.set(ad, { mA, mV: parseInt(c[iVolt], 10) || null, soc: iSoc > -1 ? c[iSoc] : null });
}
return m;
}
if (arg('suivi')) {
const pas = Math.max(10, +(arg('suivi', '30')) || 30);
const nom = `${horodateFichier()}-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);
// ⚠️ On resout le CODE-BARRES de chaque adresse AVANT de commencer, et on
// l'ecrit dans le CSV. Chaque chaine renumerote ses modules a partir de 1 :
// une adresse seule est ambigue, et une table de correspondance figee
// attribuerait les relevés d'une chaine aux modules de l'autre. C'est
// exactement le piege ouvert quand le cable a change de pack le 05/09.
const codes = new Map();
for (const ad of adresses) {
const info = await commande(l, `info ${ad}`, 8000);
const bc = (info.match(/Barcode\s*:\s*(\S+)/) || [])[1];
if (!bc) {
console.log(`Code-barres illisible pour l'adresse ${ad} : on n'enregistre pas a l'aveugle.`);
console.log('Relancer la commande — la ligne aura eu le temps de se vider.');
process.exit(1);
}
codes.set(ad, bc);
}
console.log(`Suivi toutes les ${pas} s, cellule par cellule -> ${f}`);
for (const [ad, bc] of codes) console.log(` adresse ${ad} = ${bc}`);
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) {
// Un `pwr` pour toute la chaine, avant la boucle par module. Si la
// lecture echoue on continue sans courant plutot que de perdre les
// tensions, qui sont l'essentiel du releve.
let puis = new Map();
try { puis = courantsParModule(await commande(l, 'pwr', 10000)); }
catch { /* on garde les tensions */ }
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);
const p = puis.get(ad);
fs.appendFileSync(f, [new Date().toISOString(), codes.get(ad),
p ? p.mA : '', ...v, hi - lo, ad].join(',') + '\n');
console.log(`${horodate()} ${codes.get(ad).slice(-4)} haute ${hi} mV (cellule ${c.find((x) => x.mV === hi).n})` +
` basse ${lo} mV ecart ${String(hi - lo).padStart(3)} mV` +
(p ? ` ${(p.mA / 1000).toFixed(1)} A ${p.soc || ''}` : '') +
(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 = `${horodateFichier()}-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); });