Battement de coeur du service — et le test a trouve que le service MOURAIT dans la panne qu'il doit signaler

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GwpFpy2aQ2rJok6tNQSbVj
This commit is contained in:
Tallagrand Thierry
2026-09-16 08:58:18 +02:00
parent ee1ca8aef7
commit 0fe24ff950

View File

@@ -1440,6 +1440,55 @@ async function publierPrevisionContreReel(client) {
return rows.length; 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) { async function cycle(client) {
// En blanc, on compte ce qu'on ECRIRAIT. Un essai a blanc qui renvoie // 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 // 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', host: process.env.PGHOST || '10.10.10.22',
port: Number(process.env.PGPORT || 5432), port: Number(process.env.PGPORT || 5432),
user: process.env.PGUSER || 'evcs', user: process.env.PGUSER || 'evcs',
database: process.env.PGDATABASE || 'evcs', database: process.env.PGDATABASE || 'evcs',
password: process.env.PGPASSWORD, 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) { if (!DRY) {
await client.connect(); // Un echec ici n'est plus fatal : la boucle reessaiera, et le battement
await migrer(client); // schema a jour avant le premier cycle // dira `degrade` en attendant.
try {
await assurerConnexion();
} catch (e) {
console.error(`${new Date().toISOString()} base injoignable au demarrage : ${e.message}`);
}
} }
console.log( 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` `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 // journalise au RETOUR, avec sa duree — au moment de la panne, la base n'est
// peut-etre pas joignable non plus. // peut-etre pas joignable non plus.
let echecDepuis = null; let echecDepuis = null;
let cyclesEnEchec = 0;
let dernierEchec = '';
for (;;) { for (;;) {
try { try {
if (!DRY) await assurerConnexion();
await cycle(client); await cycle(client);
if (echecDepuis !== null) { if (echecDepuis !== null) {
const duree = Math.round((Date.now() - echecDepuis) / 1000); const duree = Math.round((Date.now() - echecDepuis) / 1000);
try { 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', await journaliser(client.query.bind(client), 'systeme',
`Home Assistant injoignable pendant ${duree} s`, `Cycles en echec pendant ${duree} s : ${dernierEchec || 'cause inconnue'}`,
{ depuis: new Date(echecDepuis).toISOString(), secondes: duree }); { 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 */ } } catch { /* la base non plus n'etait peut-etre pas la : on n'insiste pas */ }
echecDepuis = null; echecDepuis = null;
} }
cyclesEnEchec = 0;
dernierEchec = '';
} catch (e) { } catch (e) {
if (echecDepuis === null) echecDepuis = Date.now(); 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}`); 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)); await new Promise((r) => setTimeout(r, INTERVAL_S * 1000));
} }