// 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() { const c = new Client({ ...PG_BASE, password: readPgPassword() }); // Un 'error' socket (backend qui tombe, connexion coupée) SANS écouteur devient une // exception NON capturée qui échappe au try/catch et TUE le process (piège signalé par // EVCS 16/09 : « un battement dans un process mort ne bat pas »). On l'écoute pour le // rendre non fatal ; l'opération await en cours rejette et reste rattrapée normalement. c.on('error', (e) => { console.error('[pg] socket error (non fatal):', e.message); }); return c; } 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, };