#!/usr/bin/env node // CARTE DES DECISIONS — qui ecrit sur quoi, et avec quels seuils figes. // // Ce que le 12/09 a montre : cinq automates ecrivent sur le contacteur reseau, // chacun localement correct, aucun ne voit les autres, et personne n avait la // liste. Une automatisation Home Assistant coupait a 35 % de SOC pendant que le // bilan energetique demandait 42,8 % — le conflit a dure sept mois sans etre vu. // // Cette carte est GENEREE depuis les sources, jamais tenue a la main : une carte // maintenue a la main pourrit, et c est precisement ce qu on veut eviter. // // node tools/carte-decisions.mjs carte + ecart depuis la derniere fois // node tools/carte-decisions.mjs --json sortie machine (entree du conseiller) import fs from 'fs'; import os from 'os'; import path from 'path'; const HA = '/mnt/ARCH3/docker/HA'; const INSTANTANE = 'docs/mesures/carte-decisions.json'; // Actionneurs dont une ecriture concurrente a une consequence physique. const ACTIONNEURS = [ ['switch.edf_general_ok_switch', 'Contacteur reseau EDF'], ['number.gx_device_ac_power_setpoint', 'Consigne de puissance reseau'], ['switch.victron_evcs_charging', 'Charge de la borne'], ['number.victron_evcs_set_current_command', 'Courant de la borne'], ['input_boolean.victron_grid_hysteresis_latch', 'Verrou hysteresis'], ['input_boolean.victron_evcs_night_grid_takeover_needed', 'Reprise reseau nuit'], ['number.gx_device_ess_min_soc_limit', 'SOC minimal de l ESS'], ]; function cleApi() { const p = path.join(os.homedir(), '.config/opencode/opencode.json'); const c = JSON.parse(fs.readFileSync(p, 'utf8')); const e = c?.mcp?.['n8n-mcp']?.environment || {}; return { url: (e.N8N_API_URL || '').replace(/\/api\/v1\/?$/, ''), key: e.N8N_API_KEY }; } // Une ECRITURE, pas une lecture : l entite apparait pres d un appel de service. function ecrit(texte, entite) { const i = texte.indexOf(entite); if (i === -1) return null; const services = []; let p = 0; while (true) { const j = texte.indexOf(entite, p); if (j === -1) break; const autour = texte.slice(Math.max(0, j - 400), j + 400); for (const m of autour.matchAll(/(turn_on|turn_off|set_value|toggle|set_current)/g)) services.push(m[1]); p = j + 1; } return services.length ? [...new Set(services)] : null; } // Seuils numeriques compares a un SOC : la famille de valeurs qui a produit // l incident. On les remonte tous, avec leur emplacement. function seuilsSoc(texte, source) { const trouves = []; const motifs = [ /soc\w*\s*(?:<=?|>=?|===?)\s*(\d{1,3})(?:\D|$)/gi, /(\d{1,3})\s*(?:<=?|>=?)\s*\w*soc/gi, /\babove:\s*(\d{1,3})\b/gi, /\bbelow:\s*(\d{1,3})\b/gi, ]; for (const re of motifs) { for (const m of texte.matchAll(re)) { const v = Number(m[1]); if (v >= 5 && v <= 100) trouves.push({ valeur: v, extrait: m[0].replace(/\s+/g, ' ').slice(0, 60), source }); } } return trouves; } const sources = []; // ── n8n ─────────────────────────────────────────────────────────────── const { url, key } = cleApi(); const r = await fetch(`${url}/api/v1/workflows?limit=100&active=true`, { headers: { 'X-N8N-API-KEY': key } }); for (const w of (await r.json()).data) { sources.push({ famille: 'n8n', nom: w.name, id: w.id, texte: JSON.stringify(w) }); } // ── Home Assistant ──────────────────────────────────────────────────── for (const f of ['automations.yaml', 'scripts.yaml']) { const p = path.join(HA, f); if (fs.existsSync(p)) sources.push({ famille: 'HA', nom: f, texte: fs.readFileSync(p, 'utf8') }); } const paquets = path.join(HA, 'packages'); if (fs.existsSync(paquets)) { for (const f of fs.readdirSync(paquets).filter(x => x.endsWith('.yaml'))) { sources.push({ famille: 'HA', nom: `packages/${f}`, texte: fs.readFileSync(path.join(paquets, f), 'utf8') }); } } // ── Carte ───────────────────────────────────────────────────────────── const carte = {}; for (const [entite, libelle] of ACTIONNEURS) { carte[entite] = { libelle, ecrivains: [], lecteurs: [] }; for (const s of sources) { if (!s.texte.includes(entite)) continue; const services = ecrit(s.texte, entite); // Un commentaire de desactivation ne compte pas comme un ecrivain. const desactive = /#\s*(DESACTIVEE|DISABLED)/i.test(s.texte) && s.famille === 'HA'; if (services) carte[entite].ecrivains.push({ famille: s.famille, nom: s.nom, id: s.id, services, desactive }); else carte[entite].lecteurs.push(`${s.famille}:${s.nom}`); } } const seuils = []; for (const s of sources) seuils.push(...seuilsSoc(s.texte, `${s.famille}:${s.nom}`)); const parValeur = {}; for (const t of seuils) (parValeur[t.valeur] ||= []).push(t); const resultat = { genere: new Date().toISOString(), carte, seuilsSoc: parValeur }; if (process.argv.includes('--json')) { console.log(JSON.stringify(resultat, null, 2)); } else { console.log('═══ QUI ECRIT SUR QUOI ═══\n'); for (const [entite, d] of Object.entries(carte)) { const actifs = d.ecrivains.filter(e => !e.desactive); console.log(`${d.libelle}`); console.log(` ${entite}`); if (!actifs.length) console.log(' (aucun ecrivain detecte)'); for (const e of actifs) { console.log(` ✎ ${e.famille.padEnd(4)} ${e.nom}${e.id ? ` (${e.id})` : ''} [${e.services.join(', ')}]`); } for (const e of d.ecrivains.filter(x => x.desactive)) { console.log(` ⊘ ${e.famille.padEnd(4)} ${e.nom} — desactive`); } if (actifs.length > 1) console.log(` ⚠️ ${actifs.length} ECRIVAINS CONCURRENTS`); console.log(` ${d.lecteurs.length} lecteur(s)\n`); } console.log('═══ SEUILS DE SOC FIGES ═══\n'); for (const v of Object.keys(parValeur).map(Number).sort((a, b) => a - b)) { const t = parValeur[v]; const oú = [...new Set(t.map(x => x.source))]; console.log(` ${String(v).padStart(3)} % ×${String(t.length).padStart(2)} ${oú.slice(0, 3).join(', ')}${oú.length > 3 ? ` +${oú.length - 3}` : ''}`); } } // ── Ecart depuis la derniere fois : c est ca, la trace des evolutions ── if (!process.argv.includes('--json')) { if (fs.existsSync(INSTANTANE)) { const av = JSON.parse(fs.readFileSync(INSTANTANE, 'utf8')); const cle = c => Object.entries(c.carte).flatMap(([e, d]) => d.ecrivains.filter(x => !x.desactive).map(x => `${e} <- ${x.famille}:${x.nom}`)); const avant = new Set(cle(av)), apres = new Set(cle(resultat)); const nouveaux = [...apres].filter(x => !avant.has(x)); const partis = [...avant].filter(x => !apres.has(x)); console.log(`\n═══ ECART DEPUIS ${av.genere.slice(0, 16).replace('T', ' ')} ═══`); if (!nouveaux.length && !partis.length) console.log(' aucun changement d ecrivain'); for (const x of nouveaux) console.log(` + ${x}`); for (const x of partis) console.log(` − ${x}`); } else { console.log('\n(premier instantane — l ecart sera affiche aux executions suivantes)'); } } fs.mkdirSync(path.dirname(INSTANTANE), { recursive: true }); fs.writeFileSync(INSTANTANE, JSON.stringify(resultat, null, 2));