Detection anomalie 'branchee mais ne charge pas' (je detecte, SPARK envoie)

Grief du proprietaire du 19/09 : JEEP branchee, du soleil, borne figee apres un
swap rapide -> rien qui charge, et il ne l'a su qu'en regardant. Le service
d'historisation detecte desormais l'episode et le SIGNALE dans un helper ;
la session OpenClaw lira ce helper et enverra l'alerte WhatsApp (son cote).

Critere, robuste par la censure sans injection : batterie pleine => MPPT bride
=> PV ~ conso => excedent ~ 0 => PAS d'alerte. Un excedent POSITIF qui persiste
(> 500 W) avec vehicule branche + borne < 300 W + etat controleur bloquant,
tenu > 10 min => il manque un preneur a de l'energie gratuite => signalement.
Un seul par episode (dedup sur son debut), helper efface a la resolution.

- helper input_text.evcs_alerte_charge (max 255, sans initial:), reload fait.
- detecterAnomalieCharge() + ecrireHelperTexte(), appele dans cycle().
- etat d'episode en processus (repart de null au redemarrage, se re-arme en 10
  min sur une anomalie reelle).
⚠️ SEUILS A CALIBRER sur l'historique avant d'etre figes. Le helper reste
INERTE tant que SPARK n'a pas cable l'envoi : deployer maintenant ne fait rien
partir.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GwpFpy2aQ2rJok6tNQSbVj
This commit is contained in:
Tallagrand Thierry
2026-09-19 18:35:38 +02:00
parent 51a3660983
commit 2d2ced54a1

View File

