// 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 "" → 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":,"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 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 === 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') }; 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 ATTENTE_UCONNECT_S = 900; // avant de questionner une voiture inconnue, laisser 15 min à Uconnect async function jeepVerdict(onEpoch) { // 'jeep' (identifiée, avec niveau) | 'pas_jeep' (lue NON branchée après le branchement) | 'inconnu' let charger, upd, hv; try { charger = (await haState(JEEP_CHARGER)).state; upd = Date.parse((await haState(JEEP_UPDATE)).state) / 1000; hv = Number((await haState(JEEP_HV)).state); } catch { return { v: 'inconnu' }; } if (!Number.isFinite(upd)) return { v: 'inconnu' }; if (charger === 'on' && upd >= onEpoch - 300 && upd <= onEpoch + 1800) return { v: 'jeep', niveau: Number.isInteger(hv) && hv >= 0 && hv <= 100 ? hv : null }; 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)` : ' (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 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 */ } } 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); } 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 ""'); 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); });