110 lines
6.7 KiB
JavaScript
110 lines
6.7 KiB
JavaScript
#!/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 <délai> [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); });
|