diff --git a/workspace/airtria-concierge-vehicule-SPEC.md b/workspace/airtria-concierge-vehicule-SPEC.md index 98797bd..58a2068 100644 --- a/workspace/airtria-concierge-vehicule-SPEC.md +++ b/workspace/airtria-concierge-vehicule-SPEC.md @@ -96,8 +96,13 @@ aucun signal. VW et JEEP sont électriquement indiscernables l'une de l'autre - ⚠️ NE PAS utiliser `evcs_lecture` : il a INSERT sur `evenement` (registre d'audit partagé) — inadapté à un agent. Rôle DÉDIÉ `airtria_ro`, LOGIN + SELECT sur `session_charge` et `evenement` uniquement, aucune écriture. - Mot de passe déposé protégé dans le workspace (.pg-airtria). Base evcs sur - 10.10.10.22:5433. + Mot de passe = variable `AIRTRIA_RO_PASSWORD`, injectée dans le conteneur par le + `env_file: .env` du compose openclaw (secret dans /mnt/ARCH3/docker/openclaw/.env, + 600, hors git). EVCS possède le rôle et peut RENOUVELER le mot de passe : le code lit + la variable d'environnement (source vivante), jamais un fichier figé — un `.pg-airtria` + périmé ferait échouer l'auth en silence. Après un changement du .env, RECRÉER le + conteneur (`docker compose up -d`) : un simple restart ne relit pas env_file. Base + evcs sur 10.10.10.22:5433. - Colonnes utiles (session_charge, 17 col) : `debut` (clef, MS), `fin`, `terminee`, `vehicule`, `vehicule_sur` (DEFAULT true → à basculer côté service), `p_max_w` + `consigne_max_a` + `courant_max_a` (règle invité), `debranche_a` @@ -325,3 +330,30 @@ aucune connaissance métier (ni routine, ni horaire, ni puissance), et aucune - Prérequis à câbler : (1) accès lecture PostgreSQL pour Airtria (`PG_LECTURE_PASSWORD`, déposé protégé comme le token HA) ; (2) création du helper `input_text.airtria_vehicle_label` (max:255) dans le paquet HA. + +## Supervision — alerte d'échec d'auth (2026-09-16) +Motivation : le 15/09, EVCS a renouvelé le mot de passe `airtria_ro` ; le concierge a +alors échoué à se connecter, mais EN SILENCE — sortie vide, donc aucun WhatsApp émis, +aucune trace visible : il cessait simplement de poser la question sans que personne le +sache. Même angle mort que le service EVCS (une trace écrite seulement AU RETOUR à la +normale, dans PG, donc jamais quand c'est PG qui tombe). + +Correctif (module `concierge-health.js`, même patron que le moniteur LLM) : +- **Battement de cœur À CHAQUE cycle** : `input_datetime.concierge_last_run` reçoit un + horodatage (valeur qui CHANGE — HA ne rafraîchit pas le `last_changed` d'un helper + réécrit à l'identique). Un concierge mort devient détectable (capteur `binary_sensor` + de fraîcheur, > 20 min → « figé »). +- **Statut live** : `input_text.concierge_status` (max:255) = `OK · HH:MM` ou + `⚠️ · HH:MM`. +- **Alerte WhatsApp UNIQUEMENT sur transition** (ok↔erreur), jamais un rappel périodique + (« events not states ») : une panne qui dure ne spamme pas le canal. Dédup par état + local `concierge-health-state.json`. +- **Chemin HA indépendant de PostgreSQL** : l'alerte « auth KO » part précisément quand + c'est l'auth qui tombe (le signal de vie n'emprunte pas le chemin qui tombe). Best-effort : + si les helpers manquent, le concierge n'échoue pas ; l'alerte WhatsApp part quand même + (elle ne dépend pas des helpers). Interrupteur `input_boolean.concierge_monitor_enabled` + (OFF = pas de WhatsApp, affichage maintenu). +- Refactor de `concierge-detection.js` : `readPgPassword()` LÈVE au lieu de `process.exit`, + mot de passe résolu paresseusement dans `makeClient()` → l'échec devient RATTRAPABLE par + l'étage live, qui le publie. Paquet HA : `concierge-health-ha-package.yaml.draft`. + Machine à états vérifiée par `concierge-health.test.js` (dédup + transitions, sans réseau). diff --git a/workspace/concierge-detection.js b/workspace/concierge-detection.js index efcef97..bba03fb 100644 --- a/workspace/concierge-detection.js +++ b/workspace/concierge-detection.js @@ -18,28 +18,52 @@ const path = require('path'); const { Client } = require('pg'); // dépendance à ajouter : npm install pg // --- Paramètres ------------------------------------------------------------- -const PG = { +// Connexion PG sans mot de passe ici : il est résolu PARESSEUSEMENT dans makeClient() +// (voir readPgPassword), pour qu'un secret absent LÈVE une erreur rattrapable au moment de +// la détection, et non un process.exit au chargement du module. L'étage live la transforme +// alors en alerte HA/WhatsApp — jamais en mort silencieuse. +const PG_BASE = { host: process.env.PGHOST || '10.10.10.22', port: Number(process.env.PGPORT || 5433), database: process.env.PGDATABASE || 'evcs', user: process.env.PGUSER || 'airtria_ro', - // mot de passe lu depuis un fichier protégé, jamais en dur ni en argument - password: readSecret(process.env.PG_PASS_FILE || - path.join(__dirname, '.pg-airtria')), }; // Règle invité (voir SPEC, sens unique) : conclusif SEULEMENT au-dessus du seuil. const CONSIGNE_MIN_A = 20; // en dessous : aucune information → demander const P_INVITE_W = 4200; // VW/JEEP ~3,6-3,8 kW ; Megane 6,36 kW à 28 A -function readSecret(file) { - try { - return fs.readFileSync(file, 'utf8').trim(); - } catch (e) { - console.error(`[concierge] Mot de passe introuvable (${file}). ` + - `Dépose-le en lecture seule (chmod 600) avant de lancer.`); - process.exit(2); +// Mot de passe airtria_ro — source, dans l'ordre : +// 1. variable d'environnement AIRTRIA_RO_PASSWORD (si un jour injectée par le compose +// via env_file — un ajout au .env du compose exige alors de RECRÉER le conteneur) ; +// 2. lecture du .env monté dans le conteneur : /home/node/.openclaw/.env, soit côté hôte +// /mnt/ARCH3/docker/openclaw-data/.env. C'EST la source réelle aujourd'hui : le +// propriétaire y met AIRTRIA_RO_PASSWORD, et comme ce fichier est monté, l'éditer +// suffit (aucune recréation). OPENCLAW_ENV_FILE peut désigner un autre fichier (tests). +// Pas de repli sur un fichier de secret dédié : EVCS possède le rôle et RENOUVELLE le mot +// de passe ; un fichier figé (.pg-airtria) donnerait un secret périmé et ferait échouer +// l'auth EN SILENCE. Si rien n'est trouvé, on LÈVE (rattrapé par l'étage live → alerte). +function envValue(file) { + const txt = fs.readFileSync(file, 'utf8'); + for (const line of txt.split('\n')) { + const m = line.match(/^\s*(?:export\s+)?AIRTRIA_RO_PASSWORD\s*=\s*(.*)$/); + if (m) { + let v = m[1].trim(); + if ((v.startsWith('"') && v.endsWith('"')) || + (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1); + if (v) return v; + } } + return null; +} + +function readPgPassword() { + if (process.env.AIRTRIA_RO_PASSWORD) return process.env.AIRTRIA_RO_PASSWORD; + const envFile = process.env.OPENCLAW_ENV_FILE || path.join(__dirname, '..', '.env'); + try { const v = envValue(envFile); if (v) return v; } catch { /* .env absent/illisible ici */ } + throw new Error('AIRTRIA_RO_PASSWORD introuvable (ni variable d\'environnement, ni ' + + `${envFile}) — ajouter la clé au .env puis relancer ` + + '(si c\'est le .env du compose, recréer le conteneur)'); } // --- Requêtes (lecture seule) ---------------------------------------------- @@ -129,7 +153,7 @@ async function main() { const since = process.env.SINCE || '2026-09-08T19:00:00+02:00'; const until = process.env.UNTIL || '2026-09-13T00:00:00+02:00'; - const client = new Client(PG); + const client = makeClient(); await client.connect(); try { const tr = await client.query(SQL_TRANSITIONS, [since, until]); @@ -183,7 +207,7 @@ async function main() { } } -function makeClient() { return new Client(PG); } +function makeClient() { return new Client({ ...PG_BASE, password: readPgPassword() }); } if (require.main === module) { main().catch(e => { console.error('[concierge]', e.message); process.exit(1); }); diff --git a/workspace/concierge-health-ha-package.yaml.draft b/workspace/concierge-health-ha-package.yaml.draft new file mode 100644 index 0000000..82af8d4 --- /dev/null +++ b/workspace/concierge-health-ha-package.yaml.draft @@ -0,0 +1,64 @@ +# concierge-health-ha-package.yaml.draft +# ---------------------------------------------------------------------------- +# Paquet Home Assistant pour la SANTÉ du concierge véhicule (alerte d'échec d'auth). +# À intégrer par le PROPRIÉTAIRE (la config HA lui appartient) : copier le contenu dans +# un package (config/packages/) ou dans la config existante, vérifier, puis recharger +# les helpers concernés (ou redémarrer HA). +# +# Ces entités sont best-effort côté concierge : s'il manque, il n'échoue pas — mais tant +# qu'elles n'existent pas, l'affichage et le battement de cœur ne s'affichent pas dans HA. +# +# ⚠️ max: 255 est OBLIGATOIRE sur input_text : le défaut est 100 et un libellé plus long +# est rejeté EN SILENCE (même piège que le helper airtria_vehicle_label). +# ---------------------------------------------------------------------------- + +input_boolean: + concierge_monitor_enabled: + name: Concierge — alertes activées + icon: mdi:bell-ring + +input_text: + concierge_status: + name: Concierge — état + max: 255 + icon: mdi:car-electric + +input_datetime: + concierge_last_run: + name: Concierge — dernier passage + has_date: true + has_time: true + icon: mdi:heart-pulse + +# --- Optionnel mais recommandé : capteur de FRAÎCHEUR du battement de cœur -------------- +# Le battement de cœur ne sert que si quelque chose le SURVEILLE. Ce capteur passe à +# "problem" si le concierge n'a pas écrit depuis > 20 min (le cron tourne toutes les +# quelques minutes) — il rend visible un concierge mort AVANT même d'avoir pu écrire un +# statut (conteneur tombé, cron arrêté). C'est le « HA crie quand le service se tait ». +template: + - binary_sensor: + - name: Concierge figé + unique_id: concierge_heartbeat_stale + device_class: problem + state: > + {% set t = states('input_datetime.concierge_last_run') %} + {% if t in ['unknown', 'unavailable', ''] %} off + {% else %} {{ (as_timestamp(now()) - as_timestamp(t)) > 1200 }} + {% endif %} + +# --- Optionnel : notifier quand figé (adapter le service notify.* et l'entity_id) ------ +# automation: +# - alias: Concierge figé -> notifier +# trigger: +# - platform: state +# entity_id: binary_sensor.concierge_fige # vérifier l'entity_id réel généré +# to: "on" +# for: { minutes: 5 } +# condition: +# - condition: state +# entity_id: input_boolean.concierge_monitor_enabled +# state: "on" +# action: +# - service: notify.whatsapp # <- adapter à ton service +# data: +# message: "⚠️ Concierge véhicule figé : plus de passage depuis > 20 min." diff --git a/workspace/concierge-health.js b/workspace/concierge-health.js new file mode 100644 index 0000000..348e0f1 --- /dev/null +++ b/workspace/concierge-health.js @@ -0,0 +1,86 @@ +// concierge-health.js — santé du concierge véhicule, publiée dans Home Assistant. +// ----------------------------------------------------------------------------- +// Même patron que le moniteur LLM (llm-monitor.js), appliqué au cycle `detect` : +// - BATTEMENT DE CŒUR à CHAQUE passage (horodatage sur input_datetime) : sans lui, un +// concierge mort — cron arrêté, conteneur tombé, auth qui échoue au chargement — est +// indiscernable d'un système sain. HA peut alors crier quand l'horodatage se fige. +// Valeur qui CHANGE à chaque fois (horodatage, pas « ok ») : HA ne rafraîchit pas le +// last_changed d'un helper réécrit à l'identique. +// - ALERTE WhatsApp UNIQUEMENT sur TRANSITION (ok↔erreur), jamais un rappel périodique +// (« events not states ») : une panne d'auth qui dure ne spamme pas le canal. +// - Chemin HA INDÉPENDANT de PostgreSQL : l'alerte « auth KO » part précisément quand +// c'est PG/l'auth qui tombe — le signal de vie n'emprunte pas le chemin qui tombe. +// - Best-effort : ne fait JAMAIS échouer le concierge (aucune entité requise, aucun jet). +// - Interrupteur HA : OFF → pas de WhatsApp, mais l'affichage reste à jour. +// ----------------------------------------------------------------------------- +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const STATE = process.env.CONCIERGE_HEALTH_STATE || + path.join(__dirname, 'concierge-health-state.json'); +const HA_BASE = process.env.HA_BASE || 'http://10.10.10.17:8123'; + +const HA_ENABLED = 'input_boolean.concierge_monitor_enabled'; // interrupteur alertes on/off +const HA_STATUS = 'input_text.concierge_status'; // libellé d'état (max 255) +const HA_LASTRUN = 'input_datetime.concierge_last_run'; // battement de cœur (chaque cycle) + +const load = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } }; +const save = (f, o) => { try { fs.writeFileSync(f, JSON.stringify(o, null, 2)); } catch { /* best-effort */ } }; +const haToken = () => { + try { return fs.readFileSync(process.env.HA_TOKEN_FILE || path.join(__dirname, '.ha-token'), 'utf8').trim(); } + catch { return null; } +}; + +async function haGet(entity) { + const tok = haToken(); if (!tok) return null; + try { + const r = await fetch(`${HA_BASE}/api/states/${entity}`, { headers: { Authorization: `Bearer ${tok}` } }); + return r.ok ? r.json() : null; + } catch { return null; } +} +async function haCall(domain, service, data) { + const tok = haToken(); if (!tok) return; + try { + await fetch(`${HA_BASE}/api/services/${domain}/${service}`, { + method: 'POST', + headers: { Authorization: `Bearer ${tok}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + } catch { /* best-effort */ } +} + +// Publie la santé du cycle et renvoie le message de TRANSITION à envoyer par WhatsApp +// (ou '' si pas de transition, ou si l'interrupteur est OFF). `ok` = le cycle detect a +// réussi (lecture PostgreSQL comprise) ; `detail` = message d'erreur si !ok. +async function publish({ ok, detail }) { + const prev = load(STATE, { status: 'ok' }); + const status = ok ? 'ok' : 'alert'; + const hhmm = new Date().toLocaleTimeString('fr-FR', + { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' }); + + // Battement de cœur À CHAQUE passage + statut live (best-effort, chemin indépendant de PG). + await haCall('input_datetime', 'set_datetime', + { entity_id: HA_LASTRUN, timestamp: Math.floor(Date.now() / 1000) }); + const label = (ok ? 'OK' : `⚠️ ${detail || 'erreur détection'}`).slice(0, 240); + await haCall('input_text', 'set_value', { entity_id: HA_STATUS, value: `${label} · ${hhmm}` }); + + // Alerte seulement sur la bascule d'état. + let out = ''; + if (status === 'alert' && prev.status !== 'alert') { + out = `⚠️ Concierge véhicule en panne : ${detail || 'erreur de détection'}. ` + + `Il ne peut plus poser la question du véhicule tant que ce n'est pas corrigé.`; + } else if (status === 'ok' && prev.status === 'alert') { + out = '✅ Concierge véhicule de nouveau opérationnel.'; + } + + save(STATE, { status, at: new Date().toISOString(), detail: ok ? null : (detail || null) }); + + if (!out) return ''; + const enabled = await haGet(HA_ENABLED); + if (enabled && enabled.state === 'off') return ''; // muet : affichage à jour, pas de WhatsApp + return out; +} + +module.exports = { publish, HA_ENABLED, HA_STATUS, HA_LASTRUN }; diff --git a/workspace/concierge-health.test.js b/workspace/concierge-health.test.js new file mode 100644 index 0000000..d8603ed --- /dev/null +++ b/workspace/concierge-health.test.js @@ -0,0 +1,39 @@ +// concierge-health.test.js — vérifie la MACHINE À ÉTATS des alertes (dédup + transitions). +// Aucun réseau, aucun secret : HA_TOKEN_FILE pointe sur un fichier absent → haToken() = null +// → aucun appel HA émis ; l'état vit dans un fichier temporaire isolé (pas celui de prod). +// Lancer : node concierge-health.test.js +'use strict'; +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const STATE = path.join(os.tmpdir(), `concierge-health-test-${process.pid}.json`); +process.env.CONCIERGE_HEALTH_STATE = STATE; +process.env.HA_TOKEN_FILE = path.join(os.tmpdir(), 'no-such-ha-token-xyz'); +try { fs.rmSync(STATE, { force: true }); } catch { /* neuf */ } + +const { publish } = require('./concierge-health.js'); + +let failures = 0; +const check = (label, cond) => { console.log(`${cond ? 'ok ' : 'FAIL'} ${label}`); if (!cond) failures++; }; + +(async () => { + const s1 = await publish({ ok: true }); + check('sain au démarrage → aucun message', s1 === ''); + + const s2 = await publish({ ok: false, detail: 'password authentication failed' }); + check('bascule sain→panne → alerte émise', /en panne/.test(s2)); + + const s3 = await publish({ ok: false, detail: 'password authentication failed' }); + check('panne persistante → PAS de ré-alerte (dédup)', s3 === ''); + + const s4 = await publish({ ok: true }); + check('bascule panne→sain → « de nouveau opérationnel »', /opérationnel/.test(s4)); + + const s5 = await publish({ ok: true }); + check('sain persistant → aucun message', s5 === ''); + + try { fs.rmSync(STATE, { force: true }); } catch { /* nettoyage */ } + if (failures) { console.error(`\n${failures} test(s) en échec`); process.exit(1); } + console.log('\nTOUT VERT — machine à états santé concierge conforme'); +})().catch(e => { console.error('[test]', e); process.exit(1); }); diff --git a/workspace/concierge-live.js b/workspace/concierge-live.js index ff50132..0770635 100644 --- a/workspace/concierge-live.js +++ b/workspace/concierge-live.js @@ -17,6 +17,7 @@ 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'); @@ -91,7 +92,8 @@ async function loadWindow() { const hhmm = (epoch) => new Date(epoch * 1000) .toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' }); -async function detect() { +// 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]; @@ -112,6 +114,19 @@ async function detect() { `(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) {