concierge: lire airtria_ro depuis l'env/.env + alerte d'echec d'auth

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
This commit is contained in:
2026-09-16 03:26:50 +02:00
parent d0805331ad
commit 6da250914d
6 changed files with 276 additions and 16 deletions

View File

@@ -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
`⚠️ <erreur> · 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).

View File

@@ -18,29 +18,53 @@ 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) ----------------------------------------------
// Transitions branché/débranché : le FAIT qui donne la frontière des groupes.
@@ -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); });

View File

@@ -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."

View File

@@ -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 };

View File

@@ -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); });

View File

@@ -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) {