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:
@@ -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).`;
|
||||
}
|
||||
try { await setVehicleLevel(niveau.valeur, 'whatsapp'); }
|
||||
catch (e) { return `⚠️ Niveau NON noté (${e.message}).`; }
|
||||
return `🔋 Niveau noté : ${niveau.valeur} %.`;
|
||||
// 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}).`; }
|
||||
}
|
||||
// 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); });
|
||||
|
||||
Reference in New Issue
Block a user