Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011uJ47NxgfpGnuuMr3f2n7Z
525 lines
24 KiB
JavaScript
525 lines
24 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
|
|
// node tools/pylontech-console.mjs --port /dev/pylontech-pack1 --suivi 600 --modules 1,2,3,4 --suffixe pack1
|
|
|
|
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\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);
|
|
// --suffixe pack1 : deux lecteurs (un par pack, sur deux machines depuis le
|
|
// 26/09) ecrivent dans le MEME dossier ; sans suffixe, deux demarrages dans la
|
|
// meme minute melangeraient leurs lignes dans un seul fichier. Le nom finit
|
|
// toujours par -suivi.csv, seul motif que lit l'ingestion.
|
|
const suffixe = arg('suffixe') ? `-${String(arg('suffixe')).replace(/[^\w-]/g, '')}` : '';
|
|
const nom = `${horodateFichier()}${suffixe}-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,adresse\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) {
|
|
// Trois essais par adresse (26/09) : sur les US5000, `info 4` est revenu
|
|
// une fois sans code-barres au demarrage — ligne pas encore videe. Un seul
|
|
// rate ne doit pas faire perdre un cycle entier au service.
|
|
let bc = null;
|
|
for (let essai = 0; essai < 3 && !bc; essai++) {
|
|
const info = await commande(l, `info ${ad}`, 8000);
|
|
bc = (info.match(/Barcode\s*:\s*(\S+)/) || [])[1] || null;
|
|
}
|
|
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');
|
|
let sansSoh = false; // passe a vrai si le firmware ignore `soh` (US5000)
|
|
// Garde anti-blocage : si la ligne serie se fige (adaptateur qui ne repond
|
|
// plus, module eteint), les lectures reviennent VIDES sans jamais lever
|
|
// d'erreur (lireJusqua resout au timeout au lieu de rejeter), et le CSV
|
|
// cesse de grossir en silence. On force alors la sortie pour que le
|
|
// superviseur rouvre le port : conteneur restart:unless-stopped sur le
|
|
// Synology, ou boucle lancer.sh sur le poste. C'est le hang qui a tue la
|
|
// version portable le 20/09 (releve muet 4 jours sans que rien ne crashe).
|
|
const STALL_MS = Math.max(pas * 3, 180) * 1000;
|
|
setInterval(() => {
|
|
let age = Infinity;
|
|
try { age = Date.now() - fs.statSync(f).mtimeMs; } catch { /* fichier absent */ }
|
|
if (age > STALL_MS) {
|
|
console.error(`${horodate()} aucun releve ecrit depuis ${Math.round(age / 1000)} s (> ${STALL_MS / 1000} s) : sortie pour relance.`);
|
|
process.exit(1);
|
|
}
|
|
}, 30000).unref();
|
|
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 c = [];
|
|
if (!sansSoh) {
|
|
const t = await commande(l, `soh ${ad}`, 10000);
|
|
// `soh` : "<n0based> <mV> <SOHCount> <statut>"
|
|
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] });
|
|
}
|
|
// ⚠️ Le firmware des US5000 (B69.26, 26/09/2026) repond « Unknown
|
|
// command 'soh' ». On bascule alors sur `bat`, pour toute la session.
|
|
if (/Unknown command/i.test(t)) sansSoh = true;
|
|
}
|
|
if (sansSoh) {
|
|
// `bat` : "<n0based> <mV> <mA> <temp> <etat> ..." (une ligne par cellule)
|
|
const t = await commande(l, `bat ${ad}`, 10000);
|
|
for (const ligne of t.split('\n')) {
|
|
const m = ligne.trim().match(/^(\d{1,2})\s+(\d{4})\s+(-?\d+)\s+(-?\d+)\s+([A-Za-z]+)/);
|
|
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); });
|