Concierge au branchement + moniteur LLM/RAM + docs

Concierge: declenche au BRANCHEMENT (transition on de evenement) au lieu de la session ;
helper porte 'branchement' (ts::text verbatim) ; fraicheur [on-10, min(now, on+30)].
Moniteur LLM/RAM (llm-monitor.js) : endpoint spark + RAM, alerte WhatsApp sur changement
d'etat, publie l'etat dans HA (last_seen a chaque passage) + interrupteur de pilotage.
Docs: SPEC a jour, MONITORING-LLM.md, brouillons de config HA.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RZsbhyGmRwZogdoBwT6C6y
This commit is contained in:
2026-09-15 04:59:15 +02:00
parent 534ae0d663
commit b834e3bf75
9 changed files with 345 additions and 94 deletions

View File

@@ -1,128 +1,122 @@
// 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.
// 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 {
buildPlugins, assign, isGuest, makeClient, SQL_TRANSITIONS, SQL_SESSIONS,
} = require('./concierge-detection.js');
const { makeClient } = 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 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'; // lecture seule (HA)
const SELECTOR = 'input_select.victron_evcs_vehicle';
const WINDOW_DAYS = 5;
const FRESH_BEFORE_S = 600, FRESH_AFTER_S = 1800;
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
// 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 {
// 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,
};
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(); }
}
// 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 { plugins, selChanges } = await loadWindow();
if (plugins.length === 0) return '';
const latest = plugins[plugins.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
if (!state) { saveJSON(STATE, { lastOn: latest.on }); return ''; } // bootstrap sans harceler
if (latest.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 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, sessions: latest.sessions.map(s => s.debut_text),
askedAt: new Date().toISOString(), pluginOn: latest.plugin.on,
ref, branchement: latest.on_text, askedAt: new Date().toISOString(), on: latest.on,
});
return `🔌 Charge démarrée à ${hhmm(latest.plugin.on)}. ` +
`Quel véhicule est branché ? (VW / JEEP / invité) [${ref}]`;
return `🔌 Voiture branchée à ${hhmm(latest.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.
// 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; // VW/JEEP (options HA)
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';
@@ -138,12 +132,11 @@ async function answer(text) {
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');
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.pluginOn * 1000)
.toLocaleString('fr-FR', { timeZone: 'Europe/Paris' });
return `✅ Noté : ${label} pour la charge du ${when}. Merci !`;
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];