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:
195
workspace/concierge-detection.js
Normal file
195
workspace/concierge-detection.js
Normal 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
156
workspace/concierge-live.js
Normal 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); });
|
||||
61
workspace/concierge-write.js
Normal file
61
workspace/concierge-write.js
Normal 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 };
|
||||
57
workspace/concierge-write.test.js
Normal file
57
workspace/concierge-write.test.js
Normal 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);
|
||||
Reference in New Issue
Block a user