#!/usr/bin/env node // ===================================================================== // RAPPORT DE NUIT — texte simple, destine a etre LU A VOIX HAUTE // ===================================================================== // Le proprietaire le recoit par courriel le matin, souvent en voiture : il se // le fait lire. D'ou les contraintes de forme, qui ne sont pas cosmetiques — // pas de tableau, pas de tiret de liste, pas d'abreviation, des phrases // courtes, et les nombres ecrits comme on les prononce (« 4,2 kilowattheures » // et non « 4.2 kWh »). // // Il n'y a RIEN a surveiller en direct : le service d'historisation tourne // toutes les soixante secondes sur une machine allumee en permanence et // enregistre deja tout. Ce fichier ne surveille pas, il LIT — et c'est // pourquoi il vit dans le service plutot que dans une session interactive, // qui, elle, s'arrete des que l'ecran se ferme. // // Usage : // node tools/rapport-nuit.mjs # affiche le rapport // node tools/rapport-nuit.mjs --envoyer # l'envoie par courriel // node tools/rapport-nuit.mjs --jour 2026-09-05 // ===================================================================== import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import pg from 'pg'; const RACINE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const WEBHOOK = 'https://n8n.airtria.pro/webhook/solar-email'; const DESTINATAIRE = 'tallagrand.thierry@gmail.com'; function envFichier() { const out = {}; for (const f of [path.join(RACINE, '.env'), '/app/.env']) { if (!fs.existsSync(f)) continue; for (const l of fs.readFileSync(f, 'utf8').split('\n')) { const i = l.indexOf('='); if (i > 0 && !l.trimStart().startsWith('#')) out[l.slice(0, i).trim()] = l.slice(i + 1).trim(); } } return out; } // « 4.2 » -> « 4,2 » : une virgule se lit, un point ne se lit pas. const n1 = (v) => (v === null || v === undefined || !Number.isFinite(Number(v))) ? null : Number(v).toFixed(1).replace('.', ','); const n2 = (v) => (v === null || v === undefined || !Number.isFinite(Number(v))) ? null : Number(v).toFixed(2).replace('.', ','); const n0 = (v) => (v === null || v === undefined || !Number.isFinite(Number(v))) ? null : Math.round(Number(v)).toString(); const heure = (d) => new Date(d).toLocaleTimeString('fr-FR', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit' }).replace(':', ' h '); function pluriel(n, sing, plur) { return n <= 1 ? `${n} ${sing}` : `${n} ${plur}`; } // --------------------------------------------------------------------- // Le rapport lui-meme. `jour` est la date du MATIN ; la nuit examinee va de // 18 heures la veille a 10 heures ce matin-la. // --------------------------------------------------------------------- // ⚠️ UNE SEULE SOURCE pour le courriel du matin ET l'agent vocal (25/09) : // `donneesNuit` fait toutes les lectures, `texteRapport` les met en phrases, et // le service d'historisation publie les memes donnees dans // sensor.evcs_bilan_nuit. Ce matin-la, l'agent sans source avait invente // « 0,56 kWh, zero reseau, arretee vers 4 h » pour une nuit a 7,9 kWh dont 2,2 // reseau. Les deux sorties ne peuvent plus diverger : meme code, memes chiffres. export async function donneesNuit(client, jour = new Date()) { const fin = new Date(jour); fin.setHours(10, 0, 0, 0); const debut = new Date(fin); debut.setDate(debut.getDate() - 1); debut.setHours(18, 0, 0, 0); const q = (sql, v = []) => client.query(sql, v); // ------------------------------------------------ le reseau const { rows: bascules } = await q( `SELECT ts, detail->>'apres' AS apres FROM evenement WHERE genre = 'reseau' AND resume LIKE 'Contacteur EDF%' AND ts >= $1 AND ts < $2 ORDER BY ts`, [debut, fin]); const fermetures = bascules.filter((b) => b.apres === 'on'); // Integration ponderee par l'intervalle REEL entre points (cap 300 s pour // qu'un trou n'extrapole rien), et fenetre plafonnee a now(). Le rapport // tourne a 06:47 alors que la fenetre va jusqu'a 10:00 : l'ancienne methode // (grille generate_series 1 min + derniere valeur connue + sum/60000) // reportait la derniere puissance connue sur les ~3 h FUTURES de la fenetre // et surestimait l'import (3,8 kWh reconstitue au lieu de 1,6 mesure, nuit du // 24/09, signale par la session SPARK via les compteurs d'energie Victron). // NE JAMAIS revenir a sum(x)/60000 sur une grille qui depasse maintenant : // elle integre du futur. La ponderation par lead(ts) n'a pas de point apres // le dernier echantillon, donc elle n'extrapole jamais. const { rows: [e] } = await q( `WITH b AS (SELECT $1::timestamptz d, LEAST($2::timestamptz, now()) f), g AS (SELECT valeur v, LEAST(EXTRACT(EPOCH FROM (lead(ts) OVER (ORDER BY ts) - ts)), 300) dt FROM mesure, b WHERE cle = 'sensor.victron_grid_power_l1' AND ts >= d AND ts < f), ev AS (SELECT valeur v, LEAST(EXTRACT(EPOCH FROM (lead(ts) OVER (ORDER BY ts) - ts)), 300) dt FROM mesure, b WHERE cle = 'sensor.victron_evcs_ac_power' AND ts >= d AND ts < f), ba AS (SELECT valeur v, LEAST(EXTRACT(EPOCH FROM (lead(ts) OVER (ORDER BY ts) - ts)), 300) dt FROM mesure, b WHERE cle = 'sensor.victron_battery_power' AND ts >= d AND ts < f) SELECT (SELECT coalesce(sum(GREATEST(v,0)*dt) FILTER (WHERE dt IS NOT NULL),0)/3600000.0 FROM g) AS importe, (SELECT coalesce(sum(GREATEST(-v,0)*dt) FILTER (WHERE dt IS NOT NULL),0)/3600000.0 FROM g) AS injecte, (SELECT coalesce(sum(GREATEST(v,0)*dt) FILTER (WHERE dt IS NOT NULL),0)/3600000.0 FROM ev) AS voiture, (SELECT coalesce(sum(GREATEST(v,0)*dt) FILTER (WHERE dt IS NOT NULL),0)/3600000.0 FROM ba) AS batterie_chargee, (SELECT count(*) FROM g WHERE v < 0) AS points_negatifs, (SELECT min(v) FROM g) AS pire_injection`, [debut, fin]); // ------------------------------------------------ la batterie const { rows: [b] } = await q( `SELECT max(valeur) AS haut, min(valeur) AS bas, (SELECT ts FROM mesure m2 WHERE m2.cle = 'sensor.victron_battery_soc' AND m2.ts >= $1 AND m2.ts < $2 ORDER BY m2.valeur ASC, m2.ts ASC LIMIT 1) AS heure_bas, -- Valeur AU DEBUT de la fenetre, pas le maximum : dire « partie de » -- en donnant le maximum de la nuit ferait commencer le recit par un -- chiffre atteint plus tard. (SELECT valeur FROM mesure m1 WHERE m1.cle = 'sensor.victron_battery_soc' AND m1.ts <= $1 ORDER BY m1.ts DESC LIMIT 1) AS depart, (SELECT valeur FROM mesure m3 WHERE m3.cle = 'sensor.victron_battery_soc' AND m3.ts < $2 ORDER BY m3.ts DESC LIMIT 1) AS maintenant FROM mesure WHERE cle = 'sensor.victron_battery_soc' AND ts >= $1 AND ts < $2`, [debut, fin]); // ------------------------------------------------ la voiture const { rows: sessions } = await q( `SELECT debut, fin, terminee, vehicule, energie_kwh, energie_compteur_kwh, energie_station_kwh, energie_reseau_kwh, source_mode FROM session_charge WHERE debut >= $1 AND debut < $2 ORDER BY debut`, [debut, fin]); // ------------------------------------------------ ce qu'on a deploye le 6 const { rows: [d] } = await q( `SELECT count(*) FILTER (WHERE etat = 'hold_on_charge_encore_elevee') AS maintiens, count(*) FILTER (WHERE etat LIKE 'turn_on_charge%') AS protections FROM decision WHERE ts >= $1 AND ts < $2`, [debut, fin]); const { rows: [h] } = await q( `SELECT count(*) AS n FROM evenement WHERE ts >= $1 AND ts < $2 AND resume LIKE '%charge maison%'`, [debut, fin]); // ------------------------------------------------ prevision solaire // Une panne de prevision est invisible autrement : les nodes n8n portent // `onError: continueRegularOutput`, donc les executions restent « success » // meme quand l'appel echoue, et quand Home Assistant est lui aussi // injoignable le message d'echec ne peut meme pas etre publie. Le service // d'historisation, lui, tourne sur l'autre NAS : c'est le seul temoin qui // survit. Releve de la nuit du 07 au 08/09 : 4 h 45 sans une seule // publication, et personne ne l'a su avant le lendemain. const { rows: [prev] } = await q( `SELECT round(EXTRACT(EPOCH FROM max(ecart)) / 60) AS trou FROM (SELECT publie_a - lag(publie_a) OVER (ORDER BY publie_a) AS ecart FROM prevision WHERE publie_a >= $1 AND publie_a < $2) t`, [debut, fin]); const trouMin = Number(prev?.trou ?? 0); // ------------------------------------------------ anomalies const { rows: soucis } = await q( `SELECT resume FROM evenement WHERE ts >= $1 AND ts < $2 AND (resume ILIKE '%injoignable%' OR resume ILIKE '%redemarr%' OR resume ILIKE '%erreur%' OR resume ILIKE '%echec%') ORDER BY ts`, [debut, fin]); const dateLue = fin.toLocaleDateString('fr-FR', { timeZone: 'Europe/Paris', weekday: 'long', day: 'numeric', month: 'long' }); return { debut, fin, dateLue, fermetures, e, b, sessions, d, h, trouMin, soucis }; } export async function texteRapport(client, jour = new Date()) { return texteDepuis(await donneesNuit(client, jour)); } export function texteDepuis({ fermetures, e, b, sessions, trouMin, soucis, dateLue }) { const p = []; // paragraphes const dire = (...l) => p.push(l.join(' ')); dire(`Rapport de la nuit du ${dateLue}.`); if (!fermetures.length) { dire('Le contacteur est reste ouvert toute la nuit : aucune energie achetee au reseau.'); } else { dire(`Le reseau a ete ferme ${pluriel(fermetures.length, 'fois', 'fois')},`, `a ${fermetures.map((f) => heure(f.ts)).join(', puis a ')}.`, `Au total ${n1(e.importe)} kilowattheures importes.`); } if (b && b.bas !== null) { dire(`La batterie est partie de ${n0(b.depart ?? b.haut)} pour cent hier soir,`, `elle est descendue au plus bas a ${n0(b.bas)} pour cent vers ${heure(b.heure_bas)},`, `et elle est ce matin a ${n0(b.maintenant)} pour cent.`); if (Number(b.bas) <= 20) { dire('Attention : elle a touche son plancher de vingt pour cent,', 'donc le reseau a pris le relais de lui-meme.'); } } if (!sessions.length) { dire('Aucun vehicule n\'a charge cette nuit.'); } else { for (const s of sessions) { const kwh = s.energie_compteur_kwh ?? s.energie_kwh; dire(`Le vehicule ${s.vehicule ?? 'non identifie'} a charge a partir de ${heure(s.debut)},`, `et il a pris ${n1(kwh)} kilowattheures.`); } } // ⚠️ Ne jamais qualifier l'injection de negligeable sans regarder la pointe : // le 6 septembre, une pointe a 2 819 watts pendant deux minutes n'avait rien // d'anodin, alors que l'energie totale restait faible. if (Number(e.points_negatifs) === 0) { dire('Cote qualite du raccordement, aucune minute d\'injection vers le reseau.'); } else { const pointe = Math.abs(Number(e.pire_injection)); dire(`Il reste ${pluriel(Number(e.points_negatifs), 'minute', 'minutes')} ou le compteur`, `est passe en negatif, au plus fort ${n0(pointe)} watts,`, `soit ${n0(e.injecte * 1000)} wattheures en tout.`, pointe >= 1000 ? 'Une pointe de cette taille merite un coup d\'oeil : elle vient d\'une charge' + ' qui disparait d\'un coup alors que la batterie ne peut plus absorber.' : 'C\'est du bruit de regulation, sans consequence.'); } if (trouMin >= 120) { const h = Math.floor(trouMin / 60); const m = trouMin % 60; dire(`Attention, la prevision solaire n'a pas ete rafraichie pendant ${h} heure${h > 1 ? 's' : ''}` + `${m ? ` et ${m} minutes` : ''}. Le bilan a donc travaille sur une courbe` + ' vieillissante pendant ce temps, et la cible de la nuit a pu retomber sur la regle des paliers.'); } if (soucis.length) { dire(`A signaler : ${soucis.map((s) => s.resume.toLowerCase()).join('. ')}.`); } else { dire('Aucun incident technique pendant la nuit.'); } // ------------------------------------------------ la conclusion const vider = Number(b?.bas ?? 100) <= 20; const cher = Number(e.importe) > 8; dire(vider || cher ? 'En resume, la nuit a demande plus au reseau que prevu. Le detail est dans le depot.' : 'En resume, la nuit s\'est passee comme prevu.'); return p.join('\n\n'); } // --------------------------------------------------------------------- export async function envoyer(texte, sujet) { const url = `${WEBHOOK}?${new URLSearchParams( { to: DESTINATAIRE, subject: sujet, message: texte })}`; const r = await fetch(url); if (!r.ok) throw new Error(`webhook courriel : HTTP ${r.status}`); return true; } // --------------------------------------------------------------------- const estCli = process.argv[1] && process.argv[1].endsWith('rapport-nuit.mjs'); if (estCli) { const env = envFichier(); const arg = (n) => { const i = process.argv.indexOf(n); return i > 0 ? process.argv[i + 1] : null; }; const jour = arg('--jour') ? new Date(arg('--jour') + 'T12:00:00') : new Date(); const client = new pg.Client({ host: process.env.PGHOST || '10.10.10.22', port: Number(process.env.PGPORT || 5433), database: 'evcs', user: 'evcs_lecture', password: env.PG_LECTURE_PASSWORD, }); await client.connect(); const texte = await texteRapport(client, jour); await client.end(); console.log(texte); if (process.argv.includes('--envoyer')) { await envoyer(texte, 'EVCS - rapport de la nuit'); console.log('\n[courriel envoye]'); } }