Le 12/09, le contacteur reseau a bascule six fois en dix minutes. Trouver le coupable a demande sept requetes enchainees — executions n8n des deux workflows, liste des ecrivains, puis automations.yaml — parce que la table des evenements disait QUE ca avait bascule, jamais PAR QUI. Home Assistant le savait pourtant. Son logbook resout le contexte d un changement d etat et nomme l auteur. Sur la coupure de 06:29:44 : auteur : automation.victron_grid_off_si_soc_35 declencheur : triggered by numeric state of sensor.victron_battery_soc Un appel par transition REELLE — quelques-uns par jour, pas par cycle. Echec de lecture => pas d auteur, la ligne est ecrite quand meme : on ne devine jamais. ⚠️ L auteur est colle au RESUME, pas seulement au detail jsonb. C est le resume que Grafana affiche en annotation sur TOUS les tableaux et dans les panneaux « journal des evenements », et que le rapport de nuit reprend. Le mettre la le rend visible partout sans toucher a un seul tableau. remonte l auteur et son declencheur : l enquete de ce matin tient desormais en UNE commande, et l observateur pourra nommer le coupable au lieu de dire « un des trois ». Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GwpFpy2aQ2rJok6tNQSbVj
164 lines
8.8 KiB
JavaScript
164 lines
8.8 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: '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 === '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' });
|
|
}
|