// concierge-live.js — Concierge véhicule Airtria, ÉTAGE QUESTION/RÉPONSE (live). // ----------------------------------------------------------------------------- // Deux sous-commandes, appelées par l'agent : // detect → cron 10 min : repère un nouveau branchement, décide // (invité auto | question | rien), imprime la question WhatsApp // ou rien. Écrit l'état en attente. Publie le helper si invité. // answer "" → réception d'une réponse WhatsApp : reconnaît le véhicule, // publie le helper pour les sessions en attente, confirme. // Tout le déterministe est ici ; l'agent ne fait que router. Voir SPEC. // ----------------------------------------------------------------------------- 'use strict'; const fs = require('fs'); const path = require('path'); const { buildPlugins, assign, isGuest, makeClient, SQL_TRANSITIONS, SQL_SESSIONS, } = require('./concierge-detection.js'); const { publishLabel } = require('./concierge-write.js'); const STATE = path.join(__dirname, 'concierge-state.json'); // progression const PENDING = path.join(__dirname, 'concierge-pending.json'); // question ouverte const HA_BASE = process.env.HA_BASE || 'http://10.10.10.17:8123'; const SELECTOR = 'input_select.victron_evcs_vehicle'; // lecture seule (HA) const WINDOW_DAYS = 5; const loadJSON = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } }; const saveJSON = (f, o) => fs.writeFileSync(f, JSON.stringify(o, null, 2)); const haToken = () => fs.readFileSync( process.env.HA_TOKEN_FILE || path.join(__dirname, '.ha-token'), 'utf8').trim(); async function haState(entity) { 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(); // { state, last_changed, attributes:{options?} } } // Changements de VALEUR du sélecteur, lus dans evenement (robuste : le détecteur // saute les lectures `unavailable`, contrairement au last_changed HA qu'un blip MQTT // remet à zéro). genre='intervention', entite=input_select.victron_evcs_vehicle. const SQL_SELECTOR_CHANGES = ` SELECT extract(epoch FROM ts) AS ts_epoch, extract(epoch FROM (detail->>'horodatage_ha')::timestamptz) AS ha_epoch FROM evenement WHERE genre = 'intervention' AND detail->>'entite' = 'input_select.victron_evcs_vehicle' AND ts >= $1 AND ts < $2 ORDER BY ts`; async function loadWindow() { const until = new Date().toISOString(); const since = new Date(Date.now() - WINDOW_DAYS * 864e5).toISOString(); const c = makeClient(); await c.connect(); try { // Requêtes séquentielles : node-postgres n'aime pas des query() concurrentes // sur un même client (avertissement de dépréciation, retrait prévu en pg@9). const tr = await c.query(SQL_TRANSITIONS, [since, until]); const se = await c.query(SQL_SESSIONS, [since, until]); const sc = await c.query(SQL_SELECTOR_CHANGES, [since, until]); const plugins = buildPlugins(tr.rows); const { groups } = assign(se.rows, plugins); const selChanges = sc.rows.map(r => Number(r.ha_epoch != null ? r.ha_epoch : r.ts_epoch)); return { groups: groups.slice().sort((a, b) => a.plugin.on - b.plugin.on), selChanges, }; } finally { await c.end(); } } // Fraîcheur du sélecteur (règle soutenue par les données, EVCS 2026-09-13) : FRAIS // seulement si un changement de sélecteur tombe dans [début − 10 min, début + 30 min]. // Un changement plus tardif (en cours de branchement) ne peut pas étiqueter CE // branchement (câble non bougé) → producteur des groupes mixtes → DEMANDER. Pas de // changement → DEMANDER. Absence d'événement ne prouve pas tout (mémoire détecteur // en-processus, redémarrages), mais dans le doute on demande = le bon côté. const FRESH_BEFORE_S = 600, FRESH_AFTER_S = 1800; function selectorFreshAt(onEpoch, selChanges) { return selChanges.some(c => c >= onEpoch - FRESH_BEFORE_S && c <= onEpoch + FRESH_AFTER_S); } const hhmm = (epoch) => new Date(epoch * 1000) .toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' }); async function detect() { const { groups, selChanges } = await loadWindow(); if (groups.length === 0) return ''; const latest = groups[groups.length - 1]; const state = loadJSON(STATE, null); // Bootstrap : au 1er lancement, prendre acte de l'historique sans harceler. if (!state) { saveJSON(STATE, { lastOn: latest.plugin.on }); return ''; } if (latest.plugin.on <= state.lastOn) return ''; // rien de nouveau // Invité conclusif (règle puissance) → publier, aucune question. // Label INVITE (majuscules, sans accent) = convention de la colonne (VW/JEEP/ // MEGANE). La règle de puissance ne sait PAS quelle voiture c'est → jamais MEGANE. if (latest.sessions.every(isGuest)) { await publishLabel(latest.sessions.map(s => s.debut_text), 'INVITE', 'regle-puissance'); saveJSON(STATE, { lastOn: latest.plugin.on }); return ''; } // Fraîcheur du sélecteur via evenement (fenêtre serrée autour du début), jamais // via last_changed (qu'un blip unavailable remet à zéro). const fresh = selectorFreshAt(latest.plugin.on, selChanges); saveJSON(STATE, { lastOn: latest.plugin.on }); // traité : pas de re-question if (fresh) return ''; // sélecteur réglé au branchement → confiance, pas de question const ref = 'V-' + Math.random().toString(36).slice(2, 5); saveJSON(PENDING, { ref, sessions: latest.sessions.map(s => s.debut_text), askedAt: new Date().toISOString(), pluginOn: latest.plugin.on, }); return `🔌 Charge démarrée à ${hhmm(latest.plugin.on)}. ` + `Quel véhicule est branché ? (VW / JEEP / invité) [${ref}]`; } // Labels en MAJUSCULES sans accent (convention de la colonne : VW/JEEP/MEGANE). // Le propriétaire peut NOMMER l'invité (MEGANE) ; sinon INVITE générique. // ZERO (moto) : accepté par WhatsApp bien que le sélecteur HA ne l'expose pas // encore — le propriétaire ajoutera l'option quand la moto sera en service. function parseLabel(text, options) { const t = (text || '').toLowerCase(); for (const o of options) if (t.includes(o.toLowerCase())) return o; // VW/JEEP (options HA) 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; } async function answer(text) { const pending = loadJSON(PENDING, null); if (!pending) return ''; 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) return ''; // non reconnu : l'agent pourra redemander, on ne publie rien await publishLabel(pending.sessions, label, 'whatsapp'); fs.rmSync(PENDING, { force: true }); const when = new Date(pending.pluginOn * 1000) .toLocaleString('fr-FR', { timeZone: 'Europe/Paris' }); return `✅ Noté : ${label} pour la charge du ${when}. Merci !`; } const cmd = process.argv[2]; (async () => { let out = ''; if (cmd === 'detect') out = await detect(); else if (cmd === 'answer') out = await answer(process.argv.slice(3).join(' ')); else { console.error('usage: concierge-live.js detect | answer ""'); process.exit(2); } if (out) console.log(out); })().catch(e => { console.error('[concierge-live]', e.message); process.exit(1); });