674 lines
40 KiB
JavaScript
674 lines
40 KiB
JavaScript
// concierge-live.js — Concierge véhicule Airtria, ÉTAGE QUESTION/RÉPONSE (live).
|
||
// -----------------------------------------------------------------------------
|
||
// Déclenche AU BRANCHEMENT (transition 'on' de binary_sensor.victron_evcs_vehicle_connected
|
||
// dans evenement), PAS à l'ouverture d'une session : au branchement du soir la borne ne
|
||
// tire rien (heures creuses à 01:24), donc attendre une session laisserait la 1re session
|
||
// naître mal étiquetée. Deux sous-commandes appelées par l'agent :
|
||
// detect → cron : repère un nouveau branchement, décide (frais → rien /
|
||
// périmé → question), imprime la question WhatsApp ou rien.
|
||
// answer "<txt>" → réponse WhatsApp : reconnaît le véhicule, publie le helper
|
||
// {branchement, label, ...}, confirme.
|
||
// La détection est chez Airtria ; la résolution du branchement, la mémorisation et
|
||
// l'application aux sessions (y compris celles à venir) sont chez EVCS. Voir SPEC.
|
||
// -----------------------------------------------------------------------------
|
||
'use strict';
|
||
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const { makeClient } = require('./concierge-detection.js');
|
||
const W = require('./concierge-write.js');
|
||
const { SELECTOR_ENTITY, SELECTOR_LABELS } = W;
|
||
// CONCIERGE_SIMULATION='{"connected":"on|off","options":[..],"plugin":{"on":<epoch>,"on_text":"..","off":null}|null,
|
||
// "pending":{..}|null,"pgDown":false}' → answer tourne SANS HA, SANS PostgreSQL et SANS AUCUNE écriture
|
||
// (tests unitaires, 30/09/2026). Hors simulation : comportement réel inchangé.
|
||
// 03/10/2026 : la simulation s'active AUSSI par un fichier drapeau `.simulation`
|
||
// a cote du script (meme JSON, ou `{}`). Un banc qui passe par l'agent complet
|
||
// ne controle pas l'environnement des commandes que l'agent lance : seul un
|
||
// fichier pose avant le banc protege l'installation (incidents du 30/09 et du
|
||
// 02/10, ecritures reelles pendant des tests).
|
||
const SIM_FILE = path.join(__dirname, '.simulation');
|
||
const SIM = process.env.CONCIERGE_SIMULATION ? JSON.parse(process.env.CONCIERGE_SIMULATION)
|
||
: fs.existsSync(SIM_FILE) ? (() => { try { return JSON.parse(fs.readFileSync(SIM_FILE, 'utf8') || '{}'); } catch { return {}; } })()
|
||
: null;
|
||
// En simulation, chaque ecriture evitee est tracee sur stderr (jamais relayee) :
|
||
// les tests comptent ainsi ce qui AURAIT ete ecrit.
|
||
const simTrace = (nom) => async (...a) => { console.error(`[SIM] ${nom} ${JSON.stringify(a)}`); };
|
||
const publishLabel = SIM ? simTrace('publishLabel') : W.publishLabel;
|
||
const setVehicleSelector = SIM ? simTrace('setVehicleSelector') : W.setVehicleSelector;
|
||
const setVehicleLevel = SIM ? simTrace('setVehicleLevel') : W.setVehicleLevel;
|
||
const setReleveKm = SIM ? simTrace('setReleveKm') : W.setReleveKm;
|
||
const health = require('./concierge-health.js');
|
||
|
||
const STATE = path.join(__dirname, 'concierge-state.json');
|
||
const PENDING = path.join(__dirname, 'concierge-pending.json');
|
||
const HA_BASE = process.env.HA_BASE || 'http://10.10.10.17:8123';
|
||
// Une seule source de vérité pour l'entité : celle du canal d'écriture.
|
||
const SELECTOR = SELECTOR_ENTITY;
|
||
const CONNECTED = 'binary_sensor.victron_evcs_vehicle_connected';
|
||
// Alerte "branchée mais ne charge pas" : EVCS détecte et écrit ce helper, on relaie.
|
||
const ALERT_HELPER = 'input_text.evcs_alerte_charge';
|
||
// Interrupteur MAÎTRE, visible et contrôlé par le propriétaire depuis HA (créé par EVCS
|
||
// le 19/09/2026). OFF par défaut (opt-in) tant que les seuils 500W/10min ne sont pas
|
||
// calibrés. Rien ne part tant qu'il n'est pas 'on'.
|
||
const ALERT_ENABLE = 'input_boolean.evcs_alerte_charge_enabled';
|
||
const ALERT_STATE = path.join(__dirname, 'concierge-alert-state.json'); // dedup sur `since`
|
||
const ALERT_KILL = path.join(__dirname, 'concierge-alert.disabled'); // 2e cran local d'urgence
|
||
// 07/10/2026, demande explicite du propriétaire (via EVCS) : alerte chauffe-eau en heures creuses EDF quand il
|
||
// ne tire que la pompe à chaleur. Le workflow ECS pose le helper ; on relaie `message` UNE fois par fenêtre
|
||
// (dedup sur `since`). Pas d'interrupteur d'opt-in ; même coupure d'urgence locale que l'alerte de charge.
|
||
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;
|
||
|
||
// 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).
|
||
const SQL_TRANS = `
|
||
SELECT ts::text AS ts_text, extract(epoch FROM ts) AS ts_epoch, detail->>'apres' AS etat
|
||
FROM evenement
|
||
WHERE detail->>'entite' = 'binary_sensor.victron_evcs_vehicle_connected'
|
||
AND ts >= $1 AND ts < $2
|
||
ORDER BY ts`;
|
||
// Changements de VALEUR du sélecteur (robuste : hors lectures unavailable).
|
||
const SQL_SEL = `
|
||
SELECT extract(epoch FROM ts) AS ts_epoch
|
||
FROM evenement
|
||
WHERE genre = 'intervention'
|
||
AND detail->>'entite' = 'input_select.victron_evcs_vehicle'
|
||
AND ts >= $1 AND ts < $2
|
||
ORDER BY ts`;
|
||
|
||
const loadJSON = (f, d) => {
|
||
if (SIM && f === STATE) return SIM.state ?? d; // simulation : état fourni par le banc
|
||
try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } };
|
||
const saveJSON = (f, o) => { if (SIM) return; fs.writeFileSync(f, JSON.stringify(o, null, 2)); }; // simulation : rien sur disque
|
||
const haToken = () => fs.readFileSync(process.env.HA_TOKEN_FILE || path.join(__dirname, '.ha-token'), 'utf8').trim();
|
||
|
||
async function haState(entity) {
|
||
if (SIM) {
|
||
if (entity === CONNECTED) return { state: SIM.connected };
|
||
if (entity === W.HELPER_ENTITY) return { state: SIM.lastLabel || '' };
|
||
if (entity === W.RELEVE_ENTITY) return { state: SIM.releves || '[]' };
|
||
if (entity === W.LEVEL_ENTITY) return { state: String(SIM.lastLevel?.value ?? ''), last_reported: SIM.lastLevel?.at };
|
||
if (entity === ECS_HELPER) return { state: SIM.ecs === undefined ? '' : (typeof SIM.ecs === 'string' ? SIM.ecs : JSON.stringify(SIM.ecs)) };
|
||
if (entity === JEEP_CHARGER) return { state: SIM.jeep?.charger ?? 'unknown' };
|
||
if (entity === JEEP_UPDATE) return { state: SIM.jeep?.update ?? 'unknown' };
|
||
if (entity === JEEP_HV) return { state: String(SIM.jeep?.hv ?? 'unknown') };
|
||
if (entity === JEEP_IGN) return { state: SIM.jeep?.ignition ?? 'off' };
|
||
if (entity === JEEP_LOC) return { state: 'x', attributes: { latitude: SIM.jeep?.lat ?? 48.7903, longitude: SIM.jeep?.lon ?? -3.5327 } };
|
||
if (entity === JEEP_LOC_AT) return { state: SIM.jeep?.locAt ?? '1970-01-01T00:00:00+00:00' };
|
||
if (entity === ZONE_JEEP) return { state: '0', attributes: { latitude: 48.7903, longitude: -3.5327, radius: 150 } };
|
||
if (entity === JEEP_ODO) return { state: '112787', last_changed: SIM.jeep?.odoChanged ?? '1970-01-01T00:00:00+00:00' };
|
||
return { state: '', attributes: { options: SIM.options || ['VW', 'JEEP', 'ZERO'] } };
|
||
}
|
||
const r = await fetch(`${HA_BASE}/api/states/${entity}`, { headers: { Authorization: `Bearer ${haToken()}` } });
|
||
if (!r.ok) throw new Error(`HA ${entity} HTTP ${r.status}`);
|
||
return r.json();
|
||
}
|
||
|
||
// Un branchement = intervalle [on … off suivant]. Défensif contre le flapping :
|
||
// un 'on' déjà ouvert est ignoré, un 'off' sans ouverture est ignoré. Chaque
|
||
// branchement porte le ts::text VERBATIM de sa transition 'on'.
|
||
function buildPlugins(rows) {
|
||
const plugins = [];
|
||
let open = null;
|
||
for (const t of rows) {
|
||
const at = Number(t.ts_epoch);
|
||
if (t.etat === 'on') { if (open === null) open = { on: at, on_text: t.ts_text, off: null }; }
|
||
else if (t.etat === 'off') { if (open !== null) { open.off = at; plugins.push(open); open = null; } }
|
||
}
|
||
if (open !== null) plugins.push(open); // branchement encore en cours
|
||
return plugins;
|
||
}
|
||
|
||
// Fraîcheur du sélecteur (EVCS 2026-09-14) : réglé dans [on−10 min, min(now, on+30 min)] ?
|
||
// La borne haute min(now, on+30) donne le comportement « au branchement » (fenêtre pas
|
||
// encore fermée) tout en gardant le garde-fou : un changement bien APRÈS le branchement
|
||
// (relu plus tard) n'est pas « frais » — c'est le producteur des groupes mixtes.
|
||
function selectorFresh(onEpoch, selChanges) {
|
||
const hi = Math.min(Date.now() / 1000, onEpoch + FRESH_AFTER_S);
|
||
return selChanges.some(c => c >= onEpoch - FRESH_BEFORE_S && c <= hi);
|
||
}
|
||
|
||
async function loadWindow() {
|
||
if (SIM) {
|
||
if (SIM.pgDown) throw new Error('PostgreSQL simulé indisponible');
|
||
return { plugins: SIM.plugin ? [SIM.plugin] : [], selChanges: [] };
|
||
}
|
||
const until = new Date().toISOString();
|
||
const since = new Date(Date.now() - WINDOW_DAYS * 864e5).toISOString();
|
||
const c = makeClient();
|
||
await c.connect();
|
||
try {
|
||
const tr = await c.query(SQL_TRANS, [since, until]);
|
||
const sc = await c.query(SQL_SEL, [since, until]);
|
||
return { plugins: buildPlugins(tr.rows), selChanges: sc.rows.map(r => Number(r.ts_epoch)) };
|
||
} finally { await c.end(); }
|
||
}
|
||
|
||
// 04/10/2026, variante (a) validée par le propriétaire : la JEEP se déclare elle-même (intégration
|
||
// Uconnect, cloud relu toutes les 15 min). Même critère qu'EVCS : chargeur Jeep « on » et dernière mise à
|
||
// jour Uconnect entre branchement −5 min et +30 min → pas de question, une ligne d'information.
|
||
const JEEP_CHARGER = 'binary_sensor.jeep_renegade_phev_ev_charger';
|
||
const JEEP_UPDATE = 'sensor.jeep_renegade_phev_last_info_update_at';
|
||
const JEEP_HV = 'sensor.jeep_renegade_phev_hvbattery_charge';
|
||
const JEEP_ODO = 'sensor.jeep_renegade_phev_odometer'; // last_changed ne bouge que si le compteur change
|
||
const JEEP_IGN = 'binary_sensor.jeep_renegade_phev_ignition';
|
||
// 07/10/2026 (accord du propriétaire) : zone de stationnement de la Jeep, créée par EVCS. Distance calculée ici
|
||
// (le libellé d'état du tracker n'est pas fiable) ; la position ne compte que si son horodatage GPS est frais.
|
||
const JEEP_LOC = 'device_tracker.jeep_renegade_phev_location';
|
||
const JEEP_LOC_AT = 'sensor.jeep_renegade_phev_last_location_update_at';
|
||
const ZONE_JEEP = 'zone.maison_jeep';
|
||
function distanceM(a1, o1, a2, o2) {
|
||
const R = 6371000, r = Math.PI / 180, dA = (a2 - a1) * r, dO = (o2 - o1) * r;
|
||
const h = Math.sin(dA / 2) ** 2 + Math.cos(a1 * r) * Math.cos(a2 * r) * Math.sin(dO / 2) ** 2;
|
||
return 2 * R * Math.asin(Math.sqrt(h));
|
||
}
|
||
const ATTENTE_UCONNECT_S = 900; // avant de questionner une voiture inconnue, laisser 15 min à Uconnect
|
||
// 07/10/2026 (accord du propriétaire) : un branchement DÉCOUVERT plus de 2 h après son heure (aller-retour
|
||
// bref rattrapé après coup par EVCS dans evenement) ne déclenche plus de question : trace seulement.
|
||
const DECOUVERTE_TARDIVE_S = 7200;
|
||
const TRACES = path.join(__dirname, 'concierge-traces.log');
|
||
const trace = (msg) => { if (SIM) { console.error('[SIM trace] ' + msg); return; }
|
||
try { fs.appendFileSync(TRACES, `${new Date().toISOString()} ${msg}\n`); } catch { /* jamais bloquant */ } };
|
||
|
||
async function jeepVerdict(onEpoch) {
|
||
// 'jeep' (identifiée, avec niveau) | 'pas_jeep' (lue NON branchée après le branchement) | 'inconnu'
|
||
let charger, upd, hv, odo, ign = 'unknown';
|
||
try { ign = (await haState(JEEP_IGN)).state; } catch { /* facultatif */ }
|
||
try {
|
||
charger = (await haState(JEEP_CHARGER)).state;
|
||
upd = Date.parse((await haState(JEEP_UPDATE)).state) / 1000;
|
||
hv = Number((await haState(JEEP_HV)).state);
|
||
odo = Date.parse((await haState(JEEP_ODO)).last_changed) / 1000;
|
||
} catch { return { v: 'inconnu' }; }
|
||
if (!Number.isFinite(upd)) return { v: 'inconnu' };
|
||
// 07/10/2026 (règle rapide, validée avec EVCS) : contact mis avec une info Uconnect fraîche (≥ branchement
|
||
// −10 min) = la Jeep roule, ce n'est pas elle sur la borne → question immédiate. La POSITION n'est pas utilisée :
|
||
// la Jeep garée est à 2,2 km de zone.home, donc « not_home » en permanence.
|
||
if (ign === 'on' && upd >= onEpoch - 600) return { v: 'pas_jeep' };
|
||
// Position GPS FRAÎCHE (≥ branchement −10 min) hors de la zone de stationnement → elle est ailleurs.
|
||
try {
|
||
const locAt = Date.parse((await haState(JEEP_LOC_AT)).state) / 1000;
|
||
if (Number.isFinite(locAt) && locAt >= onEpoch - 600) {
|
||
const loc = (await haState(JEEP_LOC)).attributes || {}, z = (await haState(ZONE_JEEP)).attributes || {};
|
||
if ([loc.latitude, loc.longitude, z.latitude, z.longitude].every(Number.isFinite)
|
||
&& distanceM(loc.latitude, loc.longitude, z.latitude, z.longitude) > (z.radius || 150))
|
||
return { v: 'pas_jeep' };
|
||
}
|
||
} catch { /* position illisible : on n'en tire rien */ }
|
||
const niveau = Number.isInteger(hv) && hv >= 0 && hv <= 100 ? hv : null;
|
||
if (charger === 'on' && upd >= onEpoch - 300 && upd <= onEpoch + 1800)
|
||
return { v: 'jeep', niveau };
|
||
// 05/10/2026 (règle EVCS, accord du propriétaire) : la Jeep a FINI UN TRAJET dans les 10 min avant le
|
||
// branchement (dernière mise à jour Uconnect dans [branchement −10 min, branchement], chargeur encore off).
|
||
// Le soir en « sans EDF » sa charge ne démarre pas et elle ne republie rien : c'est sa fin de trajet qui
|
||
// l'identifie. Niveau = celui de l'arrivée. Risque accepté : une autre voiture branchée juste après.
|
||
// + COMPTEUR CHANGÉ : une mise à jour d'une Jeep restée garée ne suffit pas (sinon on annoncerait JEEP au
|
||
// branchement de la VW alors qu'EVCS n'étiquette pas). last_changed de l'odomètre est l'heure où HA a RELU
|
||
// le cloud (jusqu'à 15 min après le trajet : 05/10, trajet fini 19:05, vu par HA 19:19) : fenêtre
|
||
// [branchement −10 min, branchement +20 min] ; l'heure du trajet reste testée par last_info_update_at.
|
||
if (charger === 'off' && upd >= onEpoch - 600 && upd <= onEpoch
|
||
&& Number.isFinite(odo) && odo >= onEpoch - 600 && odo <= onEpoch + 1200)
|
||
return { v: 'jeep', niveau, arrivee: true };
|
||
if (charger === 'off' && upd > onEpoch) return { v: 'pas_jeep' };
|
||
return { v: 'inconnu' };
|
||
}
|
||
|
||
const hhmm = (epoch) => new Date(epoch * 1000)
|
||
.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' });
|
||
|
||
// Détection pure : PEUT LEVER (erreur PG/auth via loadWindow). Renvoie la question ou ''.
|
||
async function runDetection() {
|
||
const { plugins, selChanges } = await loadWindow();
|
||
if (plugins.length === 0) return '';
|
||
const latest = plugins[plugins.length - 1];
|
||
const state = loadJSON(STATE, null);
|
||
|
||
if (!state) { saveJSON(STATE, { lastOn: latest.on }); return ''; } // bootstrap sans harceler
|
||
if (latest.on <= state.lastOn) return ''; // rien de nouveau
|
||
|
||
const jv = await jeepVerdict(latest.on);
|
||
if (jv.v === 'jeep') {
|
||
saveJSON(STATE, { lastOn: latest.on });
|
||
return `🔌 JEEP branchée à ${hhmm(latest.on)}` +
|
||
(jv.niveau !== null ? `, ${jv.niveau} % (lu par la voiture${jv.arrivee ? ' à son arrivée' : ''})` : ' (identifiée par la voiture)') +
|
||
`. Rien à faire ; si ce n'est pas elle, réponds avec le bon véhicule.`;
|
||
}
|
||
const age = Date.now() / 1000 - latest.on;
|
||
if (jv.v !== 'pas_jeep' && age < ATTENTE_UCONNECT_S) return ''; // peut-être la JEEP : on repasse au prochain cycle
|
||
|
||
if (age > DECOUVERTE_TARDIVE_S) { // la JEEP identifiée a déjà eu sa ligne d'info plus haut, même en retard
|
||
saveJSON(STATE, { lastOn: latest.on });
|
||
trace(`branchement ${latest.on_text} découvert ${Math.round(age / 60)} min après son heure : pas de question`);
|
||
return '';
|
||
}
|
||
const fresh = selectorFresh(latest.on, selChanges);
|
||
saveJSON(STATE, { lastOn: latest.on }); // traité : pas de re-question
|
||
if (fresh) return ''; // sélecteur réglé au branchement → confiance
|
||
|
||
const ref = 'V-' + Math.random().toString(36).slice(2, 5);
|
||
saveJSON(PENDING, {
|
||
ref, branchement: latest.on_text, askedAt: new Date().toISOString(), on: latest.on,
|
||
});
|
||
return `🔌 Voiture branchée à ${hhmm(latest.on)}. Quel véhicule est branché ? ` +
|
||
`(VW / JEEP / invité) [${ref}]`;
|
||
}
|
||
|
||
// Cycle detect : publie la santé À CHAQUE passage (battement de cœur HA) et n'alerte que
|
||
// sur transition. Une panne d'auth PostgreSQL n'est donc PLUS silencieuse — elle devient un
|
||
// statut HA + un WhatsApp unique. Sur reprise, le « de nouveau opérationnel » précède
|
||
// l'éventuelle question véhicule (le branchement en attente n'est pas perdu).
|
||
async function detect() {
|
||
let question = '', ok = true, detail = null;
|
||
try { question = await runDetection(); }
|
||
catch (e) { ok = false; detail = e.message; }
|
||
const transition = SIM ? '' : await health.publish({ ok, detail });
|
||
let alert = '';
|
||
if (!SIM) { try { alert = await checkAlert(); } catch { /* l'alerte ne casse jamais le cycle */ } }
|
||
let alerteEcs = '';
|
||
try { alerteEcs = await checkAlerteECS(); } catch { /* idem : jamais bloquant */ }
|
||
if (alerteEcs) alert = [alert, alerteEcs].filter(Boolean).join('\n\n');
|
||
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');
|
||
let arbitrage = '';
|
||
try { arbitrage = checkArbitrage(); } catch { /* idem : jamais bloquant */ }
|
||
if (arbitrage) alert = [alert, arbitrage].filter(Boolean).join('\n\n');
|
||
if (ok) return [transition, question, alert].filter(Boolean).join('\n\n');
|
||
return [transition, alert].filter(Boolean).join('\n\n'); // panne PG : santé + alerte quand même
|
||
}
|
||
|
||
// Labels en MAJUSCULES sans accent (convention colonne : VW/JEEP/MEGANE). Le propriétaire
|
||
// peut nommer l'invité (MEGANE) ; sinon INVITE. ZERO accepté (moto, si option ajoutée).
|
||
function parseLabel(text, options) {
|
||
const t = (text || '').toLowerCase();
|
||
for (const o of options) if (t.includes(o.toLowerCase())) return o;
|
||
if (/m[ée]gane/.test(t)) return 'MEGANE';
|
||
if (/moto|z[ée]ro/.test(t)) return 'ZERO';
|
||
if (/invit|pr[eê]t|ami|emprunt/.test(t)) return 'INVITE';
|
||
return null;
|
||
}
|
||
|
||
// Niveau ANNONCE dans le message (03/10/2026, decision du proprietaire) :
|
||
// « JEEP reste 70 % », « elle est a 40% », « 55 pour cent ». Un nombre SANS « % »
|
||
// ou « pour cent » n'est jamais pris pour un niveau (02/10 : « 92 170 »).
|
||
// Renvoie { brut, valeur } — valeur = entier 0-100 ou null si invalide ; le
|
||
// script REFUSE une valeur invalide, il ne la corrige jamais.
|
||
function parseNiveau(text) {
|
||
const m = /(\d{1,3}(?:[.,]\d+)?)\s*(?:%|pour\s*cent|pourcent)/i.exec(text || '');
|
||
if (!m) return null;
|
||
const v = Number(m[1].replace(',', '.'));
|
||
return { brut: m[1], valeur: Number.isInteger(v) && v >= 0 && v <= 100 ? v : null };
|
||
}
|
||
|
||
// RELEVES KILOMETRIQUES (04/10/2026, decision du proprietaire) : « JEEP 112694 km
|
||
// 54 % », un ou plusieurs vehicules par message. Toujours un NOM de vehicule puis
|
||
// un nombre suivi de « km » : un nombre seul n'est jamais un releve. Le niveau
|
||
// eventuel suit le km (« … km 54 % »). Les separateurs de milliers sont admis.
|
||
function parseReleves(text) {
|
||
const re = /\b(vw|jeep|z[ée]ro|moto)\b[^\d%]{0,20}?(\d{1,3}(?:[ .\u202f\u00a0]\d{3})+|\d+)\s*km\b(?:[^\d%]{0,15}?(\d{1,3}(?:[.,]\d+)?)\s*(?:%|pour\s*cent|pourcent))?/gi;
|
||
const out = [];
|
||
for (const m of (text || '').matchAll(re)) {
|
||
const nom = m[1].toLowerCase();
|
||
const vehicule = nom === 'vw' ? 'VW' : nom === 'jeep' ? 'JEEP' : 'ZERO';
|
||
const km = Number(m[2].replace(/[ .\u202f\u00a0]/g, ''));
|
||
const nv = m[3] === undefined ? null : Number(m[3].replace(',', '.'));
|
||
out.push({ vehicule, km, brutNiveau: m[3] ?? null,
|
||
niveau: nv === null ? null : (Number.isInteger(nv) && nv >= 0 && nv <= 100 ? nv : NaN),
|
||
segment: m[0] });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
const kmTexte = (km) => km.toLocaleString('fr-FR').replace(/\u202f/g, ' ');
|
||
|
||
// Le relevé se rattache-t-il au branchement en cours ? (pour l'horodater a
|
||
// l'instant du BRANCHEMENT : le compteur n'avance pas pendant la charge, et le
|
||
// service compte les charges posterieures au releve). Et peut-il etiqueter ce
|
||
// branchement ? Seulement s'il n'a pas d'etiquette et date de moins d'une heure :
|
||
// un releve de la ZERO pendant que la JEEP est branchee ne doit pas la renommer.
|
||
async function contexteBranchement() {
|
||
let connected = false;
|
||
try { connected = (await haState(CONNECTED)).state === 'on'; } catch { /* ignore */ }
|
||
if (!connected) return null;
|
||
let latest = null;
|
||
try { const w = await loadWindow(); latest = w.plugins[w.plugins.length - 1] || null; } catch { return null; }
|
||
if (!latest || latest.off !== null) return null;
|
||
let prev = {};
|
||
try { prev = JSON.parse((await haState(W.HELPER_ENTITY)).state || '{}'); } catch { /* sans etiquette */ }
|
||
const label = prev.branchement === latest.on_text && prev.label ? prev.label : null;
|
||
return { on: latest.on, label, recent: Date.now() / 1000 - latest.on <= 3600 };
|
||
}
|
||
|
||
async function answerReleves(text, releves) {
|
||
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.';
|
||
}
|
||
const ctx = releves.length === 1 ? await contexteBranchement() : null;
|
||
const r0 = releves[0];
|
||
let lignePrefixe = '', rattache = false;
|
||
if (ctx && ctx.label === r0.vehicule) rattache = true;
|
||
else if (ctx && !ctx.label && ctx.recent) {
|
||
// Branchement frais sans etiquette : le message vaut aussi confirmation
|
||
// (vehicule + niveau), exactement comme « JEEP 54 % ».
|
||
const sansKm = text.replace(r0.segment, `${r0.vehicule}${r0.niveau === null ? '' : ` ${r0.niveau} %`}`);
|
||
lignePrefixe = await answer(sansKm, { sansReleve: true });
|
||
rattache = lignePrefixe.startsWith('✅');
|
||
}
|
||
const maintenant = Math.round(Date.now() / 1000);
|
||
// 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) : (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 */ }
|
||
// 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}).`; }
|
||
}
|
||
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 = 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(nouveaux.find(r => r.niveau === null).km)} km 50 % »).`;
|
||
}
|
||
return lignePrefixe ? `${lignePrefixe} — ${ligne}` : ligne;
|
||
}
|
||
|
||
async function noterNiveau(niveau) {
|
||
if (!niveau) return '';
|
||
if (niveau.valeur === null) {
|
||
return `⚠️ Niveau NON noté : « ${niveau.brut} % » n'est pas un niveau valide (entier de 0 à 100).`;
|
||
}
|
||
// Meme dedoublonnage que l'etiquette : meme valeur saisie il y a moins de
|
||
// 5 min -> pas de reecriture (chaque ecriture recale la reference du compteur).
|
||
let dejaSaisi = false;
|
||
try {
|
||
const n = await haState(W.LEVEL_ENTITY);
|
||
dejaSaisi = Math.round(Number(n.state)) === niveau.valeur &&
|
||
Date.now() - Date.parse(n.last_reported) < 5 * 60 * 1000;
|
||
} catch { /* illisible : on ecrit */ }
|
||
if (!dejaSaisi) {
|
||
try { await setVehicleLevel(niveau.valeur, 'whatsapp'); }
|
||
catch (e) { return `⚠️ Niveau NON noté (${e.message}).`; }
|
||
}
|
||
// L'heure ecrite est celle de la SAISIE (03/10/2026) : le script n'enregistre
|
||
// aucune autre heure. La donner coupe court a « Noté : 53 % à 13:10 ».
|
||
const saisi = new Date().toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' });
|
||
return `🔋 Niveau noté : ${niveau.valeur} % (saisi à ${saisi}).`;
|
||
}
|
||
|
||
// Le branchement EN COURS a-t-il une etiquette concierge ? (03/10/2026) Un niveau
|
||
// seul ne dit pas quel vehicule est branche, et le selecteur n'est PAS une
|
||
// confirmation (session de 16:46 etiquetee JEEP pour la ZERO). Le 03/10 a 14:13,
|
||
// « JEEP » a ete annule par OpenClaw (message suivant en file) : seul « 53% » est
|
||
// passe, et le branchement est reste sans etiquette. Sans certitude -> on demande.
|
||
async function vehiculeNonConfirme() {
|
||
const question = " — ⚠️ Véhicule non confirmé pour ce branchement : lequel est-ce ?";
|
||
try {
|
||
const w = await loadWindow();
|
||
const latest = w.plugins[w.plugins.length - 1] || null;
|
||
if (!latest || latest.off !== null) return question;
|
||
const prev = JSON.parse((await haState(W.HELPER_ENTITY)).state || '{}');
|
||
return prev.branchement === latest.on_text && prev.label ? '' : question;
|
||
} catch { return question; }
|
||
}
|
||
|
||
async function answer(text, { sansReleve = false } = {}) {
|
||
if (!sansReleve) {
|
||
const releves = parseReleves(text);
|
||
if (releves.length) return await answerReleves(text, releves);
|
||
}
|
||
const niveau = parseNiveau(text);
|
||
const pending = SIM ? (SIM.pending || null) : loadJSON(PENDING, null);
|
||
let options = ['VW', 'JEEP'];
|
||
try {
|
||
const sel = await haState(SELECTOR);
|
||
if (Array.isArray(sel.attributes?.options)) options = sel.attributes.options;
|
||
} catch { /* défaut VW/JEEP */ }
|
||
const label = parseLabel(text, options);
|
||
if (!label && !niveau) return "(aucune action concierge : pas de véhicule nommé — ne relaie pas cette ligne, réponds à la question)"; // non reconnu : on ne publie rien
|
||
// Niveau SEUL (« elle est a 40 % ») : il vaut pour la voiture branchee, s'il y en a une.
|
||
if (!label) {
|
||
let connected = false;
|
||
try { connected = (await haState(CONNECTED)).state === 'on'; } catch { /* ignore */ }
|
||
if (!connected) return "Rien n'est noté : aucune voiture n'est branchée.";
|
||
const ligne = await noterNiveau(niveau);
|
||
return niveau.valeur === null ? ligne : ligne + await vehiculeNonConfirme();
|
||
}
|
||
|
||
// Cible de l'étiquette = le branchement à confirmer :
|
||
// - avec question en attente : celui du pending.
|
||
// - SANS question (message spontané, 19/09/2026) : la session EN COURS, si une
|
||
// voiture est physiquement branchée. Le 19/09 le propriétaire a écrit « JEEP »
|
||
// à 15h03 sans qu'aucune question soit posée (swap 14s non détecté) et l'info
|
||
// était jetée par l'ancienne règle « pas de pending → rien ». Son message
|
||
// compte désormais seul, tant qu'une voiture est là pour le rattacher.
|
||
let branchement, on;
|
||
if (pending) {
|
||
branchement = pending.branchement;
|
||
on = pending.on;
|
||
} else {
|
||
let connected = false;
|
||
try { connected = (await haState(CONNECTED)).state === 'on'; } catch { /* ignore */ }
|
||
if (!connected) return "Rien n'est noté : aucune voiture n'est branchée."; // rien branché → aucune session à étiqueter
|
||
let latest = null;
|
||
try { const w = await loadWindow(); latest = w.plugins[w.plugins.length - 1] || null; }
|
||
catch { return "Rien n'est noté : historique des branchements illisible (base indisponible), je ne devine pas."; } // PostgreSQL indisponible
|
||
if (!latest || latest.off !== null) return "Rien n'est noté : aucun branchement en cours dans l'historique de la borne."; // pas de branchement en cours
|
||
branchement = latest.on_text;
|
||
on = latest.on;
|
||
}
|
||
// DEDOUBLONNAGE (03/10/2026) : l'agent a deja lance answer deux fois pour le
|
||
// meme message. Meme vehicule, meme branchement, confirme il y a moins de
|
||
// 5 min -> on redonne la meme reponse SANS reecrire (sinon une seconde
|
||
// confirmation, et un evenement en double cote EVCS).
|
||
let dejaNote = false;
|
||
try {
|
||
const prev = JSON.parse((await haState(W.HELPER_ENTITY)).state || '{}');
|
||
dejaNote = prev.branchement === branchement && prev.label === label &&
|
||
Date.now() - Date.parse(prev.confirmed_at) < 5 * 60 * 1000;
|
||
} catch { /* illisible : on publie */ }
|
||
if (!dejaNote) await publishLabel(branchement, label, 'whatsapp');
|
||
// Confirmation HUMAINE d'un véhicule du FOYER : le sélecteur temps réel suit
|
||
// (il pilote l'heure de départ VW 08:50 / JEEP 06:30, donc la priorité de
|
||
// nuit). Pour MEGANE/INVITE/ZERO on n'y touche pas. Demandé le 19/09/2026 :
|
||
// « répondre WhatsApp PUIS changer le sélecteur à la main », c'est fini.
|
||
// Si l'écriture échoue, l'étiquette est déjà publiée : on le DIT au lieu
|
||
// d'échouer, pour que le propriétaire fasse le geste manuel en connaissance.
|
||
let selectorNote = '';
|
||
if (SELECTOR_LABELS.includes(label) && !dejaNote) {
|
||
try { await setVehicleSelector(label, 'whatsapp'); }
|
||
catch (e) {
|
||
selectorNote = ` — ⚠️ Sélecteur véhicule NON mis à jour (${e.message}) — ` +
|
||
`pense à le passer sur ${label} à la main.`;
|
||
}
|
||
}
|
||
if (pending && !SIM) fs.rmSync(PENDING, { force: true });
|
||
const when = new Date(on * 1000).toLocaleString('fr-FR', { timeZone: 'Europe/Paris' });
|
||
const ligneNiveau = await noterNiveau(niveau);
|
||
return `✅ Noté : ${label} pour la voiture branchée du ${when}. Merci !${selectorNote}` +
|
||
(ligneNiveau ? ` — ${ligneNiveau}` : '');
|
||
}
|
||
|
||
// Alerte "voiture branchée mais ne charge pas" (19/09/2026). EVCS détecte l'anomalie
|
||
// (véhicule branché + borne quasi 0 W + état bloquant + excédent solaire, tenu > 10 min)
|
||
// et écrit input_text.evcs_alerte_charge. Ici on relaie SON champ `message` sur WhatsApp
|
||
// UNE fois par épisode — clef de dedup = `since` — derrière un kill-switch. Le helper
|
||
// vaut '' / 'unknown' hors anomalie : rien ne part. Ne casse jamais le cycle detect.
|
||
async function checkAlert() {
|
||
if (fs.existsSync(ALERT_KILL)) return ''; // 2e cran local : coupure d'urgence
|
||
// Interrupteur maître du propriétaire (HA). Illisible → on n'envoie pas (fail-safe :
|
||
// en cas de doute, ne pas spammer).
|
||
try { if ((await haState(ALERT_ENABLE)).state !== 'on') return ''; }
|
||
catch { return ''; }
|
||
let raw;
|
||
try { raw = (await haState(ALERT_HELPER)).state; } catch { return ''; }
|
||
if (!raw || raw === 'unknown' || raw === 'unavailable') return '';
|
||
let a; try { a = JSON.parse(raw); } catch { return ''; }
|
||
if (!a || a.active !== true || !a.since || !a.message) return '';
|
||
const st = loadJSON(ALERT_STATE, {});
|
||
if (st.lastSince === a.since) return ''; // déjà relayé pour cet épisode
|
||
saveJSON(ALERT_STATE, { lastSince: a.since });
|
||
return String(a.message).slice(0, 300);
|
||
}
|
||
|
||
async function checkAlerteECS() {
|
||
if (fs.existsSync(ALERT_KILL)) return '';
|
||
let raw;
|
||
try { raw = (await haState(ECS_HELPER)).state; } catch { return ''; }
|
||
if (!raw || raw === 'unknown' || raw === 'unavailable') return '';
|
||
let a; try { a = JSON.parse(raw); } catch { return ''; }
|
||
if (!a || a.active !== true || !a.since || !a.message) return '';
|
||
const st = SIM ? (SIM.ecsState || {}) : loadJSON(ECS_STATE, {});
|
||
if (st.lastSince === a.since) return ''; // déjà relayée pour cette fenêtre HC
|
||
saveJSON(ECS_STATE, { lastSince: a.since });
|
||
return String(a.message).slice(0, 300);
|
||
}
|
||
|
||
// 10/10/2026, demande du propriétaire (via EVCS) : revue glissante. EVCS publie sensor.evcs_alerte_revue
|
||
// (id, code, texte, a) ; on relaie « ⚠️ texte » UNE fois par id, jour et nuit. Alerte de plus de 2 h (mise en
|
||
// service, retour après une panne) → notée sans envoi. Capteur absent ou illisible → rien.
|
||
const REVUE = 'sensor.evcs_alerte_revue';
|
||
const REVUE_STATE = path.join(__dirname, 'concierge-alerte-revue-state.json');
|
||
async function checkAlerteRevue() {
|
||
if (fs.existsSync(ALERT_KILL)) return '';
|
||
let s;
|
||
if (SIM) { if (!SIM.revue) return ''; s = { state: SIM.revue.id, attributes: SIM.revue }; }
|
||
else { try { s = await haState(REVUE); } catch { return ''; } }
|
||
const a = s?.attributes || {};
|
||
if (!a.id || !a.texte || s.state === 'unknown' || s.state === 'unavailable') return '';
|
||
const st = SIM ? (SIM.revueState || {}) : loadJSON(REVUE_STATE, {});
|
||
if (st.lastId === a.id) return '';
|
||
saveJSON(REVUE_STATE, { lastId: a.id });
|
||
const age = (SIM?.maintenant ?? Date.now()) - Date.parse(a.a || '');
|
||
if (!(age <= 2 * 3600 * 1000)) return ''; // trop vieille ou sans date : notée, pas envoyée
|
||
return `⚠️ ${String(a.texte).trim().slice(0, 400)}`;
|
||
}
|
||
|
||
// 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, 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';
|
||
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: Array.isArray(SIM.piles) ? { alertes: SIM.piles } : SIM.piles }; }
|
||
else { try { s = await haState(PILES); } catch { return ''; } }
|
||
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 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 '';
|
||
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
|
||
// relayé. Tout est dans arbitrage.js (seul à écrire la réponse). Tests : SIM.arbitrage → ARBITRAGE_SIMULATION,
|
||
// sans lui un banc CONCIERGE_SIMULATION ne lit pas l'arbitrage réel.
|
||
function checkArbitrage() {
|
||
if (fs.existsSync(ALERT_KILL)) return '';
|
||
if (SIM && !SIM.arbitrage) return '';
|
||
const env = SIM ? { ...process.env, ARBITRAGE_SIMULATION: JSON.stringify({ arbitrage: SIM.arbitrage, etat: SIM.arbitrageEtat || {} }) } : process.env;
|
||
const run = (c) => require('child_process').execFileSync('node', [path.join(__dirname, 'arbitrage.js'), c],
|
||
{ env, timeout: 30000, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
||
return [run('application'), run('question')].filter(Boolean).join('\n\n');
|
||
}
|
||
|
||
const cmd = process.argv[2];
|
||
(async () => {
|
||
let out = '';
|
||
if (cmd === 'detect') out = await detect();
|
||
else if (cmd === 'answer') {
|
||
try { out = await answer(process.argv.slice(3).join(' ')); }
|
||
catch (e) { out = `Rien n'est noté : erreur du script (${e.message}).`; }
|
||
}
|
||
else { console.error('usage: concierge-live.js detect | answer "<texte>"'); process.exit(2); }
|
||
// UNE seule ligne (03/10/2026) : l'agent relayait parfois une ligne sur deux.
|
||
if (out && SIM && cmd === 'answer') out += ' [SIMULATION : rien n\'a été écrit dans Home Assistant]';
|
||
if (out) console.log(out);
|
||
})().catch(e => { console.error('[concierge-live]', e.message); process.exit(1); });
|