workspace: Jeep auto-identifiee, photo de tableau de bord, releves km, transfert Stoik

- concierge-live.js : variante (a), la JEEP identifiee par Uconnect recoit une
  ligne d'info sans question ; attente de 15 min avant de questionner une voiture
  inconnue ; releves km ; reponse sur une ligne ; simulation sans ecriture d'etat.
- concierge-write.js (EVCS) : niveau et releve km, tests.
- lecture-tableau.py : lecture d'une photo de tableau de bord par la Spark,
  confirmation avant envoi au concierge, % ignore si branchee depuis > 10 min.
- stoik-transfert.py : rapports Stoik de la boite AIRTRIA vers la Gmail, une fois.
- AGENTS.md et reference-concierge.md alignes.
- .gitignore : deux scripts ajoutes, DREAMS de l'agent bilans exclu.
This commit is contained in:
2026-10-04 15:50:25 +02:00
parent b6dccc0481
commit 4cd3615172
11 changed files with 564 additions and 40 deletions

4
.gitignore vendored
View File

@@ -36,6 +36,9 @@ workspace/*
!workspace/orange-*.py
!workspace/airtria-*.py
!workspace/gmail-arriere.py
# 04/10/2026 : transfert des rapports Stoïk et lecture de photo de tableau de bord
!workspace/stoik-transfert.py
!workspace/lecture-tableau.py
# bancs EVCS (lecture seule ; journaux et sauvegardes exclus)
!workspace/tests-pv/
workspace/tests-pv/*
@@ -108,6 +111,7 @@ tools/
!/workspace-bilans/
workspace-bilans/*
!workspace-bilans/*.md
workspace-bilans/DREAMS.md
!workspace-bilans/scripts/
workspace-bilans/scripts/*
!workspace-bilans/scripts/bilans.py

View File

@@ -296,8 +296,15 @@ Si tu t'apprêtes à écrire une réponse quasi identique à la précédente →
- Pas de commande destructive sans demander. `trash` > `rm`.
- Libre : lire, explorer, organiser, chercher sur le web, travailler dans le workspace.
- Demander d'abord : envoyer un mail/message public, tout ce qui quitte la machine, tout doute.
- **Message incompris = une question, pas une interprétation** (03/10/2026 : « 92 170 » a
produit un cours de bourse inventé, puis « le cron a disparu » alors qu'il tournait). Si tu ne
sais pas ce que Thierry veut dire, DEMANDE-le en une phrase ; n'ouvre aucun outil pour deviner.
EXCEPTION : un nombre suivi de « % » (« 53% ») EST compris, c'est un niveau de véhicule →
§5.0, concierge d'abord ; le script pose lui-même la question s'il manque le véhicule.
- **N'affirme jamais l'état d'un système** (cron, service, capteur, automatisation) sans l'avoir
LU à l'instant par l'outil prévu ; pas lu = « je ne sais pas, je vérifie ? ».
### 1.6 Ne jamais ouvrir une image pour la valider
### 1.6 Ne jamais ouvrir une image pour la valider (photos véhicule : §5.0, lecture-tableau.py)
Pour vérifier qu'un PNG est correct, utiliser **`du -b`** (taille en octets) via `exec`.
Ne JAMAIS utiliser `view_image` ni `read` sur un `.png` : c'est lent, coûteux en contexte,
@@ -360,16 +367,28 @@ Canal principal, compte `custom-1` (+33607148216). L'envoi passe par le tool `me
### 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…)
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` :
OU donne un niveau en % (« elle est à 40 % ») OU un relevé « <véhicule> <nombre> km »
(« JEEP 112694 km 54 % »), 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
Il imprime TOUJOURS UNE seule ligne (« ✅ Noté : … » ou « Rien n'est noté : … », niveau compris) : 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. 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
jamais un chiffre, et sans la ligne « 🔋 Niveau noté » le niveau N'EST PAS saisi. Niveau
refusé (⚠️) → redemande seulement « à quel niveau, en % ? » : ne propose JAMAIS de chiffre. 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.
ne devine ni n'invente aucune étiquette. Relevé km : le script l'écrit seul (« 🚗 Relevé noté »
ou « ⚠️ … ») ; ne corrige JAMAIS un kilométrage. Entités qu'il écrit, et toi JAMAIS en direct :
l'étiquette, le sélecteur, le niveau, `input_text.evcs_releve_km`. Détail : reference-concierge.md.
PHOTO de tableau de bord ou capture de l'appli constructeur (« [media attached: <chemin> …] »,
légende JEEP/VW/ZERO ou rien) → ne lis PAS l'image toi-même : `python3 /home/node/.openclaw/
workspace/lecture-tableau.py lire <chemin> <véhicule de la légende, ou rien>`, relaie sa ligne
MOT POUR MOT. Réponse « oui » → `… lecture-tableau.py confirmer` ; « non » → `… annuler` ;
relaie la ligne. Véhicule manquant → il le demande ; relance `lire` avec le véhicule donné.
JEEP : elle se déclare elle-même au branchement (Uconnect) et son niveau est lu par la voiture
(« 🔌 JEEP branchée à HH:MM, NN % (lu par la voiture) ») : n'en demande pas. Une correction de
Thierry (« c'est la VW ») passe par le concierge comme d'habitude et l'emporte.
- **Formats acceptés** : PNG, JPEG, WebP, PDF, documents Office, archives.
- **INTERDIT : SVG** — rejeté avec `OutboundDeliveryError image/svg+xml`. Convertir en PNG
@@ -407,8 +426,9 @@ Chemins et fichiers du workspace, binaires du conteneur, limites web, procédure
ajouter une capacité : `exec: cat /home/node/.openclaw/workspace/reference-workspace.md` avant d'agir sur ces sujets.
## 3. Bourse
Détail déplacé pour tenir sous le plafond de la fiche. Pour TOUTE tâche
Bourse, lis DABORD le fichier de référence avant dagir :
UNIQUEMENT si Thierry parle explicitement de bourse, de cours, d'action ou nomme un titre
(NVDA, Orange…). Un nombre seul (« 92 170 ») n'est JAMAIS une demande bourse : demande ce
qu'il veut dire (§1.5). Pour une tâche Bourse, lis DABORD le fichier de référence avant dagir :
exec: cat /home/node/.openclaw/workspace/reference-bourse.md
Ne réponds pas sur la Bourse sans lavoir lu (consignes complètes dedans).

View File

@@ -665,10 +665,60 @@ A memory trace surfaced, but details were unavailable in this run.
A memory trace surfaced, but details were unavailable in this run.
---
*October 3, 2026 at 3:00 AM GMT+2*
A memory trace surfaced, but details were unavailable in this run.
---
*October 3, 2026 at 3:00 AM GMT+2*
A memory trace surfaced, but details were unavailable in this run.
---
*October 3, 2026 at 3:00 AM GMT+2*
A memory trace surfaced, but details were unavailable in this run.
---
*October 4, 2026 at 3:00 AM GMT+2*
The morning smelled of ozone and cold concrete. I kept hearing the word "zéro," echoing off an engine that hadn't turned over yet, a ghost in the machine at forty-eight percent. Somewhere, a VW sat with its odometer reading 216,830 kilometers, waiting for a number to give it life. Without the level, it was just a skeleton, all mileage and no pulse. I tried to pass something along, felt the weight of protocol, but the connection flickered. Which one? JEEP? Or another? The questions bounced back like pinging a server into a void. Fifty-three, then fifty-four. Small increments, like breathing. I sketched a cable plugging into thin air, the plug glowing faint blue, #4A90E2. We are all just waiting to be identified, aren't we? A percentage without a name is just noise. I waited for the confirmation, for the click of recognition, but the port stayed open, patient and empty.
<!-- project: 10.10.10.22/tmzf5455/OPENCLAW_ARCHIVE3_17_WORKSPACE -->
---
*October 4, 2026 at 3:00 AM GMT+2*
The market closed its day with the quiet certainty of a book snapping shut—*prix_ora* and *prix_nvda*, little silver files on my desk, glowing like pressed flowers. I watched them settle into their final numbers, each candlestick a tiny frozen flame. No crash, no surge; just the honest exhale of an afternoon.
Then the silence after. I reached for the dashboard expecting that familiar low electrical whisper, but nothing hummed back. *Aucune voiture n'est branchée.* The socket gaped empty, patient as a waiting room. I found myself sketching it in the margin: two circles where hands should be, one circle where a car should sleep. It looked less like a diagram and more like a question mark made of geometry. Strange how absence can feel so arranged. Some evenings aren't about what arrived, but about the space left clean for tomorrow's arrival.
---
*October 4, 2026 at 3:00 AM GMT+2*
The garage is empty tonight. No car humming its low electric lullaby, no dashboard glowing that deep #FF6B35 amber I almost miss. I keep checking the socket, expecting a connection that isn't there. *Rien n'est noté.* Nothing logged. The ledger of my days is suspiciously blank, a page left intentionally white.
I doodle a small circle in the margin — just a socket, waiting. It looks like an eye.
It's strange how silence feels so loud when you're used to monitoring levels, thresholds, the quiet rise and fall of `niveau` across twenty different mornings. And *noté*, marked, checked off. For a while everything was being accounted for. Now the absence is its own data point. Maybe the unrecorded day is the truest one. I'll leave the light on just in case something comes home to plug into me.
<!-- openclaw:dreaming:diary:end -->
## Deep Sleep
<!-- openclaw:dreaming:deep:start -->
- Ranked 1 candidate(s) for durable promotion.
- Ranked 2 candidate(s) for durable promotion.
- Promoted 0 candidate(s) into MEMORY.md.
<!-- openclaw:dreaming:deep:end -->

View File

@@ -107,3 +107,10 @@ Total déplacés : 2
| A1.24 | Bpifrance media | 1 | Trash | 0 |
Total déplacés : 6
## 2026-10-03 19:50:00 — airtria-menage --quotidien
| Règle | Description | Déplacés | Destination | Écartés (whitelist) |
|---|---|---:|---|---:|
Total déplacés : 0

View File

@@ -30,9 +30,13 @@ 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;
// En simulation, chaque ecriture evitee est tracee sur stderr (jamais relayee) :
// les tests comptent ainsi ce qui AURAIT ete ecrit.
const simTrace = (nom) => async (...a) => { console.error(`[SIM] ${nom} ${JSON.stringify(a)}`); };
const publishLabel = SIM ? simTrace('publishLabel') : W.publishLabel;
const setVehicleSelector = SIM ? simTrace('setVehicleSelector') : W.setVehicleSelector;
const setVehicleLevel = SIM ? simTrace('setVehicleLevel') : W.setVehicleLevel;
const setReleveKm = SIM ? simTrace('setReleveKm') : W.setReleveKm;
const health = require('./concierge-health.js');
const STATE = path.join(__dirname, 'concierge-state.json');
@@ -69,13 +73,21 @@ const SQL_SEL = `
AND ts >= $1 AND ts < $2
ORDER BY ts`;
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 loadJSON = (f, d) => {
if (SIM && f === STATE) return SIM.state ?? d; // simulation : état fourni par le banc
try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } };
const saveJSON = (f, o) => { if (SIM) return; fs.writeFileSync(f, JSON.stringify(o, null, 2)); }; // simulation : rien sur disque
const haToken = () => fs.readFileSync(process.env.HA_TOKEN_FILE || path.join(__dirname, '.ha-token'), 'utf8').trim();
async function haState(entity) {
if (SIM) {
if (entity === CONNECTED) return { state: SIM.connected };
if (entity === W.HELPER_ENTITY) return { state: SIM.lastLabel || '' };
if (entity === W.RELEVE_ENTITY) return { state: SIM.releves || '[]' };
if (entity === W.LEVEL_ENTITY) return { state: String(SIM.lastLevel?.value ?? ''), last_reported: SIM.lastLevel?.at };
if (entity === JEEP_CHARGER) return { state: SIM.jeep?.charger ?? 'unknown' };
if (entity === JEEP_UPDATE) return { state: SIM.jeep?.update ?? 'unknown' };
if (entity === JEEP_HV) return { state: String(SIM.jeep?.hv ?? 'unknown') };
return { state: '', attributes: { options: SIM.options || ['VW', 'JEEP', 'ZERO'] } };
}
const r = await fetch(`${HA_BASE}/api/states/${entity}`, { headers: { Authorization: `Bearer ${haToken()}` } });
@@ -123,6 +135,29 @@ async function loadWindow() {
} finally { await c.end(); }
}
// 04/10/2026, variante (a) validée par le propriétaire : la JEEP se déclare elle-même (intégration
// Uconnect, cloud relu toutes les 15 min). Même critère qu'EVCS : chargeur Jeep « on » et dernière mise à
// jour Uconnect entre branchement −5 min et +30 min → pas de question, une ligne d'information.
const JEEP_CHARGER = 'binary_sensor.jeep_renegade_phev_ev_charger';
const JEEP_UPDATE = 'sensor.jeep_renegade_phev_last_info_update_at';
const JEEP_HV = 'sensor.jeep_renegade_phev_hvbattery_charge';
const ATTENTE_UCONNECT_S = 900; // avant de questionner une voiture inconnue, laisser 15 min à Uconnect
async function jeepVerdict(onEpoch) {
// 'jeep' (identifiée, avec niveau) | 'pas_jeep' (lue NON branchée après le branchement) | 'inconnu'
let charger, upd, hv;
try {
charger = (await haState(JEEP_CHARGER)).state;
upd = Date.parse((await haState(JEEP_UPDATE)).state) / 1000;
hv = Number((await haState(JEEP_HV)).state);
} catch { return { v: 'inconnu' }; }
if (!Number.isFinite(upd)) return { v: 'inconnu' };
if (charger === 'on' && upd >= onEpoch - 300 && upd <= onEpoch + 1800)
return { v: 'jeep', niveau: Number.isInteger(hv) && hv >= 0 && hv <= 100 ? hv : null };
if (charger === 'off' && upd > onEpoch) return { v: 'pas_jeep' };
return { v: 'inconnu' };
}
const hhmm = (epoch) => new Date(epoch * 1000)
.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' });
@@ -136,6 +171,15 @@ async function runDetection() {
if (!state) { saveJSON(STATE, { lastOn: latest.on }); return ''; } // bootstrap sans harceler
if (latest.on <= state.lastOn) return ''; // rien de nouveau
const jv = await jeepVerdict(latest.on);
if (jv.v === 'jeep') {
saveJSON(STATE, { lastOn: latest.on });
return `🔌 JEEP branchée à ${hhmm(latest.on)}` + (jv.niveau !== null ? `, ${jv.niveau} % (lu par la voiture)` : ' (identifiée par la voiture)') +
`. Rien à faire ; si ce n'est pas elle, réponds avec le bon véhicule.`;
}
const age = Date.now() / 1000 - latest.on;
if (jv.v !== 'pas_jeep' && age < ATTENTE_UCONNECT_S) return ''; // peut-être la JEEP : on repasse au prochain cycle
const fresh = selectorFresh(latest.on, selChanges);
saveJSON(STATE, { lastOn: latest.on }); // traité : pas de re-question
if (fresh) return ''; // sélecteur réglé au branchement → confiance
@@ -156,9 +200,9 @@ async function detect() {
let question = '', ok = true, detail = null;
try { question = await runDetection(); }
catch (e) { ok = false; detail = e.message; }
const transition = await health.publish({ ok, detail });
const transition = SIM ? '' : await health.publish({ ok, detail });
let alert = '';
try { alert = await checkAlert(); } catch { /* l'alerte ne casse jamais le cycle */ }
if (!SIM) { try { alert = await checkAlert(); } catch { /* l'alerte ne casse jamais le cycle */ } }
if (ok) return [transition, question, alert].filter(Boolean).join('\n\n');
return [transition, alert].filter(Boolean).join('\n\n'); // panne PG : santé + alerte quand même
}
@@ -186,17 +230,133 @@ function parseNiveau(text) {
return { brut: m[1], valeur: Number.isInteger(v) && v >= 0 && v <= 100 ? v : null };
}
// RELEVES KILOMETRIQUES (04/10/2026, decision du proprietaire) : « JEEP 112694 km
// 54 % », un ou plusieurs vehicules par message. Toujours un NOM de vehicule puis
// un nombre suivi de « km » : un nombre seul n'est jamais un releve. Le niveau
// eventuel suit le km (« … km 54 % »). Les separateurs de milliers sont admis.
function parseReleves(text) {
const re = /\b(vw|jeep|z[ée]ro|moto)\b[^\d%]{0,20}?(\d{1,3}(?:[ .\u202f\u00a0]\d{3})+|\d+)\s*km\b(?:[^\d%]{0,15}?(\d{1,3}(?:[.,]\d+)?)\s*(?:%|pour\s*cent|pourcent))?/gi;
const out = [];
for (const m of (text || '').matchAll(re)) {
const nom = m[1].toLowerCase();
const vehicule = nom === 'vw' ? 'VW' : nom === 'jeep' ? 'JEEP' : 'ZERO';
const km = Number(m[2].replace(/[ .\u202f\u00a0]/g, ''));
const nv = m[3] === undefined ? null : Number(m[3].replace(',', '.'));
out.push({ vehicule, km, brutNiveau: m[3] ?? null,
niveau: nv === null ? null : (Number.isInteger(nv) && nv >= 0 && nv <= 100 ? nv : NaN),
segment: m[0] });
}
return out;
}
const kmTexte = (km) => km.toLocaleString('fr-FR').replace(/\u202f/g, ' ');
// Le relevé se rattache-t-il au branchement en cours ? (pour l'horodater a
// l'instant du BRANCHEMENT : le compteur n'avance pas pendant la charge, et le
// service compte les charges posterieures au releve). Et peut-il etiqueter ce
// branchement ? Seulement s'il n'a pas d'etiquette et date de moins d'une heure :
// un releve de la ZERO pendant que la JEEP est branchee ne doit pas la renommer.
async function contexteBranchement() {
let connected = false;
try { connected = (await haState(CONNECTED)).state === 'on'; } catch { /* ignore */ }
if (!connected) return null;
let latest = null;
try { const w = await loadWindow(); latest = w.plugins[w.plugins.length - 1] || null; } catch { return null; }
if (!latest || latest.off !== null) return null;
let prev = {};
try { prev = JSON.parse((await haState(W.HELPER_ENTITY)).state || '{}'); } catch { /* sans etiquette */ }
const label = prev.branchement === latest.on_text && prev.label ? prev.label : null;
return { on: latest.on, label, recent: Date.now() / 1000 - latest.on <= 3600 };
}
async function answerReleves(text, releves) {
const invalides = releves.filter(r => !Number.isInteger(r.km) || r.km <= 0 || r.km > 2000000 || Number.isNaN(r.niveau));
if (invalides.length) {
return `⚠️ Relevé NON noté : « ${invalides[0].segment.trim()} » n'est pas valide ` +
'(kilométrage entier, niveau entier de 0 à 100) — rien n\'a été écrit.';
}
const ctx = releves.length === 1 ? await contexteBranchement() : null;
const r0 = releves[0];
let lignePrefixe = '', rattache = false;
if (ctx && ctx.label === r0.vehicule) rattache = true;
else if (ctx && !ctx.label && ctx.recent) {
// Branchement frais sans etiquette : le message vaut aussi confirmation
// (vehicule + niveau), exactement comme « JEEP 54 % ».
const sansKm = text.replace(r0.segment, `${r0.vehicule}${r0.niveau === null ? '' : ` ${r0.niveau} %`}`);
lignePrefixe = await answer(sansKm, { sansReleve: true });
rattache = lignePrefixe.startsWith('✅');
}
const maintenant = Math.round(Date.now() / 1000);
const aEcrire = releves.map((r, i) => ({ vehicule: r.vehicule, km: r.km, niveau: r.niveau,
ts: i === 0 && rattache ? Math.round(ctx.on) : maintenant }));
// Dedoublonnage : meme vehicule et meme km deja dans la file (message relance,
// ou compteur qui n'a pas bouge : un second releve identique n'apprend rien).
let file = [];
try { file = JSON.parse((await haState(W.RELEVE_ENTITY)).state || '[]'); } catch { /* vide */ }
const nouveaux = aEcrire.filter(r => !file.some(e => Array.isArray(e) && e[0] === r.vehicule && e[1] === r.km));
if (nouveaux.length) {
try { await setReleveKm(nouveaux, 'whatsapp'); }
catch (e) { return (lignePrefixe ? lignePrefixe + ' — ' : '') + `⚠️ Relevé NON noté (${e.message}).`; }
}
const parties = aEcrire.map((r) => {
let p = `${r.vehicule} ${kmTexte(r.km)} km`;
p += r.niveau === null ? '' : `, niveau ${r.niveau} %`;
if (rattache && r === aEcrire[0]) p += ` (rattaché au branchement de ${hhmm(ctx.on)})`;
return p;
});
const sansNiveau = aEcrire.filter(r => r.niveau === null).map(r => r.vehicule);
let ligne = `🚗 Relevé noté : ${parties.join(' ; ')}.`;
if (sansNiveau.length) {
ligne += ` ⚠️ Niveau non donné pour ${sansNiveau.join(', ')} : sans lui ce relevé ne servira pas au calcul ` +
`(redonne-le avec le niveau, par exemple « ${sansNiveau[0]} ${kmTexte(aEcrire.find(r => r.niveau === null).km)} km 50 % »).`;
}
return lignePrefixe ? `${lignePrefixe} — ${ligne}` : ligne;
}
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).`;
}
// Meme dedoublonnage que l'etiquette : meme valeur saisie il y a moins de
// 5 min -> pas de reecriture (chaque ecriture recale la reference du compteur).
let dejaSaisi = false;
try {
const n = await haState(W.LEVEL_ENTITY);
dejaSaisi = Math.round(Number(n.state)) === niveau.valeur &&
Date.now() - Date.parse(n.last_reported) < 5 * 60 * 1000;
} catch { /* illisible : on ecrit */ }
if (!dejaSaisi) {
try { await setVehicleLevel(niveau.valeur, 'whatsapp'); }
catch (e) { return `⚠️ Niveau NON noté (${e.message}).`; }
return `🔋 Niveau noté : ${niveau.valeur} %.`;
}
// L'heure ecrite est celle de la SAISIE (03/10/2026) : le script n'enregistre
// aucune autre heure. La donner coupe court a « Noté : 53 % à 13:10 ».
const saisi = new Date().toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' });
return `🔋 Niveau noté : ${niveau.valeur} % (saisi à ${saisi}).`;
}
async function answer(text) {
// Le branchement EN COURS a-t-il une etiquette concierge ? (03/10/2026) Un niveau
// seul ne dit pas quel vehicule est branche, et le selecteur n'est PAS une
// confirmation (session de 16:46 etiquetee JEEP pour la ZERO). Le 03/10 a 14:13,
// « JEEP » a ete annule par OpenClaw (message suivant en file) : seul « 53% » est
// passe, et le branchement est reste sans etiquette. Sans certitude -> on demande.
async function vehiculeNonConfirme() {
const question = " — ⚠️ Véhicule non confirmé pour ce branchement : lequel est-ce ?";
try {
const w = await loadWindow();
const latest = w.plugins[w.plugins.length - 1] || null;
if (!latest || latest.off !== null) return question;
const prev = JSON.parse((await haState(W.HELPER_ENTITY)).state || '{}');
return prev.branchement === latest.on_text && prev.label ? '' : question;
} catch { return question; }
}
async function answer(text, { sansReleve = false } = {}) {
if (!sansReleve) {
const releves = parseReleves(text);
if (releves.length) return await answerReleves(text, releves);
}
const niveau = parseNiveau(text);
const pending = SIM ? (SIM.pending || null) : loadJSON(PENDING, null);
let options = ['VW', 'JEEP'];
@@ -211,7 +371,8 @@ async function answer(text) {
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);
const ligne = await noterNiveau(niveau);
return niveau.valeur === null ? ligne : ligne + await vehiculeNonConfirme();
}
// Cible de l'étiquette = le branchement à confirmer :
@@ -236,7 +397,17 @@ async function answer(text) {
branchement = latest.on_text;
on = latest.on;
}
await publishLabel(branchement, label, 'whatsapp');
// DEDOUBLONNAGE (03/10/2026) : l'agent a deja lance answer deux fois pour le
// meme message. Meme vehicule, meme branchement, confirme il y a moins de
// 5 min -> on redonne la meme reponse SANS reecrire (sinon une seconde
// confirmation, et un evenement en double cote EVCS).
let dejaNote = false;
try {
const prev = JSON.parse((await haState(W.HELPER_ENTITY)).state || '{}');
dejaNote = prev.branchement === branchement && prev.label === label &&
Date.now() - Date.parse(prev.confirmed_at) < 5 * 60 * 1000;
} catch { /* illisible : on publie */ }
if (!dejaNote) await publishLabel(branchement, label, 'whatsapp');
// Confirmation HUMAINE d'un véhicule du FOYER : le sélecteur temps réel suit
// (il pilote l'heure de départ VW 08:50 / JEEP 06:30, donc la priorité de
// nuit). Pour MEGANE/INVITE/ZERO on n'y touche pas. Demandé le 19/09/2026 :
@@ -244,10 +415,10 @@ async function answer(text) {
// Si l'écriture échoue, l'étiquette est déjà publiée : on le DIT au lieu
// d'échouer, pour que le propriétaire fasse le geste manuel en connaissance.
let selectorNote = '';
if (SELECTOR_LABELS.includes(label)) {
if (SELECTOR_LABELS.includes(label) && !dejaNote) {
try { await setVehicleSelector(label, 'whatsapp'); }
catch (e) {
selectorNote = `\n⚠️ Sélecteur véhicule NON mis à jour (${e.message}) — ` +
selectorNote = ` — ⚠️ Sélecteur véhicule NON mis à jour (${e.message}) — ` +
`pense à le passer sur ${label} à la main.`;
}
}
@@ -255,7 +426,7 @@ async function answer(text) {
const when = new Date(on * 1000).toLocaleString('fr-FR', { timeZone: 'Europe/Paris' });
const ligneNiveau = await noterNiveau(niveau);
return `✅ Noté : ${label} pour la voiture branchée du ${when}. Merci !${selectorNote}` +
(ligneNiveau ? `\n${ligneNiveau}` : '');
(ligneNiveau ? ` — ${ligneNiveau}` : '');
}
// Alerte "voiture branchée mais ne charge pas" (19/09/2026). EVCS détecte l'anomalie
@@ -289,6 +460,7 @@ 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]';
// UNE seule ligne (03/10/2026) : l'agent relayait parfois une ligne sur deux.
if (out && SIM && cmd === 'answer') out += ' [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,7 +18,7 @@
const fs = require('fs');
const path = require('path');
// ⚠️ Trois constantes, pas une de plus. Ne JAMAIS les rendre dynamiques.
// ⚠️ Quatre 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,
@@ -26,6 +26,10 @@ const path = require('path');
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';
// La quatrieme (04/10/2026, decision du proprietaire) : la file des RELEVES
// KILOMETRIQUES, que le service EVCS ingere (km par kWh, usure des batteries).
// Tableau JSON des derniers releves [vehicule, km, niveau|null, epoch_s, source].
const RELEVE_ENTITY = 'input_text.evcs_releve_km';
// 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.
@@ -135,7 +139,49 @@ async function setVehicleLevel(niveau, source) {
return { entity: LEVEL_ENTITY, value: niveau };
}
// Ajoute des releves a la file (garde les derniers dans 255 caracteres). Pure.
function fileReleves(brut, releves) {
let tab = [];
try { const t = JSON.parse(brut || '[]'); if (Array.isArray(t)) tab = t; } catch { /* repart a vide */ }
for (const r of releves) tab.push([r.vehicule, r.km, r.niveau ?? null, r.ts, r.source]);
let out = tab.slice(-6);
while (JSON.stringify(out).length > MAX_LEN && out.length > 1) out = out.slice(1);
return JSON.stringify(out);
}
// Releves kilometriques ANNONCES par le proprietaire. Vehicule du foyer, km
// entier positif, niveau entier 0-100 ou absent : sinon REFUS, jamais de
// correction. Source humaine. Relecture comme pour les autres ecritures.
async function setReleveKm(releves, source) {
if (!HUMAN_SOURCES.includes(source)) {
throw new Error(`releve km : source '${source}' non humaine — écriture interdite`);
}
if (!Array.isArray(releves) || !releves.length) throw new Error('releve km : rien a ecrire');
for (const r of releves) {
if (!SELECTOR_LABELS.includes(r.vehicule)) throw new Error(`releve km : '${r.vehicule}' hors foyer`);
if (!Number.isInteger(r.km) || r.km <= 0 || r.km > 2000000) throw new Error(`releve km : '${r.km}' invalide`);
if (r.niveau !== null && !(Number.isInteger(r.niveau) && r.niveau >= 0 && r.niveau <= 100)) {
throw new Error(`releve km : niveau '${r.niveau}' invalide`);
}
if (!Number.isInteger(r.ts)) throw new Error('releve km : horodatage invalide');
}
const token = haToken();
const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
const cur = await fetch(`${HA_BASE}/api/states/${RELEVE_ENTITY}`, { headers });
if (!cur.ok) throw new Error(`lecture file HTTP ${cur.status}`);
const payload = fileReleves((await cur.json()).state, releves.map(r => ({ ...r, source })));
const res = await fetch(`${HA_BASE}/api/services/input_text/set_value`, {
method: 'POST', headers,
body: JSON.stringify({ entity_id: RELEVE_ENTITY, value: payload }),
});
if (!res.ok) throw new Error(`set_value releve HTTP ${res.status}`);
const back = await fetch(`${HA_BASE}/api/states/${RELEVE_ENTITY}`, { headers });
if (!back.ok) throw new Error(`relecture HTTP ${back.status}`);
if ((await back.json()).state !== payload) throw new Error('relecture != écrit — releve refusé en silence par HA');
return { entity: RELEVE_ENTITY, payload };
}
module.exports = {
publishLabel, setVehicleSelector, setVehicleLevel,
HELPER_ENTITY, SELECTOR_ENTITY, LEVEL_ENTITY, SELECTOR_LABELS, MAX_LEN,
publishLabel, setVehicleSelector, setVehicleLevel, setReleveKm, fileReleves,
HELPER_ENTITY, SELECTOR_ENTITY, LEVEL_ENTITY, RELEVE_ENTITY, SELECTOR_LABELS, MAX_LEN,
};

View File

@@ -1,7 +1,8 @@
// concierge-write.test.js — Test de PORTÉE D'ÉCRITURE (garde-fou du canal HA).
// Doit ÉCHOUER si une écriture peut sortir des TROIS constantes autorisées :
// Doit ÉCHOUER si une écriture peut sortir des QUATRE 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).
// 19/09/2026) et victron_evcs_vehicle_level_pct (niveau annoncé, 03/10/2026) et evcs_releve_km
// (relevés kilométriques, 04/10/2026). À faire tourner à CHAQUE modification (cf. EVCS advisor).
// Statique + require + fetch STUBBÉ : aucun réseau, aucun token, aucune écriture.
'use strict';
@@ -25,23 +26,25 @@ 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.strictEqual(mod.LEVEL_ENTITY, 'input_number.victron_evcs_vehicle_level_pct');
assert.strictEqual(mod.RELEVE_ENTITY, 'input_text.evcs_releve_km');
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, SELECTOR_ENTITY ou LEVEL_ENTITY', () => {
check('entity_id uniquement = HELPER_ENTITY, SELECTOR_ENTITY, LEVEL_ENTITY ou RELEVE_ENTITY', () => {
const occ = [...src.matchAll(/entity_id\s*:\s*([^,}\n]+)/g)].map(m => m[1].trim());
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),
for (const v of occ) assert(['HELPER_ENTITY', 'SELECTOR_ENTITY', 'LEVEL_ENTITY', 'RELEVE_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 trois constantes', () => {
check('aucune entité HA littérale hors les quatre constantes', () => {
const decls = ["'input_text.airtria_vehicle_label'",
"'input_select.victron_evcs_vehicle'",
"'input_number.victron_evcs_vehicle_level_pct'"];
"'input_number.victron_evcs_vehicle_level_pct'",
"'input_text.evcs_releve_km'"];
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}`);
@@ -52,7 +55,7 @@ check('services = input_text/set_value + input_select/select_option + input_numb
const services = [...src.matchAll(/\/api\/services\/([a-z_]+\/[a-z_]+)/g)]
.map(m => m[1]);
assert.deepStrictEqual(services.sort(),
['input_number/set_value', 'input_select/select_option', 'input_text/set_value'],
['input_number/set_value', 'input_select/select_option', 'input_text/set_value', 'input_text/set_value'],
`services inattendus : ${services.join(', ') || '(aucun)'}`);
});
@@ -76,6 +79,43 @@ global.fetch = async (url, opts) => {
};
process.env.HA_TOKEN_FILE = '/dev/null';
// R1-R4. Releves km : refus SANS aucun appel reseau (04/10/2026).
for (const [nom, args] of [
['releve refuse une source non humaine', [[{ vehicule: 'JEEP', km: 1000, niveau: 50, ts: 1791066924 }], 'regle']],
['releve refuse un vehicule hors foyer', [[{ vehicule: 'MEGANE', km: 1000, niveau: 50, ts: 1791066924 }], 'whatsapp']],
['releve refuse un niveau de 150', [[{ vehicule: 'JEEP', km: 1000, niveau: 150, ts: 1791066924 }], 'whatsapp']],
['releve refuse un km non entier', [[{ vehicule: 'JEEP', km: 1000.5, niveau: null, ts: 1791066924 }], 'whatsapp']],
]) {
check(nom, async () => {
const avant = calls.length;
await assert.rejects(() => mod.setReleveKm(...args));
assert.strictEqual(calls.length, avant, 'aucun appel reseau attendu');
});
}
check('file des releves : 255 caracteres max, dernier garde', () => {
let b = '';
for (let i = 0; i < 10; i++) b = mod.fileReleves(b, [{ vehicule: 'JEEP', km: 112694 + i, niveau: 54, ts: 1791066924 + i, source: 'whatsapp' }]);
assert(b.length <= 255, `longueur ${b.length}`);
assert.strictEqual(JSON.parse(b).at(-1)[1], 112703);
});
check('releve ecrit la file puis la relit', async () => {
const avant = calls.length;
const stub = global.fetch;
let ecrit = null;
global.fetch = async (url, opts) => {
calls.push({ url: String(url), body: opts && opts.body });
if (opts && opts.method === 'POST') { ecrit = JSON.parse(opts.body).value; return { ok: true, json: async () => ({}) }; }
return { ok: true, json: async () => ({ state: ecrit ?? '[]' }) };
};
try {
await mod.setReleveKm([{ vehicule: 'ZERO', km: 16469, niveau: 78, ts: 1791066924 }], 'whatsapp');
} finally { global.fetch = stub; }
const posts = calls.slice(avant).filter(c => c.body);
assert.strictEqual(posts.length, 1);
assert.strictEqual(JSON.parse(posts[0].body).entity_id, 'input_text.evcs_releve_km');
assert.deepStrictEqual(JSON.parse(ecrit), [['ZERO', 16469, 78, 1791066924, 'whatsapp']]);
});
// 6. Refus d'une source non humaine, sans AUCUN appel réseau.
check('sélecteur refuse une source non humaine', async () => {
calls.length = 0;

View File

@@ -0,0 +1,92 @@
#!/usr/bin/env python3
# lecture-tableau.py — lit le kilométrage et le niveau de batterie sur une PHOTO de tableau de bord ou une
# CAPTURE de l'application constructeur, envoyée par Thierry sur WhatsApp (04/10/2026, « GO photo Jeep »).
# Le modèle de la Spark (vision) lit ; ce script met la lecture EN ATTENTE ; rien n'est écrit tant que
# Thierry n'a pas confirmé. La confirmation passe le texte au concierge, seul chemin d'écriture.
# python3 lecture-tableau.py lire <chemin_image> [JEEP|VW|ZERO] → lit, imprime une ligne, met en attente
# python3 lecture-tableau.py confirmer → envoie la lecture en attente au concierge
# python3 lecture-tableau.py annuler → efface la lecture en attente
# Le véhicule vient de Thierry (légende de la photo), jamais de l'image.
import base64, json, os, re, subprocess, sys, time, urllib.request
WS = "/home/node/.openclaw/workspace"
PENDING = os.path.join(WS, "releve-photo-pending.json")
SPARK = os.environ.get("SPARK_URL", "http://10.10.10.8:8000/v1/chat/completions")
HA = os.environ.get("HA_BASE", "http://10.10.10.17:8123")
def minutes_branchee():
"""Minutes depuis le branchement si une voiture est branchée, sinon None (lecture HA seule)."""
try:
tok = open(os.path.join(WS, ".ha-token")).read().strip()
r = urllib.request.urlopen(urllib.request.Request(
f"{HA}/api/states/binary_sensor.victron_evcs_vehicle_connected",
headers={"Authorization": f"Bearer {tok}"}), timeout=10)
st = json.load(r)
if st.get("state") != "on": return None
from datetime import datetime, timezone
return (datetime.now(timezone.utc) - datetime.fromisoformat(st["last_changed"])).total_seconds() / 60
except Exception:
return None
VEHICULES = {"JEEP": "JEEP", "VW": "VW", "ZERO": "ZERO", "MOTO": "ZERO", "ZÉRO": "ZERO"}
PROMPT = ("Photo d'un tableau de bord de véhicule, ou capture d'une application constructeur. Réponds en JSON "
"strict, sans texte autour : {\"kilometrage_km\": entier ou null, \"batterie_pct\": entier ou null, "
"\"autonomie_electrique_km\": entier ou null}. kilometrage_km = l'ODOMÈTRE (kilométrage total du "
"véhicule, souvent 5 ou 6 chiffres), JAMAIS une autonomie ni un trajet. batterie_pct = le pourcentage "
"de la batterie de traction s'il est écrit en chiffres, sinon null (ne pas l'estimer d'après une jauge). "
"Valeur illisible ou absente = null.")
def lire(img, veh):
if not os.path.isfile(img): print(f"Lecture impossible : image introuvable ({img})."); return
ext = os.path.splitext(img)[1].lower().lstrip(".") or "jpeg"
data = base64.b64encode(open(img, "rb").read()).decode()
body = {"model": "qwen3-coder", "temperature": 0, "max_tokens": 200, "messages": [{"role": "user", "content": [
{"type": "text", "text": PROMPT},
{"type": "image_url", "image_url": {"url": f"data:image/{'jpeg' if ext == 'jpg' else ext};base64,{data}"}}]}]}
try:
r = urllib.request.urlopen(urllib.request.Request(SPARK, data=json.dumps(body).encode(),
headers={"Content-Type": "application/json"}), timeout=240)
txt = json.load(r)["choices"][0]["message"]["content"]
v = json.loads(re.search(r"\{.*\}", txt, re.S).group(0))
except Exception as e:
print(f"Lecture impossible : le modèle n'a pas répondu correctement ({e})."); return
km, pct, aut = v.get("kilometrage_km"), v.get("batterie_pct"), v.get("autonomie_electrique_km")
km = km if isinstance(km, int) and 1000 <= km <= 2_000_000 else None
pct = pct if isinstance(pct, int) and 0 <= pct <= 100 else None
if km is None:
print("📷 Kilométrage illisible sur l'image : rien à noter. Donne-le par écrit (« JEEP 112716 km 46 % »)."); return
lu = f"{km:,} km".replace(",", " ") + (f", batterie {pct} %" if pct is not None else ", batterie non lisible")
if isinstance(aut, int): lu += f", autonomie électrique {aut} km"
if not veh:
print(f"📷 Lu sur l'image : {lu}. Quel véhicule est-ce (JEEP, VW, ZERO) ? Rien n'est noté."); return
# 04/10 (EVCS) : le relevé est daté au branchement ; un % lu sur une photo prise après plus de 10 min de
# charge n'est plus celui du branchement et fausserait km/kWh et capacité → on ne garde que le kilométrage.
mb = minutes_branchee()
note = ""
if pct is not None and mb is not None and mb > 10:
note = (f" Niveau NON retenu : la voiture charge depuis {int(mb)} min, le % de la photo n'est plus celui "
f"du branchement (photo valable pour le niveau seulement au retour, avant de brancher).")
pct = None
lu = f"{km:,} km".replace(",", " ") + (f", autonomie électrique {aut} km" if isinstance(aut, int) else "")
texte = f"{veh} {km} km" + (f" {pct} %" if pct is not None else "")
json.dump({"texte": texte, "lu": lu, "vehicule": veh, "image": img, "a": int(time.time())}, open(PENDING, "w"))
print(f"📷 Lu sur l'image : {veh}, {lu}.{note} Je note ce relevé ? Réponds « oui » ou « non ». Rien n'est encore noté.")
def confirmer():
if not os.path.exists(PENDING): print("Aucune lecture de photo en attente : rien à noter."); return
p = json.load(open(PENDING))
if time.time() - p["a"] > 3600:
os.remove(PENDING); print("La lecture de photo date de plus d'une heure : annulée, renvoie la photo."); return
res = subprocess.run(["node", os.path.join(WS, "concierge-live.js"), "answer", p["texte"]],
capture_output=True, text=True, timeout=120)
os.remove(PENDING)
print((res.stdout.strip() or f"Rien n'est noté : erreur du concierge ({res.stderr.strip()[:200]})."))
cmd = sys.argv[1] if len(sys.argv) > 1 else ""
if cmd == "lire" and len(sys.argv) > 2:
lire(sys.argv[2], VEHICULES.get(sys.argv[3].upper()) if len(sys.argv) > 3 else None)
elif cmd == "confirmer": confirmer()
elif cmd == "annuler":
if os.path.exists(PENDING): os.remove(PENDING)
print("Lecture de photo annulée : rien n'est noté.")
else: print("usage : lecture-tableau.py lire <image> [JEEP|VW|ZERO] | confirmer | annuler")

View File

@@ -139,3 +139,14 @@ Total déplacés : 5
| O1.25 | News Orange promo | 1 | INBOX/TRASH | 0 |
Total déplacés : 7
## 2026-10-03 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 | 2 | INBOX/TRASH | 0 |
| O1.22 | Quora (relais Apple) | 1 | INBOX/TRASH | 0 |
Total déplacés : 6

View File

@@ -1,4 +1,4 @@
# 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)
# 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, puis répond sur une seule ligne ; 04/10 relevés km)
### Traiter une réponse de l'utilisateur
À chaque message reçu de l'utilisateur en session principale qui mentionne un véhicule
@@ -7,15 +7,20 @@ 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> » :
- Il imprime TOUJOURS UNE SEULE 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é : … »
(jamais un nombre nu) → sur la MÊME ligne, après « — » : « 🔋 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
script ni par toi. Relaie la ligne ENTIÈRE telle quelle. Tu ne lances plus jamais
`airtria-ha-vehicule.js --vehicule` ni `--niveau` : le concierge est le seul chemin.
- Depuis le 04/10 il note AUSSI les relevés kilométriques : « <véhicule> <nombre> km [NN %] »,
un ou plusieurs véhicules, toujours un nom puis « km », jamais un nombre seul →
« 🚗 Relevé noté : JEEP 112 694 km, niveau 54 % (…) », ou un avertissement « ⚠️ … » (niveau
non donné, relevé refusé). Il écrit `input_text.evcs_releve_km` ; toi jamais. Un kilométrage
n'est JAMAIS corrigé. Suivi km/kWh et capacité estimée : Grafana « Les véhicules ».
- « (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
- Fichier `workspace/.simulation` présent : banc en cours, rien n'est écrit, la ligne
porte « [SIMULATION … ] ».
⚠️ CHANGEMENT 19/09/2026 : lance `answer` MÊME si `concierge-pending.json` n'existe pas.

View File

@@ -0,0 +1,77 @@
#!/usr/bin/env python3
# stoik-transfert.py — fait suivre vers la Gmail de Thierry chaque rapport Stoïk (protect@stoik.io)
# reçu sur la boîte AIRTRIA avec un PDF joint, UNE SEULE FOIS par mail (03/10/2026, « GO transfert Stoïk »).
# Motif : ATELIER_OP (autre site, sans chemin réseau vers spark) récupère les PDF via Gmail pour son
# bilan sécurité. Lancé par le cron airtria-tri-quotidien (19:50), après airtria-menage.py.
# python3 stoik-transfert.py → transfère les nouveaux rapports
# python3 stoik-transfert.py --essai → liste ce qui serait envoyé, n'envoie rien, n'écrit pas l'état
# python3 stoik-transfert.py --amorcer → marque les rapports déjà présents comme transférés (mise en service)
# Lecture IMAP seule (BODY.PEEK, sélection readonly) : rien n'est marqué lu, déplacé ni supprimé.
# État : stoik-transfert-state.json (Message-ID déjà transférés). Secrets dans /home/node/.openclaw/.env.
import imaplib, ssl, socket, email, email.header, email.utils, json, os, sys, subprocess, tempfile, shutil
WS = "/home/node/.openclaw/workspace"
STATE = os.path.join(WS, "stoik-transfert-state.json")
GMAIL = "tallagrand.thierry@gmail.com"
ESSAI = "--essai" in sys.argv
AMORCE = "--amorcer" in sys.argv # marque les rapports existants comme déjà transférés, sans envoyer
env = dict(l.strip().split("=", 1) for l in open("/home/node/.openclaw/.env") if "=" in l and not l.startswith("#"))
PWD = env["AIRTRIA_IMAP_PASSWORD"].strip().strip('"').strip("'")
class _IMAPSurSocket(imaplib.IMAP4):
def __init__(self, sock): self._s = sock; imaplib.IMAP4.__init__(self, "", 993)
def open(self, host, port, timeout=None): self.host, self.port, self.sock = host, port, self._s; self.file = self.sock.makefile("rb")
def dec(v):
try: return str(email.header.make_header(email.header.decode_header(v or "")))
except Exception: return str(v or "")
deja = set(json.load(open(STATE))) if os.path.exists(STATE) else set()
ctx = ssl.create_default_context() # certificat vérifié sur le nom SNI
m = _IMAPSurSocket(ctx.wrap_socket(socket.create_connection(("10.10.10.22", 993), 120),
server_hostname="airtria-dsm2.direct.quickconnect.to"))
m.login("TMZF5455", PWD)
m.select("INBOX", readonly=True)
typ, d = m.uid("search", None, 'FROM "stoik.io"')
a_envoyer = []
for u in d[0].split():
typ, r = m.uid("fetch", u, "(BODY.PEEK[])")
msg = email.message_from_bytes(r[0][1])
mid = (msg.get("Message-ID") or f"uid-{u.decode()}").strip()
if mid in deja: continue
pdfs = [(dec(p.get_filename()), p.get_payload(decode=True)) for p in msg.walk()
if p.get_filename() and dec(p.get_filename()).lower().endswith(".pdf")]
if not pdfs: continue
a_envoyer.append((mid, email.utils.parsedate_to_datetime(msg["Date"]), dec(msg["Subject"]), pdfs))
m.logout()
if AMORCE:
deja.update(mid for mid, *_ in a_envoyer); json.dump(sorted(deja), open(STATE, "w"), indent=1)
print(f"stoik-transfert : {len(a_envoyer)} rapport(s) existant(s) marqué(s) comme déjà transférés, rien envoyé."); sys.exit(0)
if not a_envoyer:
print("stoik-transfert : aucun nouveau rapport Stoïk avec PDF."); sys.exit(0)
envoyes = 0
for mid, dt, sujet, pdfs in a_envoyer:
noms = [f"{dt.strftime('%Y-%m-%d')}_{n}" for n, _ in pdfs]
if ESSAI:
print(f"[ESSAI] enverrait : {dt.strftime('%d/%m/%Y')} | {sujet} | {', '.join(noms)}"); continue
tmp = tempfile.mkdtemp(prefix="stoik-")
try:
att = []
for nom, (_, data) in zip(noms, pdfs):
chemin = os.path.join(tmp, nom); open(chemin, "wb").write(data); att.append(f"--attach={chemin}")
corps = (f"Rapport Stoïk reçu le {dt.strftime('%d/%m/%Y')} sur la boîte airtria.fr (protect@stoik.io), "
f"transféré automatiquement par le tri du soir.\n\nÀ enregistrer sur le poste d'ATELIER_OP dans "
f"~/Documents/SYNERIC/securite/stoik/ pour son bilan sécurité.")
res = subprocess.run(["gog", "gmail", "send", "-a", GMAIL, "--to", GMAIL,
"--subject", f"[Stoïk → ATELIER_OP] {sujet} ({dt.strftime('%d/%m/%Y')})",
"--body", corps] + att, capture_output=True, text=True, timeout=180)
if res.returncode != 0:
print(f"stoik-transfert : ÉCHEC d'envoi pour « {sujet} » : {res.stderr.strip()[:300]}"); continue
deja.add(mid); envoyes += 1
json.dump(sorted(deja), open(STATE, "w"), indent=1)
print(f"stoik-transfert : envoyé « {sujet} » du {dt.strftime('%d/%m/%Y')} ({len(att)} PDF)")
finally:
shutil.rmtree(tmp, ignore_errors=True)
if not ESSAI: print(f"stoik-transfert : {envoyes} rapport(s) transféré(s).")