Compare commits

...

2 Commits

Author SHA1 Message Date
Tallagrand Thierry
b882d3fcbe Detecteur de silence cote Home Assistant, eprouve sur ses deux modes de panne
binary_sensor.historisation_evcs_muette + sensor de l age du battement.
Bascule a 'on' dans les deux cas : statut degrade (le service vit mais ses
cycles echouent, la base typiquement) et battement plus vieux que 5 minutes
(le service ne publie plus du tout). Cinq minutes = cinq cycles manques.

Verifie en simulant les deux pannes sur le capteur source : degrade+frais -> on,
ok+vieux de 10 min -> on avec age=10, puis retour a off en 18 s des que le
service de production a repris la main. Un detecteur qu on n a pas vu detecter
n est pas un detecteur.

⚠️ Toute automatisation qui alerte sur ce drapeau doit porter for: 00:05:00 :
le capteur source est cree par l API, il n existe donc pas pendant une minute au
plus apres un redemarrage de Home Assistant, et le drapeau passerait a on
brievement a chaque fois.

Le paquet porte aussi la raison pour laquelle un capteur DEDIE est necessaire
la ou la fraicheur d une entite metier ne dit rien : last_reported == last_changed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GwpFpy2aQ2rJok6tNQSbVj
2026-09-16 09:01:57 +02:00
Tallagrand Thierry
0fe24ff950 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
2026-09-16 08:58:18 +02:00
2 changed files with 211 additions and 7 deletions

View File

@@ -0,0 +1,80 @@
# Detection du silence du service d'historisation (10.10.10.22)
# ---------------------------------------------------------------------------
# Pose le 16/09/2026, sur validation du proprietaire.
#
# POURQUOI CE PAQUET EXISTE. Jusqu'ici, un cycle du service en echec n'ecrivait
# que sur console.error — le journal du conteneur, que personne ne lit — et
# l'incident n'etait journalise qu'au RETOUR a la normale, dans PostgreSQL,
# c'est-a-dire jamais quand c'est PostgreSQL qui est tombe. Base injoignable :
# plus de mesures, plus d'etiquetage, et le rapport de nuit de 06:47 ne partait
# pas, sans qu'un mot ne le dise.
#
# ⚠️ 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.
# Releve le meme jour sur une installation parfaitement saine :
# vehicle_connected inchange depuis 752 min, night_target_soc depuis 194,
# source_mode depuis 139 — trois faux positifs sur cinq entites.
# La vivacite se lit donc sur le battement SEUL, dont l'etat est un horodatage :
# il change forcement a chaque ecriture. Les entites metier se lisent pour leur
# VALEUR, jamais pour leur fraicheur.
#
# La source `sensor.evcs_historisation_heartbeat` est creee par l'API a chaque
# cycle : elle disparait au redemarrage de Home Assistant et se recree dans la
# minute. D'ou le `for:` obligatoire sur toute automatisation (voir plus bas).
template:
- sensor:
- name: "Historisation EVCS — âge du battement"
unique_id: evcs_historisation_age_min
unit_of_measurement: "min"
state: >
{% set t = states('sensor.evcs_historisation_heartbeat') %}
{% if t not in ['unknown','unavailable',''] %}
{{ ((as_timestamp(now()) - as_timestamp(t, 0)) / 60) | round(0) }}
{% else %}unknown{% endif %}
- binary_sensor:
# Le service tourne toutes les 60 s. Cinq minutes, c'est cinq cycles
# manques : assez long pour ignorer un hoquet, assez court pour qu'une
# panne de nuit ne coute pas une journee de mesures.
#
# Deux pannes distinctes, un seul drapeau :
# - MUET : le service ne publie plus du tout (processus mort,
# conteneur arrete, NAS eteint, reseau coupe) ;
# - DEGRADE : le service vit mais ses cycles echouent — typiquement la
# base injoignable. Le battement porte alors la cause.
- name: "Historisation EVCS — muette"
unique_id: evcs_historisation_muette
device_class: problem
state: >
{% set t = states('sensor.evcs_historisation_heartbeat') %}
{% if t in ['unknown','unavailable',''] %}on
{% elif state_attr('sensor.evcs_historisation_heartbeat','statut') == 'degrade' %}on
{% else %}
{{ (as_timestamp(now()) - as_timestamp(t, 0)) > 300 }}
{% endif %}
attributes:
statut: >
{% set t = states('sensor.evcs_historisation_heartbeat') %}
{% if t in ['unknown','unavailable',''] %}muet
{% else %}{{ state_attr('sensor.evcs_historisation_heartbeat','statut') or 'inconnu' }}{% endif %}
cause: >
{{ state_attr('sensor.evcs_historisation_heartbeat','dernier_echec') or '' }}
cycles_en_echec: >
{{ state_attr('sensor.evcs_historisation_heartbeat','cycles_en_echec') or 0 }}
# ⚠️ TOUTE AUTOMATISATION QUI ALERTE SUR CE DRAPEAU DOIT PORTER
# for: "00:05:00"
# Le capteur source est cree par l'API : au redemarrage de Home Assistant il
# n'existe pas pendant une minute au plus, et le drapeau passe donc a `on`
# brievement a chaque redemarrage. Sans le `for:`, chaque redemarrage produirait
# une fausse alerte.
#
# ⚠️ CE QUE CE DETECTEUR NE COUVRE PAS : si 10.10.10.17 tombe, Home Assistant
# meurt avec, et ce paquet avec lui. Cette direction-la est couverte dans
# l'autre sens par le chien de garde qui tourne sur 10.10.10.22 et surveille
# n8n. Chaque machine surveille l'autre, aucune ne se surveille elle-meme ;
# aucune des deux briques n'est complete seule.

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));
} }