#!/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'); } console.log(`Suivi toutes les ${pas} s, cellule par cellule -> ${f}`); 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 (;;) { 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); });