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:
@@ -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
|
- ⚠️ NE PAS utiliser `evcs_lecture` : il a INSERT sur `evenement` (registre
|
||||||
d'audit partagé) — inadapté à un agent. Rôle DÉDIÉ `airtria_ro`, LOGIN +
|
d'audit partagé) — inadapté à un agent. Rôle DÉDIÉ `airtria_ro`, LOGIN +
|
||||||
SELECT sur `session_charge` et `evenement` uniquement, aucune écriture.
|
SELECT sur `session_charge` et `evenement` uniquement, aucune écriture.
|
||||||
Mot de passe déposé protégé dans le workspace (.pg-airtria). Base evcs sur
|
Mot de passe = variable `AIRTRIA_RO_PASSWORD`, injectée dans le conteneur par le
|
||||||
10.10.10.22:5433.
|
`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`,
|
- Colonnes utiles (session_charge, 17 col) : `debut` (clef, MS), `fin`,
|
||||||
`terminee`, `vehicule`, `vehicule_sur` (DEFAULT true → à basculer côté service),
|
`terminee`, `vehicule`, `vehicule_sur` (DEFAULT true → à basculer côté service),
|
||||||
`p_max_w` + `consigne_max_a` + `courant_max_a` (règle invité), `debranche_a`
|
`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
|
- 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
|
(`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.
|
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).
|
||||||
|
|||||||
@@ -18,29 +18,53 @@ const path = require('path');
|
|||||||
const { Client } = require('pg'); // dépendance à ajouter : npm install pg
|
const { Client } = require('pg'); // dépendance à ajouter : npm install pg
|
||||||
|
|
||||||
// --- Paramètres -------------------------------------------------------------
|
// --- 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',
|
host: process.env.PGHOST || '10.10.10.22',
|
||||||
port: Number(process.env.PGPORT || 5433),
|
port: Number(process.env.PGPORT || 5433),
|
||||||
database: process.env.PGDATABASE || 'evcs',
|
database: process.env.PGDATABASE || 'evcs',
|
||||||
user: process.env.PGUSER || 'airtria_ro',
|
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.
|
// Règle invité (voir SPEC, sens unique) : conclusif SEULEMENT au-dessus du seuil.
|
||||||
const CONSIGNE_MIN_A = 20; // en dessous : aucune information → demander
|
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
|
const P_INVITE_W = 4200; // VW/JEEP ~3,6-3,8 kW ; Megane 6,36 kW à 28 A
|
||||||
|
|
||||||
function readSecret(file) {
|
// Mot de passe airtria_ro — source, dans l'ordre :
|
||||||
try {
|
// 1. variable d'environnement AIRTRIA_RO_PASSWORD (si un jour injectée par le compose
|
||||||
return fs.readFileSync(file, 'utf8').trim();
|
// via env_file — un ajout au .env du compose exige alors de RECRÉER le conteneur) ;
|
||||||
} catch (e) {
|
// 2. lecture du .env monté dans le conteneur : /home/node/.openclaw/.env, soit côté hôte
|
||||||
console.error(`[concierge] Mot de passe introuvable (${file}). ` +
|
// /mnt/ARCH3/docker/openclaw-data/.env. C'EST la source réelle aujourd'hui : le
|
||||||
`Dépose-le en lecture seule (chmod 600) avant de lancer.`);
|
// propriétaire y met AIRTRIA_RO_PASSWORD, et comme ce fichier est monté, l'éditer
|
||||||
process.exit(2);
|
// 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) ----------------------------------------------
|
// --- Requêtes (lecture seule) ----------------------------------------------
|
||||||
// Transitions branché/débranché : le FAIT qui donne la frontière des groupes.
|
// 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 since = process.env.SINCE || '2026-09-08T19:00:00+02:00';
|
||||||
const until = process.env.UNTIL || '2026-09-13T00: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();
|
await client.connect();
|
||||||
try {
|
try {
|
||||||
const tr = await client.query(SQL_TRANSITIONS, [since, until]);
|
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) {
|
if (require.main === module) {
|
||||||
main().catch(e => { console.error('[concierge]', e.message); process.exit(1); });
|
main().catch(e => { console.error('[concierge]', e.message); process.exit(1); });
|
||||||
|
|||||||
64
workspace/concierge-health-ha-package.yaml.draft
Normal file
64
workspace/concierge-health-ha-package.yaml.draft
Normal 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."
|
||||||
86
workspace/concierge-health.js
Normal file
86
workspace/concierge-health.js
Normal 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 };
|
||||||
39
workspace/concierge-health.test.js
Normal file
39
workspace/concierge-health.test.js
Normal 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); });
|
||||||
@@ -17,6 +17,7 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { makeClient } = require('./concierge-detection.js');
|
const { makeClient } = require('./concierge-detection.js');
|
||||||
const { publishLabel } = require('./concierge-write.js');
|
const { publishLabel } = require('./concierge-write.js');
|
||||||
|
const health = require('./concierge-health.js');
|
||||||
|
|
||||||
const STATE = path.join(__dirname, 'concierge-state.json');
|
const STATE = path.join(__dirname, 'concierge-state.json');
|
||||||
const PENDING = path.join(__dirname, 'concierge-pending.json');
|
const PENDING = path.join(__dirname, 'concierge-pending.json');
|
||||||
@@ -91,7 +92,8 @@ async function loadWindow() {
|
|||||||
const hhmm = (epoch) => new Date(epoch * 1000)
|
const hhmm = (epoch) => new Date(epoch * 1000)
|
||||||
.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' });
|
.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();
|
const { plugins, selChanges } = await loadWindow();
|
||||||
if (plugins.length === 0) return '';
|
if (plugins.length === 0) return '';
|
||||||
const latest = plugins[plugins.length - 1];
|
const latest = plugins[plugins.length - 1];
|
||||||
@@ -112,6 +114,19 @@ async function detect() {
|
|||||||
`(VW / JEEP / invité) [${ref}]`;
|
`(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
|
// 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).
|
// peut nommer l'invité (MEGANE) ; sinon INVITE. ZERO accepté (moto, si option ajoutée).
|
||||||
function parseLabel(text, options) {
|
function parseLabel(text, options) {
|
||||||
|
|||||||
Reference in New Issue
Block a user