Files
OPENCLAW_ARCHIVE3_17/workspace/concierge-live.js
Thierry Tallagrand 534ae0d663 Concierge vehicule : code (detection, live, write + test de portee)
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
2026-09-14 02:39:00 +02:00

157 lines
7.5 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).
// -----------------------------------------------------------------------------
// 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); });