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:
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user