#!/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. // ⚠️ LE PROPRIETAIRE EST UNE DECISION HUMAINE, PAS UNE DEDUCTION. // La carte sait lire QUI PEUT ecrire ; elle ne peut pas deviner QUI DEVRAIT. // Sans cette declaration, le conseiller voit bien le conflit mais designe le // mauvais coupable — essai du 12/09 : il a accuse les deux workflows n8n alors // que l intrus etait une automatisation Home Assistant. // Un ecrivain hors de cette liste n est pas forcement fautif : il est A // EXPLIQUER. C est precisement ce qui manquait pendant sept mois. const ACTIONNEURS = [ ['switch.edf_general_ok_switch', 'Contacteur reseau EDF', 'Victron - Battery / Grid Hysteresis Fix V1', 'seul cet automate ouvre et ferme le contacteur ; le controleur EVCS le DEMANDE via le helper de reprise nuit, les scripts sont des interventions manuelles assumees'], ['number.gx_device_ac_power_setpoint', 'Consigne de puissance reseau', 'HA Snapshot EVCS Surplus Controller V2.6', 'depuis le 01/09 : fermer le contacteur ne suffit pas, il faut demander la puissance'], ['switch.victron_evcs_charging', 'Charge de la borne', 'HA Snapshot EVCS Surplus Controller V2.6', 'proprietaire unique'], ['number.victron_evcs_set_current_command', 'Courant de la borne', 'HA Snapshot EVCS Surplus Controller V2.6', 'les scripts BOOST sont des interventions manuelles, reecrites au cycle suivant'], ['input_boolean.victron_grid_hysteresis_latch', 'Verrou hysteresis', 'Victron - Battery / Grid Hysteresis Fix V1', 'les scripts d urgence le forcent volontairement'], ['input_boolean.victron_evcs_night_grid_takeover_needed', 'Reprise reseau nuit', 'HA Snapshot EVCS Surplus Controller V2.6', 'proprietaire unique'], ['number.gx_device_ess_min_soc_limit', 'SOC minimal de l ESS', null, 'aucun automate ne devrait y toucher — reglage GX'], ]; 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, proprietaire, note] of ACTIONNEURS) { carte[entite] = { libelle, proprietaire, note, 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`); } console.log(` proprietaire declare : ${d.proprietaire || 'aucun'}`); const intrus = actifs.filter(e => e.nom !== d.proprietaire); if (intrus.length) console.log(` ⚠️ ${intrus.length} ECRIVAIN(S) HORS PROPRIETAIRE : ${intrus.map(e => e.nom).join(', ')}`); 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)'); } } // ── Injection dans le briefing du conseiller ────────────────────────── // La carte est STRUCTURELLE : elle change quand on ajoute un automate, pas // toutes les 15 minutes. Elle a donc sa place dans le prompt, regeneree ici // plutot que transmise a chaque cycle. Bornes explicites pour qu une relecture // voie tout de suite ce qui est genere et ce qui est ecrit a la main. if (process.argv.includes('--injecte')) { const P = 'src/advisor/BUILD_PROMPT.js'; const DEBUT = ' // <<< CARTE GENEREE — ne pas editer a la main, voir tools/carte-decisions.mjs'; const FIN = ' // CARTE GENEREE >>>'; const lignes = [" 'QUI ECRIT SUR QUOI (carte generee depuis les sources) :',"]; for (const [entite, d] of Object.entries(carte)) { const actifs = d.ecrivains.filter(e => !e.desactive); if (actifs.length < 2) continue; const court = n => n.replace(/\.yaml$/, '').replace(/^(HA Snapshot |Victron - )/, ''); const intrus = actifs.filter(e => e.nom !== d.proprietaire).map(e => court(e.nom)); lignes.push(` '- ${d.libelle} : proprietaire = ${court(d.proprietaire || 'aucun')}${intrus.length ? `, AUTRES ECRIVAINS : ${intrus.join(', ')}` : ''}',`); } const vals = Object.keys(parValeur).map(Number).sort((a, b) => a - b); lignes.push(` 'SEUILS DE SOC FIGES dans le code : ${vals.join(', ')} % — chacun peut contredire le bilan.',`); lignes.push(" 'Quand un actionneur bascule de facon incoherente, le coupable le plus probable',"); lignes.push(" 'est un ecrivain AUTRE QUE SON PROPRIETAIRE. Nomme-le.',"); let src = fs.readFileSync(P, 'utf8'); const bloc = [DEBUT, ...lignes, FIN].join('\n'); src = src.includes(DEBUT) ? src.slice(0, src.indexOf(DEBUT)) + bloc + src.slice(src.indexOf(FIN) + FIN.length) : src.replace(" 'STRATEGIES POSSIBLES :',", bloc + "\n '',\n 'STRATEGIES POSSIBLES :',"); fs.writeFileSync(P, src); console.log(`\ncarte injectee dans ${P} (${lignes.length} lignes)`); } fs.mkdirSync(path.dirname(INSTANTANE), { recursive: true }); fs.writeFileSync(INSTANTANE, JSON.stringify(resultat, null, 2));