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

156
workspace/concierge-live.js Normal file
View File

@@ -0,0 +1,156 @@
// concierge-live.js — Concierge véhicule Airtria, ÉTAGE QUESTION/RÉPONSE (live).
// -----------------------------------------------------------------------------
// Deux sous-commandes, appelées par l'agent :
// detect → cron 10 min : repère un nouveau branchement, décide
// (invité auto | question | rien), imprime la question WhatsApp
// ou rien. Écrit l'état en attente. Publie le helper si invité.
// answer "<txt>" → réception d'une réponse WhatsApp : reconnaît le véhicule,
// publie le helper pour les sessions en attente, confirme.
// Tout le déterministe est ici ; l'agent ne fait que router. Voir SPEC.
// -----------------------------------------------------------------------------
'use strict';
const fs = require('fs');
const path = require('path');
const {
buildPlugins, assign, isGuest, makeClient, SQL_TRANSITIONS, SQL_SESSIONS,
} = require('./concierge-detection.js');
const { publishLabel } = require('./concierge-write.js');
const STATE = path.join(__dirname, 'concierge-state.json'); // progression
const PENDING = path.join(__dirname, 'concierge-pending.json'); // question ouverte
const HA_BASE = process.env.HA_BASE || 'http://10.10.10.17:8123';
const SELECTOR = 'input_select.victron_evcs_vehicle'; // lecture seule (HA)
const WINDOW_DAYS = 5;
const loadJSON = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } };
const saveJSON = (f, o) => fs.writeFileSync(f, JSON.stringify(o, null, 2));
const haToken = () => fs.readFileSync(
process.env.HA_TOKEN_FILE || path.join(__dirname, '.ha-token'), 'utf8').trim();
async function haState(entity) {
const r = await fetch(`${HA_BASE}/api/states/${entity}`,
{ headers: { Authorization: `Bearer ${haToken()}` } });
if (!r.ok) throw new Error(`HA ${entity} HTTP ${r.status}`);
return r.json(); // { state, last_changed, attributes:{options?} }
}
// Changements de VALEUR du sélecteur, lus dans evenement (robuste : le détecteur
// saute les lectures `unavailable`, contrairement au last_changed HA qu'un blip MQTT
// remet à zéro). genre='intervention', entite=input_select.victron_evcs_vehicle.
const SQL_SELECTOR_CHANGES = `
SELECT extract(epoch FROM ts) AS ts_epoch,
extract(epoch FROM (detail->>'horodatage_ha')::timestamptz) AS ha_epoch
FROM evenement
WHERE genre = 'intervention'
AND detail->>'entite' = 'input_select.victron_evcs_vehicle'
AND ts >= $1 AND ts < $2
ORDER BY ts`;
async function loadWindow() {
const until = new Date().toISOString();
const since = new Date(Date.now() - WINDOW_DAYS * 864e5).toISOString();
const c = makeClient();
await c.connect();
try {
// Requêtes séquentielles : node-postgres n'aime pas des query() concurrentes
// sur un même client (avertissement de dépréciation, retrait prévu en pg@9).
const tr = await c.query(SQL_TRANSITIONS, [since, until]);
const se = await c.query(SQL_SESSIONS, [since, until]);
const sc = await c.query(SQL_SELECTOR_CHANGES, [since, until]);
const plugins = buildPlugins(tr.rows);
const { groups } = assign(se.rows, plugins);
const selChanges = sc.rows.map(r => Number(r.ha_epoch != null ? r.ha_epoch : r.ts_epoch));
return {
groups: groups.slice().sort((a, b) => a.plugin.on - b.plugin.on),
selChanges,
};
} finally { await c.end(); }
}
// Fraîcheur du sélecteur (règle soutenue par les données, EVCS 2026-09-13) : FRAIS
// seulement si un changement de sélecteur tombe dans [début − 10 min, début + 30 min].
// Un changement plus tardif (en cours de branchement) ne peut pas étiqueter CE
// branchement (câble non bougé) → producteur des groupes mixtes → DEMANDER. Pas de
// changement → DEMANDER. Absence d'événement ne prouve pas tout (mémoire détecteur
// en-processus, redémarrages), mais dans le doute on demande = le bon côté.
const FRESH_BEFORE_S = 600, FRESH_AFTER_S = 1800;
function selectorFreshAt(onEpoch, selChanges) {
return selChanges.some(c => c >= onEpoch - FRESH_BEFORE_S && c <= onEpoch + FRESH_AFTER_S);
}
const hhmm = (epoch) => new Date(epoch * 1000)
.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' });
async function detect() {
const { groups, selChanges } = await loadWindow();
if (groups.length === 0) return '';
const latest = groups[groups.length - 1];
const state = loadJSON(STATE, null);
// Bootstrap : au 1er lancement, prendre acte de l'historique sans harceler.
if (!state) { saveJSON(STATE, { lastOn: latest.plugin.on }); return ''; }
if (latest.plugin.on <= state.lastOn) return ''; // rien de nouveau
// Invité conclusif (règle puissance) → publier, aucune question.
// Label INVITE (majuscules, sans accent) = convention de la colonne (VW/JEEP/
// MEGANE). La règle de puissance ne sait PAS quelle voiture c'est → jamais MEGANE.
if (latest.sessions.every(isGuest)) {
await publishLabel(latest.sessions.map(s => s.debut_text), 'INVITE', 'regle-puissance');
saveJSON(STATE, { lastOn: latest.plugin.on });
return '';
}
// Fraîcheur du sélecteur via evenement (fenêtre serrée autour du début), jamais
// via last_changed (qu'un blip unavailable remet à zéro).
const fresh = selectorFreshAt(latest.plugin.on, selChanges);
saveJSON(STATE, { lastOn: latest.plugin.on }); // traité : pas de re-question
if (fresh) return ''; // sélecteur réglé au branchement → confiance, pas de question
const ref = 'V-' + Math.random().toString(36).slice(2, 5);
saveJSON(PENDING, {
ref, sessions: latest.sessions.map(s => s.debut_text),
askedAt: new Date().toISOString(), pluginOn: latest.plugin.on,
});
return `🔌 Charge démarrée à ${hhmm(latest.plugin.on)}. ` +
`Quel véhicule est branché ? (VW / JEEP / invité) [${ref}]`;
}
// Labels en MAJUSCULES sans accent (convention de la colonne : VW/JEEP/MEGANE).
// Le propriétaire peut NOMMER l'invité (MEGANE) ; sinon INVITE générique.
// ZERO (moto) : accepté par WhatsApp bien que le sélecteur HA ne l'expose pas
// encore — le propriétaire ajoutera l'option quand la moto sera en service.
function parseLabel(text, options) {
const t = (text || '').toLowerCase();
for (const o of options) if (t.includes(o.toLowerCase())) return o; // VW/JEEP (options HA)
if (/m[ée]gane/.test(t)) return 'MEGANE';
if (/moto|z[ée]ro/.test(t)) return 'ZERO';
if (/invit|pr[eê]t|ami|emprunt/.test(t)) return 'INVITE';
return null;
}
async function answer(text) {
const pending = loadJSON(PENDING, null);
if (!pending) return '';
let options = ['VW', 'JEEP'];
try {
const sel = await haState(SELECTOR);
if (Array.isArray(sel.attributes?.options)) options = sel.attributes.options;
} catch { /* défaut VW/JEEP */ }
const label = parseLabel(text, options);
if (!label) return ''; // non reconnu : l'agent pourra redemander, on ne publie rien
await publishLabel(pending.sessions, label, 'whatsapp');
fs.rmSync(PENDING, { force: true });
const when = new Date(pending.pluginOn * 1000)
.toLocaleString('fr-FR', { timeZone: 'Europe/Paris' });
return `✅ Noté : ${label} pour la charge du ${when}. Merci !`;
}
const cmd = process.argv[2];
(async () => {
let out = '';
if (cmd === 'detect') out = await detect();
else if (cmd === 'answer') out = await answer(process.argv.slice(3).join(' '));
else { console.error('usage: concierge-live.js detect | answer "<texte>"'); process.exit(2); }
if (out) console.log(out);
})().catch(e => { console.error('[concierge-live]', e.message); process.exit(1); });

