Files
OPENCLAW_ARCHIVE3_17/workspace/concierge-live.js
Thierry Tallagrand 5dc72349e5 workspace: niveau du vehicule par le concierge (option A), simulation par drapeau
- concierge-write.js : setVehicleLevel (entier 0-100, refus sans correction,
  relecture), ZERO dans SELECTOR_LABELS ; tests 13/13.
- concierge-live.js answer : reconnait « NN % », ecrit le niveau, lignes
  « 🔋 Niveau noté » / « ⚠️ Niveau NON noté ».
- Drapeau workspace/.simulation lu par concierge-live.js et airtria-ha-vehicule.js :
  aucune ecriture HA pendant un banc agent.
- airtria-ha-vehicule.js : validation de tous les arguments avant ecriture.
- AGENTS.md et reference-concierge.md : vehicule et niveau passent par le concierge.
- Journaux de tri Orange et AIRTRIA, DREAMS.
2026-10-03 00:19:15 +02:00

295 lines
15 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 W = require('./concierge-write.js');
const { SELECTOR_ENTITY, SELECTOR_LABELS } = W;
// CONCIERGE_SIMULATION='{"connected":"on|off","options":[..],"plugin":{"on":<epoch>,"on_text":"..","off":null}|null,
// "pending":{..}|null,"pgDown":false}' → answer tourne SANS HA, SANS PostgreSQL et SANS AUCUNE écriture
// (tests unitaires, 30/09/2026). Hors simulation : comportement réel inchangé.
// 03/10/2026 : la simulation s'active AUSSI par un fichier drapeau `.simulation`
// a cote du script (meme JSON, ou `{}`). Un banc qui passe par l'agent complet
// ne controle pas l'environnement des commandes que l'agent lance : seul un
// fichier pose avant le banc protege l'installation (incidents du 30/09 et du
// 02/10, ecritures reelles pendant des tests).
const SIM_FILE = path.join(__dirname, '.simulation');
const SIM = process.env.CONCIERGE_SIMULATION ? JSON.parse(process.env.CONCIERGE_SIMULATION)
: fs.existsSync(SIM_FILE) ? (() => { try { return JSON.parse(fs.readFileSync(SIM_FILE, 'utf8') || '{}'); } catch { return {}; } })()
: null;
const publishLabel = SIM ? async () => {} : W.publishLabel;
const setVehicleSelector = SIM ? async () => {} : W.setVehicleSelector;
const setVehicleLevel = SIM ? async () => {} : W.setVehicleLevel;
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';
// Une seule source de vérité pour l'entité : celle du canal d'écriture.
const SELECTOR = SELECTOR_ENTITY;
const CONNECTED = 'binary_sensor.victron_evcs_vehicle_connected';
// Alerte "branchée mais ne charge pas" : EVCS détecte et écrit ce helper, on relaie.
const ALERT_HELPER = 'input_text.evcs_alerte_charge';
// Interrupteur MAÎTRE, visible et contrôlé par le propriétaire depuis HA (créé par EVCS
// le 19/09/2026). OFF par défaut (opt-in) tant que les seuils 500W/10min ne sont pas
// calibrés. Rien ne part tant qu'il n'est pas 'on'.
const ALERT_ENABLE = 'input_boolean.evcs_alerte_charge_enabled';
const ALERT_STATE = path.join(__dirname, 'concierge-alert-state.json'); // dedup sur `since`
const ALERT_KILL = path.join(__dirname, 'concierge-alert.disabled'); // 2e cran local d'urgence
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) {
if (SIM) {
if (entity === CONNECTED) return { state: SIM.connected };
return { state: '', attributes: { options: SIM.options || ['VW', 'JEEP', 'ZERO'] } };
}
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() {
if (SIM) {
if (SIM.pgDown) throw new Error('PostgreSQL simulé indisponible');
return { plugins: SIM.plugin ? [SIM.plugin] : [], selChanges: [] };
}
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 });
let alert = '';
try { alert = await checkAlert(); } catch { /* l'alerte ne casse jamais le cycle */ }
if (ok) return [transition, question, alert].filter(Boolean).join('\n\n');
return [transition, alert].filter(Boolean).join('\n\n'); // panne PG : santé + alerte quand même
}
// 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;
}
// Niveau ANNONCE dans le message (03/10/2026, decision du proprietaire) :
// « JEEP reste 70 % », « elle est a 40% », « 55 pour cent ». Un nombre SANS « % »
// ou « pour cent » n'est jamais pris pour un niveau (02/10 : « 92 170 »).
// Renvoie { brut, valeur } — valeur = entier 0-100 ou null si invalide ; le
// script REFUSE une valeur invalide, il ne la corrige jamais.
function parseNiveau(text) {
const m = /(\d{1,3}(?:[.,]\d+)?)\s*(?:%|pour\s*cent|pourcent)/i.exec(text || '');
if (!m) return null;
const v = Number(m[1].replace(',', '.'));
return { brut: m[1], valeur: Number.isInteger(v) && v >= 0 && v <= 100 ? v : null };
}
async function noterNiveau(niveau) {
if (!niveau) return '';
if (niveau.valeur === null) {
return `⚠️ Niveau NON noté : « ${niveau.brut} % » n'est pas un niveau valide (entier de 0 à 100).`;
}
try { await setVehicleLevel(niveau.valeur, 'whatsapp'); }
catch (e) { return `⚠️ Niveau NON noté (${e.message}).`; }
return `🔋 Niveau noté : ${niveau.valeur} %.`;
}
async function answer(text) {
const niveau = parseNiveau(text);
const pending = SIM ? (SIM.pending || null) : loadJSON(PENDING, null);
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 && !niveau) return "(aucune action concierge : pas de véhicule nommé — ne relaie pas cette ligne, réponds à la question)"; // non reconnu : on ne publie rien
// Niveau SEUL (« elle est a 40 % ») : il vaut pour la voiture branchee, s'il y en a une.
if (!label) {
let connected = false;
try { connected = (await haState(CONNECTED)).state === 'on'; } catch { /* ignore */ }
if (!connected) return "Rien n'est noté : aucune voiture n'est branchée.";
return await noterNiveau(niveau);
}
// Cible de l'étiquette = le branchement à confirmer :
// - avec question en attente : celui du pending.
// - SANS question (message spontané, 19/09/2026) : la session EN COURS, si une
// voiture est physiquement branchée. Le 19/09 le propriétaire a écrit « JEEP »
// à 15h03 sans qu'aucune question soit posée (swap 14s non détecté) et l'info
// était jetée par l'ancienne règle « pas de pending → rien ». Son message
// compte désormais seul, tant qu'une voiture est là pour le rattacher.
let branchement, on;
if (pending) {
branchement = pending.branchement;
on = pending.on;
} else {
let connected = false;
try { connected = (await haState(CONNECTED)).state === 'on'; } catch { /* ignore */ }
if (!connected) return "Rien n'est noté : aucune voiture n'est branchée."; // rien branché → aucune session à étiqueter
let latest = null;
try { const w = await loadWindow(); latest = w.plugins[w.plugins.length - 1] || null; }
catch { return "Rien n'est noté : historique des branchements illisible (base indisponible), je ne devine pas."; } // PostgreSQL indisponible
if (!latest || latest.off !== null) return "Rien n'est noté : aucun branchement en cours dans l'historique de la borne."; // pas de branchement en cours
branchement = latest.on_text;
on = latest.on;
}
await publishLabel(branchement, label, 'whatsapp');
// Confirmation HUMAINE d'un véhicule du FOYER : le sélecteur temps réel suit
// (il pilote l'heure de départ VW 08:50 / JEEP 06:30, donc la priorité de
// nuit). Pour MEGANE/INVITE/ZERO on n'y touche pas. Demandé le 19/09/2026 :
// « répondre WhatsApp PUIS changer le sélecteur à la main », c'est fini.
// Si l'écriture échoue, l'étiquette est déjà publiée : on le DIT au lieu
// d'échouer, pour que le propriétaire fasse le geste manuel en connaissance.
let selectorNote = '';
if (SELECTOR_LABELS.includes(label)) {
try { await setVehicleSelector(label, 'whatsapp'); }
catch (e) {
selectorNote = `\n⚠️ Sélecteur véhicule NON mis à jour (${e.message}) — ` +
`pense à le passer sur ${label} à la main.`;
}
}
if (pending && !SIM) fs.rmSync(PENDING, { force: true });
const when = new Date(on * 1000).toLocaleString('fr-FR', { timeZone: 'Europe/Paris' });
const ligneNiveau = await noterNiveau(niveau);
return `✅ Noté : ${label} pour la voiture branchée du ${when}. Merci !${selectorNote}` +
(ligneNiveau ? `\n${ligneNiveau}` : '');
}
// Alerte "voiture branchée mais ne charge pas" (19/09/2026). EVCS détecte l'anomalie
// (véhicule branché + borne quasi 0 W + état bloquant + excédent solaire, tenu > 10 min)
// et écrit input_text.evcs_alerte_charge. Ici on relaie SON champ `message` sur WhatsApp
// UNE fois par épisode — clef de dedup = `since` — derrière un kill-switch. Le helper
// vaut '' / 'unknown' hors anomalie : rien ne part. Ne casse jamais le cycle detect.
async function checkAlert() {
if (fs.existsSync(ALERT_KILL)) return ''; // 2e cran local : coupure d'urgence
// Interrupteur maître du propriétaire (HA). Illisible → on n'envoie pas (fail-safe :
// en cas de doute, ne pas spammer).
try { if ((await haState(ALERT_ENABLE)).state !== 'on') return ''; }
catch { return ''; }
let raw;
try { raw = (await haState(ALERT_HELPER)).state; } catch { return ''; }
if (!raw || raw === 'unknown' || raw === 'unavailable') return '';
let a; try { a = JSON.parse(raw); } catch { return ''; }
if (!a || a.active !== true || !a.since || !a.message) return '';
const st = loadJSON(ALERT_STATE, {});
if (st.lastSince === a.since) return ''; // déjà relayé pour cet épisode
saveJSON(ALERT_STATE, { lastSince: a.since });
return String(a.message).slice(0, 300);
}
const cmd = process.argv[2];
(async () => {
let out = '';
if (cmd === 'detect') out = await detect();
else if (cmd === 'answer') {
try { out = await answer(process.argv.slice(3).join(' ')); }
catch (e) { out = `Rien n'est noté : erreur du script (${e.message}).`; }
}
else { console.error('usage: concierge-live.js detect | answer "<texte>"'); process.exit(2); }
if (out && SIM && cmd === 'answer') out += '\n[SIMULATION : rien n\'a été écrit dans Home Assistant]';
if (out) console.log(out);
})().catch(e => { console.error('[concierge-live]', e.message); process.exit(1); });