@@ -1090,6 +1090,14 @@ const CANAUX_HUMAINS = new Set(['whatsapp', 'proprietaire']);
const LABELS = new Set(['VW', 'JEEP', 'MEGANE', 'INVITE', 'ZERO']); const LABELS = new Set(['VW', 'JEEP', 'MEGANE', 'INVITE', 'ZERO']);
let dernierBlancVu = null; // anti-repetition du journal en essai a blanc let dernierBlancVu = null; // anti-repetition du journal en essai a blanc
// Detection d'anomalie "branchee mais ne charge pas" (19/09/2026). Etat
// d'episode EN PROCESSUS : instant ou la condition a commence a tenir, null
// sinon. Sur un redemarrage il repart de null — une anomalie reelle et
// persistante se re-arme en ANOMALIE_CHARGE_MIN. On garde aussi l'horodatage
// du dernier episode SIGNALE pour ne journaliser qu'une fois par episode.
let anomalieChargeDepuis = null;
let anomalieChargeSignalee = null;
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// LA REPONSE DU PROPRIETAIRE PILOTE LE SELECTEUR // LA REPONSE DU PROPRIETAIRE PILOTE LE SELECTEUR
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
@@ -1691,6 +1699,92 @@ async function appliquerOrdreAirtria(sql, client, st) {
return 1; return 1;
} }
// ---------------------------------------------------------------------
// ANOMALIE "branchee mais ne charge pas" (19/09/2026)
// ---------------------------------------------------------------------
// Grief du proprietaire : la JEEP branchee, du soleil dispo, et rien qui
// charge (borne figee apres un swap trop rapide). Je DETECTE, la session
// OpenClaw (SPARK) ENVOIE l'alerte WhatsApp en lisant le helper.
//
// Critere, et pourquoi il ne se declenche PAS sur une voiture vraiment pleine :
// sans injection reseau, une batterie pleine fait BRIDER le MPPT (censure) donc
// PV ~ conso donc excedent ~ 0 -> pas d'alerte. Un excedent POSITIF qui PERSISTE
// avec voiture branchee et borne a ~0 W signifie qu'il manque un preneur a de
// l'energie gratuite : ca, ca vaut un signalement.
// - vehicule branche
// - borne ac_power < SEUIL_CHARGE_W (ne charge pas)
// - etat controleur bloquant (source_mode "Bloque" OU surplus_state d'attente)
// - excedent solaire (pv - conso) > ANOMALIE_CHARGE_SURPLUS_W
// - tenu >= ANOMALIE_CHARGE_MIN
// ⚠️ SEUILS A CALIBRER sur l'historique avant d'etre figes (lecon des
// oscillateurs) — valeurs de depart conservatrices. Un seul signalement par
// episode. Le helper reste INERTE tant que SPARK n'a pas cable l'envoi.
const ANOMALIE_CHARGE_MIN = 10; // minutes
const ANOMALIE_CHARGE_SURPLUS_W = 500; // excedent solaire minimal
const ETATS_BLOQUANTS = new Set([
'day_vehicle_full_idle', 'idle_wait_conditions', 'idle_wait_source',
'stop_source_lost', 'night_session_done_idle', 'night_recovery_done_idle',
]);
async function detecterAnomalieCharge(sql, st) {
const g = (id) => st.get(id);
const connecte = txt(g('binary_sensor.victron_evcs_vehicle_connected')?.state) === 'on';
const acPower = num(g('sensor.victron_evcs_ac_power')?.state) ?? 0;
const pv = num(g('sensor.victron_pv_power_system')?.state) ?? 0;
const conso = num(g('sensor.victron_consumption_power_l1')?.state) ?? 0;
const excedent = pv - conso; // sans injection, ~ puissance batterie au signe pres
const sourceMode = txt(g('input_text.victron_evcs_source_mode')?.state) || '';
const surplusState = (txt(g('input_text.victron_evcs_surplus_state')?.state) || '').toLowerCase();
const bloquant = sourceMode.startsWith('Bloqu') || ETATS_BLOQUANTS.has(surplusState);
const condition = connecte && acPower < SEUIL_CHARGE_W && bloquant
&& excedent > ANOMALIE_CHARGE_SURPLUS_W;
const now = Date.now();
if (!condition) {
// fin d'episode : on efface le helper si un episode etait actif
if (anomalieChargeDepuis !== null) {
anomalieChargeDepuis = null;
await ecrireHelperTexte('input_text.evcs_alerte_charge', '');
}
return 0;
}
if (anomalieChargeDepuis === null) anomalieChargeDepuis = now;
const tenuMin = (now - anomalieChargeDepuis) / 60000;
if (tenuMin < ANOMALIE_CHARGE_MIN) return 0;
// Episode confirme. Un seul signalement par episode (clef = son debut).
if (anomalieChargeSignalee === anomalieChargeDepuis) return 0;
anomalieChargeSignalee = anomalieChargeDepuis;
const since = new Date(anomalieChargeDepuis).toISOString();
const message = `Vehicule branche depuis ${Math.round(tenuMin)} min, `
+ `${Math.round(excedent)} W de soleil dispo, mais la borne ne charge pas `
+ `(${sourceMode || surplusState}). Verifier la borne / rebrancher.`;
await ecrireHelperTexte('input_text.evcs_alerte_charge',
JSON.stringify({ active: true, since, excedent_w: Math.round(excedent),
source_mode: sourceMode, surplus_state: surplusState, message }));
await journaliser(sql, 'anomalie',
`Branchee mais ne charge pas : ${Math.round(excedent)} W de soleil perdu depuis ${Math.round(tenuMin)} min`,
{ since, excedent_w: Math.round(excedent), ac_power_w: acPower,
source_mode: sourceMode, surplus_state: surplusState });
return 1;
}
// Petit ecrivain de helper texte, reutilise par la detection d'anomalie.
async function ecrireHelperTexte(entite, valeur) {
if (DRY) return;
try {
await fetch(`${HA_URL}/api/services/input_text/set_value`, {
method: 'POST',
headers: { Authorization: `Bearer ${HA_TOKEN}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ entity_id: entite, value: valeur }),
signal: AbortSignal.timeout(8000),
});
} catch { /* le helper reste a sa valeur, on reessaiera au prochain cycle */ }
}
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
@@ -1712,6 +1806,9 @@ async function cycle(client) {
prevision: await ecrirePrevision(sql, st), prevision: await ecrirePrevision(sql, st),
session: await ecrireSession(sql, client, st), session: await ecrireSession(sql, client, st),
evenement: await detecterEvenements(sql, st), evenement: await detecterEvenements(sql, st),
anomalie: await detecterAnomalieCharge(sql, st).catch((e) => {
console.error(`detection anomalie charge en echec : ${e.message}`); return 0;
}),
etiquette: await appliquerEtiquetteConcierge(sql, client, st).catch((e) => { etiquette: await appliquerEtiquetteConcierge(sql, client, st).catch((e) => {
console.error(`etiquette concierge en echec : ${e.message}`); return 0; console.error(`etiquette concierge en echec : ${e.message}`); return 0;
}) + await appliquerEtiquettesEnAttente(sql, client).catch((e) => { }) + await appliquerEtiquettesEnAttente(sql, client).catch((e) => {