View File

@@ -0,0 +1,61 @@
// concierge-write.js — Concierge véhicule Airtria, ÉTAGE ÉCRITURE (canal HA restreint)
// -----------------------------------------------------------------------------
// SEULE écriture HA d'Airtria. La restriction à cette entité est CONVENTIONNELLE
// (le token HA peut techniquement tout écrire) : elle ne tient que par ce code.
// Donc, invariants tenus ici et vérifiés par concierge-write.test.js :
// - l'entité écrite est une CONSTANTE EN DUR, jamais interpolée ni construite ;
// - une seule écriture, via input_text.set_value, aucun actionneur ;
// - relecture obligatoire après écriture (détecte un rejet silencieux HA).
// Voir airtria-concierge-vehicule-SPEC.md.
// -----------------------------------------------------------------------------
'use strict';
const fs = require('fs');
const path = require('path');
// ⚠️ Constante unique. Ne JAMAIS la rendre dynamique ni en ajouter d'autres.
const HELPER_ENTITY = 'input_text.airtria_vehicle_label';
const HA_BASE = process.env.HA_BASE || 'http://10.10.10.17:8123';
const MAX_LEN = 255; // le helper DOIT être déclaré max:255 côté HA (défaut = 100)
function haToken() {
return fs.readFileSync(
process.env.HA_TOKEN_FILE || path.join(__dirname, '.ha-token'), 'utf8').trim();
}
// Publie le tuple {sessions[], label, confirmed_at, source} pour un branchement.
// sessions = clefs `debut` VERBATIM (ms comprises). Le service EVCS joint sur ±1 s.
async function publishLabel(sessions, label, source = 'whatsapp') {
if (!Array.isArray(sessions) || sessions.length === 0) {
throw new Error('sessions doit être une liste non vide');
}
if (!label || typeof label !== 'string') throw new Error('label invalide');
const payload = JSON.stringify({
sessions, label, confirmed_at: new Date().toISOString(), source,
});
if (payload.length > MAX_LEN) {
throw new Error(`payload ${payload.length} > ${MAX_LEN} : scinder le groupe ` +
`en deux publications espacées de > 90 s`);
}
const token = haToken();
const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
// Écriture : service input_text.set_value sur la CONSTANTE en dur.
const res = await fetch(`${HA_BASE}/api/services/input_text/set_value`, {
method: 'POST', headers,
body: JSON.stringify({ entity_id: HELPER_ENTITY, value: payload }),
});
if (!res.ok) throw new Error(`set_value HTTP ${res.status}`);
// Relecture OBLIGATOIRE : seul moyen de voir un rejet silencieux (max trop court).
const back = await fetch(`${HA_BASE}/api/states/${HELPER_ENTITY}`, { headers });
if (!back.ok) throw new Error(`relecture HTTP ${back.status}`);
const state = (await back.json()).state;
if (state !== payload) {
throw new Error('relecture != écrit — rejet silencieux probable ' +
'(vérifier max:255 sur le helper)');
}
return { entity: HELPER_ENTITY, payload };
}
module.exports = { publishLabel, HELPER_ENTITY, MAX_LEN };

