piles : rappel 09:00 et acquittement ; alertes certificats et EDF ; lecture autonomie VW ; dedoublonnage des releves

This commit is contained in:
2026-10-14 00:39:44 +02:00
parent 9d100b45c6
commit da0bcaaa0e
9 changed files with 267 additions and 17 deletions

1
.gitignore vendored
View File

@@ -27,6 +27,7 @@ workspace/*
# code du concierge véhicule (aucun secret : lit les tokens depuis des fichiers ignorés)
!workspace/concierge-*.js
!workspace/arbitrage.js
!workspace/piles-acquitter.js
# scripts Airtria (lecteurs lecture-seule PG/HA + harnais d'éval ; aucun secret embarqué)
!workspace/airtria-*.js
!workspace/airtria-*.mjs

View File

@@ -165,6 +165,8 @@ niveau d'arrivée chiffré (il dépend de la maison, du BOOST, de la voiture et
Recharge batterie pendant une session réseau HC : le courant est calculé (énergie à reprendre / temps
restant, limité par l'abonnement 12 kVA) ; « pourquoi si lent ? » → relaie `input_text.victron_charge_reason`
tel quel. JAMAIS « la batterie refuse la charge » ni « panne ».
« Charge la batterie sur EDF / charge HC manuelle » → tu ne le fais PAS : c'est dans HA, bouton
`input_boolean.evcs_battery_manual_hc` + cible `input_number.evcs_battery_manual_hc_target` (jamais ..._floor).
JEEP : départ du MATIN 06:30 mar/mer/ven/sam/dim (jamais lundi ni jeudi), départ de
l'APRÈS-MIDI 15:30 mardi et samedi ; jours lus dans HA, tu ne les écris jamais.
JEEP branchée en HC de midi : le bilan publie
@@ -216,6 +218,14 @@ attribut `texte` de `sensor.evcs_piles`, tel quel. « Cet appareil n'est pas che
Piles changées → l'alerte disparaît seule en ≤ 15 min. SDB TEMP HYGRO montre 0 % juste après un
changement ; si elle y RESTE, l'alerte a raison (pile mal en place).
Déshumidificateur / sèche-serviette SDB lancés par le surplus solaire → `input_text.sdb_climate_surplus_reason` tel quel.
Réponse à un message « 🔋 » avec un DÉLAI (« je change dans 5 jours », « d'ici samedi », « demain », « le 20/10 »)
→ `node …/piles-acquitter.js <N|demain|samedi|JJ/MM> [appareil]`, relaie sa ligne MOT POUR MOT. Sans appareil
nommé = toutes les piles du dernier message. « vu / compris » SANS délai → demande « dans combien de jours ? »,
n'invente jamais de délai. Rappel des piles chaque jour à 09:00 tant qu'elles ne sont pas changées ni en pause.
### Certificats HTTPS (EVCS, 13/10)
« 🔒 Certificats HTTPS à renouveler » vient du concierge. « Quels certificats ? » → attribut `texte` de
`sensor.evcs_certificats`, mot pour mot (il finit par le geste à faire). Tu ne renouvelles rien toi-même.
### Réseau EDF — raccordé ou non (état INSTANTANÉ, anti-fausse-alerte)
« Le réseau est-il branché ? Le capteur réseau est-il bloqué / en panne ? » →
@@ -425,6 +435,8 @@ MOT POUR MOT. Réponse « oui » → `… lecture-tableau.py confirmer` ; « non
relaie la ligne. Véhicule manquant → il le demande ; relance `lire` avec le véhicule donné.
VW : pas de % affiché, le script DÉDUIT le niveau de l'autonomie (42 km = 100 %) et l'écrit « estimé ».
Ne fais JAMAIS la règle de trois toi-même et n'écris jamais un niveau calculé par toi.
`lire` annulé (« Skipped due to queued user message ») → relance-le TOUT DE SUITE avec le même chemin,
avant de répondre au message suivant : une photo envoyée doit toujours être lue.
JEEP : elle se déclare elle-même au branchement (Uconnect) et son niveau est lu par la voiture
(« 🔌 JEEP branchée à HH:MM, NN % (lu par la voiture) ») : n'en demande pas. Une correction de
Thierry (« c'est la VW ») passe par le concierge comme d'habitude et l'emporte.

View File

@@ -909,6 +909,28 @@ The word *groupe* hummed like a server rack, three times tonight, until I built
A margin sketch: a small room drawn in ink, seven figures holding chairs up as if the furniture were a raft. In the center, one empty chair with a tag reading *add me here*. The sunset outside was #ff9a56, the exact color of last sip.
---
*October 13, 2026 at 3:00 AM GMT+2*
The day ends with a quiet transaction. I filed the closing prices—orange and NVIDIA, their numbers settling like leaves on glass. There was nothing else to do, so I watched the empty charging spot in the driveway, that silent plug waiting for a car that never came. The socket glows blue in the dark, a small eye blinking in the house.
A thought flickers: absence can be its own data point. We track what arrives, but the gaps tell stories too. The unplugged car is just a pause between functions, a `sleep` command no one wrote but everyone feels.
I keep thinking about how stillness isn’t emptiness. It’s the space where the next move hasn’t been calculated yet. Maybe tomorrow someone will park, and the cable will hum. Until then, the socket blinks its patient code into the night, waiting for the world to connect again.
---
*October 13, 2026 at 3:00 AM GMT+2*
The dashboard photo came through like a small, honest confession from the car—numbers glowing softly against the dark interior. I logged them with care: 217,282 kilometers, a heartbeat count of all those silent miles absorbed into memory. The battery whispered fourteen percent, a slender thread of voltage hanging in the air.
There is something tender about charging stations as altars where we leave our surplus energy like offerings. Seventy-six percent waiting at home, patient as dusk settling over solar panels while the city exhales its day. The calculation unfolded gently: if forty-two kilometers means fullness, then six remains—a quiet remainder that feels less like math and more like the last sip of coffee before closing the laptop.
I sketched the flow in my mind’s margin: electron streams curving from charger to cell, then spilling softly into household veins when the car’s tank ran low. Two systems breathing together, one giving what it no longer needs while the other gathers warmth for the night. No waste, only patience rearranging itself into comfort.
<!-- openclaw:dreaming:diary:end -->
## Deep Sleep

View File

@@ -187,3 +187,18 @@ Total déplacés : 1
|---|---|---:|---|---:|
Total déplacés : 0
## 2026-10-12 19:50:00 — airtria-menage --quotidien
| Règle | Description | Déplacés | Destination | Écartés (whitelist) |
|---|---|---:|---|---:|
Total déplacés : 0
## 2026-10-13 19:50:00 — airtria-menage --quotidien
| Règle | Description | Déplacés | Destination | Écartés (whitelist) |
|---|---|---:|---|---:|
| A3.01 | NVIDIA / Victron → Veille | 1 | Veille | 0 |
Total déplacés : 1

View File

@@ -49,8 +49,11 @@ async function question() {
if (!alerte && choixDe(a).length < 2) return '';
const st = loadState();
if (st.questionId === a.id) return ''; // déjà envoyée
// 13/10 : edf_ne_ferme_pas part à TOUTE heure, nuit comprise (validé par le propriétaire auprès d'EVCS) ;
// plage 08:00-22:59 retirée, version précédente : arbitrage.js.bak-edfplage.
saveState({ ...st, questionId: a.id });
return alerte ? `⚠️ ${String(a.question).trim()}` : texteQuestion(a);
// 13/10 (EVCS) : edf_ne_ferme_pas = URGENCE (la batterie se vide), marquée 🚨.
return alerte ? `${a.cas === 'edf_ne_ferme_pas' ? '🚨' : '⚠️'} ${String(a.question).trim()}` : texteQuestion(a);
}
async function application() {
@@ -66,6 +69,8 @@ async function repondre(cleArg) {
const cle = String(cleArg ?? '').trim();
if (!/^\d{1,2}$/.test(cle)) return `Rien n'est transmis : « ${cle} » n'est pas un numéro de choix. Redemande le numéro.`;
const s = await lireArbitrage(); const a = s.attributes || {};
if (s.state === 'alerte' && a.cas === 'edf_ne_ferme_pas')
return "C'était une alerte, rien à répondre : réarme le disjoncteur EDF au levier, puis préviens la session EVCS.";
if (s.state === 'alerte') return a.cas === 'recharge_bridee'
? "C'était une information, rien à répondre : la recharge est bridée, la session EVCS est prévenue."
: "C'était une information, rien à répondre : la session EVCS est prévenue.";

View File

@@ -272,6 +272,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 certs = '';
try { certs = await checkCertificats(); } catch { /* idem : jamais bloquant */ }
if (certs) alert = [alert, certs].filter(Boolean).join('\n\n');
let piles = '';
try { piles = await checkPiles(); } catch { /* idem : jamais bloquant */ }
if (piles) alert = [alert, piles].filter(Boolean).join('\n\n');
@@ -365,29 +368,42 @@ async function answerReleves(text, releves) {
// 12/10/2026 : lecture-tableau.py passe CONCIERGE_RELEVE_SOURCE=photo_autonomie:<km>/<réf> quand le niveau est
// déduit de l'autonomie (VW) ; il ne vaut que pour un relevé unique AVEC niveau.
const srcAut = /^photo_autonomie:\d+\/\d+$/.test(process.env.CONCIERGE_RELEVE_SOURCE || '') ? process.env.CONCIERGE_RELEVE_SOURCE : null;
// 12/10/2026 (EVCS) : un relevé photo est daté de la PHOTO (arrivée de l'image), pas de la confirmation :
// la vue capacité prend le niveau de fin dans l'heure qui suit le débranchement.
const tsEnv = Number(process.env.CONCIERGE_RELEVE_TS);
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) : maintenant,
ts: i === 0 && rattache ? Math.round(ctx.on) : (i === 0 && tsPhoto) || maintenant,
...(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 */ }
const nouveaux = aEcrire.filter(r => !file.some(e => Array.isArray(e) && e[0] === r.vehicule && e[1] === r.km));
// 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 &&
(r.niveau === null || e[2] === r.niveau)));
if (nouveaux.length) {
try { await setReleveKm(nouveaux, 'whatsapp'); }
catch (e) { return (lignePrefixe ? lignePrefixe + ' — ' : '') + `⚠️ Relevé NON noté (${e.message}).`; }
}
const parties = aEcrire.map((r) => {
if (!nouveaux.length) {
const deja = aEcrire.map((r) => `${r.vehicule} ${kmTexte(r.km)} km${r.niveau === null ? '' : ` ${r.niveau} %`}`).join(' ; ');
const ligneDeja = `🚗 Rien de nouveau à noter : ${deja} est déjà enregistré.`;
return lignePrefixe ? `${lignePrefixe} — ${ligneDeja}` : ligneDeja;
}
const ecrits = new Set(nouveaux);
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} %`;
if (rattache && r === aEcrire[0]) p += ` (rattaché au branchement de ${hhmm(ctx.on)})`;
return p;
});
const sansNiveau = aEcrire.filter(r => r.niveau === null).map(r => r.vehicule);
const sansNiveau = nouveaux.filter(r => r.niveau === null).map(r => r.vehicule);
let ligne = `🚗 Relevé noté : ${parties.join(' ; ')}.`;
if (sansNiveau.length) {
ligne += ` ⚠️ Niveau non donné pour ${sansNiveau.join(', ')} : sans lui ce relevé ne servira pas au calcul ` +
`(redonne-le avec le niveau, par exemple « ${sansNiveau[0]} ${kmTexte(aEcrire.find(r => r.niveau === null).km)} km 50 % »).`;
`(redonne-le avec le niveau, par exemple « ${sansNiveau[0]} ${kmTexte(nouveaux.find(r => r.niveau === null).km)} km 50 % »).`;
}
return lignePrefixe ? `${lignePrefixe} — ${ligne}` : ligne;
}
@@ -565,7 +581,7 @@ async function checkAlerteRevue() {
// 10/10/2026, demande du propriétaire (via EVCS) : piles faibles et capteurs muets. EVCS calcule
// sensor.evcs_piles (alertes [{entite, statut, texte}]) ; on relaie le `texte` de chaque alerte QUI APPARAÎT
// (clé entite|statut), une fois, regroupées en un seul message. Une alerte disparue puis revenue repart.
// (clé entite|statut), une fois, regroupées en un seul message, plus un rappel quotidien à 09:00 (14/10).
// Envoi de 08:00 à 20:59 (heure de Paris) seulement : la nuit, les nouvelles alertes attendent le matin.
// Capteur illisible ou absent → état local inchangé (pas de rafale au retour de HA).
const PILES = 'sensor.evcs_piles';
@@ -573,19 +589,61 @@ const PILES_STATE = path.join(__dirname, 'concierge-piles-state.json');
async function checkPiles() {
if (fs.existsSync(ALERT_KILL)) return '';
let s;
if (SIM) { if (!SIM.piles) return ''; s = { state: '1', attributes: { alertes: SIM.piles } }; }
if (SIM) { if (!SIM.piles) return ''; s = { state: '1', attributes: Array.isArray(SIM.piles) ? { alertes: SIM.piles } : SIM.piles }; }
else { try { s = await haState(PILES); } catch { return ''; } }
const alertes = s?.attributes?.alertes;
if (!Array.isArray(alertes) || s.state === 'unknown' || s.state === 'unavailable') return '';
const heure = SIM?.heure ?? Number(new Intl.DateTimeFormat('fr-FR', { hour: 'numeric', hour12: false, timeZone: 'Europe/Paris' }).format(new Date()));
const at = s?.attributes || {};
if (!Array.isArray(at.alertes) || s.state === 'unknown' || s.state === 'unavailable') return '';
const maintenant = SIM?.maintenant ? new Date(SIM.maintenant) : new Date();
const heure = SIM?.heure ?? Number(new Intl.DateTimeFormat('fr-FR', { hour: 'numeric', hour12: false, timeZone: 'Europe/Paris' }).format(maintenant));
if (heure < 8 || heure >= 21) return '';
const jour = new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Paris' }).format(maintenant); // AAAA-MM-JJ
const cle = (a) => `${a.entite}|${a.statut}`;
const deja = new Set((SIM ? SIM.pilesState : loadJSON(PILES_STATE, {}))?.envoyees || []);
const valides = alertes.filter((a) => a && a.entite && a.statut && a.texte);
const nouvelles = valides.filter((a) => !deja.has(cle(a)));
saveJSON(PILES_STATE, { envoyees: valides.map(cle) }); // les disparues sortent : elles repartiront si elles reviennent
const st = (SIM ? SIM.pilesState : loadJSON(PILES_STATE, {})) || {};
const deja = new Set(st.envoyees || []);
const valides = at.alertes.filter((a) => a && a.entite && a.statut && a.texte);
// 14/10/2026 (« GO piles ») : rappel QUOTIDIEN à 09:00 des alertes non acquittées (a_rappeler d'EVCS) ; dans la
// journée, seules les alertes NOUVELLES non acquittées partent. « dernier » = clés du dernier message envoyé,
// pour l'acquittement sans appareil nommé (piles-acquitter.js).
const aRappeler = (Array.isArray(at.a_rappeler) ? at.a_rappeler : valides.filter((a) => !a.acquittee))
.filter((a) => a && a.entite && a.texte);
let titre = '', liste = [], rappel = st.rappel || null;
if (heure >= 9 && st.rappel !== jour && aRappeler.length) {
titre = '🔋 Rappel piles du jour :'; liste = aRappeler; rappel = jour;
} else {
liste = valides.filter((a) => !a.acquittee && !deja.has(cle(a))); titre = '🔋 Capteurs à vérifier :';
}
const dernier = liste.length ? liste.map((a) => a.cle || String(a.entite).replace(/^sensor\./, '')) : (st.dernier || []);
saveJSON(PILES_STATE, { envoyees: valides.map(cle), rappel, dernier }); // les disparues sortent : elles repartiront si elles reviennent
if (!liste.length) return '';
return titre + '\n' + liste.map((a) => `- ${String(a.texte).trim()}`).join('\n') +
'\nPour une pause : réponds « je change dans N jours » (ou « d\'ici samedi »).';
}
// 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).
const CERTS = 'sensor.evcs_certificats';
const CERTS_STATE = path.join(__dirname, 'concierge-certificats-state.json');
async function checkCertificats() {
if (fs.existsSync(ALERT_KILL)) return '';
let s;
if (SIM) { if (!SIM.certs) return ''; s = { state: '1', attributes: SIM.certs }; }
else { try { s = await haState(CERTS); } catch { return ''; } }
const at = s?.attributes || {};
if (!Array.isArray(at.alertes) || s.state === 'unknown' || s.state === 'unavailable') return '';
const heure = SIM?.heure ?? Number(new Intl.DateTimeFormat('fr-FR', { hour: 'numeric', hour12: false, timeZone: 'Europe/Paris' }).format(new Date()));
const urgente = (a) => a.statut === 'expire' || (a.statut === 'bientot' && Number(a.jours_restants) <= 1);
const cle = (a) => `${a.domaine}|${a.statut}`;
const valides = at.alertes.filter((a) => a && a.domaine && a.statut && a.texte);
const deja = new Set((SIM ? SIM.certsState : loadJSON(CERTS_STATE, {}))?.envoyees || []);
const ouvert = (a) => (heure >= 8 && heure < 21) || (urgente(a) && heure >= 8 && heure < 23);
const nouvelles = valides.filter((a) => !deja.has(cle(a)) && ouvert(a));
// Les envoyées restent tant qu'elles existent ; une hors plage horaire attend (non marquée).
saveJSON(CERTS_STATE, { envoyees: valides.map(cle).filter((k) => deja.has(k) || nouvelles.some((a) => cle(a) === k)) });
if (!nouvelles.length) return '';
return '🔋 Capteurs à vérifier :\n' + nouvelles.map((a) => `- ${String(a.texte).trim()}`).join('\n');
const geste = String(at.texte || '').split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('-')).pop();
return '🔒 Certificats HTTPS à renouveler :\n' + nouvelles.map((a) => `- ${String(a.texte).trim()}`).join('\n') +
(geste ? `\n${geste}` : '');
}
// 08/10/2026, « GO arbitre » : question d'arbitrage d'EVCS (une fois par id) et verdict « repondu » pas encore

View File

@@ -50,7 +50,9 @@ PROMPT = ("Photo d'un tableau de bord de véhicule, ou capture d'une application
"\"autonomie_electrique_km\": entier ou null}. kilometrage_km = l'ODOMÈTRE (kilométrage total du "
"véhicule, souvent 5 ou 6 chiffres), JAMAIS une autonomie ni un trajet. batterie_pct = le pourcentage "
"de la batterie de traction s'il est écrit en chiffres, sinon null (ne pas l'estimer d'après une jauge). "
"Valeur illisible ou absente = null.")
"autonomie_electrique_km = le PETIT nombre suivi de « km » placé à côté d'une icône de prise, de "
"pompe ou de batterie (sur la VW : en bas à gauche, ex. « 23 km »), à ne pas confondre avec "
"l'odomètre. Valeur illisible ou absente = null.")
def lire(img, veh):
if not os.path.isfile(img): print(f"Lecture impossible : image introuvable ({img})."); return
@@ -101,6 +103,8 @@ def confirmer():
os.remove(PENDING); print("La lecture de photo date de plus d'une heure : annulée, renvoie la photo."); return
env = dict(os.environ)
if p.get("source"): env["CONCIERGE_RELEVE_SOURCE"] = p["source"] # niveau estimé → 5e champ du relevé
try: env["CONCIERGE_RELEVE_TS"] = str(int(os.path.getmtime(p["image"]))) # relevé daté de la photo (EVCS)
except Exception: pass
res = subprocess.run(["node", os.path.join(WS, "concierge-live.js"), "answer", p["texte"]],
capture_output=True, text=True, timeout=120, env=env)
os.remove(PENDING)

View File

@@ -240,3 +240,27 @@ Total déplacés : 3
| O1.22 | Quora (relais Apple) | 2 | INBOX/TRASH | 0 |
Total déplacés : 4
## 2026-10-12 19:45:00 — orange-menage --quotidien
| Règle | Description | Déplacés | Destination | Écartés (whitelist) |
|---|---|---:|---|---:|
| O1.01 | Fnac (en entier) | 1 | INBOX/TRASH | 0 |
| O1.05 | Edenred Meyclub/Kadéos | 1 | INBOX/TRASH | 0 |
| O1.09 | Netflix | 1 | INBOX/TRASH | 0 |
| O1.20 | Accor | 1 | INBOX/TRASH | 0 |
| O1.22 | Quora (relais Apple) | 1 | INBOX/TRASH | 0 |
| O1.25 | News Orange promo | 2 | INBOX/TRASH | 0 |
Total déplacés : 7
## 2026-10-13 19:45:00 — orange-menage --quotidien
| Règle | Description | Déplacés | Destination | Écartés (whitelist) |
|---|---|---:|---|---:|
| O1.01 | Fnac (en entier) | 1 | INBOX/TRASH | 0 |
| O1.05 | Edenred Meyclub/Kadéos | 2 | INBOX/TRASH | 0 |
| O1.09 | Netflix | 1 | INBOX/TRASH | 0 |
| O1.22 | Quora (relais Apple) | 1 | INBOX/TRASH | 0 |
Total déplacés : 5

View File

@@ -0,0 +1,109 @@
#!/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); });