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
220 lines
11 KiB
JavaScript
220 lines
11 KiB
JavaScript
// concierge-detection.js — Concierge véhicule Airtria, ÉTAGE DÉTECTION (lecture seule)
|
|
// -----------------------------------------------------------------------------
|
|
// Rôle : à partir de PostgreSQL (rôle airtria_ro, SELECT seul), détecter les
|
|
// branchements et les sessions de charge, regrouper les sessions par branchement
|
|
// via le journal `evenement`, et décider pour chaque groupe : « invité auto » ou
|
|
// « poser la question ». AUCUNE écriture, nulle part. Aucun envoi WhatsApp ici.
|
|
//
|
|
// Ce module est la base réutilisée par le concierge live ; le mode --calibration
|
|
// se contente d'imprimer le regroupement pour le valider contre la vérité terrain
|
|
// (semaine 08-12/09 : structure attendue 2-3-2-1-1-1, 5 questions, Megane auto).
|
|
//
|
|
// Design complet et raisons : voir airtria-concierge-vehicule-SPEC.md
|
|
// -----------------------------------------------------------------------------
|
|
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { Client } = require('pg'); // dépendance à ajouter : npm install pg
|
|
|
|
// --- Paramètres -------------------------------------------------------------
|
|
// 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',
|
|
};
|
|
|
|
// 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
|
|
|
|
// 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.
|
|
// On ordonne sur ts, on borne les intervalles sur horodatage_ha (vérité physique
|
|
// même après un rattrapage post-coupure). `entite` n'existe que sur les transitions.
|
|
const SQL_TRANSITIONS = `
|
|
SELECT extract(epoch FROM ts) AS ts_epoch,
|
|
extract(epoch FROM (detail->>'horodatage_ha')::timestamptz) AS ha_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`;
|
|
|
|
// Sessions de charge. `debut` renvoyé AUSSI en texte VERBATIM (ms + offset) :
|
|
// c'est la clef de jointure exacte que le helper devra porter (voir SPEC).
|
|
const SQL_SESSIONS = `
|
|
SELECT debut::text AS debut_text,
|
|
extract(epoch FROM debut) AS debut_epoch,
|
|
extract(epoch FROM debranche_a) AS debranche_epoch,
|
|
vehicule, vehicule_sur,
|
|
p_max_w, consigne_max_a, courant_max_a,
|
|
energie_compteur_kwh, energie_kwh, terminee
|
|
FROM session_charge
|
|
WHERE debut >= $1 AND debut < $2
|
|
ORDER BY debut`;
|
|
|
|
// --- Logique de regroupement -----------------------------------------------
|
|
// Un branchement = [on ... off suivant]. Défensif face au flapping du capteur :
|
|
// un 'on' déjà ouvert est ignoré, un 'off' sans ouverture est ignoré.
|
|
function buildPlugins(transitions) {
|
|
const plugins = [];
|
|
let open = null;
|
|
for (const t of transitions) {
|
|
const at = t.ha_epoch != null ? Number(t.ha_epoch) : Number(t.ts_epoch);
|
|
if (t.etat === 'on') {
|
|
if (open === null) open = { on: at, 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;
|
|
}
|
|
|
|
function isGuest(s) {
|
|
return s.consigne_max_a != null && s.p_max_w != null &&
|
|
Number(s.consigne_max_a) >= CONSIGNE_MIN_A && Number(s.p_max_w) > P_INVITE_W;
|
|
}
|
|
|
|
// Associe chaque session à son branchement. Une session hors de tout intervalle
|
|
// connu = frontière absente (coupure du service) → INCERTAINE : on la traitera
|
|
// seule (question par session), jamais sur une frontière reconstruite.
|
|
// Tolérance sur la borne BASSE : la session qui ouvre un branchement est écrite
|
|
// quelques dizaines de ms AVANT que l'événement 'on' soit journalisé (même cycle,
|
|
// session d'abord — observé 62 ms le 09/09). Une borne stricte l'exclurait. 2 s
|
|
// ne peut pas happer le branchement précédent (aucun écart off→on observé < 2 min).
|
|
const ASSIGN_TOL_S = 2;
|
|
function assign(sessions, plugins) {
|
|
const groups = plugins.map(p => ({ plugin: p, sessions: [] }));
|
|
const uncertain = [];
|
|
for (const s of sessions) {
|
|
const d = Number(s.debut_epoch);
|
|
const g = groups.find(g =>
|
|
d >= g.plugin.on - ASSIGN_TOL_S && (g.plugin.off === null || d <= g.plugin.off));
|
|
if (g) g.sessions.push(s); else uncertain.push(s);
|
|
}
|
|
return { groups: groups.filter(g => g.sessions.length > 0), uncertain };
|
|
}
|
|
|
|
// Décision par groupe : tous invités → auto (pas de question) ; sinon 1 question.
|
|
// (Le test « sélecteur périmé » est fait en live via HA ; en calibration, le
|
|
// sélecteur était figé toute la semaine, donc tout non-invité → question.)
|
|
function decide(group) {
|
|
const allGuest = group.sessions.every(isGuest);
|
|
return allGuest ? { action: 'auto-invite' } : { action: 'question' };
|
|
}
|
|
|
|
// --- Programme --------------------------------------------------------------
|
|
async function main() {
|
|
// ⚠️ Une fenêtre d'analyse doit COMMENCER SUR UN 'on', jamais au milieu d'un
|
|
// branchement : sinon le premier événement est un 'off' orphelin et la session
|
|
// ouverte avant la fenêtre tombe hors intervalle (classée « incertaine » à tort).
|
|
// En production ce n'est pas un souci (on suit le flux) ; seulement au rejeu.
|
|
// Calibration : 08/09 19:00 = juste avant le 'on' de 19:39 (6 branchements complets).
|
|
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 = makeClient();
|
|
await client.connect();
|
|
try {
|
|
const tr = await client.query(SQL_TRANSITIONS, [since, until]);
|
|
const se = await client.query(SQL_SESSIONS, [since, until]);
|
|
const plugins = buildPlugins(tr.rows);
|
|
const { groups, uncertain } = assign(se.rows, plugins);
|
|
|
|
let questions = 0;
|
|
let mixedGroups = 0;
|
|
console.log(`\n=== Calibration ${since} → ${until} ===`);
|
|
console.log(`Branchements détectés : ${plugins.length} | ` +
|
|
`sessions : ${se.rows.length} | transitions : ${tr.rows.length}`);
|
|
const shape = [];
|
|
for (const g of groups) {
|
|
const d = decide(g);
|
|
if (d.action === 'question') questions++;
|
|
shape.push(g.sessions.length);
|
|
// Invariant : un seul câble → toutes les sessions d'un branchement portent
|
|
// la même étiquette. Un groupe à étiquettes mixtes = frontière fausse ici
|
|
// OU ligne fausse en base ; les deux méritent une alerte (contrôle continu).
|
|
const labels = [...new Set(g.sessions.map(s => s.vehicule).filter(Boolean))];
|
|
const mixed = labels.length > 1;
|
|
if (mixed) mixedGroups++;
|
|
const on = new Date(g.plugin.on * 1000).toISOString();
|
|
const off = g.plugin.off ? new Date(g.plugin.off * 1000).toISOString() : 'EN COURS';
|
|
console.log(`\n[branchement ${on} → ${off}] ${d.action}` +
|
|
(mixed ? ` 🚨 ÉTIQUETTES MIXTES: ${labels.join(', ')}` : ''));
|
|
for (const s of g.sessions) {
|
|
const div = (s.energie_compteur_kwh != null && s.energie_kwh != null &&
|
|
Math.abs(Number(s.energie_compteur_kwh) - Number(s.energie_kwh)) > 0.5)
|
|
? ' ⚠️ écart compteur/intégré' : '';
|
|
console.log(` ${s.debut_text} p_max=${s.p_max_w}W ` +
|
|
`consigne=${s.consigne_max_a}A compteur=${s.energie_compteur_kwh}kWh` +
|
|
` [étiquette base: ${s.vehicule}/${s.vehicule_sur}]` +
|
|
`${isGuest(s) ? ' → INVITÉ (règle puissance)' : ''}${div}`);
|
|
}
|
|
}
|
|
if (uncertain.length) {
|
|
console.log(`\n[INCERTAIN — hors frontière connue → question par session]`);
|
|
for (const s of uncertain) {
|
|
questions++;
|
|
console.log(` ${s.debut_text} (aucun branchement encadrant)`);
|
|
}
|
|
}
|
|
console.log(`\n--- Résumé ---`);
|
|
console.log(`Structure des groupes : ${shape.join('-')} (attendu 2-3-2-1-1-1)`);
|
|
console.log(`Questions à poser : ${questions} (attendu 5)`);
|
|
console.log(`Groupes à étiquettes mixtes : ${mixedGroups} (attendu 0)`);
|
|
} finally {
|
|
await client.end();
|
|
}
|
|
}
|
|
|
|
function makeClient() { return new Client({ ...PG_BASE, password: readPgPassword() }); }
|
|
|
|
if (require.main === module) {
|
|
main().catch(e => { console.error('[concierge]', e.message); process.exit(1); });
|
|
}
|
|
|
|
module.exports = {
|
|
buildPlugins, assign, isGuest, decide, makeClient,
|
|
SQL_TRANSITIONS, SQL_SESSIONS, CONSIGNE_MIN_A, P_INVITE_W,
|
|
};
|