diff --git a/HANDOFF.md b/HANDOFF.md index c1ce749..77fcc48 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -3742,3 +3742,113 @@ un seul module. **A instruire avant de commander quoi que ce soit.** --- *Document mis a jour le : 05/09/2026 — bridage identifie : une cellule, des 75 % de SOC, prouve sans cable serie* + +## 49. Session du 05/09/2026 — Le bridage a un coupable nomme : le module d'adresse 3 + +Le proprietaire a exporte, depuis BatteryView (machine Windows 10.10.10.93), +l'historique et les evenements de deux modules. Ces exports sont la sortie +BRUTE de la console du BMS : trente-sept jours d'enregistrements, un toutes les +trente minutes, **avec les quinze tensions de cellule a chaque fois**. Il n'y +avait donc rien a attendre de l'apres-midi — la preuve etait deja ecrite. + +### Le protocole console, releve et non devine +Le module declare lui-meme sa configuration dans la reponse a `info` : + +``` +Device name : US2000C Cell Number : 15 +Board : NF4.E2 Specification : 48V/50AH +Main Soft version : B69.13.0.0 +EPONPort rate : 1200 Console Port rate : 115200 +``` + +Invite `pylon_debug>`, fin de reponse `$$`, pagination par +`Press [Enter] to be continued`. Commandes utilisees par BatteryView : +`info`, `stat`, `data history`, **`datalist history dtls `** (quinze +enregistrements par page). Tout est reproductible depuis Linux. + +### Cinq cellules sur quinze, et le BMS le dit lui-meme +Sur 1798 enregistrements du module **K220803C30160319, adresse 3** : + +| cellule | tension max | tension min | +|---|---:|---:| +| 1, 3, 4, 5, 7, 8, 9, 11, 13, 14 | 3436 a 3445 mV | 3251 a 3256 mV | +| **2, 6, 10, 12, 15** | **3554 a 3649 mV** | **3128 a 3236 mV** | + +Ces cinq-la **montent plus haut en charge ET descendent plus bas en decharge** : +signature d'une capacite moindre, elles se remplissent et se vident avant les +autres. Ce n'est pas une erreur de mesure — le BMS leur attribue d'ailleurs un +pourcentage de charge different des dix autres (100 % contre 96 %) dans chaque +enregistrement. + +Deux sources independantes concordent exactement : le groupe deduit des +trente-sept jours d'historique et les cinq cellules les plus basses du releve +instantane du 05/09 08:35 sont **les memes cinq**. + +### L'ecart n'apparait qu'en haut, ce qui explique tout +Ecart entre cellules, en charge : + +| SOC | ecart median | cellule la plus haute | +|---|---:|---:| +| 60-69 % | **12 mV** | 3416 mV | +| 70-79 % | 27 mV | **3621 mV** | +| 80-89 % | **168 mV** | **3649 mV** | + +A 60-69 % le module est irreprochable. C'est pourquoi une capture du matin ne +peut rien montrer, et pourquoi le proprietaire « ne voyait rien » dans ses logs +precedents. 3649 mV place la cellule dans la zone de protection : le BMS reduit +alors le courant de charge autorise pour tout le parc. + +### Le contraste avec un module sain est brutal +Compteurs `stat`, deux modules du meme parc : + +| | K220803C30160322 (adresse 1) | K220803C30160319 (adresse 3) | +|---|---:|---:| +| Bat HV Times | **7** | **416** | +| Bat OV Times | 8 | 179 | +| CYCLE Times | 0 | 24 | +| **SOH** | **100** | **0** | + +⚠️ **Le BMS note lui-meme ce module 0 sur 100.** Et c'est celui que le +proprietaire croyait « le moins merdique » (sa BAT4). Le module qu'il juge le +plus abime, sa BAT3, est deja hors du parc — il n'est pour rien dans le bridage +quotidien. **Le bridage vient d'un module encore en service.** + +### C'est chronique, pas en fuite +Depassements de 3550 mV : 1 a 4 releves par jour, **tous les jours depuis le +30/07**, sans tendance. Le desequilibre est installe et stable. C'est une +bonne nouvelle pour la reparation : rien ne s'emballe. + +### L'horloge du module avance d'environ huit heures +Le dernier enregistrement porte `26-09-05 16:42:48` alors que l'export a ete +fait a 08:35. Correlation du SOC du module contre le SOC du parc mesure dans +InfluxDB : minimum net a **7 h 45 - 8 h 15 d'avance** (ecart moyen 9,7 points +contre 30 points sans decalage). La correlation sur le courant donne 6 h, +mais elle est ecartee : un enregistrement ne peut pas etre posterieur a +l'export qui le lit. + +⚠️ **Toute date lue dans l'historique du BMS doit etre corrigee d'environ +-8 h** avant d'etre comparee a InfluxDB, aux journaux n8n ou a Home Assistant. +A remettre a l'heure depuis la console. + +### Outils ajoutes +- **`tools/pylontech-console.mjs`** — console serie, sans aucune dependance + (stty pour la ligne, fs pour les octets). `--detecter` · `--commande help` · + `--historique` (extraction paginee complete) · **`--suivi 30`** (releve + cellule par cellule toutes les 30 s, horodate a l'heure REELLE de la machine + et non a celle du module, en CSV). Le lecteur de table a ete eprouve sur la + sortie reelle de BatteryView avant tout branchement. + ⚠️ `data` numerote les cellules de 0 a 14, `datalist history dtls` de 1 a 15. + L'outil ramene tout sur 1..15. +- **`tools/analyse-historique-pylontech.py`** — rejoue l'analyse ci-dessus sur + n'importe quel export. + +### Cote materiel +Le cable console mesure 30 cm, ce qui oblige a tenir le portable devant +l'armoire. La rallonge doit se faire **cote USB**, pas cote RS232 : une +rallonge USB active de 5 m coute une dizaine d'euros et supprime le besoin de +support. Les quatre pilotes USB-serie usuels (ftdi_sio, ch341, cp210x, pl2303) +sont presents sur le poste Ubuntu, et brltty — qui capture souvent les +adaptateurs CH340 — y est desactive. + +--- +*Document mis a jour le : 05/09/2026 (~11:40) — cellules 2, 6, 10, 12, 15 du module adresse 3 identifiees comme cause du bridage ; SOH 0 contre 100 sur un module sain* diff --git a/docs/mesures/batteries/Capture d'écran 2026-09-05 083417.png b/docs/mesures/batteries/Capture d'écran 2026-09-05 083417.png new file mode 100755 index 0000000..2dfb8d4 Binary files /dev/null and b/docs/mesures/batteries/Capture d'écran 2026-09-05 083417.png differ diff --git a/docs/mesures/batteries/Capture d'écran 2026-09-05 083518.png b/docs/mesures/batteries/Capture d'écran 2026-09-05 083518.png new file mode 100755 index 0000000..e8f1ae8 Binary files /dev/null and b/docs/mesures/batteries/Capture d'écran 2026-09-05 083518.png differ diff --git a/docs/mesures/batteries/Capture d'écran 2026-09-05 083623.png b/docs/mesures/batteries/Capture d'écran 2026-09-05 083623.png new file mode 100755 index 0000000..6e7dd66 Binary files /dev/null and b/docs/mesures/batteries/Capture d'écran 2026-09-05 083623.png differ diff --git a/docs/mesures/batteries/Capture d'écran 2026-09-05 083654.png b/docs/mesures/batteries/Capture d'écran 2026-09-05 083654.png new file mode 100755 index 0000000..9dc87dc Binary files /dev/null and b/docs/mesures/batteries/Capture d'écran 2026-09-05 083654.png differ diff --git a/docs/mesures/batteries/Capture d'écran 2026-09-05 083729.png b/docs/mesures/batteries/Capture d'écran 2026-09-05 083729.png new file mode 100755 index 0000000..2d02c6f Binary files /dev/null and b/docs/mesures/batteries/Capture d'écran 2026-09-05 083729.png differ diff --git a/docs/mesures/batteries/Capture d'écran 2026-09-05 083804.png b/docs/mesures/batteries/Capture d'écran 2026-09-05 083804.png new file mode 100755 index 0000000..756a0c4 Binary files /dev/null and b/docs/mesures/batteries/Capture d'écran 2026-09-05 083804.png differ diff --git a/docs/mesures/batteries/Capture d'écran 2026-09-05 084531.png b/docs/mesures/batteries/Capture d'écran 2026-09-05 084531.png new file mode 100755 index 0000000..35a9cab Binary files /dev/null and b/docs/mesures/batteries/Capture d'écran 2026-09-05 084531.png differ diff --git a/docs/mesures/batteries/Capture d'écran 2026-09-05 084549.png b/docs/mesures/batteries/Capture d'écran 2026-09-05 084549.png new file mode 100755 index 0000000..a504ef0 Binary files /dev/null and b/docs/mesures/batteries/Capture d'écran 2026-09-05 084549.png differ diff --git a/docs/mesures/batteries/Capture d'écran 2026-09-05 084623.png b/docs/mesures/batteries/Capture d'écran 2026-09-05 084623.png new file mode 100755 index 0000000..0b878f3 Binary files /dev/null and b/docs/mesures/batteries/Capture d'écran 2026-09-05 084623.png differ diff --git a/docs/mesures/batteries/Capture d'écran 2026-09-05 084642.png b/docs/mesures/batteries/Capture d'écran 2026-09-05 084642.png new file mode 100755 index 0000000..6fa524e Binary files /dev/null and b/docs/mesures/batteries/Capture d'écran 2026-09-05 084642.png differ diff --git a/docs/mesures/batteries/Capture d'écran 2026-09-05 084656.png b/docs/mesures/batteries/Capture d'écran 2026-09-05 084656.png new file mode 100755 index 0000000..f3bca46 Binary files /dev/null and b/docs/mesures/batteries/Capture d'écran 2026-09-05 084656.png differ diff --git a/docs/mesures/batteries/K220803C30160319_event_20260905083554.txt b/docs/mesures/batteries/K220803C30160319_event_20260905083554.txt new file mode 100755 index 0000000..9504f80 Binary files /dev/null and b/docs/mesures/batteries/K220803C30160319_event_20260905083554.txt differ diff --git a/docs/mesures/batteries/K220803C30160319_event_20260905084321.txt b/docs/mesures/batteries/K220803C30160319_event_20260905084321.txt new file mode 100755 index 0000000..6f94faf Binary files /dev/null and b/docs/mesures/batteries/K220803C30160319_event_20260905084321.txt differ diff --git a/docs/mesures/batteries/K220803C30160319_history_20260905083542.txt b/docs/mesures/batteries/K220803C30160319_history_20260905083542.txt new file mode 100755 index 0000000..cce00fd Binary files /dev/null and b/docs/mesures/batteries/K220803C30160319_history_20260905083542.txt differ diff --git a/docs/mesures/batteries/K220803C30160319_history_20260905083818.txt b/docs/mesures/batteries/K220803C30160319_history_20260905083818.txt new file mode 100755 index 0000000..c1b0713 Binary files /dev/null and b/docs/mesures/batteries/K220803C30160319_history_20260905083818.txt differ diff --git a/docs/mesures/batteries/K220803C30160322_event_20260724100819.txt b/docs/mesures/batteries/K220803C30160322_event_20260724100819.txt new file mode 100755 index 0000000..f8b07ac Binary files /dev/null and b/docs/mesures/batteries/K220803C30160322_event_20260724100819.txt differ diff --git a/docs/mesures/batteries/K220803C30160322_history_20260724100806.txt b/docs/mesures/batteries/K220803C30160322_history_20260724100806.txt new file mode 100755 index 0000000..76514c1 Binary files /dev/null and b/docs/mesures/batteries/K220803C30160322_history_20260724100806.txt differ diff --git a/docs/mesures/batteries/batteryview_run_log_0724100701.txt b/docs/mesures/batteries/batteryview_run_log_0724100701.txt new file mode 100755 index 0000000..4f8d03a Binary files /dev/null and b/docs/mesures/batteries/batteryview_run_log_0724100701.txt differ diff --git a/docs/mesures/batteries/batteryview_run_log_0905082536.txt b/docs/mesures/batteries/batteryview_run_log_0905082536.txt new file mode 100755 index 0000000..337f2e0 Binary files /dev/null and b/docs/mesures/batteries/batteryview_run_log_0905082536.txt differ diff --git a/docs/mesures/batteries/batteryview_run_log_0905083309.txt b/docs/mesures/batteries/batteryview_run_log_0905083309.txt new file mode 100755 index 0000000..3573247 Binary files /dev/null and b/docs/mesures/batteries/batteryview_run_log_0905083309.txt differ diff --git a/docs/mesures/batteries/scanlog_0724100702.xlsx b/docs/mesures/batteries/scanlog_0724100702.xlsx new file mode 100755 index 0000000..e04311d Binary files /dev/null and b/docs/mesures/batteries/scanlog_0724100702.xlsx differ diff --git a/docs/mesures/batteries/scanlog_0905083309.xlsx b/docs/mesures/batteries/scanlog_0905083309.xlsx new file mode 100755 index 0000000..ee84554 Binary files /dev/null and b/docs/mesures/batteries/scanlog_0905083309.xlsx differ diff --git a/tools/analyse-historique-pylontech.py b/tools/analyse-historique-pylontech.py new file mode 100644 index 0000000..483a3f7 --- /dev/null +++ b/tools/analyse-historique-pylontech.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Analyse les exports d'historique BatteryView d'un module Pylontech. + +BatteryView enregistre la sortie brute de la console du module. Chaque ligne +de `datalist history dtls ` porte l'etat du module ET les quinze cellules, +toutes les trente minutes. Le module en garde 1798, soit trente-sept jours. + + python3 tools/analyse-historique-pylontech.py docs/mesures/batteries/*_history_*.txt + +Ce que l'outil cherche : quelles cellules divergent, de combien, a quel niveau +de charge, et si cela s'aggrave. Les tensions sont en millivolts. +""" +import collections +import glob +import re +import sys + +# Au-dela, le BMS commence a reduire le courant de charge autorise. +SEUIL_HAUT_MV = 3550 + + +def lire(chemin): + """Retourne les enregistrements, dedoublonnes par identifiant. + + Les pages de `datalist history dtls` se chevauchent : le meme + enregistrement apparait dans plusieurs exports. L'identifiant tranche. + """ + recs = {} + brut = open(chemin, "rb").read() + txt = brut.decode("utf-16" if brut[:2] in (b"\xff\xfe", b"\xfe\xff") else "utf-8", + errors="replace").replace("\r", "") + for ligne in txt.split("\n"): + ch = ligne.split() + if len(ch) < 18 + 15 * 6: + continue + if not re.fullmatch(r"\d+", ch[0]) or not re.fullmatch(r"\d\d-\d\d-\d\d", ch[1]): + continue + recs[int(ch[0])] = dict( + id=int(ch[0]), jour=ch[1], heure=ch[2], + volt=int(ch[3]), curr=int(ch[4]), + etat=ch[10], soc=int(ch[16].rstrip("%")), + cellules=[int(ch[18 + k * 6 + 1]) for k in range(15)], + ) + return recs + + +def main(motifs): + recs = {} + for m in motifs: + for f in sorted(glob.glob(m)): + recs.update(lire(f)) + r = [recs[k] for k in sorted(recs)] + if not r: + sys.exit("Aucun enregistrement lisible.") + + print(f"{len(r)} enregistrements, du {r[0]['jour']} {r[0]['heure']} " + f"au {r[-1]['jour']} {r[-1]['heure']} (heure INTERNE du module)") + print() + + print("TENSION EXTREME ATTEINTE PAR CHAQUE CELLULE") + hauts = [] + for i in range(15): + vs = [x["cellules"][i] for x in r] + marque = "" + if max(vs) > SEUIL_HAUT_MV: + marque = " <-- monte trop haut ET descend trop bas" + hauts.append(i + 1) + print(f" cellule {i+1:>2} : max {max(vs)} mV min {min(vs)} mV{marque}") + print() + if hauts: + print(f" Les cellules {', '.join(map(str, hauts))} se distinguent des " + f"{15 - len(hauts)} autres.") + print(" Monter plus haut en charge ET descendre plus bas en decharge est la") + print(" signature d'une capacite moindre : elles se remplissent et se vident") + print(" avant les autres. Ce n'est pas un defaut de mesure.") + print() + + print("ECART ENTRE CELLULES, EN CHARGE, SELON LE NIVEAU DE CHARGE") + print(f" {'SOC':>9} {'releves':>8} {'ecart median':>14} {'ecart max':>11} {'cellule la plus haute':>22}") + tr = collections.defaultdict(list) + for x in r: + if x["etat"] == "Charge": + tr[min(90, x["soc"] // 10 * 10)].append(x) + for k in sorted(tr): + e = sorted(max(x["cellules"]) - min(x["cellules"]) for x in tr[k]) + vm = max(max(x["cellules"]) for x in tr[k]) + print(f" {k:>4}-{k+9:<4} {len(e):>8} {e[len(e)//2]:>11} mV {max(e):>8} mV {vm:>19} mV") + print() + + print(f"DEPASSEMENTS DE {SEUIL_HAUT_MV} mV, PAR JOUR") + j = collections.Counter(x["jour"] for x in r if max(x["cellules"]) > SEUIL_HAUT_MV) + if not j: + print(" aucun") + else: + for k in sorted(j): + print(f" {k} : {j[k]:>2} releve(s) sur 48") + n = sorted(j.values()) + print(f" total {sum(j.values())} releves sur {len(r)}, repartis sur {len(j)} jours ; " + f"median {n[len(n)//2]} par jour") + print(" Un nombre stable jour apres jour decrit un desequilibre chronique,") + print(" pas une degradation en cours.") + print() + + print("LES DIX PIRES MOMENTS") + for x in sorted(r, key=lambda z: max(z["cellules"]) - min(z["cellules"]), reverse=True)[:10]: + hi, lo = max(x["cellules"]), min(x["cellules"]) + print(f" {x['jour']} {x['heure']} SOC {x['soc']:>3}% {x['etat']:<7} " + f"parc {x['volt']/1000:5.2f} V {x['curr']/1000:>6.2f} A " + f"ecart {hi-lo:>3} mV haute = cellule {x['cellules'].index(hi)+1} ({hi} mV)") + + +if __name__ == "__main__": + main(sys.argv[1:] or ["docs/mesures/batteries/*_history_*.txt"]) diff --git a/tools/pylontech-console.mjs b/tools/pylontech-console.mjs new file mode 100644 index 0000000..9e66d0a --- /dev/null +++ b/tools/pylontech-console.mjs @@ -0,0 +1,280 @@ +#!/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.'); + let f = journal(nom, `# releve du ${horodate()} sur ${dev}\n`); + f = journal(nom, await commande(l, 'info', 8000)); + for (let i = depuis; i < 1800; i += 15) { + const txt = await commande(l, `datalist history dtls ${i}`, 20000); + journal(nom, txt); + if (!/\d{2}-\d{2}-\d{2}/.test(txt)) break; + process.stdout.write(`\r enregistrement ${i}... `); + } + console.log(`\nEcrit 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); });