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 - **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 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. branché + niveau saisi). Ne jamais le déduire d'autre chose.
ÉCRITURE (la seule autorisée vers HA) : quand Thierry annonce un branchement (« je ÉCRITURE : véhicule ET niveau passent par le concierge (§5.0), qui note les deux et le
branche la Jeep », « la VW est à 40 % », « la moto est branchée ») → s'il manque le dit dans sa ligne (« ✅ Noté : JEEP … » puis « 🔋 Niveau noté : 70 % » ou « ⚠️ Niveau NON
véhicule OU le niveau, POSE la question « quel véhicule, et à quel niveau (%) ? », puis noté … »). Ne lance JAMAIS `--vehicule` ni `--niveau` toi-même. S'il manque le véhicule
`node …/airtria-ha-vehicule.js --vehicule JEEP|VW|ZERO --niveau <entier 0-100>` et OU le niveau, POSE la question « quel véhicule, et à quel niveau (%) ? » ; sa réponse
relaie la ligne « écrit et relu » telle quelle (un chiffre identique au précédent compte repasse par le concierge. Niveau valable s'il est donné au plus 1 h avant le branchement ou après.
aussi comme nouvelle saisie). Niveau à saisir 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 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 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 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). (texte et pièces jointes).
### 5.0 ⚠️ RÉFLEXE CONCIERGE VÉHICULE — AVANT TOUTE AUTRE RÉPONSE ### 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…), 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` : 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>"` `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 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 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 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 ; 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. 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. 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 --> <!-- openclaw:dreaming:diary:end -->
## Deep Sleep ## Deep Sleep

View File

@@ -36,13 +36,21 @@ async function ha(method, url, body) {
} finally { clearTimeout(t); } } finally { clearTimeout(t); }
} }
const etat = async (e) => ha('GET', `/api/states/${e}`); 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 () => { (async () => {
if (modeArg !== undefined) { if (modeArg !== undefined) {
const m = await etat(MODE); const opts = m.attributes.options || []; const m = await etat(MODE); const opts = m.attributes.options || [];
const o = opts.find((x) => x.toLowerCase() === String(modeArg).toLowerCase()); 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); } 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)); await new Promise((r) => setTimeout(r, 800));
const lu = (await etat(MODE)).state; const lu = (await etat(MODE)).state;
if (lu !== o) { console.log(`erreur : écrit ${o} mais HA relit ${lu}`); process.exit(1); } 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(', ')})`); 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; } 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) { if (vehicule !== undefined) {
const o = options.find((x) => x.toLowerCase() === String(vehicule).toLowerCase()); 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); } 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) { if (niveauArg !== undefined) {
const n = Number(niveauArg); 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); } 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) { if (departArg !== undefined) {
let heure = '00:00'; 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')}`; 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); } 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)); await new Promise((r) => setTimeout(r, 800));
affiche(await etat(SEL), await etat(NIV), await etat(DEP), 'écrit et relu — '); 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 | | A3.01 | NVIDIA / Victron → Veille | 1 | Veille | 0 |
Total déplacés : 5 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, // 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 // "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é. // (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 publishLabel = SIM ? async () => {} : W.publishLabel;
const setVehicleSelector = SIM ? async () => {} : W.setVehicleSelector; const setVehicleSelector = SIM ? async () => {} : W.setVehicleSelector;
const setVehicleLevel = SIM ? async () => {} : W.setVehicleLevel;
const health = require('./concierge-health.js'); const health = require('./concierge-health.js');
const STATE = path.join(__dirname, 'concierge-state.json'); const STATE = path.join(__dirname, 'concierge-state.json');
@@ -165,7 +174,30 @@ function parseLabel(text, options) {
return null; 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) { async function answer(text) {
const niveau = parseNiveau(text);
const pending = SIM ? (SIM.pending || null) : loadJSON(PENDING, null); const pending = SIM ? (SIM.pending || null) : loadJSON(PENDING, null);
let options = ['VW', 'JEEP']; let options = ['VW', 'JEEP'];
try { try {
@@ -173,7 +205,14 @@ async function answer(text) {
if (Array.isArray(sel.attributes?.options)) options = sel.attributes.options; if (Array.isArray(sel.attributes?.options)) options = sel.attributes.options;
} catch { /* défaut VW/JEEP */ } } catch { /* défaut VW/JEEP */ }
const label = parseLabel(text, options); 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 : // Cible de l'étiquette = le branchement à confirmer :
// - avec question en attente : celui du pending. // - avec question en attente : celui du pending.
@@ -214,7 +253,9 @@ async function answer(text) {
} }
if (pending && !SIM) fs.rmSync(PENDING, { force: true }); if (pending && !SIM) fs.rmSync(PENDING, { force: true });
const when = new Date(on * 1000).toLocaleString('fr-FR', { timeZone: 'Europe/Paris' }); 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 // 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}).`; } 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); } 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); if (out) console.log(out);
})().catch(e => { console.error('[concierge-live]', e.message); process.exit(1); }); })().catch(e => { console.error('[concierge-live]', e.message); process.exit(1); });

