Historisation : allers-retours brefs de la borne et du contacteur retrouves dans l'historique HA (echange VW -> JEEP de 20 s)

This commit is contained in:
Tallagrand Thierry
2026-10-06 23:28:56 +02:00
parent f189df588d
commit 28851c83e4
4 changed files with 166 additions and 7 deletions

24
tools/allers-retours.mjs Normal file
View File

@@ -0,0 +1,24 @@
// Transitions d'etat retrouvees dans l'historique HA (06/10/2026).
// Voir ALLERS_RETOURS_SUIVIS dans historise-evcs.mjs : un aller-retour plus
// bref qu'un cycle du service ne se voit que par le `last_changed` de HA.
const txt = (s) => (s ?? '').toString().trim();
export const ETATS_ABSENTS = new Set(['', 'unavailable', 'unknown']);
// Transitions reelles entre `depuisIso` (exclu) et `jusquaIso` (inclus), en
// partant de l'etat `depart`. Les lectures indisponibles ne comptent pas.
// Pure (testee) : `serie` = reponse de /api/history/period pour une entite.
export function transitionsDansSerie(serie, depart, depuisIso, jusquaIso) {
const depuis = Date.parse(depuisIso), jusqua = Date.parse(jusquaIso);
const res = [];
let courant = depart;
for (const e of serie || []) {
const t = Date.parse(e.last_changed);
if (!(t > depuis && t <= jusqua)) continue;
const etat = txt(e.state);
if (ETATS_ABSENTS.has(etat) || etat === courant) continue;
res.push({ avant: courant, apres: etat, a: e.last_changed });
courant = etat;
}
return res;
}

View File

@@ -32,6 +32,7 @@ import pg from 'pg';
import { creerSchemaKm, ingererReleves, ingererNiveauSaisi, ingererJeep } from './kilometrage.mjs';
import { texteRapport, envoyer, donneesNuit, texteDepuis } from './rapport-nuit.mjs';
import { diagnosticPv, etatCellules, productionJour, chargeSolaire, bilanNuit, etatReseau, importJour, minuitLocal, COMPTEURS_IMPORT, COMPTEUR_EDF, consoJour, COMPTEURS_CONSO, projectionSoir, previsionNuit, vrmVersCourbes, nuitMaisonPrevue } from './diagnostics-agent.mjs';
import { transitionsDansSerie } from './allers-retours.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const ONCE = process.argv.includes('--once');
@@ -158,10 +159,13 @@ function demarrageHaMs(st) {
return n >= 10 ? meilleur * 1000 : null;
}
async function journaliser(sql, genre, resume, detail) {
async function journaliser(sql, genre, resume, detail, ts = null) {
// `ts` : horodatage HA d'une transition retrouvee apres coup (aller-retour
// bref, cf. detecterEvenements) ; sinon l'heure d'ecriture (defaut de la table).
await sql(
`INSERT INTO evenement (genre, source, resume, detail) VALUES ($1, $2, $3, $4)`,
[genre, 'historisation', resume, JSON.stringify(detail || {})]
`INSERT INTO evenement (ts, genre, source, resume, detail)
VALUES (COALESCE($5::timestamptz, now()), $1, $2, $3, $4)`,
[genre, 'historisation', resume, JSON.stringify(detail || {}), ts]
);
console.log(` evenement [${genre}] ${resume}`);
}
@@ -430,13 +434,37 @@ async function ecrirePrevision(sql, st) {
// Memoire entre cycles. Au PREMIER passage on enregistre l'etat sans rien
// journaliser : sinon chaque redemarrage du service produirait douze fausses
// transitions. Seul le demarrage du service lui-meme est consigne.
const memoire = { amorce: false, demarrageHa: null, valeurs: new Map() };
const memoire = { amorce: false, demarrageHa: null, valeurs: new Map(), changes: new Map() };
// ALLERS-RETOURS BREFS (06/10/2026). Le service lit l'etat une fois par cycle
// (~75 s) : un debranchement-rebranchement de 20 s (echange VW -> JEEP a
// 19:52:36 / 19:52:56 le 06/10) tombe entre deux lectures, l'etat est le meme
// avant et apres, et AUCUN branchement n'etait vu — ni par l'etiquetage, ni par
// le concierge qui lit cette table. Le `last_changed` de HA, lui, avance : sur
// ces entites, s'il a bouge alors que l'etat est identique, on relit
// l'historique HA de l'intervalle et on journalise chaque transition a SON
// horodatage. C'est la signature d'un echange de vehicule.
const ALLERS_RETOURS_SUIVIS = new Set([
'binary_sensor.victron_evcs_vehicle_connected',
'switch.edf_general_ok_switch',
]);
async function transitionsCachees(id, depart, depuisIso, jusquaIso) {
const r = await fetch(
`${HA_URL}/api/history/period/${encodeURIComponent(depuisIso)}?end_time=` +
`${encodeURIComponent(new Date(Date.parse(jusquaIso) + 1000).toISOString())}` +
`&filter_entity_id=${id}&minimal_response&no_attributes`,
{ headers: { Authorization: `Bearer ${HA_TOKEN}` }, signal: AbortSignal.timeout(15000) },
);
if (!r.ok) throw new Error(`historique HA ${r.status}`);
const series = await r.json();
return transitionsDansSerie(series[0], depart, depuisIso, jusquaIso);
}
async function detecterEvenements(sql, st) {
if (!memoire.amorce) {
for (const [id] of TRANSITIONS_SUIVIES.map((t) => [t[0]])) {
const e = st.get(id);
if (e) memoire.valeurs.set(id, txt(e.state));
if (e) { memoire.valeurs.set(id, txt(e.state)); memoire.changes.set(id, e.last_changed); }
}
memoire.demarrageHa = demarrageHaMs(st);
memoire.amorce = true;
@@ -473,8 +501,30 @@ async function detecterEvenements(sql, st) {
// lecture. La consigner ferait deux lignes de bruit a chaque hoquet MQTT.
if (cur === '' || cur === 'unavailable' || cur === 'unknown') continue;
const prec = memoire.valeurs.get(id);
const changePrec = memoire.changes.get(id);
memoire.changes.set(id, e.last_changed);
if (prec === undefined) { memoire.valeurs.set(id, cur); continue; }
if (prec === cur) continue;
if (prec === cur) {
if (!ALLERS_RETOURS_SUIVIS.has(id) || !changePrec
|| !(Date.parse(e.last_changed) > Date.parse(changePrec))) continue;
let cachees = [];
try {
cachees = await transitionsCachees(id, prec, changePrec, e.last_changed);
} catch (err) {
console.log(` aller-retour ${id} : historique illisible (${err.message})`);
}
for (const tr of cachees) {
const quiC = await auteurDeLaTransition(id, tr.a);
const resumeC = `${libelle} : ${tr.avant} -> ${tr.apres} (aller-retour bref, retrouve dans l historique HA)`
+ (quiC ? (quiC.type === 'derivation' ? ` — derive de ${quiC.nom}` : ` — par ${quiC.nom}`) : '');
await journaliser(sql, genre, resumeC, {
entite: id, avant: tr.avant, apres: tr.apres, horodatage_ha: tr.a, aller_retour: true,
...(quiC ? { auteur: quiC.auteur, auteur_nom: quiC.nom, auteur_type: quiC.type, declencheur: quiC.declencheur } : {}),
}, tr.a);
n++;
}
continue;
}
const qui = await auteurDeLaTransition(id, e.last_changed);
// L auteur est colle au RESUME, pas seulement au detail : c est le resume
// que Grafana affiche en annotation sur tous les tableaux et dans les