Le 12/09 : cinq automates ecrivent sur le contacteur reseau, chacun localement correct, aucun ne voit les autres, et personne n avait la liste. Une automatisation HA coupait a 35 % de SOC pendant que le bilan demandait 42,8 % — le conflit a dure sept mois sans etre vu. Genere depuis les sources (n8n par l API, automatisations, scripts et paquets HA), jamais tenu a la main : une carte maintenue a la main pourrit, et c est exactement ce qu on veut eviter. Chaque execution ecrit un instantane et affiche l ecart avec le precedent — la trace des evolutions demandee. Premiere sortie : 4 ecrivains concurrents sur le contacteur reseau, 2 sur le courant de la borne, 2 sur le verrou hysteresis, et dix valeurs de SOC figees entre 20 et 100 dont le 35 fautif, present six fois. Destine a etre lu par le conseiller : c est ce qui lui permettra de dire « cet ecrivain contredit celui-la », au lieu d un detecteur code en dur qui doublerait une capacite qu il a deja demontree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GwpFpy2aQ2rJok6tNQSbVj
161 lines
7.3 KiB
JavaScript
161 lines
7.3 KiB
JavaScript
#!/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));
|