// ===================================================================== // PILES DES CAPTEURS (10/10/2026, demande du proprietaire) // Le capteur temperature/humidite de la SDB est reste muet six jours (piles) // sans que personne ne le sache. Ce module repere, parmi les etats HA, les // capteurs d'APPAREILS a pile dont la pile est faible ou qui ne publient plus ; // le service d'historisation publie sensor.evcs_piles et SPARK previent par // WhatsApp. Fonction pure (tests : tests/piles/run.mjs). // ===================================================================== export const SEUIL_PCT = 20; // pile faible a 20 % et moins // HYSTERESIS (13/10) : une alerte ne retombe qu'a 40 % et plus (en pratique : un // changement de piles). GRENIER 13 -> 19 -> 6 % et Chambre Gerard ont fait // partir 5 messages le 13/10, l'alerte disparaissant puis revenant autour de 20 %. export const SEUIL_RETOUR_PCT = 40; export const MUET_H = 24; // indisponible depuis plus de 24 h // Pas des piles de capteurs : parc Victron, voiture, telephones, reglages GX. const EXCLUS = /^(sensor|number|binary_sensor)\.(victron_|gx_device_|airtria_8_|jeep_|iphone_|ipad_|samsung_)/; /** * @param {Array} etats liste des etats HA ({entity_id, state, attributes, last_changed}) * @param {number} maintenantMs * @param {string[]} ignorees entites que le proprietaire a demande d'ignorer */ // ACQUITTEMENT (13/10, demande du proprietaire) : « j'ai compris, je change les // piles dans N jours » -> plus de rappel pour cet appareil jusqu'a la date dite. // input_text.evcs_piles_acquittees = « cle:AAAA-MM-JJ,cle:AAAA-MM-JJ », cle = // entite sans « sensor. » ; valable jusqu'a la fin de ce jour (heure de Paris). // Ecrit par le concierge (SPARK) ; une cle d'un appareil qui n'est plus en // alerte (piles changees) est simplement sans effet. export function lireAcquittements(texte) { const out = {}; for (const part of String(texte || '').split(',')) { const m = /^\s*([a-z0-9_]+)\s*:\s*(\d{4}-\d{2}-\d{2})\s*$/.exec(part); if (!m) continue; const fin = Date.parse(`${m[2]}T23:59:59+02:00`); if (Number.isFinite(fin)) out[`sensor.${m[1]}`] = { jusquAu: m[2], finMs: fin }; } return out; } /** * @param {object} opts precedentes : entites en alerte au passage precedent (hysteresis) * acquittements : resultat de lireAcquittements */ export function pilesAAlerter(etats, maintenantMs, ignorees = [], opts = {}) { const ign = new Set(ignorees.map((x) => x.trim()).filter(Boolean)); const prec = new Set(opts.precedentes || []); const acq = opts.acquittements || {}; const alertes = []; for (const s of etats) { const id = s.entity_id; if (!id.startsWith('sensor.') || EXCLUS.test(id) || ign.has(id)) continue; const a = s.attributes || {}; const enPct = a.device_class === 'battery' && (a.unit_of_measurement === '%' || a.unit_of_measurement === undefined); const enEnum = /_etat_batterie$/.test(id); if (!enPct && !enEnum) continue; const nom = String(a.friendly_name || id).replace(/\s+/g, ' ').trim(); const v = String(s.state ?? '').trim().toLowerCase(); const depuisMs = Date.parse(s.last_changed); if (v === 'unavailable' || v === 'unknown' || v === '') { const h = Number.isFinite(depuisMs) ? (maintenantMs - depuisMs) / 3600000 : Infinity; if (h > MUET_H) alertes.push({ entite: id, nom, statut: 'muet', niveau: null, depuis: s.last_changed, texte: `${nom} ne publie plus depuis ${Number.isFinite(h) ? Math.round(h / 24) + ' j' : 'longtemps'} (pile vide ou appareil hors ligne)` }); continue; } if (enPct) { const n = Number(v); if (Number.isFinite(n) && (n <= SEUIL_PCT || (prec.has(id) && n < SEUIL_RETOUR_PCT))) alertes.push({ entite: id, nom, statut: 'faible', niveau: n, depuis: s.last_changed, texte: `${nom} : pile a ${n} %` }); } else if (v === 'low' || (v === 'middle' && prec.has(id))) { // Meme hysteresis pour les etats : Chambre Gerard battait entre low et // middle le 13/10 (deux messages). L'alerte ne retombe qu'a « high ». alertes.push({ entite: id, nom, statut: 'faible', niveau: 'low', depuis: s.last_changed, texte: `${nom} : pile faible` }); } } alertes.sort((x, y) => (x.statut === y.statut ? x.nom.localeCompare(y.nom) : x.statut === 'faible' ? -1 : 1)); for (const a of alertes) { const k = acq[a.entite]; a.acquittee = !!(k && k.finMs >= maintenantMs); if (a.acquittee) a.jusqu_au = k.jusquAu; a.cle = a.entite.replace(/^sensor\./, ''); } const aRappeler = alertes.filter((a) => !a.acquittee); const acquittees = alertes.filter((a) => a.acquittee); const jour = (iso) => iso.split('-').reverse().slice(0, 2).join('/'); return { alertes, a_rappeler: aRappeler, acquittees, texte: !alertes.length ? 'Toutes les piles des capteurs sont bonnes.' : [...aRappeler.map((x) => `- ${x.texte}`), ...(acquittees.length ? [`Deja vues, pas de rappel avant la date dite : ${acquittees.map((x) => `${x.nom} (jusqu'au ${jour(x.jusqu_au)})`).join(', ')}.`] : [])].join('\n'), }; }