diff --git a/tools/historise-evcs.mjs b/tools/historise-evcs.mjs index 3419d94..5a9dbe0 100644 --- a/tools/historise-evcs.mjs +++ b/tools/historise-evcs.mjs @@ -1440,6 +1440,55 @@ async function publierPrevisionContreReel(client) { return rows.length; } +// --------------------------------------------------------------------- +// BATTEMENT DE COEUR — la seule entite dont l immobilite soit interpretable +// --------------------------------------------------------------------- +// Defaut que ceci corrige, et il etait beant : un cycle en echec n ecrivait que +// sur console.error, c est-a-dire dans le journal du conteneur que personne ne +// lit. Et l incident n etait journalise qu au RETOUR a la normale, dans +// PostgreSQL — donc precisement la ou l on ne peut pas ecrire quand c est +// PostgreSQL qui est tombe. Base injoignable : chaque cycle echouait en silence +// indefiniment, et le rapport de 06:47 n arrivait jamais sans un mot. +// +// ⚠️ POURQUOI UN CAPTEUR DEDIE ET PAS LA FRAICHEUR D UNE ENTITE METIER. +// Mesure du 16/09 : Home Assistant ne materialise PAS une ecriture identique, et +// `last_reported` — le champ introduit en 2024 pour marquer chaque remontee +// d etat — est rigoureusement EGAL a `last_changed`. Aucun champ de l API ne +// distingue donc un ecrivain mort d un ecrivain qui republie la meme valeur. +// Sur une installation saine, `vehicle_connected` etait inchange depuis 752 min +// (voiture branchee la veille), `source_mode` depuis 139 min (voiture pleine) : +// trois faux positifs sur cinq entites si l on lit la fraicheur comme une panne. +// La vivacite se lit donc ICI et nulle part ailleurs, parce que l etat de ce +// capteur est un HORODATAGE — il change forcement a chaque ecriture. +// +// ⚠️ ET L APPEL SE FAIT HORS DU try/catch DU CYCLE. C est tout le correctif : +// si la base tombe, le battement part quand meme, en portant `degrade` et la +// cause. Un battement qui ne survit pas a la panne qu il doit signaler ne sert +// a rien. +// +// Forme figee avec la session OpenClaw le 16/09, pour que les deux battements +// du systeme soient identiques (le sien : sensor.concierge_heartbeat). +async function publierBattement({ statut, baseJoignable, cyclesEnEchec, dernierEchec }) { + const corps = { + state: new Date().toISOString(), + attributes: { + friendly_name: 'Historisation EVCS — battement de coeur', + icon: statut === 'ok' ? 'mdi:heart-pulse' : 'mdi:heart-broken', + statut, + base_joignable: baseJoignable, + cycles_en_echec: cyclesEnEchec, + dernier_echec: dernierEchec || '', + }, + }; + const r = await fetch(`${HA_URL}/api/states/sensor.evcs_historisation_heartbeat`, { + method: 'POST', + headers: { Authorization: `Bearer ${HA_TOKEN}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(corps), + signal: AbortSignal.timeout(8000), + }); + if (!r.ok) throw new Error(`HTTP ${r.status}`); +} + async function cycle(client) { // En blanc, on compte ce qu'on ECRIRAIT. Un essai a blanc qui renvoie // toujours zero ne verifie rien — c'est le piege classique du test qui ne @@ -1493,20 +1542,64 @@ async function cycle(client) { } } -const client = new pg.Client({ +// --------------------------------------------------------------------- +// CONNEXION A LA BASE — resiliente, et ce n'est pas du luxe +// --------------------------------------------------------------------- +// ⚠️ DEFAUT MESURE LE 16/09, en eprouvant le battement de coeur : quand la +// connexion PostgreSQL tombe EN COURS DE ROUTE, le client `pg` emet un +// evenement 'error' sur le socket. Un 'error' sans ecouteur est une exception +// NON CAPTUREE — elle ne passe pas par le try/catch du cycle, qui n'attend +// qu'une promesse, et elle TUE LE PROCESSUS. Le service mourait donc +// exactement dans la panne que le battement existe pour signaler. +// +// Deux consequences, toutes deux traitees ici : +// 1. un ecouteur 'error' obligatoire, qui marque la connexion morte au lieu +// de faire tomber le processus ; +// 2. une reconnexion, car un client `pg` n'est PAS reutilisable apres erreur : +// sans cela le service resterait vivant mais definitivement muet, et il +// faudrait un redemarrage a la main pour qu'il reparte quand la base +// revient. +// +// Et la connexion initiale ne doit plus etre fatale : base absente au demarrage, +// le service tourne quand meme et publie `degrade`. Un service qui meurt ne +// peut rien signaler. +const CONFIG_PG = { 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, -}); +}; + +let client = null; +let clientMort = true; +let schemaFait = false; + +async function assurerConnexion() { + if (client && !clientMort) return; + if (client) { try { await client.end(); } catch { /* deja morte */ } client = null; } + const c = new pg.Client(CONFIG_PG); + c.on('error', (e) => { + clientMort = true; + console.error(`${new Date().toISOString()} connexion base perdue : ${e.message}`); + }); + await c.connect(); + if (!schemaFait) { await migrer(c); schemaFait = true; } + client = c; + clientMort = false; +} if (!DRY) { - await client.connect(); - await migrer(client); // schema a jour avant le premier cycle + // Un echec ici n'est plus fatal : la boucle reessaiera, et le battement + // dira `degrade` en attendant. + try { + await assurerConnexion(); + } catch (e) { + console.error(`${new Date().toISOString()} base injoignable au demarrage : ${e.message}`); + } } console.log( - `Historisation EVCS — HA=${HA_URL} base=${DRY ? '(blanc)' : client.host + ':' + client.port} ` + + `Historisation EVCS — HA=${HA_URL} base=${DRY ? '(blanc)' : CONFIG_PG.host + ':' + CONFIG_PG.port} ` + `intervalle=${INTERVAL_S}s` ); @@ -1523,22 +1616,53 @@ if (ONCE) { // journalise au RETOUR, avec sa duree — au moment de la panne, la base n'est // peut-etre pas joignable non plus. let echecDepuis = null; +let cyclesEnEchec = 0; +let dernierEchec = ''; for (;;) { try { + if (!DRY) await assurerConnexion(); await cycle(client); if (echecDepuis !== null) { const duree = Math.round((Date.now() - echecDepuis) / 1000); try { + // Le message disait « Home Assistant injoignable » quelle que soit la + // cause. Depuis que la base peut tomber sans tuer le service, c'est + // souvent faux : on nomme ce qui a reellement echoue. await journaliser(client.query.bind(client), 'systeme', - `Home Assistant injoignable pendant ${duree} s`, - { depuis: new Date(echecDepuis).toISOString(), secondes: duree }); + `Cycles en echec pendant ${duree} s : ${dernierEchec || 'cause inconnue'}`, + { depuis: new Date(echecDepuis).toISOString(), secondes: duree, + cycles: cyclesEnEchec, cause: dernierEchec }); } catch { /* la base non plus n'etait peut-etre pas la : on n'insiste pas */ } echecDepuis = null; } + cyclesEnEchec = 0; + dernierEchec = ''; } catch (e) { if (echecDepuis === null) echecDepuis = Date.now(); + cyclesEnEchec += 1; + dernierEchec = String(e.message).slice(0, 200); console.error(`${new Date().toISOString()} cycle en echec : ${e.message}`); } + + // HORS du try/catch ci-dessus, et volontairement : c'est ce qui fait que la + // panne la plus silencieuse — base morte, cycle avorte a chaque passe — + // devient visible. Son propre echec ne doit pas tuer la boucle non plus. + if (!DRY) { + try { + await publierBattement({ + statut: cyclesEnEchec === 0 ? 'ok' : 'degrade', + baseJoignable: cyclesEnEchec === 0, + cyclesEnEchec, + dernierEchec, + }); + } catch (e) { + // Home Assistant injoignable : il ne reste que le journal du conteneur. + // C'est la seule panne que ce mecanisme ne peut pas signaler lui-meme — + // elle est couverte dans l'autre sens par le chien de garde sur .22. + console.error(`${new Date().toISOString()} battement non publie : ${e.message}`); + } + } + await new Promise((r) => setTimeout(r, INTERVAL_S * 1000)); }