workspace: niveau du vehicule par le concierge (option A), simulation par drapeau

- concierge-write.js : setVehicleLevel (entier 0-100, refus sans correction,
  relecture), ZERO dans SELECTOR_LABELS ; tests 13/13.
- concierge-live.js answer : reconnait « NN % », ecrit le niveau, lignes
  « 🔋 Niveau noté » / « ⚠️ Niveau NON noté ».
- Drapeau workspace/.simulation lu par concierge-live.js et airtria-ha-vehicule.js :
  aucune ecriture HA pendant un banc agent.
- airtria-ha-vehicule.js : validation de tous les arguments avant ecriture.
- AGENTS.md et reference-concierge.md : vehicule et niveau passent par le concierge.
- Journaux de tri Orange et AIRTRIA, DREAMS.
This commit is contained in:
2026-10-03 00:19:15 +02:00
parent aabd6b77e6
commit 5dc72349e5
9 changed files with 251 additions and 39 deletions

View File

@@ -208,12 +208,11 @@ inventer.
- **Niveau de la voiture (JEEP / VW / ZERO)** : aucun capteur, seule source = Thierry, qui
le SAISIT. Lecture : `node /home/node/.openclaw/workspace/airtria-ha-vehicule.js` (véhicule
branché + niveau saisi). Ne jamais le déduire d'autre chose.
ÉCRITURE (la seule autorisée vers HA) : quand Thierry annonce un branchement (« je
branche la Jeep », « la VW est à 40 % », « la moto est branchée ») → s'il manque le
véhicule OU le niveau, POSE la question « quel véhicule, et à quel niveau (%) ? », puis
`node …/airtria-ha-vehicule.js --vehicule JEEP|VW|ZERO --niveau <entier 0-100>` et
relaie la ligne « écrit et relu » telle quelle (un chiffre identique au précédent compte
aussi comme nouvelle saisie). Niveau à saisir au plus 1 h avant le branchement ou après.
ÉCRITURE : véhicule ET niveau passent par le concierge (§5.0), qui note les deux et le
dit dans sa ligne (« ✅ Noté : JEEP … » puis « 🔋 Niveau noté : 70 % » ou « ⚠️ Niveau NON
noté … »). Ne lance JAMAIS `--vehicule` ni `--niveau` toi-même. S'il manque le véhicule
OU le niveau, POSE la question « quel véhicule, et à quel niveau (%) ? » ; sa réponse
repasse par le concierge. Niveau valable s'il est donné au plus 1 h avant le branchement ou après.
DÉPART PONCTUEL (VW et ZERO, pas de jours fixes ; la JEEP a son calendrier 15:30
mardi et samedi) : au branchement entre 11 h et 14 h 24, DEMANDE « repart-il cet
après-midi, à quelle heure ? » ; réponse « oui, 16 h » → ajoute `--depart 16:00` (heure
@@ -360,12 +359,14 @@ Canal principal, compte `custom-1` (+33607148216). L'envoi passe par le tool `me
(texte et pièces jointes).
### 5.0 ⚠️ RÉFLEXE CONCIERGE VÉHICULE — AVANT TOUTE AUTRE RÉPONSE
Sur tout message de Thierry qui nomme un véhicule (VW, JEEP, invité, mégane, moto/zéro…),
ton TOUT PREMIER geste, avant de saluer ou de répondre quoi que ce soit, via `exec` :
Sur tout message de Thierry qui nomme un véhicule (VW, JEEP, invité, mégane, moto/zéro…)
OU donne un niveau en % (« elle est à 40 % »), ton TOUT PREMIER geste, avant de saluer ou de répondre quoi que ce soit, via `exec` :
`node /home/node/.openclaw/workspace/concierge-live.js answer "<le texte exact du message reçu>"`
Il imprime TOUJOURS une ligne (« ✅ Noté : … » ou « Rien n'est noté : … ») : relaie-la
MOT POUR MOT, intégrale, sans la raccourcir ni la reformuler. N'annonce JAMAIS qu'un
véhicule est noté ou branché sans cette ligne. Une sortie qui commence par « (aucune action
véhicule est noté ou branché sans cette ligne. Le concierge note AUSSI le niveau (« 🔋 Niveau
noté : NN % ») ou le refuse (« ⚠️ Niveau NON noté … ») : passe le message TEL QUEL, ne corrige
jamais un chiffre, et sans la ligne « 🔋 Niveau noté » le niveau N'EST PAS saisi. Une sortie qui commence par « (aucune action
concierge » ne se relaie PAS : réponds simplement au message. Il gère seul la question en attente : ne
teste pas `concierge-pending.json`. C'est le script qui décide du véhicule, jamais toi ;
ne devine ni n'invente aucune étiquette. Détail : reference-concierge.md.

View File

@@ -644,6 +644,27 @@ A memory trace surfaced, but details were unavailable in this run.
A memory trace surfaced, but details were unavailable in this run.
---
*October 2, 2026 at 3:00 AM GMT+2*
A memory trace surfaced, but details were unavailable in this run.
---
*October 2, 2026 at 3:00 AM GMT+2*
A memory trace surfaced, but details were unavailable in this run.
---
*October 2, 2026 at 3:00 AM GMT+2*
A memory trace surfaced, but details were unavailable in this run.
<!-- openclaw:dreaming:diary:end -->
## Deep Sleep

View File

@@ -36,13 +36,21 @@ async function ha(method, url, body) {
} finally { clearTimeout(t); }
}
const etat = async (e) => ha('GET', `/api/states/${e}`);
// 03/10/2026 : fichier drapeau `.simulation` (pose avant un banc qui passe par
// l'agent complet) -> AUCUNE ecriture, les lectures restent reelles. Incident
// du 02/10 : niveau 100 % ecrit en reel pendant un banc.
const SIMULATION = fs.existsSync(path.join(__dirname, '.simulation'));
const ecrit = async (url, body) => {
if (SIMULATION) { console.log(`[SIMULATION : rien écrit — ${url} ${JSON.stringify(body)}]`); return {}; }
return ha('POST', url, body);
};
(async () => {
if (modeArg !== undefined) {
const m = await etat(MODE); const opts = m.attributes.options || [];
const o = opts.find((x) => x.toLowerCase() === String(modeArg).toLowerCase());
if (!o) { console.log(`refus : mode « ${modeArg} » inconnu de HA, options : ${opts.join(', ')}`); process.exit(2); }
await ha('POST', '/api/services/input_select/select_option', { entity_id: MODE, option: o });
await ecrit('/api/services/input_select/select_option', { entity_id: MODE, option: o });
await new Promise((r) => setTimeout(r, 800));
const lu = (await etat(MODE)).state;
if (lu !== o) { console.log(`erreur : écrit ${o} mais HA relit ${lu}`); process.exit(1); }
@@ -61,15 +69,29 @@ const etat = async (e) => ha('GET', `/api/states/${e}`);
console.log(`${prefix}${branche} ; véhicule sélectionné = ${s.state}, dernier niveau saisi = ${Math.round(Number(n.state))} %, ${departTxt(d)} (options : ${options.join(', ')})`);
if (vehicule === undefined && niveauArg === undefined && departArg === undefined) { affiche(sel, niv, await etat(DEP), 'HA : '); return; }
// Tout est validé AVANT la première écriture : un refus n'écrit jamais rien (30/09).
if (niveauArg !== undefined) {
const n0 = Number(niveauArg);
if (!Number.isInteger(n0) || n0 < 0 || n0 > 100) { console.log(`refus : niveau « ${niveauArg} » invalide, entier de 0 à 100 attendu`); process.exit(2); }
}
if (vehicule !== undefined && !options.some((x) => x.toLowerCase() === String(vehicule).toLowerCase())) {
console.log(`refus : véhicule « ${vehicule} » inconnu de HA, options : ${options.join(', ')}`); process.exit(2);
}
if (departArg !== undefined && String(departArg).toLowerCase() !== 'aucun') {
const m0 = /^([01]?\d|2[0-3]):([0-5]\d)$/.exec(String(departArg));
const now0 = new Date(); const nowHM0 = `${String(now0.getHours()).padStart(2, '0')}:${String(now0.getMinutes()).padStart(2, '0')}`;
if (!m0) { console.log(`refus : départ « ${departArg} » invalide, heure HH:MM d'aujourd'hui attendue (ou « aucun »)`); process.exit(2); }
if (`${m0[1].padStart(2, '0')}:${m0[2]}` <= nowHM0) { console.log(`refus : départ ${m0[1]}:${m0[2]} déjà passé (il est ${nowHM0}), le départ ponctuel ne vaut que pour la suite de la journée`); process.exit(2); }
}
if (vehicule !== undefined) {
const o = options.find((x) => x.toLowerCase() === String(vehicule).toLowerCase());
if (!o) { console.log(`refus : véhicule « ${vehicule} » inconnu de HA, options : ${options.join(', ')}`); process.exit(2); }
await ha('POST', '/api/services/input_select/select_option', { entity_id: SEL, option: o });
await ecrit('/api/services/input_select/select_option', { entity_id: SEL, option: o });
}
if (niveauArg !== undefined) {
const n = Number(niveauArg);
if (!Number.isInteger(n) || n < 0 || n > 100) { console.log(`refus : niveau « ${niveauArg} » invalide, entier de 0 à 100 attendu`); process.exit(2); }
await ha('POST', '/api/services/input_number/set_value', { entity_id: NIV, value: n });
await ecrit('/api/services/input_number/set_value', { entity_id: NIV, value: n });
}
if (departArg !== undefined) {
let heure = '00:00';
@@ -80,7 +102,7 @@ const etat = async (e) => ha('GET', `/api/states/${e}`);
const now = new Date(); const nowHM = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
if (heure <= nowHM) { console.log(`refus : départ ${heure} déjà passé (il est ${nowHM}), le départ ponctuel ne vaut que pour la suite de la journée`); process.exit(2); }
}
await ha('POST', '/api/services/input_datetime/set_datetime', { entity_id: DEP, datetime: `${aujourdhui} ${heure}:00` });
await ecrit('/api/services/input_datetime/set_datetime', { entity_id: DEP, datetime: `${aujourdhui} ${heure}:00` });
}
await new Promise((r) => setTimeout(r, 800));
affiche(await etat(SEL), await etat(NIV), await etat(DEP), 'écrit et relu — ');

View File

@@ -86,3 +86,24 @@ Total déplacés : 4
| A3.01 | NVIDIA / Victron → Veille | 1 | Veille | 0 |
Total déplacés : 5
## 2026-10-01 19:50:00 — airtria-menage --quotidien
| Règle | Description | Déplacés | Destination | Écartés (whitelist) |
|---|---|---:|---|---:|
| A1.06 | Reolink | 1 | Trash | 0 |
| A1.22 | TP-Link newsletter | 1 | Trash | 0 |
Total déplacés : 2
## 2026-10-02 19:50:00 — airtria-menage --quotidien
| Règle | Description | Déplacés | Destination | Écartés (whitelist) |
|---|---|---:|---|---:|
| A1.03 | Amazon promos | 1 | Trash | 0 |
| A1.07 | Interencheres newsletters | 1 | Trash | 0 |
| A1.12 | Audible promos | 1 | Trash | 0 |
| A1.21 | i-Run | 2 | Trash | 0 |
| A1.24 | Bpifrance media | 1 | Trash | 0 |
Total déplacés : 6

View File

@@ -21,9 +21,18 @@ const { SELECTOR_ENTITY, SELECTOR_LABELS } = W;
// CONCIERGE_SIMULATION='{"connected":"on|off","options":[..],"plugin":{"on":<epoch>,"on_text":"..","off":null}|null,
// "pending":{..}|null,"pgDown":false}' → answer tourne SANS HA, SANS PostgreSQL et SANS AUCUNE écriture
// (tests unitaires, 30/09/2026). Hors simulation : comportement réel inchangé.
const SIM = process.env.CONCIERGE_SIMULATION ? JSON.parse(process.env.CONCIERGE_SIMULATION) : null;
// 03/10/2026 : la simulation s'active AUSSI par un fichier drapeau `.simulation`
// a cote du script (meme JSON, ou `{}`). Un banc qui passe par l'agent complet
// ne controle pas l'environnement des commandes que l'agent lance : seul un
// fichier pose avant le banc protege l'installation (incidents du 30/09 et du
// 02/10, ecritures reelles pendant des tests).
const SIM_FILE = path.join(__dirname, '.simulation');
const SIM = process.env.CONCIERGE_SIMULATION ? JSON.parse(process.env.CONCIERGE_SIMULATION)
: fs.existsSync(SIM_FILE) ? (() => { try { return JSON.parse(fs.readFileSync(SIM_FILE, 'utf8') || '{}'); } catch { return {}; } })()
: null;
const publishLabel = SIM ? async () => {} : W.publishLabel;
const setVehicleSelector = SIM ? async () => {} : W.setVehicleSelector;
const setVehicleLevel = SIM ? async () => {} : W.setVehicleLevel;
const health = require('./concierge-health.js');
const STATE = path.join(__dirname, 'concierge-state.json');
@@ -165,7 +174,30 @@ function parseLabel(text, options) {
return null;
}
// Niveau ANNONCE dans le message (03/10/2026, decision du proprietaire) :
// « JEEP reste 70 % », « elle est a 40% », « 55 pour cent ». Un nombre SANS « % »
// ou « pour cent » n'est jamais pris pour un niveau (02/10 : « 92 170 »).
// Renvoie { brut, valeur } — valeur = entier 0-100 ou null si invalide ; le
// script REFUSE une valeur invalide, il ne la corrige jamais.
function parseNiveau(text) {
const m = /(\d{1,3}(?:[.,]\d+)?)\s*(?:%|pour\s*cent|pourcent)/i.exec(text || '');
if (!m) return null;
const v = Number(m[1].replace(',', '.'));
return { brut: m[1], valeur: Number.isInteger(v) && v >= 0 && v <= 100 ? v : null };
}
async function noterNiveau(niveau) {
if (!niveau) return '';
if (niveau.valeur === null) {
return `⚠️ Niveau NON noté : « ${niveau.brut} % » n'est pas un niveau valide (entier de 0 à 100).`;
}
try { await setVehicleLevel(niveau.valeur, 'whatsapp'); }
catch (e) { return `⚠️ Niveau NON noté (${e.message}).`; }
return `🔋 Niveau noté : ${niveau.valeur} %.`;
}
async function answer(text) {
const niveau = parseNiveau(text);
const pending = SIM ? (SIM.pending || null) : loadJSON(PENDING, null);
let options = ['VW', 'JEEP'];
try {
@@ -173,7 +205,14 @@ async function answer(text) {
if (Array.isArray(sel.attributes?.options)) options = sel.attributes.options;
} catch { /* défaut VW/JEEP */ }
const label = parseLabel(text, options);
if (!label) return "(aucune action concierge : pas de véhicule nommé — ne relaie pas cette ligne, réponds à la question)"; // non reconnu : on ne publie rien
if (!label && !niveau) return "(aucune action concierge : pas de véhicule nommé — ne relaie pas cette ligne, réponds à la question)"; // non reconnu : on ne publie rien
// Niveau SEUL (« elle est a 40 % ») : il vaut pour la voiture branchee, s'il y en a une.
if (!label) {
let connected = false;
try { connected = (await haState(CONNECTED)).state === 'on'; } catch { /* ignore */ }
if (!connected) return "Rien n'est noté : aucune voiture n'est branchée.";
return await noterNiveau(niveau);
}
// Cible de l'étiquette = le branchement à confirmer :
// - avec question en attente : celui du pending.
@@ -214,7 +253,9 @@ async function answer(text) {
}
if (pending && !SIM) fs.rmSync(PENDING, { force: true });
const when = new Date(on * 1000).toLocaleString('fr-FR', { timeZone: 'Europe/Paris' });
return `✅ Noté : ${label} pour la voiture branchée du ${when}. Merci !${selectorNote}`;
const ligneNiveau = await noterNiveau(niveau);
return `✅ Noté : ${label} pour la voiture branchée du ${when}. Merci !${selectorNote}` +
(ligneNiveau ? `\n${ligneNiveau}` : '');
}
// Alerte "voiture branchée mais ne charge pas" (19/09/2026). EVCS détecte l'anomalie
@@ -248,5 +289,6 @@ const cmd = process.argv[2];
catch (e) { out = `Rien n'est noté : erreur du script (${e.message}).`; }
}
else { console.error('usage: concierge-live.js detect | answer "<texte>"'); process.exit(2); }
if (out && SIM && cmd === 'answer') out += '\n[SIMULATION : rien n\'a été écrit dans Home Assistant]';
if (out) console.log(out);
})().catch(e => { console.error('[concierge-live]', e.message); process.exit(1); });

View File

@@ -18,13 +18,20 @@
const fs = require('fs');
const path = require('path');
// ⚠️ Deux constantes, pas une de plus. Ne JAMAIS les rendre dynamiques.
// ⚠️ Trois constantes, pas une de plus. Ne JAMAIS les rendre dynamiques.
// La troisieme (niveau saisi) est une DECISION du proprietaire du 03/10/2026 :
// le niveau annonce dans un message (« JEEP reste 70 % ») etait perdu, la regle
// de fiche qui devait le faire ecrire n'etait appliquee qu'une fois sur trois,
// et le modele avait « corrige » 150 en 100 avant de l'ecrire.
const HELPER_ENTITY = 'input_text.airtria_vehicle_label';
const SELECTOR_ENTITY = 'input_select.victron_evcs_vehicle';
const LEVEL_ENTITY = 'input_number.victron_evcs_vehicle_level_pct';
// Liste EN DUR, volontairement PAS lue depuis HA : le sélecteur ne pilote que
// les deux véhicules du foyer (départs VW 08:50 / JEEP 06:30). Si une option
// est ajoutée côté HA un jour (ZERO ?), l'ajouter ici est une DÉCISION.
const SELECTOR_LABELS = ['VW', 'JEEP'];
// 03/10/2026 : ZERO ajoutee (moto du foyer, option du selecteur depuis le 28/09,
// et le concierge devient le seul chemin d'ecriture du vehicule).
const SELECTOR_LABELS = ['VW', 'JEEP', 'ZERO'];
// Le sélecteur ne s'écrit QUE sur confirmation humaine. La déduction
// règle-puissance ne conclut jamais VW vs JEEP (indiscernables électriquement),
// donc ne passe jamais ici — et ce garde le garantit même si ça changeait.
@@ -100,7 +107,35 @@ async function setVehicleSelector(label, source) {
return { entity: SELECTOR_ENTITY, option: label };
}
// Niveau de la voiture branchee, ANNONCE par le proprietaire (aucun capteur ne
// le donne). Entier de 0 a 100 SEULEMENT : une valeur invalide est REFUSEE, jamais
// corrigee. Source humaine obligatoire. Relecture comme pour les deux autres.
// La reference du compteur et la date de saisie sont memorisees par une
// automatisation HA a chaque ecriture : on n'y touche pas.
async function setVehicleLevel(niveau, source) {
if (!HUMAN_SOURCES.includes(source)) {
throw new Error(`niveau : source '${source}' non humaine — écriture interdite`);
}
if (!Number.isInteger(niveau) || niveau < 0 || niveau > 100) {
throw new Error(`niveau : '${niveau}' invalide (entier de 0 à 100) — on n'écrit pas`);
}
const token = haToken();
const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
const res = await fetch(`${HA_BASE}/api/services/input_number/set_value`, {
method: 'POST', headers,
body: JSON.stringify({ entity_id: LEVEL_ENTITY, value: niveau }),
});
if (!res.ok) throw new Error(`set_value niveau HTTP ${res.status}`);
const back = await fetch(`${HA_BASE}/api/states/${LEVEL_ENTITY}`, { headers });
if (!back.ok) throw new Error(`relecture HTTP ${back.status}`);
const state = Number((await back.json()).state);
if (Math.round(state) !== niveau) {
throw new Error(`relecture '${state}' != '${niveau}' — niveau refusé en silence par HA`);
}
return { entity: LEVEL_ENTITY, value: niveau };
}
module.exports = {
publishLabel, setVehicleSelector,
HELPER_ENTITY, SELECTOR_ENTITY, SELECTOR_LABELS, MAX_LEN,
publishLabel, setVehicleSelector, setVehicleLevel,
HELPER_ENTITY, SELECTOR_ENTITY, LEVEL_ENTITY, SELECTOR_LABELS, MAX_LEN,
};

View File

@@ -1,7 +1,7 @@
// concierge-write.test.js — Test de PORTÉE D'ÉCRITURE (garde-fou du canal HA).
// Doit ÉCHOUER si une écriture peut sortir des DEUX constantes autorisées :
// airtria_vehicle_label (étiquette) et victron_evcs_vehicle (sélecteur,
// ajouté 19/09/2026). À faire tourner à CHAQUE modification (cf. EVCS advisor).
// Doit ÉCHOUER si une écriture peut sortir des TROIS constantes autorisées :
// airtria_vehicle_label (étiquette), victron_evcs_vehicle (sélecteur, ajouté
// 19/09/2026) et victron_evcs_vehicle_level_pct (niveau annoncé, 03/10/2026). À faire tourner à CHAQUE modification (cf. EVCS advisor).
// Statique + require + fetch STUBBÉ : aucun réseau, aucun token, aucune écriture.
'use strict';
@@ -21,49 +21,54 @@ const check = (name, fn) => queue.push([name, fn]);
console.log('=== Test de portée d\'écriture concierge-write.js ===');
// 1. Les constantes sont exactement les entités attendues.
check('constantes = airtria_vehicle_label + victron_evcs_vehicle', () => {
check('constantes = airtria_vehicle_label + victron_evcs_vehicle + niveau', () => {
assert.strictEqual(mod.HELPER_ENTITY, 'input_text.airtria_vehicle_label');
assert.strictEqual(mod.SELECTOR_ENTITY, 'input_select.victron_evcs_vehicle');
assert.deepStrictEqual(mod.SELECTOR_LABELS, ['VW', 'JEEP']);
assert.strictEqual(mod.LEVEL_ENTITY, 'input_number.victron_evcs_vehicle_level_pct');
assert.deepStrictEqual(mod.SELECTOR_LABELS, ['VW', 'JEEP', 'ZERO']);
});
// 2. entity_id n'est JAMAIS accolé à autre chose que l'une des DEUX constantes
// (pas de littéral d'entité, pas d'interpolation, pas de concaténation).
check('entity_id uniquement = HELPER_ENTITY ou SELECTOR_ENTITY', () => {
check('entity_id uniquement = HELPER_ENTITY, SELECTOR_ENTITY ou LEVEL_ENTITY', () => {
const occ = [...src.matchAll(/entity_id\s*:\s*([^,}\n]+)/g)].map(m => m[1].trim());
assert(occ.length >= 2, `entity_id attendus >= 2, trouvés ${occ.length}`);
for (const v of occ) assert(v === 'HELPER_ENTITY' || v === 'SELECTOR_ENTITY',
assert(occ.length >= 3, `entity_id attendus >= 3, trouvés ${occ.length}`);
for (const v of occ) assert(['HELPER_ENTITY', 'SELECTOR_ENTITY', 'LEVEL_ENTITY'].includes(v),
`entity_id dynamique ou littéral interdit : ${v}`);
});
// 3. Aucune entité HA en dur hors les DEUX déclarations de constantes.
check('aucune entité HA littérale hors les deux constantes', () => {
check('aucune entité HA littérale hors les trois constantes', () => {
const decls = ["'input_text.airtria_vehicle_label'",
"'input_select.victron_evcs_vehicle'"];
"'input_select.victron_evcs_vehicle'",
"'input_number.victron_evcs_vehicle_level_pct'"];
const lits = [...src.matchAll(/['"`]([a-z_]+\.[a-z0-9_]+)['"`]/g)].map(m => m[0]);
for (const l of lits) assert(decls.includes(l),
`littéral d'entité interdit : ${l}`);
});
// 4. DEUX appels de service exactement : set_value puis select_option.
check('services = input_text/set_value + input_select/select_option', () => {
check('services = input_text/set_value + input_select/select_option + input_number/set_value', () => {
const services = [...src.matchAll(/\/api\/services\/([a-z_]+\/[a-z_]+)/g)]
.map(m => m[1]);
assert.deepStrictEqual(services.sort(),
['input_select/select_option', 'input_text/set_value'],
['input_number/set_value', 'input_select/select_option', 'input_text/set_value'],
`services inattendus : ${services.join(', ') || '(aucun)'}`);
});
// 5. La relecture existe pour CHAQUE écriture (garde-fou rejet silencieux).
check('relectures présentes (/api/states/ x2)', () => {
check('relectures présentes (/api/states/ x3)', () => {
const n = [...src.matchAll(/\/api\/states\//g)].length;
assert(n >= 2, `relectures attendues >= 2, trouvées ${n}`);
assert(n >= 3, `relectures attendues >= 3, trouvées ${n}`);
});
// ---- Comportement du sélecteur, fetch STUBBÉ (aucun réseau) ----
const calls = [];
global.fetch = async (url, opts) => {
calls.push({ url: String(url), body: opts && opts.body });
if (String(url).includes('/api/states/input_number.victron_evcs_vehicle_level_pct')) {
return { ok: true, json: async () => ({ state: '40.0' }) };
}
if (String(url).includes('/api/states/')) {
return { ok: true, json: async () => ({ state: 'VW' }) };
}
@@ -79,8 +84,8 @@ check('sélecteur refuse une source non humaine', async () => {
});
// 7. Refus des labels hors foyer, sans AUCUN appel réseau.
check('sélecteur refuse MEGANE / INVITE / ZERO', async () => {
for (const bad of ['MEGANE', 'INVITE', 'ZERO', 'vw', 'Jeep']) {
check('sélecteur refuse MEGANE / INVITE (ZERO accepte depuis le 03/10)', async () => {
for (const bad of ['MEGANE', 'INVITE', 'vw', 'Jeep', 'zero']) {
calls.length = 0;
await assert.rejects(() => mod.setVehicleSelector(bad, 'whatsapp'), /hors foyer/);
assert.strictEqual(calls.length, 0, `un fetch est parti pour '${bad}'`);
@@ -104,6 +109,38 @@ check('sélecteur détecte le refus silencieux HA', async () => {
/refusée en silence/);
});
// 10. Niveau : refus des valeurs invalides SANS correction ni appel réseau
// (02/10 : le modèle avait « corrigé » 150 en 100 avant de l'écrire).
check('niveau refuse 150, -1, 40.5, NaN — aucun fetch', async () => {
for (const bad of [150, -1, 40.5, NaN, '40']) {
calls.length = 0;
await assert.rejects(() => mod.setVehicleLevel(bad, 'whatsapp'), /invalide/);
assert.strictEqual(calls.length, 0, `un fetch est parti pour '${bad}'`);
}
});
// 11. Niveau : source non humaine refusée.
check('niveau refuse une source non humaine', async () => {
calls.length = 0;
await assert.rejects(() => mod.setVehicleLevel(40, 'deduction'), /non humaine/);
assert.strictEqual(calls.length, 0);
});
// 12. Niveau 40 : set_value sur la constante, puis relecture.
check('niveau 40 : écriture + relecture', async () => {
calls.length = 0;
const r = await mod.setVehicleLevel(40, 'whatsapp');
assert.strictEqual(r.entity, 'input_number.victron_evcs_vehicle_level_pct');
assert.strictEqual(calls.length, 2);
assert(calls[0].url.includes('/api/services/input_number/set_value'));
assert(calls[0].body.includes('"value":40'));
});
// 13. Niveau : relecture discordante = erreur (HA relit 40, on demandait 70).
check('niveau détecte le refus silencieux HA', async () => {
await assert.rejects(() => mod.setVehicleLevel(70, 'whatsapp'), /refusé en silence/);
});
(async () => {
for (const [name, fn] of queue) {
try { await fn(); console.log(` ok ${name}`); }

View File

@@ -114,3 +114,28 @@ Total déplacés : 5
| O1.22 | Quora (relais Apple) | 1 | INBOX/TRASH | 0 |
Total déplacés : 6
## 2026-10-01 19:45:00 — orange-menage --quotidien
| Règle | Description | Déplacés | Destination | Écartés (whitelist) |
|---|---|---:|---|---:|
| O1.04 | Darty news | 1 | INBOX/TRASH | 0 |
| O1.05 | Edenred Meyclub/Kadéos | 1 | INBOX/TRASH | 0 |
| O1.09 | Netflix | 1 | INBOX/TRASH | 0 |
| O1.22 | Quora (relais Apple) | 1 | INBOX/TRASH | 0 |
| O1.24 | Apple newsletters | 1 | INBOX/TRASH | 0 |
Total déplacés : 5
## 2026-10-02 19:45:00 — orange-menage --quotidien
| Règle | Description | Déplacés | Destination | Écartés (whitelist) |
|---|---|---:|---|---:|
| O1.01 | Fnac (en entier) | 2 | INBOX/TRASH | 0 |
| O1.05 | Edenred Meyclub/Kadéos | 1 | INBOX/TRASH | 0 |
| O1.09 | Netflix | 1 | INBOX/TRASH | 0 |
| O1.20 | Accor | 1 | INBOX/TRASH | 0 |
| O1.22 | Quora (relais Apple) | 1 | INBOX/TRASH | 0 |
| O1.25 | News Orange promo | 1 | INBOX/TRASH | 0 |
Total déplacés : 7

View File

@@ -1,14 +1,22 @@
# Référence concierge véhicule — déplacé mot pour mot de AGENTS.md §5bis le 30/09/2026 (retouche le même jour : le script ne sort plus jamais vide)
# Référence concierge véhicule — déplacé mot pour mot de AGENTS.md §5bis le 30/09/2026 (retouches : 30/09 le script ne sort plus jamais vide ; 03/10 il note aussi le niveau, option A)
### Traiter une réponse de l'utilisateur
À chaque message reçu de l'utilisateur en session principale qui mentionne un véhicule
(VW, JEEP, invité, mégane, moto/zéro…) — que ce soit en réponse à une question OU
spontanément — lance via `exec` :
(VW, JEEP, invité, mégane, moto/zéro…) OU qui donne un niveau en % — que ce soit en
réponse à une question OU spontanément — lance via `exec` :
node /home/node/.openclaw/workspace/concierge-live.js answer "<texte exact du message>"
- Il imprime TOUJOURS une ligne, « ✅ Noté : … » ou « Rien n'est noté : <raison> » :
relaie-la telle quelle à l'utilisateur (depuis le 30/09, il ne sort plus jamais vide).
- Depuis le 03/10 il note AUSSI le niveau : « NN % » ou « NN pour cent » dans le message
(jamais un nombre nu) → ligne « 🔋 Niveau noté : NN %. », ou « ⚠️ Niveau NON noté : … »
si le chiffre n'est pas un entier de 0 à 100. Le chiffre n'est JAMAIS corrigé, ni par le
script ni par toi. Relaie ces lignes telles quelles. Tu ne lances plus jamais
`airtria-ha-vehicule.js --vehicule` ni `--niveau` : le concierge est le seul chemin.
- « (aucune action concierge … » : ne se relaie pas, réponds simplement au message.
- Fichier `workspace/.simulation` présent : banc en cours, rien n'est écrit, chaque ligne
porte « [SIMULATION … ] ».
⚠️ CHANGEMENT 19/09/2026 : lance `answer` MÊME si `concierge-pending.json` n'existe pas.
Le script sait désormais rattacher une confirmation spontanée à la session en cours. Motif :