View File

@@ -18,13 +18,20 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); 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 HELPER_ENTITY = 'input_text.airtria_vehicle_label';
const SELECTOR_ENTITY = 'input_select.victron_evcs_vehicle'; 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 // 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 // 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. // 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 // Le sélecteur ne s'écrit QUE sur confirmation humaine. La déduction
// règle-puissance ne conclut jamais VW vs JEEP (indiscernables électriquement), // 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. // 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 }; 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 = { module.exports = {
publishLabel, setVehicleSelector, publishLabel, setVehicleSelector, setVehicleLevel,
HELPER_ENTITY, SELECTOR_ENTITY, SELECTOR_LABELS, MAX_LEN, 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). // 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 : // Doit ÉCHOUER si une écriture peut sortir des TROIS constantes autorisées :
// airtria_vehicle_label (étiquette) et victron_evcs_vehicle (sélecteur, // airtria_vehicle_label (étiquette), victron_evcs_vehicle (sélecteur, ajouté
// ajouté 19/09/2026). À faire tourner à CHAQUE modification (cf. EVCS advisor). // 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. // Statique + require + fetch STUBBÉ : aucun réseau, aucun token, aucune écriture.
'use strict'; '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 ==='); console.log('=== Test de portée d\'écriture concierge-write.js ===');
// 1. Les constantes sont exactement les entités attendues. // 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.HELPER_ENTITY, 'input_text.airtria_vehicle_label');
assert.strictEqual(mod.SELECTOR_ENTITY, 'input_select.victron_evcs_vehicle'); 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 // 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). // (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()); 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}`); assert(occ.length >= 3, `entity_id attendus >= 3, trouvés ${occ.length}`);
for (const v of occ) assert(v === 'HELPER_ENTITY' || v === 'SELECTOR_ENTITY', for (const v of occ) assert(['HELPER_ENTITY', 'SELECTOR_ENTITY', 'LEVEL_ENTITY'].includes(v),
`entity_id dynamique ou littéral interdit : ${v}`); `entity_id dynamique ou littéral interdit : ${v}`);
}); });
// 3. Aucune entité HA en dur hors les DEUX déclarations de constantes. // 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'", 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]); const lits = [...src.matchAll(/['"`]([a-z_]+\.[a-z0-9_]+)['"`]/g)].map(m => m[0]);
for (const l of lits) assert(decls.includes(l), for (const l of lits) assert(decls.includes(l),
`littéral d'entité interdit : ${l}`); `littéral d'entité interdit : ${l}`);
}); });
// 4. DEUX appels de service exactement : set_value puis select_option. // 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)] const services = [...src.matchAll(/\/api\/services\/([a-z_]+\/[a-z_]+)/g)]
.map(m => m[1]); .map(m => m[1]);
assert.deepStrictEqual(services.sort(), 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)'}`); `services inattendus : ${services.join(', ') || '(aucun)'}`);
}); });
// 5. La relecture existe pour CHAQUE écriture (garde-fou rejet silencieux). // 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; 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) ---- // ---- Comportement du sélecteur, fetch STUBBÉ (aucun réseau) ----
const calls = []; const calls = [];
global.fetch = async (url, opts) => { global.fetch = async (url, opts) => {
calls.push({ url: String(url), body: opts && opts.body }); 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/')) { if (String(url).includes('/api/states/')) {
return { ok: true, json: async () => ({ state: 'VW' }) }; 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. // 7. Refus des labels hors foyer, sans AUCUN appel réseau.
check('sélecteur refuse MEGANE / INVITE / ZERO', async () => { check('sélecteur refuse MEGANE / INVITE (ZERO accepte depuis le 03/10)', async () => {
for (const bad of ['MEGANE', 'INVITE', 'ZERO', 'vw', 'Jeep']) { for (const bad of ['MEGANE', 'INVITE', 'vw', 'Jeep', 'zero']) {
calls.length = 0; calls.length = 0;
await assert.rejects(() => mod.setVehicleSelector(bad, 'whatsapp'), /hors foyer/); await assert.rejects(() => mod.setVehicleSelector(bad, 'whatsapp'), /hors foyer/);
assert.strictEqual(calls.length, 0, `un fetch est parti pour '${bad}'`); 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/); /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 () => { (async () => {
for (const [name, fn] of queue) { for (const [name, fn] of queue) {
try { await fn(); console.log(` ok ${name}`); } 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 | | O1.22 | Quora (relais Apple) | 1 | INBOX/TRASH | 0 |
Total déplacés : 6 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 ### Traiter une réponse de l'utilisateur
À chaque message reçu de l'utilisateur en session principale qui mentionne un véhicule À 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 (VW, JEEP, invité, mégane, moto/zéro…) OU qui donne un niveau en % — que ce soit en
spontanément — lance via `exec` : réponse à une question OU spontanément — lance via `exec` :
node /home/node/.openclaw/workspace/concierge-live.js answer "<texte exact du message>" 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> » : - 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). 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. ⚠️ 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 : Le script sait désormais rattacher une confirmation spontanée à la session en cours. Motif :