// 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 "" → 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 { publishLabel } = require('./concierge-write.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'; const SELECTOR = 'input_select.victron_evcs_vehicle'; 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) => { 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(); } // 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() { 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(); } } const hhmm = (epoch) => new Date(epoch * 1000) .toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' }); async function detect() { 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 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}]`; } // 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; } 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 : on ne publie rien await publishLabel(pending.branchement, label, 'whatsapp'); fs.rmSync(PENDING, { force: true }); const when = new Date(pending.on * 1000).toLocaleString('fr-FR', { timeZone: 'Europe/Paris' }); return `✅ Noté : ${label} pour la voiture branchée 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); });