Files
EVCS/tools/enquete.mjs
Tallagrand Thierry 2345b87d04 Les observations du conseiller cessent de s evaporer
Ses helpers sont ecrases toutes les quinze minutes : un signalement grave
emis a 3 h du matin avait disparu au reveil, et rien dans la memoire du
projet n en gardait trace. C est precisement ce que le proprietaire
demandait quand il a dit vouloir un outil pour garder une trace des
evolutions — voir un comportement idiot du systeme ne sert a rien si
personne ne peut le relire le lendemain.

Une table `observation`, alimentee par le service d historisation, clef sur
l horodatage du marqueur du conseiller : rejouer un cycle n invente rien et
un conseiller a l arret n ecrit pas la meme ligne toutes les minutes. La
confiance est extraite de `_reason` (« [NN%] la piste ») pour rester
interrogeable sans decouper une chaine en SQL.

`node tools/enquete.mjs observations [heures] [gravite]` ne montre par
defaut que ce qui a ete signale, et compte a part les cycles calmes : un
observateur qui fonctionne est silencieux la plupart du temps, et noyer les
signalements sous des centaines de « rien a dire » les rendrait invisibles.

Essai a blanc : observation=1, le service ecrirait bien sa ligne.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GwpFpy2aQ2rJok6tNQSbVj
2026-09-13 11:30:18 +02:00

184 lines
10 KiB
JavaScript

