From 0fe24ff950333720b710f4f3e2b768006c72f691 Mon Sep 17 00:00:00 2001 From: Tallagrand Thierry Date: Wed, 16 Sep 2026 08:58:18 +0200 Subject: [PATCH] =?UTF-8?q?Battement=20de=20coeur=20du=20service=20?= =?UTF-8?q?=E2=80=94=20et=20le=20test=20a=20trouve=20que=20le=20service=20?= =?UTF-8?q?MOURAIT=20dans=20la=20panne=20qu'il=20doit=20signaler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Valide par le proprietaire le 16/09. Le service publie desormais sensor.evcs_historisation_heartbeat a chaque cycle : etat = horodatage ISO 8601 UTC, attributs statut (ok|degrade), base_joignable, cycles_en_echec, dernier_echec. Forme figee avec la session OpenClaw pour que les deux battements du systeme soient identiques. POURQUOI UN CAPTEUR DEDIE. 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, trois entites sur cinq paraissaient figees. La vivacite se lit donc sur ce capteur seul, dont l'etat est un horodatage : il change forcement a chaque ecriture. L'appel est HORS du try/catch du cycle, dans les deux branches. C'est tout le correctif : base morte, le battement part quand meme en portant degrade et la cause. Avant, un cycle en echec n'ecrivait que sur console.error, et l'incident n'etait journalise qu'au RETOUR a la normale — dans PostgreSQL, donc jamais quand c'est PostgreSQL qui est tombe. ⚠️ CE QUE LE TEST A TROUVE, ET QUI RENDAIT LE BATTEMENT INUTILE. Eprouve sur une base JETABLE derriere un relais TCP coupable a volonte (pas sur la production : deux instances sur la meme base double-compteraient l'energie des sessions, le UPDATE energie_kwh = energie_kwh + ... n'est pas idempotent). Premier essai : le service a CRASHE a la coupure. Le client pg emet un evenement 'error' sur le socket ; sans ecouteur, c'est une exception non capturee qui ne passe PAS par le try/catch du cycle — il n'attend qu'une promesse — et qui tue le processus. Le service mourait donc exactement dans la panne que le battement existe pour signaler. Trois correctifs qui en decoulent : un ecouteur 'error' qui marque la connexion morte au lieu de faire tomber le processus ; une reconnexion, un client pg n'etant pas reutilisable apres erreur (sans elle le service resterait vivant mais definitivement muet) ; et une connexion initiale non fatale, pour qu'une base absente au demarrage ne tue pas le service — un service mort ne signale rien. Sequence complete verifiee : base vivante -> ok ; coupure -> service VIVANT, degrade, base_joignable false, cycles_en_echec 2, cause « connect ECONNREFUSED » ; retour -> reconnexion automatique, ok, et l'episode journalise avec sa duree. Le message de reprise disait « Home Assistant injoignable » quelle que soit la cause ; il nomme desormais ce qui a reellement echoue. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GwpFpy2aQ2rJok6tNQSbVj --- tools/historise-evcs.mjs | 138 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 131 insertions(+), 7 deletions(-) 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)); }