Compare commits
2 Commits
ee1ca8aef7
...
b882d3fcbe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b882d3fcbe | ||
|
|
0fe24ff950 |
80
packages/evcs_historisation.yaml
Executable file
80
packages/evcs_historisation.yaml
Executable 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.
|
||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user