concierge : autonomie dans les releves texte, compteur qui baisse refuse, datation au branchement, faux branchements, alerte disjoncteur ; fiche : boost batterie, disjoncteur

This commit is contained in:
2026-10-18 16:50:27 +02:00
parent da0bcaaa0e
commit 1f6b53701f
6 changed files with 297 additions and 7 deletions

View File

@@ -60,6 +60,7 @@ const ECS_HELPER = 'input_text.evcs_alerte_chauffe_eau';
const ECS_STATE = path.join(__dirname, 'concierge-alerte-ecs-state.json');
const WINDOW_DAYS = 5;
const FRESH_BEFORE_S = 600, FRESH_AFTER_S = 1800;
const BRANCHEMENT_FANTOME_S = 120;
// Transitions de connexion : clef `branchement` = ts::text VERBATIM (EVCS joint dessus,
// PAS horodatage_ha qui est antérieur de 3-51 s et ferait manquer le branchement en cours).
@@ -227,6 +228,13 @@ async function runDetection() {
if (!state) { saveJSON(STATE, { lastOn: latest.on }); return ''; } // bootstrap sans harceler
if (latest.on <= state.lastOn) return ''; // rien de nouveau
// 15/10/2026 : 01:30:29 → 01:30:30, branché 1 s avec un statut borne « Inconnu (-1) » = soubresaut de la borne,
// pas une voiture. Un branchement DÉJÀ terminé en moins de 2 min ne pose pas de question (tracé seulement).
if (latest.off !== null && latest.off - latest.on < BRANCHEMENT_FANTOME_S) {
saveJSON(STATE, { lastOn: latest.on });
trace(`branchement ${latest.on_text} terminé après ${Math.round(latest.off - latest.on)} s : soubresaut, pas de question`);
return '';
}
const jv = await jeepVerdict(latest.on);
if (jv.v === 'jeep') {
@@ -272,6 +280,9 @@ async function detect() {
let revue = '';
try { revue = await checkAlerteRevue(); } catch { /* idem : jamais bloquant */ }
if (revue) alert = [alert, revue].filter(Boolean).join('\n\n');
let disj = '';
try { disj = await checkDisjoncteur(); } catch { /* idem : jamais bloquant */ }
if (disj) alert = [alert, disj].filter(Boolean).join('\n\n');
let certs = '';
try { certs = await checkCertificats(); } catch { /* idem : jamais bloquant */ }
if (certs) alert = [alert, certs].filter(Boolean).join('\n\n');
@@ -347,16 +358,81 @@ async function contexteBranchement() {
return { on: latest.on, label, recent: Date.now() / 1000 - latest.on <= 3600 };
}
async function answerReleves(text, releves) {
// Autonomie électrique à 100 % du véhicule, lue dans HA (même référence que lecture-tableau.py) ; null si absente.
async function autonomieRef(vehicule) {
if (SIM) return SIM.autonomieRef?.[vehicule] ?? null;
try {
const v = Math.round(Number((await haState(`input_number.victron_evcs_autonomie_${vehicule.toLowerCase()}_km`)).state));
return v >= 10 && v <= 1000 ? v : null;
} catch { return null; }
}
// kWh livrés par la borne depuis `onEpoch` (compteur total, puis puissance vue, historique HA) ; null si illisible
// (→ pas de rattachement).
const COMPTEUR_BORNE = 'sensor.evcs_hq2239dnt7p_total_energy';
const PUISSANCE_BORNE = 'sensor.victron_evcs_ac_power';
async function energieDepuis(onEpoch) {
if (SIM) return SIM.energieLivree ?? 0;
try {
const debut = new Date(onEpoch * 1000).toISOString(), fin = new Date(onEpoch * 1000 + 120000).toISOString();
const r = await fetch(`${HA_BASE}/api/history/period/${debut}?end_time=${encodeURIComponent(fin)}&filter_entity_id=${COMPTEUR_BORNE}&minimal_response&no_attributes`,
{ headers: { Authorization: `Bearer ${haToken()}` } });
const avant = Number((await r.json())?.[0]?.[0]?.state);
const maintenant = Number((await haState(COMPTEUR_BORNE)).state);
if (!Number.isFinite(avant) || !Number.isFinite(maintenant)) return null;
const kwh = Math.round((maintenant - avant) * 10) / 10;
if (kwh > 0) return kwh;
// Le compteur remonte par paliers ou en retard (EVCS, 16/10) : second critère, une puissance de charge
// ≥ 500 W vue depuis le branchement compte comme énergie livrée.
const rp = await fetch(`${HA_BASE}/api/history/period/${debut}?filter_entity_id=${PUISSANCE_BORNE}&minimal_response&no_attributes`,
{ headers: { Authorization: `Bearer ${haToken()}` } });
const serie = (await rp.json())?.[0];
if (!Array.isArray(serie)) return null;
return serie.some((x) => Number(x.state) >= 500) ? 0.1 : 0;
} catch { return null; }
}
async function answerReleves(text, relevesLus) {
let releves = relevesLus;
const invalides = releves.filter(r => !Number.isInteger(r.km) || r.km <= 0 || r.km > 2000000 || Number.isNaN(r.niveau));
if (invalides.length) {
return `⚠️ Relevé NON noté : « ${invalides[0].segment.trim()} » n'est pas valide ` +
'(kilométrage entier, niveau entier de 0 à 100) — rien n\'a été écrit.';
}
// 18/10/2026 (EVCS, « VW 5km 217384 ») : 1) un petit « N km » ≤ autonomie de référence du véhicule, avec un
// grand nombre nu dans le message, = AUTONOMIE électrique : compteur = le grand nombre, niveau = N / référence
// (source « whatsapp_autonomie:N/réf », estimé). 2) Un compteur INFÉRIEUR au dernier relevé du véhicule n'est
// jamais noté : question en retour, rien n'est écrit (ni étiquette, ni niveau, ni relevé).
if (releves.length === 1 && releves[0].niveau === null && releves[0].km < 1000) {
const grands = [...String(text).replace(releves[0].segment, ' ').matchAll(/(?<![\d%])(\d{1,3}(?:[ .\u202f\u00a0]\d{3})+|\d{4,7})(?!\s*(?:%|\d))/g)]
.map((m) => Number(m[1].replace(/[ .\u202f\u00a0]/g, ''))).filter((n) => n >= 1000);
const ref = grands.length === 1 ? await autonomieRef(releves[0].vehicule) : null;
if (ref && releves[0].km <= ref * 1.2) {
const aut = releves[0].km;
releves = [{ ...releves[0], km: grands[0], niveau: Math.min(100, Math.round(aut * 100 / ref)),
source: `whatsapp_autonomie:${aut}/${ref}`, autonomie: aut, ref }];
} else if (grands.length === 1) {
return `⚠️ Rien n'est noté : je ne sais pas lequel est le compteur (${kmTexte(grands[0])} ou ${releves[0].km} km). ` +
`Redonne-le sous la forme « ${releves[0].vehicule} ${kmTexte(grands[0])} km 40 % ».`;
}
}
let file = [];
try { file = JSON.parse((await haState(W.RELEVE_ENTITY)).state || '[]'); } catch { /* vide */ }
for (const r of releves) {
const dernier = Math.max(0, ...file.filter((e) => Array.isArray(e) && e[0] === r.vehicule && Number.isInteger(e[1])).map((e) => e[1]));
if (dernier && r.km < dernier) {
return `⚠️ Rien n'est noté : ${r.vehicule} ${kmTexte(r.km)} km est inférieur au dernier relevé (${kmTexte(dernier)} km). ` +
(r.km < 1000 ? `Si ${r.km} km est l'autonomie, redonne aussi le compteur, par exemple « ${r.vehicule} ${r.km} km ${dernier + 50} ».`
: 'Vérifie le compteur et redonne-le.');
}
}
const ctx = releves.length === 1 ? await contexteBranchement() : null;
const r0 = releves[0];
let lignePrefixe = '', rattache = false;
if (ctx && ctx.label === r0.vehicule) rattache = true;
// 16/10/2026 (EVCS, cas VW 15/10 14:09 → relevé de 01:08 après 0,64 kWh) : un relevé n'est daté du branchement
// que s'il arrive dans l'heure ET qu'aucune énergie n'a été livrée depuis ; sinon il garde l'heure du message
// ou de la photo (même logique que la règle des 10 min pour un % photographié).
if (ctx && ctx.label === r0.vehicule) rattache = ctx.recent && (await energieDepuis(ctx.on)) === 0;
else if (ctx && !ctx.label && ctx.recent) {
// Branchement frais sans etiquette : le message vaut aussi confirmation
// (vehicule + niveau), exactement comme « JEEP 54 % ».
@@ -374,11 +450,9 @@ async function answerReleves(text, releves) {
const tsPhoto = releves.length === 1 && Number.isInteger(tsEnv) && tsEnv > maintenant - 2 * 86400 && tsEnv <= maintenant ? tsEnv : null;
const aEcrire = releves.map((r, i) => ({ vehicule: r.vehicule, km: r.km, niveau: r.niveau,
ts: i === 0 && rattache ? Math.round(ctx.on) : (i === 0 && tsPhoto) || maintenant,
...(srcAut && releves.length === 1 && r.niveau !== null ? { source: srcAut } : {}) }));
...(r.source ? { source: r.source } : srcAut && releves.length === 1 && r.niveau !== null ? { source: srcAut } : {}) }));
// Dedoublonnage : meme vehicule et meme km deja dans la file (message relance,
// ou compteur qui n'a pas bouge : un second releve identique n'apprend rien).
let file = [];
try { file = JSON.parse((await haState(W.RELEVE_ENTITY)).state || '[]'); } catch { /* vide */ }
// ou compteur qui n'a pas bouge : un second releve identique n'apprend rien). File lue plus haut.
// 12/10/2026 (EVCS) : doublon = même véhicule + même km ET (même niveau, ou nouveau sans niveau). Un relevé AVEC
// niveau là où l'existant n'en a pas est AJOUTÉ (son propre horodatage), sans remplacer l'ancien.
const nouveaux = aEcrire.filter(r => !file.some(e => Array.isArray(e) && e[0] === r.vehicule && e[1] === r.km &&
@@ -396,6 +470,8 @@ async function answerReleves(text, releves) {
const parties = aEcrire.filter((r) => ecrits.has(r)).map((r) => {
let p = `${r.vehicule} ${kmTexte(r.km)} km`;
p += r.niveau === null ? '' : `, niveau ${r.niveau} %`;
const ra = releves.find((x) => x.autonomie !== undefined && x.km === r.km);
if (ra && r.niveau !== null) p += ` (estimé : ${ra.autonomie} km d'autonomie sur ${ra.ref} km)`;
if (rattache && r === aEcrire[0]) p += ` (rattaché au branchement de ${hhmm(ctx.on)})`;
return p;
});
@@ -449,6 +525,8 @@ async function vehiculeNonConfirme() {
async function answer(text, { sansReleve = false } = {}) {
if (!sansReleve) {
// 18/10 : « VW 217384 5 km » → « VW 5 km 217384 » (compteur nu puis autonomie : même lecture que l'ordre inverse).
text = String(text).replace(/\b(vw|jeep|z[ée]ro|moto)\b(\s+)(\d{4,7}|\d{1,3}(?:[ .\u202f\u00a0]\d{3})+)\s+(\d{1,3})\s*km\b/i, '$1$2$4 km $3');
const releves = parseReleves(text);
if (releves.length) return await answerReleves(text, releves);
}
@@ -619,6 +697,27 @@ async function checkPiles() {
'\nPour une pause : réponds « je change dans N jours » (ou « d\'ici samedi »).';
}
// 17/10/2026, demande du propriétaire (via EVCS) : déclenchement du disjoncteur EDF sans ordre du système
// (fuite de courant, coupure). EVCS publie sensor.evcs_alerte_disjoncteur (état = id d'épisode ou « aucune »,
// attribut texte) ; on relaie « ⚡ texte » UNE fois par id, à TOUTE heure. Le nombre qui monte sous le même id ne
// renvoie rien. Épisode commencé il y a plus de 2 h (redémarrage, mise en service) → noté sans envoi.
const DISJ = 'sensor.evcs_alerte_disjoncteur';
const DISJ_STATE = path.join(__dirname, 'concierge-alerte-disjoncteur-state.json');
async function checkDisjoncteur() {
if (fs.existsSync(ALERT_KILL)) return '';
let s;
if (SIM) { if (!SIM.disj) return ''; s = SIM.disj; }
else { try { s = await haState(DISJ); } catch { return ''; } }
const id = s?.state, a = s?.attributes || {};
if (!id || ['aucune', 'unknown', 'unavailable'].includes(id) || !a.texte) return '';
const st = SIM ? (SIM.disjState || {}) : loadJSON(DISJ_STATE, {});
if (st.lastId === id) return '';
saveJSON(DISJ_STATE, { lastId: id });
const age = (SIM?.maintenant ?? Date.now()) - Date.parse(a.debut || '');
if (!(age <= 2 * 3600 * 1000)) return '';
return `⚡ ${String(a.texte).trim().slice(0, 400)}`;
}
// 13/10/2026, demande du propriétaire (via EVCS) : certificats HTTPS du reverse proxy. Même logique que les piles :
// alertes NOUVELLES (clé domaine|statut) regroupées, de 08:00 à 20:59 ; une alerte urgente (expiré, ou ≤ 1 jour)
// part aussi jusqu'à 22:59. On ajoute le geste à faire (dernière ligne du `texte` d'EVCS).