#!/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 [heures=6]', quoi: 'transitions enregistrees d un actionneur ou d un etat' }, { nom: 'mesures', usage: 'mesures [fin]', quoi: 'grandeurs mesurees sur une fenetre', alias: Object.keys(ALIAS) }, { nom: 'decisions', usage: 'decisions [date]', quoi: 'ce qu un workflow n8n a decide, cycle par cycle' }, { nom: 'ecrivains', usage: 'ecrivains ', 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 ', 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' }); }