Concierge vehicule : code (detection, live, write + test de portee)

Complete le commit doc precedent : ajoute le code JS du concierge, exclu jusqu'ici
par la liste blanche workspace/*. Exception ciblee !workspace/concierge-*.js dans
.gitignore (aucun secret : les tokens sont lus depuis .ha-token/.pg-airtria, ignores).

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-14 02:39:00 +02:00
parent 40a4605a31
commit 534ae0d663
5 changed files with 471 additions and 0 deletions

View File

@@ -0,0 +1,195 @@
// 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 -------------------------------------------------------------
const PG = {
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);
}
}
// --- 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 = new Client(PG);
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); }
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,
};