Files
OPENCLAW_ARCHIVE3_17/workspace/concierge-live.js

561 lines
32 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;
// En simulation, chaque ecriture evitee est tracee sur stderr (jamais relayee) :
// les tests comptent ainsi ce qui AURAIT ete ecrit.
const simTrace = (nom) => async (...a) => { console.error(`[SIM] ${nom} ${JSON.stringify(a)}`); };
const publishLabel = SIM ? simTrace('publishLabel') : W.publishLabel;
const setVehicleSelector = SIM ? simTrace('setVehicleSelector') : W.setVehicleSelector;
const setVehicleLevel = SIM ? simTrace('setVehicleLevel') : W.setVehicleLevel;
const setReleveKm = SIM ? simTrace('setReleveKm') : W.setReleveKm;
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
// 07/10/2026, demande explicite du propriétaire (via EVCS) : alerte chauffe-eau en heures creuses EDF quand il
// ne tire que la pompe à chaleur. Le workflow ECS pose le helper ; on relaie `message` UNE fois par fenêtre
// (dedup sur `since`). Pas d'interrupteur d'opt-in ; même coupure d'urgence locale que l'alerte de charge.
const ECS_HELPER = 'input_text.evcs_alerte_chauffe_eau';
const ECS_STATE = path.join(__dirname, 'concierge-alerte-ecs-state.json');
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) => {
if (SIM && f === STATE) return SIM.state ?? d; // simulation : état fourni par le banc
try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } };
const saveJSON = (f, o) => { if (SIM) return; fs.writeFileSync(f, JSON.stringify(o, null, 2)); }; // simulation : rien sur disque
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 };
if (entity === W.HELPER_ENTITY) return { state: SIM.lastLabel || '' };
if (entity === W.RELEVE_ENTITY) return { state: SIM.releves || '[]' };
if (entity === W.LEVEL_ENTITY) return { state: String(SIM.lastLevel?.value ?? ''), last_reported: SIM.lastLevel?.at };
if (entity === ECS_HELPER) return { state: SIM.ecs === undefined ? '' : (typeof SIM.ecs === 'string' ? SIM.ecs : JSON.stringify(SIM.ecs)) };
if (entity === JEEP_CHARGER) return { state: SIM.jeep?.charger ?? 'unknown' };
if (entity === JEEP_UPDATE) return { state: SIM.jeep?.update ?? 'unknown' };
if (entity === JEEP_HV) return { state: String(SIM.jeep?.hv ?? 'unknown') };
if (entity === JEEP_IGN) return { state: SIM.jeep?.ignition ?? 'off' };
if (entity === JEEP_LOC) return { state: 'x', attributes: { latitude: SIM.jeep?.lat ?? 48.7903, longitude: SIM.jeep?.lon ?? -3.5327 } };
if (entity === JEEP_LOC_AT) return { state: SIM.jeep?.locAt ?? '1970-01-01T00:00:00+00:00' };
if (entity === ZONE_JEEP) return { state: '0', attributes: { latitude: 48.7903, longitude: -3.5327, radius: 150 } };
if (entity === JEEP_ODO) return { state: '112787', last_changed: SIM.jeep?.odoChanged ?? '1970-01-01T00:00:00+00:00' };
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(); }
}
// 04/10/2026, variante (a) validée par le propriétaire : la JEEP se déclare elle-même (intégration
// Uconnect, cloud relu toutes les 15 min). Même critère qu'EVCS : chargeur Jeep « on » et dernière mise à
// jour Uconnect entre branchement −5 min et +30 min → pas de question, une ligne d'information.
const JEEP_CHARGER = 'binary_sensor.jeep_renegade_phev_ev_charger';
const JEEP_UPDATE = 'sensor.jeep_renegade_phev_last_info_update_at';
const JEEP_HV = 'sensor.jeep_renegade_phev_hvbattery_charge';
const JEEP_ODO = 'sensor.jeep_renegade_phev_odometer'; // last_changed ne bouge que si le compteur change
const JEEP_IGN = 'binary_sensor.jeep_renegade_phev_ignition';
// 07/10/2026 (accord du propriétaire) : zone de stationnement de la Jeep, créée par EVCS. Distance calculée ici
// (le libellé d'état du tracker n'est pas fiable) ; la position ne compte que si son horodatage GPS est frais.
const JEEP_LOC = 'device_tracker.jeep_renegade_phev_location';
const JEEP_LOC_AT = 'sensor.jeep_renegade_phev_last_location_update_at';
const ZONE_JEEP = 'zone.maison_jeep';
function distanceM(a1, o1, a2, o2) {
const R = 6371000, r = Math.PI / 180, dA = (a2 - a1) * r, dO = (o2 - o1) * r;
const h = Math.sin(dA / 2) ** 2 + Math.cos(a1 * r) * Math.cos(a2 * r) * Math.sin(dO / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(h));
}
const ATTENTE_UCONNECT_S = 900; // avant de questionner une voiture inconnue, laisser 15 min à Uconnect
// 07/10/2026 (accord du propriétaire) : un branchement DÉCOUVERT plus de 2 h après son heure (aller-retour
// bref rattrapé après coup par EVCS dans evenement) ne déclenche plus de question : trace seulement.
const DECOUVERTE_TARDIVE_S = 7200;
const TRACES = path.join(__dirname, 'concierge-traces.log');
const trace = (msg) => { if (SIM) { console.error('[SIM trace] ' + msg); return; }
try { fs.appendFileSync(TRACES, `${new Date().toISOString()} ${msg}\n`); } catch { /* jamais bloquant */ } };
async function jeepVerdict(onEpoch) {
// 'jeep' (identifiée, avec niveau) | 'pas_jeep' (lue NON branchée après le branchement) | 'inconnu'
let charger, upd, hv, odo, ign = 'unknown';
try { ign = (await haState(JEEP_IGN)).state; } catch { /* facultatif */ }
try {
charger = (await haState(JEEP_CHARGER)).state;
upd = Date.parse((await haState(JEEP_UPDATE)).state) / 1000;
hv = Number((await haState(JEEP_HV)).state);
odo = Date.parse((await haState(JEEP_ODO)).last_changed) / 1000;
} catch { return { v: 'inconnu' }; }
if (!Number.isFinite(upd)) return { v: 'inconnu' };
// 07/10/2026 (règle rapide, validée avec EVCS) : contact mis avec une info Uconnect fraîche (≥ branchement
// −10 min) = la Jeep roule, ce n'est pas elle sur la borne → question immédiate. La POSITION n'est pas utilisée :
// la Jeep garée est à 2,2 km de zone.home, donc « not_home » en permanence.
if (ign === 'on' && upd >= onEpoch - 600) return { v: 'pas_jeep' };
// Position GPS FRAÎCHE (≥ branchement −10 min) hors de la zone de stationnement → elle est ailleurs.
try {
const locAt = Date.parse((await haState(JEEP_LOC_AT)).state) / 1000;
if (Number.isFinite(locAt) && locAt >= onEpoch - 600) {
const loc = (await haState(JEEP_LOC)).attributes || {}, z = (await haState(ZONE_JEEP)).attributes || {};
if ([loc.latitude, loc.longitude, z.latitude, z.longitude].every(Number.isFinite)
&& distanceM(loc.latitude, loc.longitude, z.latitude, z.longitude) > (z.radius || 150))
return { v: 'pas_jeep' };
}
} catch { /* position illisible : on n'en tire rien */ }
const niveau = Number.isInteger(hv) && hv >= 0 && hv <= 100 ? hv : null;
if (charger === 'on' && upd >= onEpoch - 300 && upd <= onEpoch + 1800)
return { v: 'jeep', niveau };
// 05/10/2026 (règle EVCS, accord du propriétaire) : la Jeep a FINI UN TRAJET dans les 10 min avant le
// branchement (dernière mise à jour Uconnect dans [branchement −10 min, branchement], chargeur encore off).
// Le soir en « sans EDF » sa charge ne démarre pas et elle ne republie rien : c'est sa fin de trajet qui
// l'identifie. Niveau = celui de l'arrivée. Risque accepté : une autre voiture branchée juste après.
// + COMPTEUR CHANGÉ : une mise à jour d'une Jeep restée garée ne suffit pas (sinon on annoncerait JEEP au
// branchement de la VW alors qu'EVCS n'étiquette pas). last_changed de l'odomètre est l'heure où HA a RELU
// le cloud (jusqu'à 15 min après le trajet : 05/10, trajet fini 19:05, vu par HA 19:19) : fenêtre
// [branchement −10 min, branchement +20 min] ; l'heure du trajet reste testée par last_info_update_at.
if (charger === 'off' && upd >= onEpoch - 600 && upd <= onEpoch
&& Number.isFinite(odo) && odo >= onEpoch - 600 && odo <= onEpoch + 1200)
return { v: 'jeep', niveau, arrivee: true };
if (charger === 'off' && upd > onEpoch) return { v: 'pas_jeep' };
return { v: 'inconnu' };
}
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 jv = await jeepVerdict(latest.on);
if (jv.v === 'jeep') {
saveJSON(STATE, { lastOn: latest.on });
return `🔌 JEEP branchée à ${hhmm(latest.on)}` +
(jv.niveau !== null ? `, ${jv.niveau} % (lu par la voiture${jv.arrivee ? ' à son arrivée' : ''})` : ' (identifiée par la voiture)') +
`. Rien à faire ; si ce n'est pas elle, réponds avec le bon véhicule.`;
}
const age = Date.now() / 1000 - latest.on;
if (jv.v !== 'pas_jeep' && age < ATTENTE_UCONNECT_S) return ''; // peut-être la JEEP : on repasse au prochain cycle
if (age > DECOUVERTE_TARDIVE_S) { // la JEEP identifiée a déjà eu sa ligne d'info plus haut, même en retard
saveJSON(STATE, { lastOn: latest.on });
trace(`branchement ${latest.on_text} découvert ${Math.round(age / 60)} min après son heure : pas de question`);
return '';
}
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 = SIM ? '' : await health.publish({ ok, detail });
let alert = '';
if (!SIM) { try { alert = await checkAlert(); } catch { /* l'alerte ne casse jamais le cycle */ } }
let alerteEcs = '';
try { alerteEcs = await checkAlerteECS(); } catch { /* idem : jamais bloquant */ }
if (alerteEcs) alert = [alert, alerteEcs].filter(Boolean).join('\n\n');
let arbitrage = '';
try { arbitrage = checkArbitrage(); } catch { /* idem : jamais bloquant */ }
if (arbitrage) alert = [alert, arbitrage].filter(Boolean).join('\n\n');
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 };
}
// RELEVES KILOMETRIQUES (04/10/2026, decision du proprietaire) : « JEEP 112694 km
// 54 % », un ou plusieurs vehicules par message. Toujours un NOM de vehicule puis
// un nombre suivi de « km » : un nombre seul n'est jamais un releve. Le niveau
// eventuel suit le km (« … km 54 % »). Les separateurs de milliers sont admis.
function parseReleves(text) {
const re = /\b(vw|jeep|z[ée]ro|moto)\b[^\d%]{0,20}?(\d{1,3}(?:[ .\u202f\u00a0]\d{3})+|\d+)\s*km\b(?:[^\d%]{0,15}?(\d{1,3}(?:[.,]\d+)?)\s*(?:%|pour\s*cent|pourcent))?/gi;
const out = [];
for (const m of (text || '').matchAll(re)) {
const nom = m[1].toLowerCase();
const vehicule = nom === 'vw' ? 'VW' : nom === 'jeep' ? 'JEEP' : 'ZERO';
const km = Number(m[2].replace(/[ .\u202f\u00a0]/g, ''));
const nv = m[3] === undefined ? null : Number(m[3].replace(',', '.'));
out.push({ vehicule, km, brutNiveau: m[3] ?? null,
niveau: nv === null ? null : (Number.isInteger(nv) && nv >= 0 && nv <= 100 ? nv : NaN),
segment: m[0] });
}
return out;
}
const kmTexte = (km) => km.toLocaleString('fr-FR').replace(/\u202f/g, ' ');
// Le relevé se rattache-t-il au branchement en cours ? (pour l'horodater a
// l'instant du BRANCHEMENT : le compteur n'avance pas pendant la charge, et le
// service compte les charges posterieures au releve). Et peut-il etiqueter ce
// branchement ? Seulement s'il n'a pas d'etiquette et date de moins d'une heure :
// un releve de la ZERO pendant que la JEEP est branchee ne doit pas la renommer.
async function contexteBranchement() {
let connected = false;
try { connected = (await haState(CONNECTED)).state === 'on'; } catch { /* ignore */ }
if (!connected) return null;
let latest = null;
try { const w = await loadWindow(); latest = w.plugins[w.plugins.length - 1] || null; } catch { return null; }
if (!latest || latest.off !== null) return null;
let prev = {};
try { prev = JSON.parse((await haState(W.HELPER_ENTITY)).state || '{}'); } catch { /* sans etiquette */ }
const label = prev.branchement === latest.on_text && prev.label ? prev.label : null;
return { on: latest.on, label, recent: Date.now() / 1000 - latest.on <= 3600 };
}
async function answerReleves(text, releves) {
const invalides = releves.filter(r => !Number.isInteger(r.km) || r.km <= 0 || r.km > 2000000 || Number.isNaN(r.niveau));
if (invalides.length) {
return `⚠️ Relevé NON noté : « ${invalides[0].segment.trim()} » n'est pas valide ` +
'(kilométrage entier, niveau entier de 0 à 100) — rien n\'a été écrit.';
}
const ctx = releves.length === 1 ? await contexteBranchement() : null;
const r0 = releves[0];
let lignePrefixe = '', rattache = false;
if (ctx && ctx.label === r0.vehicule) rattache = true;
else if (ctx && !ctx.label && ctx.recent) {
// Branchement frais sans etiquette : le message vaut aussi confirmation
// (vehicule + niveau), exactement comme « JEEP 54 % ».
const sansKm = text.replace(r0.segment, `${r0.vehicule}${r0.niveau === null ? '' : ` ${r0.niveau} %`}`);
lignePrefixe = await answer(sansKm, { sansReleve: true });
rattache = lignePrefixe.startsWith('✅');
}
const maintenant = Math.round(Date.now() / 1000);
const aEcrire = releves.map((r, i) => ({ vehicule: r.vehicule, km: r.km, niveau: r.niveau,
ts: i === 0 && rattache ? Math.round(ctx.on) : maintenant }));
// Dedoublonnage : meme vehicule et meme km deja dans la file (message relance,
// ou compteur qui n'a pas bouge : un second releve identique n'apprend rien).
let file = [];
try { file = JSON.parse((await haState(W.RELEVE_ENTITY)).state || '[]'); } catch { /* vide */ }
const nouveaux = aEcrire.filter(r => !file.some(e => Array.isArray(e) && e[0] === r.vehicule && e[1] === r.km));
if (nouveaux.length) {
try { await setReleveKm(nouveaux, 'whatsapp'); }
catch (e) { return (lignePrefixe ? lignePrefixe + ' — ' : '') + `⚠️ Relevé NON noté (${e.message}).`; }
}
const parties = aEcrire.map((r) => {
let p = `${r.vehicule} ${kmTexte(r.km)} km`;
p += r.niveau === null ? '' : `, niveau ${r.niveau} %`;
if (rattache && r === aEcrire[0]) p += ` (rattaché au branchement de ${hhmm(ctx.on)})`;
return p;
});
const sansNiveau = aEcrire.filter(r => r.niveau === null).map(r => r.vehicule);
let ligne = `🚗 Relevé noté : ${parties.join(' ; ')}.`;
if (sansNiveau.length) {
ligne += ` ⚠️ Niveau non donné pour ${sansNiveau.join(', ')} : sans lui ce relevé ne servira pas au calcul ` +
`(redonne-le avec le niveau, par exemple « ${sansNiveau[0]} ${kmTexte(aEcrire.find(r => r.niveau === null).km)} km 50 % »).`;
}
return lignePrefixe ? `${lignePrefixe} — ${ligne}` : ligne;
}
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).`;
}
// Meme dedoublonnage que l'etiquette : meme valeur saisie il y a moins de
// 5 min -> pas de reecriture (chaque ecriture recale la reference du compteur).
let dejaSaisi = false;
try {
const n = await haState(W.LEVEL_ENTITY);
dejaSaisi = Math.round(Number(n.state)) === niveau.valeur &&
Date.now() - Date.parse(n.last_reported) < 5 * 60 * 1000;
} catch { /* illisible : on ecrit */ }
if (!dejaSaisi) {
try { await setVehicleLevel(niveau.valeur, 'whatsapp'); }
catch (e) { return `⚠️ Niveau NON noté (${e.message}).`; }
}
// L'heure ecrite est celle de la SAISIE (03/10/2026) : le script n'enregistre
// aucune autre heure. La donner coupe court a « Noté : 53 % à 13:10 ».
const saisi = new Date().toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' });
return `🔋 Niveau noté : ${niveau.valeur} % (saisi à ${saisi}).`;
}
// Le branchement EN COURS a-t-il une etiquette concierge ? (03/10/2026) Un niveau
// seul ne dit pas quel vehicule est branche, et le selecteur n'est PAS une
// confirmation (session de 16:46 etiquetee JEEP pour la ZERO). Le 03/10 a 14:13,
// « JEEP » a ete annule par OpenClaw (message suivant en file) : seul « 53% » est
// passe, et le branchement est reste sans etiquette. Sans certitude -> on demande.
async function vehiculeNonConfirme() {
const question = " — ⚠️ Véhicule non confirmé pour ce branchement : lequel est-ce ?";
try {
const w = await loadWindow();
const latest = w.plugins[w.plugins.length - 1] || null;
if (!latest || latest.off !== null) return question;
const prev = JSON.parse((await haState(W.HELPER_ENTITY)).state || '{}');
return prev.branchement === latest.on_text && prev.label ? '' : question;
} catch { return question; }
}
async function answer(text, { sansReleve = false } = {}) {
if (!sansReleve) {
const releves = parseReleves(text);
if (releves.length) return await answerReleves(text, releves);
}
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.";
const ligne = await noterNiveau(niveau);
return niveau.valeur === null ? ligne : ligne + await vehiculeNonConfirme();
}
// 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;
}
// DEDOUBLONNAGE (03/10/2026) : l'agent a deja lance answer deux fois pour le
// meme message. Meme vehicule, meme branchement, confirme il y a moins de
// 5 min -> on redonne la meme reponse SANS reecrire (sinon une seconde
// confirmation, et un evenement en double cote EVCS).
let dejaNote = false;
try {
const prev = JSON.parse((await haState(W.HELPER_ENTITY)).state || '{}');
dejaNote = prev.branchement === branchement && prev.label === label &&
Date.now() - Date.parse(prev.confirmed_at) < 5 * 60 * 1000;
} catch { /* illisible : on publie */ }
if (!dejaNote) 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) && !dejaNote) {
try { await setVehicleSelector(label, 'whatsapp'); }
catch (e) {
selectorNote = ` — ⚠️ 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 ? ` — ${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);
}
async function checkAlerteECS() {
if (fs.existsSync(ALERT_KILL)) return '';
let raw;
try { raw = (await haState(ECS_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 = SIM ? (SIM.ecsState || {}) : loadJSON(ECS_STATE, {});
if (st.lastSince === a.since) return ''; // déjà relayée pour cette fenêtre HC
saveJSON(ECS_STATE, { lastSince: a.since });
return String(a.message).slice(0, 300);
}
// 08/10/2026, « GO arbitre » : question d'arbitrage d'EVCS (une fois par id) et verdict « repondu » pas encore
// relayé. Tout est dans arbitrage.js (seul à écrire la réponse). Tests : SIM.arbitrage → ARBITRAGE_SIMULATION,
// sans lui un banc CONCIERGE_SIMULATION ne lit pas l'arbitrage réel.
function checkArbitrage() {
if (fs.existsSync(ALERT_KILL)) return '';
if (SIM && !SIM.arbitrage) return '';
const env = SIM ? { ...process.env, ARBITRAGE_SIMULATION: JSON.stringify({ arbitrage: SIM.arbitrage, etat: SIM.arbitrageEtat || {} }) } : process.env;
const run = (c) => require('child_process').execFileSync('node', [path.join(__dirname, 'arbitrage.js'), c],
{ env, timeout: 30000, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
return [run('application'), run('question')].filter(Boolean).join('\n\n');
}
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); }
// UNE seule ligne (03/10/2026) : l'agent relayait parfois une ligne sur deux.
if (out && SIM && cmd === 'answer') out += ' [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); });