Complete le commit doc precedent : ajoute le code JS du concierge, exclu jusqu'ici par la liste blanche workspace/*. Exception ciblee !workspace/concierge-*.js dans .gitignore (aucun secret : les tokens sont lus depuis .ha-token/.pg-airtria, ignores). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RZsbhyGmRwZogdoBwT6C6y
157 lines
7.5 KiB
JavaScript
157 lines
7.5 KiB
JavaScript
// 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 "<txt>" → 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 "<texte>"'); process.exit(2); }
|
||
if (out) console.log(out);
|
||
})().catch(e => { console.error('[concierge-live]', e.message); process.exit(1); });
|