#!/usr/bin/env node
// COUCHE D OUTILS D ENQUETE — lecture seule.
//
// Le 12/09, trouver pourquoi le contacteur reseau oscillait a demande sept
// requetes enchainees : la table des evenements, les mesures, les executions
// des deux workflows, la liste des ecrivains, puis automations.yaml. Rien dans
// cette enquete n exigeait un humain — seulement d appeler des outils et
// d enchainer d apres ce qu on vient de lire.
//
// Ces sous-commandes sont donc exposees en LIGNE DE COMMANDE, pas enfouies dans
// un script qui embarquerait son propre modele : OpenClaw, un agent local ou
// n importe quel autre pilote doivent pouvoir les appeler sans reecriture.
//
// LECTURE SEULE, sans exception. Aucune sous-commande n ecrit quoi que ce soit.
//
// node tools/enquete.mjs outils
// node tools/enquete.mjs bascules switch.edf_general_ok_switch 3
// node tools/enquete.mjs mesures soc,grid,pv "2026-09-12 06:20" "2026-09-12 06:40"
// node tools/enquete.mjs decisions ATswvGfG4KJUls0K "06:24" "06:27"
// node tools/enquete.mjs ecrivains switch.edf_general_ok_switch
// node tools/enquete.mjs bilan 6
// node tools/enquete.mjs cherche "35"
import fs from 'fs';
import os from 'os';
import path from 'path';
import { execFileSync } from 'child_process';
const HA = '/mnt/ARCH3/docker/HA';
const PG = { h: '10.10.10.22', p: '5433', u: 'evcs_lecture', d: 'evcs' };
const ALIAS = {
soc: 'sensor.victron_battery_soc',
courant: 'sensor.victron_battery_current',
pv: 'sensor.victron_pv_power_system',
maison: 'sensor.victron_consumption_power_l1',
grid: 'sensor.victron_grid_power_l1',
evcs: 'sensor.victron_evcs_ac_power',
consigne: 'number.gx_device_ac_power_setpoint',
};
function motDePasse() {
const env = fs.readFileSync('.env', 'utf8');
const m = env.match(/^PG_LECTURE_PASSWORD=(.*)$/m);
return (m ? m[1] : '').trim().replace(/^["']|["']$/g, '');
}
function sql(requete) {
const out = execFileSync('psql', ['-h', PG.h, '-p', PG.p, '-U', PG.u, '-d', PG.d, '-At', '-F', '\t', '-c', requete],
{ env: { ...process.env, PGPASSWORD: motDePasse() }, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 });
return out.trim().split('\n').filter(Boolean).map(l => l.split('\t'));
}
function cleN8n() {
const c = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.config/opencode/opencode.json'), 'utf8'));
const e = c?.mcp?.['n8n-mcp']?.environment || {};
return { url: (e.N8N_API_URL || '').replace(/\/api\/v1\/?$/, ''), key: e.N8N_API_KEY };
}
const sortie = o => console.log(JSON.stringify(o, null, 2));
const [, , commande, ...args] = process.argv;
if (!commande || commande === 'outils') {
sortie({
lecture_seule: true,
outils: [
{ nom: 'bascules', usage: 'bascules <entite|motif> [heures=6]', quoi: 'transitions enregistrees d un actionneur ou d un etat' },
{ nom: 'mesures', usage: 'mesures <cles> <debut> [fin]', quoi: 'grandeurs mesurees sur une fenetre', alias: Object.keys(ALIAS) },
{ nom: 'decisions', usage: 'decisions <workflowId> <hhmm_debut> <hhmm_fin> [date]', quoi: 'ce qu un workflow n8n a decide, cycle par cycle' },
{ nom: 'ecrivains', usage: 'ecrivains <entite>', quoi: 'qui peut ecrire sur un actionneur, et qui en est le proprietaire declare' },
{ nom: 'bilan', usage: 'bilan [heures=6]', quoi: 'ce que le bilan energetique demandait' },
{ nom: 'observations', usage: 'observations [heures=24] [gravite]', quoi: 'ce que le conseiller a signale, et a quelle gravite' },
{ nom: 'cherche', usage: 'cherche <motif>', quoi: 'ou ce motif apparait dans les automatisations, scripts et paquets Home Assistant' },
],
});
} else if (commande === 'bascules') {
const [motif, h = '6'] = args;
const r = sql(`select to_char(ts at time zone 'Europe/Paris','YYYY-MM-DD HH24:MI:SS'), genre, resume,
coalesce(detail->>'auteur',''), coalesce(detail->>'declencheur','')
from evenement where ts > now() - interval '${Number(h)} hours'
${motif ? `and resume ilike '%${motif.replace(/'/g, '')}%'` : ''}
order by ts desc limit 80`);
const lignes = r.map(([quand, genre, resume, auteur, declencheur]) =>
({ quand, genre, resume, ...(auteur ? { auteur } : {}), ...(declencheur ? { declencheur } : {}) }));
// Un ecart court entre deux transitions du meme objet est la signature d une
// oscillation : on le calcule ici pour que l appelant n ait pas a le refaire.
for (let i = 0; i < lignes.length - 1; i++) {
const a = Date.parse(lignes[i].quand.replace(' ', 'T'));
const b = Date.parse(lignes[i + 1].quand.replace(' ', 'T'));
const objet = l => l.resume.split(':')[0];
if (objet(lignes[i]) === objet(lignes[i + 1])) lignes[i].secondes_depuis_precedente = Math.round((a - b) / 1000);
}
sortie({ transitions: lignes.length, lignes });
} else if (commande === 'mesures') {
const [cles, debut, fin = 'now()'] = args;
const liste = cles.split(',').map(c => ALIAS[c.trim()] || c.trim());
const borne = t => t === 'now()' ? 'now()' : `'${t}'::timestamp at time zone 'Europe/Paris'`;
const r = sql(`select to_char(ts at time zone 'Europe/Paris','HH24:MI:SS'), cle, round(valeur::numeric,1)
from mesure where cle in (${liste.map(c => `'${c}'`).join(',')})
and ts >= ${borne(debut)} and ts <= ${borne(fin)} order by ts limit 400`);
sortie({ points: r.length, lignes: r.map(([h, cle, v]) => ({ h, cle: cle.replace(/^(sensor|number)\.(victron_|gx_device_)?/, ''), v: Number(v) })) });
} else if (commande === 'decisions') {
const [wid, hd, hf, date = new Date().toISOString().slice(0, 10)] = args;
const { url, key } = cleN8n();
const r = await fetch(`${url}/api/v1/executions?workflowId=${wid}&limit=60`, { headers: { 'X-N8N-API-KEY': key } });
const liste = (await r.json()).data.filter(x => {
const hhmm = new Date(x.startedAt).toLocaleTimeString('fr-FR', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit' });
return x.startedAt.startsWith(date) && hhmm >= hd && hhmm <= hf;
});
const out = [];
for (const x of liste.slice(0, 12)) {
const d = await (await fetch(`${url}/api/v1/executions/${x.id}?includeData=true`, { headers: { 'X-N8N-API-KEY': key } })).json();
const run = d?.data?.resultData?.runData || {};
const heure = new Date(x.startedAt).toLocaleTimeString('fr-FR', { timeZone: 'Europe/Paris' });
const noeuds = {};
for (const [nom, v] of Object.entries(run)) {
const j = v?.[0]?.data?.main?.[0]?.[0]?.json;
if (!j) continue;
const garde = {};
for (const [k, val] of Object.entries(j)) {
if (['decision', 'computed', 'context', 'nightEvcsPreview'].includes(k) && typeof val === 'object') {
garde[k] = Object.fromEntries(Object.entries(val).filter(([, x]) => typeof x !== 'object').slice(0, 14));
}
}
if (Object.keys(garde).length) noeuds[nom] = garde;
}
out.push({ heure, statut: x.status, noeuds });
}
sortie({ executions: out.length, out });
} else if (commande === 'ecrivains') {
const [entite] = args;
const c = JSON.parse(fs.readFileSync('docs/mesures/carte-decisions.json', 'utf8'));
const d = c.carte[entite];
if (!d) { sortie({ erreur: 'entite inconnue de la carte', connues: Object.keys(c.carte) }); process.exit(0); }
const actifs = d.ecrivains.filter(e => !e.desactive);
sortie({
entite, libelle: d.libelle, proprietaire_declare: d.proprietaire, note: d.note,
ecrivains: actifs,
hors_proprietaire: actifs.filter(e => e.nom !== d.proprietaire).map(e => `${e.famille}:${e.nom}`),
desactives: d.ecrivains.filter(e => e.desactive).map(e => e.nom),
carte_generee: c.genere,
});
} else if (commande === 'bilan') {
const [h = '6'] = args;
const r = sql(`select to_char(ts at time zone 'Europe/Paris','HH24:MI'), statut, round(soc_pct),
round(soc_requis_pct::numeric,1), round(energie_a_couvrir_kwh::numeric,2), heure_croisement, reseau_necessaire
from bilan where ts > now() - interval '${Number(h)} hours'
and extract(minute from ts at time zone 'Europe/Paris')::int % 30 = 0 order by ts desc limit 40`);
sortie({ lignes: r.map(([h2, statut, soc, requis, couvrir, croisement, reseau]) =>
({ h: h2, statut, soc: Number(soc), soc_requis: Number(requis), a_couvrir_kwh: Number(couvrir), croisement, reseau_necessaire: reseau === 't' })) });
} else if (commande === 'observations') {
// Par defaut on ne montre QUE ce qui a ete signale : un conseiller qui
// fonctionne est silencieux la plupart du temps, et noyer les signalements
// dans des centaines de « rien a dire » les rendrait invisibles.
const [h = '24', gravite] = args;
const filtre = gravite ? `and gravite = '${gravite.replace(/'/g, '')}'` : "and gravite <> 'aucune'";
const r = sql(`select to_char(ts at time zone 'Europe/Paris','DD/MM HH24:MI'), gravite,
constat, ce_qui_cloche, piste, round(soc_pct), statut
from observation where ts > now() - interval '${Number(h)} hours' ${filtre}
order by ts desc limit 40`);
const calmes = sql(`select count(*) from observation
where ts > now() - interval '${Number(h)} hours' and gravite = 'aucune'`);
sortie({
fenetre_heures: Number(h),
cycles_calmes: Number(calmes[0]?.[0] ?? 0),
signalements: r.length,
lignes: r.map(([q, g, c, cl, p, soc, st]) =>
({ quand: q, gravite: g, constat: c, ce_qui_cloche: cl, piste: p, soc: Number(soc), statut: st })),
});
} else if (commande === 'cherche') {
const [motif] = args;
const trouves = [];
const fichiers = ['automations.yaml', 'scripts.yaml',
...(fs.existsSync(path.join(HA, 'packages')) ? fs.readdirSync(path.join(HA, 'packages')).filter(f => f.endsWith('.yaml')).map(f => `packages/${f}`) : [])];
for (const f of fichiers) {
const p = path.join(HA, f);
if (!fs.existsSync(p)) continue;
const lignes = fs.readFileSync(p, 'utf8').split('\n');
lignes.forEach((l, i) => {
if (l.includes(motif)) trouves.push({ fichier: f, ligne: i + 1, texte: l.trim().slice(0, 160), commente: l.trim().startsWith('#') });
});
}
sortie({ motif, occurrences: trouves.length, trouves: trouves.slice(0, 40) });
} else {
sortie({ erreur: `commande inconnue: ${commande}`, aide: 'node tools/enquete.mjs outils' });
}