#!/usr/bin/env node // piles-acquitter.js — ACQUITTEMENT d'une alerte de piles (14/10/2026, « GO piles », contrat d'EVCS commit ac1d006). // Thierry répond à un message « 🔋 » par « je change dans 5 jours » / « d'ici samedi » : pas de rappel avant cette date. // node piles-acquitter.js [appareil...] // délai : N (jours), « demain », un jour de la semaine (« samedi » = le prochain), ou JJ/MM // appareil : un mot du nom ou de la clé (« grenier », « sylvie ») ; AUCUN = toutes les alertes du DERNIER message // Seule écriture : input_text.evcs_piles_acquittees = « AAAA-MM-JJ:cle|cle,… » (≤ 255 car.), groupes échus retirés // d'abord, relecture après écriture. Le délai vient de Thierry, jamais du script : sans délai → refus. // Simulation : fichier `.simulation` → aucune écriture ; PILES_SIMULATION='{"piles":{attributs},"helper":"…", // "dernier":[…],"aujourdhui":"AAAA-MM-JJ"}' → ni lecture ni écriture HA (tests). 'use strict'; const fs = require('fs'); const path = require('path'); const HA_BASE = process.env.HA_BASE || 'http://10.10.10.17:8123'; const token = () => fs.readFileSync(path.join(__dirname, '.ha-token'), 'utf8').trim(); const SENSOR = 'sensor.evcs_piles'; const HELPER = 'input_text.evcs_piles_acquittees'; const MAX = 255; const STATE = path.join(__dirname, 'concierge-piles-state.json'); const SIMENV = process.env.PILES_SIMULATION ? JSON.parse(process.env.PILES_SIMULATION) : null; const SIMULATION = !!SIMENV || fs.existsSync(path.join(__dirname, '.simulation')); const JOURS = ['dimanche', 'lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi']; async function ha(method, url, body) { const ac = new AbortController(); const t = setTimeout(() => ac.abort(), 10000); try { const r = await fetch(`${HA_BASE}${url}`, { method, signal: ac.signal, headers: { Authorization: `Bearer ${token()}`, 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined }); if (!r.ok) throw new Error(`HA ${r.status} sur ${url}`); return r.json(); } finally { clearTimeout(t); } } const aujourdhui = () => SIMENV?.aujourdhui || new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Paris' }).format(new Date()); const plusJours = (iso, n) => { const d = new Date(`${iso}T12:00:00Z`); d.setUTCDate(d.getUTCDate() + n); return d.toISOString().slice(0, 10); }; const jjmm = (iso) => `${iso.slice(8, 10)}/${iso.slice(5, 7)}`; function echeance(arg) { const a = String(arg || '').trim().toLowerCase(); const auj = aujourdhui(); if (/^\d{1,3}$/.test(a)) { const n = Number(a); return n >= 1 && n <= 180 ? plusJours(auj, n) : null; } if (a === 'demain') return plusJours(auj, 1); const j = JOURS.indexOf(a); if (j >= 0) { const w = new Date(`${auj}T12:00:00Z`).getUTCDay(); return plusJours(auj, ((j - w + 7) % 7) || 7); } const m = a.match(/^(\d{1,2})\/(\d{1,2})$/); if (m) { let an = Number(auj.slice(0, 4)); let iso = `${an}-${m[2].padStart(2, '0')}-${m[1].padStart(2, '0')}`; if (Number.isNaN(Date.parse(iso))) return null; if (iso <= auj) iso = `${an + 1}${iso.slice(4)}`; return plusJours(auj, 180) >= iso ? iso : null; } return null; } (async () => { const [delai, ...appareils] = process.argv.slice(2); const fin = echeance(delai); if (!fin) { console.log(`Rien n'est noté : délai « ${delai ?? ''} » non compris. Demande-le : « dans combien de jours changes-tu les piles ? »`); return; } const piles = SIMENV ? { attributes: SIMENV.piles || {} } : await ha('GET', `/api/states/${SENSOR}`); const alertes = (piles.attributes?.alertes || []).filter((a) => a && a.cle); let cibles; if (appareils.length) { const mots = appareils.map((m) => m.toLowerCase()); cibles = alertes.filter((a) => mots.some((m) => a.cle.toLowerCase().includes(m) || String(a.nom || '').toLowerCase().includes(m))); if (!cibles.length) { console.log(`Rien n'est noté : aucun appareil en alerte ne correspond à « ${appareils.join(' ')} ».`); return; } } else { const dernier = SIMENV ? (SIMENV.dernier || []) : (() => { try { return JSON.parse(fs.readFileSync(STATE, 'utf8')).dernier || []; } catch { return []; } })(); cibles = alertes.filter((a) => dernier.includes(a.cle)); if (!cibles.length) { console.log("Rien n'est noté : je ne retrouve pas le dernier message de piles. Précise l'appareil (ex. « grenier dans 5 jours »)."); return; } } const brut = SIMENV ? (SIMENV.helper ?? '') : (await ha('GET', `/api/states/${HELPER}`)).state; const auj = aujourdhui(); // 14/10 (EVCS, commit 5d638f3) : format groupé « AAAA-MM-JJ:cle|cle,AAAA-MM-JJ:cle », cle = 6 hex (attribut cle). // Lecture : date la plus lointaine par clé ; groupes échus (< aujourd'hui) retirés ; clés renouvelées remplacées. const parDate = new Map(); // cle → date for (const g of String(brut && brut !== 'unknown' && brut !== 'unavailable' ? brut : '').split(',')) { const m = g.trim().match(/^(\d{4}-\d{2}-\d{2}):(.*)$/); // clé mal formée ignorée seule, pas le groupe if (!m || m[1] < auj) continue; for (const k of m[2].split('|')) if (/^[0-9a-f]{6}$/.test(k) && !(parDate.get(k) > m[1])) parDate.set(k, m[1]); } for (const a of cibles) parDate.delete(a.cle); const serialise = (map) => { const g = new Map(); for (const [k, d] of map) g.set(d, [...(g.get(d) || []), k]); return [...g].sort(([a], [b]) => a.localeCompare(b)).map(([d, ks]) => `${d}:${ks.join('|')}`).join(','); }; const notees = [], horsPlace = []; for (const a of cibles) { if (!/^[0-9a-f]{6}$/.test(a.cle)) { horsPlace.push(a); continue; } parDate.set(a.cle, fin); if (serialise(parDate).length <= MAX) notees.push(a); else { parDate.delete(a.cle); horsPlace.push(a); } } const liste = [serialise(parDate)].filter(Boolean); const valeur = liste.join(','); const noms = (l) => l.map((a) => a.nom || a.cle).join(', '); let fait = ''; if (notees.length) { if (SIMULATION) fait = ` [SIMULATION : rien écrit — ${HELPER} = ${valeur}]`; else { await ha('POST', '/api/services/input_text/set_value', { entity_id: HELPER, value: valeur }); await new Promise((r) => setTimeout(r, 800)); const lu = (await ha('GET', `/api/states/${HELPER}`)).state; if (lu !== valeur) { console.log(`Rien n'est noté : écrit mais HA relit « ${String(lu).slice(0, 80)} ».`); return; } } } let out = notees.length ? `🔋 Noté : pas de rappel pour ${noms(notees)} avant le ${jjmm(fin)}.` : "Rien n'est noté."; if (horsPlace.length) out += ` ⚠️ Pas de place pour ${noms(horsPlace)} (limite HA de 255 caractères) : ils restent rappelés.`; console.log(out + fait); })().catch((e) => { console.log(`Rien n'est noté : erreur (${e.message}).`); process.exit(1); });