#!/usr/bin/env node // Verse dans PostgreSQL les tensions cellule par cellule relevees sur la // console des BMS Pylontech, pour qu'elles soient tracables dans Grafana a // cote du reste du systeme. // // Deux sources, deux formats : // - les exports d'historique (`--historique`, ou `datalist history dtls`) // un enregistrement toutes les 30 minutes, jusqu'a 1798 par module ; // - les CSV de suivi (`--suivi`), au pas choisi pendant un essai. // // A executer LA OU le mot de passe existe, c'est-a-dire dans le conteneur // d'historisation, qui monte le depot en lecture seule : // // sudo docker compose exec historisation \ // node tools/importer-cellules.mjs 'docs/mesures/batteries/*' // // --a-blanc analyse et compte, n'ecrit rien. import fs from 'node:fs'; import path from 'node:path'; import pg from 'pg'; const args = process.argv.slice(2); const BLANC = args.includes('--a-blanc'); const motifs = args.filter((a) => !a.startsWith('--')); // Correspondance adresse -> code-barres pour les CSV de suivi, qui ne portent // que l'adresse. Chaque chaine renumerote a partir de 1 : une adresse seule // est ambigue dans ce parc. Completer au fil des relevés `info `. // Decalage d'horloge MESURE au chronometre, par module, en minutes. Ces // valeurs viennent de la lecture directe du RTC face a l'horloge du poste, le // 05/09/2026 — pas d'une deduction. Chaque module a son propre RTC. // // chaine du pack ancien, `pwr` a 12:16 -> RTC 18:07:52 => +5 h 52 // module isole (…0324), `time` a 12:47 -> RTC 18:56:35 => +6 h 09 // chaine du pack recent, `pwr` a 12:23 -> RTC 18:36:33 => +6 h 13 // // La deduction automatique se trompait de 1 h 30 a 4 h : le tampon du BMS est // circulaire et contient des enregistrements anterieurs a des remises a // l'heure, donc le plus recent des horodatages n'est pas fiable. // Repere : ces valeurs ont ete mesurees au chronometre le 05/09/2026 et // servent a CONTROLER la deduction, pas a la remplacer. // pack ancien +5 h 52 · module isole +6 h 09 · pack recent +6 h 13 const CARTE_SUIVI = { 1: 'K220803C30160322', // pack ancien, adresse 1 2: 'K220803C30160323', // pack ancien, adresse 2 — le module sain de sa chaine 3: 'K220803C30160319', // pack ancien, adresse 3 }; // ⚠️ Cette carte ne vaut QUE pour la chaine du pack ancien. Le pack recent // renumerote lui aussi ses modules de 1 a 4 : y appliquer cette carte // attribuerait ses relevés aux mauvais modules. Un suivi fait sur l'autre // chaine demandera une carte distincte, choisie a l'import. const lireTexte = (f) => { const brut = fs.readFileSync(f); const utf16 = brut[0] === 0xff && brut[1] === 0xfe; return brut.toString(utf16 ? 'utf16le' : 'utf8').replace(/\r/g, ''); }; // ---------------------------------------------------------------- historique function lireHistorique(f) { const txt = lireTexte(f); const bc = txt.match(/Barcode\s*:\s*(\S+)/); if (!bc) return null; const recs = new Map(); for (const ligne of txt.split('\n')) { const c = ligne.split(/\s+/).filter(Boolean); if (c.length < 18 + 15 * 6) continue; if (!/^\d+$/.test(c[0]) || !/^\d\d-\d\d-\d\d$/.test(c[1])) continue; const [a, m, j] = c[1].split('-').map(Number); const [H, M, S] = c[2].split(':').map(Number); recs.set(+c[0], { // Heure MODULE, corrigee plus bas. Le module ecrit une heure de mur // LOCALE : il faut l'interpreter comme telle, jamais comme de l'UTC. // Le faire ajoutait exactement le decalage de Paris (2 h en ete) a // chaque decalage d'horloge deduit — d'ou les « +7 h 27 » et « +9 h 59 » // qui semblaient absurdes et qui, corriges, tombent sur les valeurs // mesurees au chronometre. brut: new Date(2000 + a, m - 1, j, H, M, S).getTime(), courant: +c[4], soc: parseInt(c[16], 10), etat: c[10], mv: Array.from({ length: 15 }, (_, k) => +c[18 + k * 6 + 1]), }); } if (!recs.size) return null; // L'horloge des modules derive de plusieurs heures. Plutot que de la // demander, on la DEDUIT : l'enregistrement le plus RECENT d'un export est, // a une demi-periode pres, contemporain de l'export lui-meme. // // ⚠️ Le plus recent n'est PAS celui de plus grand index : le tampon du BMS // est circulaire, il reecrit par-dessus le plus ancien. Trier par index // donnait des decalages de -865 h. const rangs = [...recs.keys()].sort((x, y) => recs.get(x).brut - recs.get(y).brut); // Depuis le 05/09, `pylontech-console.mjs --historique` inscrit le decalage // en tete, mesure a la seconde de l'export. C'est la seule valeur exacte : // on la prefere toujours a la deduction. const entete = txt.match(/^# decalage_minutes\s*:\s*(-?\d+)/m); const force = args.find((a) => a.startsWith('--decalage=')); let decalageMin, origine; if (force) { decalageMin = +force.split('=')[1]; origine = 'impose'; } else if (entete) { decalageMin = +entete[1]; origine = 'mesure a l\'export'; } else { const dernier = recs.get(rangs[rangs.length - 1]).brut; decalageMin = Math.round((dernier - fs.statSync(f).mtimeMs) / 60000); origine = 'DEDUIT, approximatif'; } const decalageMs = decalageMin * 60000; // Garde : au-dela d'une journee, la deduction ne tient plus (fichier // recopie, horodatage de fichier perdu). On refuse plutot que de deplacer // des mesures n'importe ou. if (Math.abs(decalageMin) > 24 * 60) { throw new Error(`decalage deduit aberrant (${Math.round(decalageMin / 60)} h)`); } const lignes = []; for (const r of rangs) { const x = recs.get(r); const t = new Date(x.brut - decalageMs); for (let k = 0; k < 15; k++) { lignes.push([t.toISOString(), bc[1], k + 1, x.mv[k], x.soc, x.courant, x.etat, 'historique']); } } return { module: bc[1], decalageMin, origine, lignes, nb: rangs.length }; } // ------------------------------------------------- historique distant (CSV) // Produit par `--historique-distant ` : deja horodate a l'heure // REELLE (le decalage est lu et applique a l'ecriture) et deja nomme par // code-barres. Rien a corriger, rien a deviner. function lireHistoDistant(f) { const lignes = []; const l = lireTexte(f).trim().split('\n'); const cols = l[0].split(','); const iC = cols.indexOf('c1_mV'); for (const ligne of l.slice(1)) { const c = ligne.split(','); if (c.length < iC + 15) continue; for (let k = 0; k < 15; k++) { const mv = +c[iC + k]; if (!(mv > 1000)) continue; lignes.push([c[0], c[1], k + 1, mv, c[4] === '' ? null : +c[4], c[3] === '' ? null : +c[3], c[2] || null, 'historique']); } } return lignes.length ? { module: l[1].split(',')[1], lignes } : null; } // --------------------------------------------------------------------- suivi function lireSuivi(f) { const lignes = []; const modules = new Set(); for (const l of lireTexte(f).trim().split('\n').slice(1)) { const c = l.split(','); if (c.length < 18) continue; // Depuis le 05/09 le suivi ecrit directement le code-barres. Les fichiers // anterieurs ne portent que `moduleN` : la table ci-dessus les traduit, et // elle ne vaut que pour la chaine du pack ancien, seule interrogee alors. const brut = String(c[1]); let mod; if (/^[A-Z]\d{6}[A-Z]\d+$/.test(brut)) mod = brut; else { const ad = parseInt(brut.replace(/\D/g, ''), 10); if (!Number.isFinite(ad)) continue; mod = CARTE_SUIVI[ad] || `pack_ancien:${ad}`; } modules.add(mod); const cour = c[2] === '' ? null : +c[2]; for (let k = 0; k < 15; k++) { const mv = +c[3 + k]; if (!(mv > 1000)) continue; lignes.push([c[0], mod, k + 1, mv, null, cour, c[1] || null, 'suivi']); } } return lignes.length ? { modules: [...modules], lignes } : null; } // ---------------------------------------------------------------------- main const fichiers = []; for (const m of motifs) { const d = path.dirname(m), base = path.basename(m); const re = new RegExp('^' + base.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$'); for (const n of fs.readdirSync(d)) if (re.test(n)) fichiers.push(path.join(d, n)); } if (!fichiers.length) { console.error('Aucun fichier.'); process.exit(1); } // Un module extrait par `--historique-distant` a des horodatages EXACTS (le // decalage est lu au chronometre). Ses vieux exports texte, eux, reposent sur // une deduction. Importer les deux pour le meme module poserait deux series // decalees de plusieurs heures l'une de l'autre, et les courbes seraient // illisibles. La source exacte gagne, quitte a perdre quelques enregistrements. const exacts = new Set(); for (const f of fichiers) { const m = path.basename(f).match(/-histo-distant-(\S+?)\.csv$/); if (m) exacts.add(m[1]); } const paquets = []; for (const f of fichiers.sort()) { const n = path.basename(f); try { if (n.includes('-histo-distant-')) { const r = lireHistoDistant(f); if (r) { paquets.push(r.lignes); console.log(` ${n}\n ${r.module}, ${r.lignes.length / 15} enregistrements, horodates a l'export`); } } else if (n.endsWith('-suivi.csv')) { const r = lireSuivi(f); if (r) { paquets.push(r.lignes); console.log(` ${n} suivi, ${r.lignes.length / 15} releves, modules ${r.modules.join(' ')}`); } } else if (/histor/i.test(n) && n.endsWith('.txt')) { const bc = (lireTexte(f).match(/Barcode\s*:\s*(\S+)/) || [])[1]; if (bc && exacts.has(bc)) { console.log(` ${n} ecarte : ${bc} a un releve horodate a l'export`); continue; } // Les exports d'EVENEMENTS portent les memes colonnes mais decrivent des // declenchements, pas un echantillonnage regulier : les melanger ferait // des courbes fausses. const r = lireHistorique(f); if (r) { paquets.push(r.lignes); const d = r.decalageMin; const t0 = r.lignes[0][0].slice(0, 16).replace('T', ' '); const t1 = r.lignes[r.lignes.length - 1][0].slice(0, 16).replace('T', ' '); console.log(` ${n}\n ${r.module}, ${r.nb} enregistrements, horloge ` + `${d >= 0 ? '+' : '-'}${Math.trunc(Math.abs(d) / 60)} h ${Math.abs(d % 60)} min` + ` (${r.origine}) -> ${t0} a ${t1}`); } } } catch (e) { console.error(` ${n} ignore : ${e.message}`); } } const toutes = paquets.flat(); console.log(`\n${toutes.length} lignes cellule au total.`); if (BLANC) { console.log('A blanc : rien ecrit.'); process.exit(0); } const client = new pg.Client({ host: process.env.PGHOST || '10.10.10.22', port: Number(process.env.PGPORT || 5432), user: process.env.PGUSER || 'evcs', database: process.env.PGDATABASE || 'evcs', password: process.env.PGPASSWORD, }); await client.connect(); // --remplacer : effacer d'abord les lignes des modules concernes pour la meme // source. Sans cela, corriger un decalage d'horloge AJOUTE une seconde copie // decalee au lieu de remplacer la premiere — la cle primaire porte l'instant. if (args.includes('--remplacer')) { // Les noms provisoires de la forme `pack_ancien:2` datent d'avant que le // code-barres du module soit connu. Une fois nomme, l'ancien nom ferait un // module fantome dans les listes. const prov = await client.query("DELETE FROM cellule WHERE module LIKE '%:%'"); if (prov.rowCount) console.log(` efface ${prov.rowCount} lignes sous un nom provisoire`); const paires = [...new Set(toutes.map((l) => l[1] + '\u0000' + l[7]))] .map((x) => x.split('\u0000')); for (const [mod, src] of paires) { const r = await client.query('DELETE FROM cellule WHERE module = $1 AND source = $2', [mod, src]); console.log(` efface ${r.rowCount} lignes de ${mod} (${src})`); } } let ecrites = 0; for (let i = 0; i < toutes.length; i += 500) { const lot = toutes.slice(i, i + 500); const val = lot.map((_, k) => `($${k * 8 + 1},$${k * 8 + 2},$${k * 8 + 3},$${k * 8 + 4},$${k * 8 + 5},$${k * 8 + 6},$${k * 8 + 7},$${k * 8 + 8})`).join(','); const r = await client.query( `INSERT INTO cellule (ts, module, cellule, mv, soc, courant_ma, etat, source) VALUES ${val} ON CONFLICT (ts, module, cellule) DO NOTHING`, lot.flat()); ecrites += r.rowCount; process.stdout.write(`\r ${i + lot.length} / ${toutes.length} `); } console.log(`\n${ecrites} lignes nouvelles, ${toutes.length - ecrites} deja presentes.`); await client.end();