Files
OPENCLAW_ARCHIVE3_17/workspace/concierge-live.js
Thierry Tallagrand b834e3bf75 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
2026-09-15 04:59:15 +02:00

150 lines
7.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 { 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 "<texte>"'); process.exit(2); }
if (out) console.log(out);
})().catch(e => { console.error('[concierge-live]', e.message); process.exit(1); });