Files
EVCS/tools/carte-decisions.mjs
Tallagrand Thierry 23dc09a344 Carte des decisions : qui ecrit sur quoi, et les seuils figes
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
2026-09-12 07:14:08 +02:00

161 lines
7.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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));