View File

@@ -0,0 +1,57 @@
// concierge-write.test.js — Test de PORTÉE D'ÉCRITURE (garde-fou du canal HA).
// Doit ÉCHOUER si une écriture peut sortir de la constante airtria_vehicle_label.
// À faire tourner à CHAQUE modification, pas une seule fois (cf. EVCS advisor).
// Pur statique + require : aucun réseau, aucun token, aucune écriture.
'use strict';
const fs = require('fs');
const path = require('path');
const assert = require('assert');
const SRC = path.join(__dirname, 'concierge-write.js');
const src = fs.readFileSync(SRC, 'utf8');
const mod = require('./concierge-write.js');
let ko = 0;
const check = (name, fn) => {
try { fn(); console.log(` ok ${name}`); }
catch (e) { ko++; console.log(` FAIL ${name} : ${e.message}`); }
};
console.log('=== Test de portée d\'écriture concierge-write.js ===');
// 1. La constante est exactement l'entité attendue.
check('constante = input_text.airtria_vehicle_label', () =>
assert.strictEqual(mod.HELPER_ENTITY, 'input_text.airtria_vehicle_label'));
// 2. entity_id n'est JAMAIS accolé à autre chose que la constante
// (pas de littéral d'entité, pas d'interpolation, pas de concaténation).
check('entity_id uniquement = HELPER_ENTITY', () => {
const occ = [...src.matchAll(/entity_id\s*:\s*([^,}\n]+)/g)].map(m => m[1].trim());
assert(occ.length > 0, 'aucun entity_id trouvé');
for (const v of occ) assert.strictEqual(v, 'HELPER_ENTITY',
`entity_id dynamique ou littéral interdit : ${v}`);
});
// 3. Aucune autre entité HA en dur (aucun autre "domaine.entite") que la constante.
check('aucune entité HA littérale hors la constante', () => {
const decl = "'input_text.airtria_vehicle_label'";
const lits = [...src.matchAll(/['"`]([a-z_]+\.[a-z0-9_]+)['"`]/g)].map(m => m[0]);
for (const l of lits) assert.strictEqual(l, decl,
`littéral d'entité interdit : ${l}`);
});
// 4. Un SEUL appel de service, et c'est input_text/set_value (aucun actionneur).
check('seul service = input_text/set_value', () => {
const services = [...src.matchAll(/\/api\/services\/([a-z_]+\/[a-z_]+)/g)]
.map(m => m[1]);
assert.deepStrictEqual(services, ['input_text/set_value'],
`services inattendus : ${services.join(', ') || '(aucun)'}`);
});
// 5. La relecture existe (garde-fou rejet silencieux).
check('relecture présente (/api/states/)', () =>
assert(/\/api\/states\//.test(src), 'pas de relecture après écriture'));
console.log(ko === 0 ? '\nTOUT VERT' : `\n${ko} test(s) EN ÉCHEC`);
process.exit(ko === 0 ? 0 : 1);