Le mot de passe airtria_ro est desormais lu depuis AIRTRIA_RO_PASSWORD (variable d'environnement, sinon .env monte dans le conteneur), plus depuis .pg-airtria : EVCS renouvelle ce mot de passe, un fichier fige faisait echouer l'auth EN SILENCE. readPgPassword LEVE (rattrapable) au lieu de process.exit ; mot de passe resolu paresseusement dans makeClient. Nouveau module concierge-health.js (meme patron que le moniteur LLM) : battement de coeur horodate a CHAQUE cycle detect, statut live dans HA, et alerte WhatsApp UNIQUEMENT sur transition ok<->erreur (dedup, pas de rappel periodique). Chemin HA independant de PostgreSQL : l'alerte auth KO part meme quand c'est l'auth qui tombe. Best-effort : si les helpers HA manquent, le concierge n'echoue pas. - concierge-health.test.js : machine a etats (dedup + transitions) verifiee, sans reseau - concierge-health-ha-package.yaml.draft : helpers HA (a integrer cote proprietaire) - SPEC : source du secret corrigee + section supervision Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RZsbhyGmRwZogdoBwT6C6y
165 lines
7.8 KiB
JavaScript
165 lines
7.8 KiB
JavaScript
// 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 health = require('./concierge-health.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' });
|
||
|
||
// Détection pure : PEUT LEVER (erreur PG/auth via loadWindow). Renvoie la question ou ''.
|
||
async function runDetection() {
|
||
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}]`;
|
||
}
|
||
|
||
// Cycle detect : publie la santé À CHAQUE passage (battement de cœur HA) et n'alerte que
|
||
// sur transition. Une panne d'auth PostgreSQL n'est donc PLUS silencieuse — elle devient un
|
||
// statut HA + un WhatsApp unique. Sur reprise, le « de nouveau opérationnel » précède
|
||
// l'éventuelle question véhicule (le branchement en attente n'est pas perdu).
|
||
async function detect() {
|
||
let question = '', ok = true, detail = null;
|
||
try { question = await runDetection(); }
|
||
catch (e) { ok = false; detail = e.message; }
|
||
const transition = await health.publish({ ok, detail });
|
||
if (ok) return [transition, question].filter(Boolean).join('\n\n');
|
||
return transition; // échec : WhatsApp seulement à la bascule vers panne
|
||
}
|
||
|
||
// 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); });
|