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
This commit is contained in:
Tallagrand Thierry
2026-09-12 07:14:08 +02:00
parent 040d2fc730
commit 23dc09a344
2 changed files with 714 additions and 0 deletions

View File

@@ -0,0 +1,554 @@
{
"genere": "2026-09-12T05:13:34.948Z",
"carte": {
"switch.edf_general_ok_switch": {
"libelle": "Contacteur reseau EDF",
"ecrivains": [
{
"famille": "n8n",
"nom": "Victron - Battery / Grid Hysteresis Fix V1",
"id": "7iIlu7LLTW7TZA8o",
"services": [
"turn_on",
"turn_off"
],
"desactive": false
},
{
"famille": "n8n",
"nom": "HA Snapshot EVCS Surplus Controller V2.6",
"id": "ATswvGfG4KJUls0K",
"services": [
"turn_on",
"set_value",
"turn_off"
],
"desactive": false
},
{
"famille": "HA",
"nom": "automations.yaml",
"services": [
"turn_on",
"turn_off"
],
"desactive": false
},
{
"famille": "HA",
"nom": "scripts.yaml",
"services": [
"turn_on",
"turn_off",
"set_value"
],
"desactive": false
}
],
"lecteurs": [
"n8n:SDB Climate & Surplus Manager V2.2",
"n8n:Water Heater Advisor V2.3",
"n8n:Victron - Garage Thermal Surplus Manager V2.2",
"n8n:EVCS Energy Balance V1",
"n8n:Victron - Flexible Load Advisor V1",
"n8n:Victron - Auto Charge Manager V1.2",
"n8n:EVCS Advisor Shadow V1",
"n8n:Water Heater Advisor ECS 6 Nodes + Build + Execute"
]
},
"number.gx_device_ac_power_setpoint": {
"libelle": "Consigne de puissance reseau",
"ecrivains": [
{
"famille": "n8n",
"nom": "HA Snapshot EVCS Surplus Controller V2.6",
"id": "ATswvGfG4KJUls0K",
"services": [
"turn_on",
"set_value",
"turn_off"
],
"desactive": false
}
],
"lecteurs": [
"n8n:EVCS Advisor Shadow V1"
]
},
"switch.victron_evcs_charging": {
"libelle": "Charge de la borne",
"ecrivains": [
{
"famille": "n8n",
"nom": "HA Snapshot EVCS Surplus Controller V2.6",
"id": "ATswvGfG4KJUls0K",
"services": [
"turn_off",
"turn_on",
"set_current"
],
"desactive": false
}
],
"lecteurs": [
"n8n:Victron - Battery / Grid Hysteresis Fix V1",
"n8n:EVCS Advisor Shadow V1"
]
},
"number.victron_evcs_set_current_command": {
"libelle": "Courant de la borne",
"ecrivains": [
{
"famille": "n8n",
"nom": "HA Snapshot EVCS Surplus Controller V2.6",
"id": "ATswvGfG4KJUls0K",
"services": [
"turn_on",
"turn_off",
"set_current",
"set_value"
],
"desactive": false
},
{
"famille": "HA",
"nom": "scripts.yaml",
"services": [
"set_value",
"set_current"
],
"desactive": false
}
],
"lecteurs": []
},
"input_boolean.victron_grid_hysteresis_latch": {
"libelle": "Verrou hysteresis",
"ecrivains": [
{
"famille": "n8n",
"nom": "Victron - Battery / Grid Hysteresis Fix V1",
"id": "7iIlu7LLTW7TZA8o",
"services": [
"set_value",
"turn_on",
"turn_off"
],
"desactive": false
},
{
"famille": "HA",
"nom": "scripts.yaml",
"services": [
"turn_off",
"set_value",
"turn_on"
],
"desactive": false
}
],
"lecteurs": [
"n8n:EVCS Advisor Shadow V1"
]
},
"input_boolean.victron_evcs_night_grid_takeover_needed": {
"libelle": "Reprise reseau nuit",
"ecrivains": [
{
"famille": "n8n",
"nom": "HA Snapshot EVCS Surplus Controller V2.6",
"id": "ATswvGfG4KJUls0K",
"services": [
"turn_on",
"turn_off"
],
"desactive": false
}
],
"lecteurs": [
"n8n:Victron - Battery / Grid Hysteresis Fix V1",
"n8n:Victron - Auto Charge Manager V1.2",
"n8n:EVCS Advisor Shadow V1"
]
},
"number.gx_device_ess_min_soc_limit": {
"libelle": "SOC minimal de l ESS",
"ecrivains": [],
"lecteurs": []
}
},
"seuilsSoc": {
"20": [
{
"valeur": 20,
"extrait": "SOC <= 20)",
"source": "n8n:HA Snapshot EVCS Surplus Controller V2.6"
},
{
"valeur": 20,
"extrait": "SOC <= 20)",
"source": "n8n:HA Snapshot EVCS Surplus Controller V2.6"
},
{
"valeur": 20,
"extrait": "soc < 20 ",
"source": "n8n:Victron - Auto Charge Manager V1.2"
},
{
"valeur": 20,
"extrait": "soc < 20 ",
"source": "n8n:Victron - Auto Charge Manager V1.2"
}
],
"22": [
{
"valeur": 22,
"extrait": "soc < 22)",
"source": "n8n:Victron - Auto Charge Manager V1.2"
},
{
"valeur": 22,
"extrait": "SOC < 22%",
"source": "n8n:Victron - Auto Charge Manager V1.2"
},
{
"valeur": 22,
"extrait": "soc < 22)",
"source": "n8n:Victron - Auto Charge Manager V1.2"
},
{
"valeur": 22,
"extrait": "SOC < 22%",
"source": "n8n:Victron - Auto Charge Manager V1.2"
},
{
"valeur": 22,
"extrait": "SoC < 22%",
"source": "HA:automations.yaml"
},
{
"valeur": 22,
"extrait": "SoC < 22%",
"source": "HA:automations.yaml"
},
{
"valeur": 22,
"extrait": "below: 22",
"source": "HA:automations.yaml"
},
{
"valeur": 22,
"extrait": "below: 22",
"source": "HA:automations.yaml"
}
],
"25": [
{
"valeur": 25,
"extrait": "soc > 25 ",
"source": "n8n:HA Snapshot EVCS Surplus Controller V2.6"
},
{
"valeur": 25,
"extrait": "soc > 25 ",
"source": "n8n:HA Snapshot EVCS Surplus Controller V2.6"
}
],
"35": [
{
"valeur": 35,
"extrait": "SOC > 35%",
"source": "n8n:HA Snapshot EVCS Surplus Controller V2.6"
},
{
"valeur": 35,
"extrait": "soc <= 35;",
"source": "n8n:HA Snapshot EVCS Surplus Controller V2.6"
},
{
"valeur": 35,
"extrait": "SOC > 35%",
"source": "n8n:HA Snapshot EVCS Surplus Controller V2.6"
},
{
"valeur": 35,
"extrait": "soc <= 35;",
"source": "n8n:HA Snapshot EVCS Surplus Controller V2.6"
},
{
"valeur": 35,
"extrait": "SoC > 35%",
"source": "HA:automations.yaml"
},
{
"valeur": 35,
"extrait": "above: 35",
"source": "HA:automations.yaml"
}
],
"40": [
{
"valeur": 40,
"extrait": "soc > 40 ",
"source": "n8n:HA Snapshot EVCS Surplus Controller V2.6"
},
{
"valeur": 40,
"extrait": "soc >= 40 ",
"source": "n8n:HA Snapshot EVCS Surplus Controller V2.6"
},
{
"valeur": 40,
"extrait": "soc >= 40 ",
"source": "n8n:HA Snapshot EVCS Surplus Controller V2.6"
},
{
"valeur": 40,
"extrait": "soc > 40 ",
"source": "n8n:HA Snapshot EVCS Surplus Controller V2.6"
},
{
"valeur": 40,
"extrait": "soc >= 40 ",
"source": "n8n:HA Snapshot EVCS Surplus Controller V2.6"
},
{
"valeur": 40,
"extrait": "soc >= 40 ",
"source": "n8n:HA Snapshot EVCS Surplus Controller V2.6"
}
],
"60": [
{
"valeur": 60,
"extrait": "soc > 60 ",
"source": "n8n:HA Snapshot EVCS Surplus Controller V2.6"
},
{
"valeur": 60,
"extrait": "soc >= 60 ",
"source": "n8n:HA Snapshot EVCS Surplus Controller V2.6"
},
{
"valeur": 60,
"extrait": "soc >= 60 ",
"source": "n8n:HA Snapshot EVCS Surplus Controller V2.6"
},
{
"valeur": 60,
"extrait": "soc > 60 ",
"source": "n8n:HA Snapshot EVCS Surplus Controller V2.6"
},
{
"valeur": 60,
"extrait": "soc >= 60 ",
"source": "n8n:HA Snapshot EVCS Surplus Controller V2.6"
},
{
"valeur": 60,
"extrait": "soc >= 60 ",
"source": "n8n:HA Snapshot EVCS Surplus Controller V2.6"
}
],
"80": [
{
"valeur": 80,
"extrait": "soc > 80 ",
"source": "n8n:HA Snapshot EVCS Surplus Controller V2.6"
},
{
"valeur": 80,
"extrait": "soc >= 80 ",
"source": "n8n:HA Snapshot EVCS Surplus Controller V2.6"
},
{
"valeur": 80,
"extrait": "soc >= 80 ",
"source": "n8n:HA Snapshot EVCS Surplus Controller V2.6"
},
{
"valeur": 80,
"extrait": "soc >= 80 ",
"source": "n8n:HA Snapshot EVCS Surplus Controller V2.6"
},
{
"valeur": 80,
"extrait": "soc > 80 ",
"source": "n8n:HA Snapshot EVCS Surplus Controller V2.6"
},
{
"valeur": 80,
"extrait": "soc >= 80 ",
"source": "n8n:HA Snapshot EVCS Surplus Controller V2.6"
},
{
"valeur": 80,
"extrait": "soc >= 80 ",
"source": "n8n:HA Snapshot EVCS Surplus Controller V2.6"
},
{
"valeur": 80,
"extrait": "soc >= 80 ",
"source": "n8n:HA Snapshot EVCS Surplus Controller V2.6"
},
{
"valeur": 80,
"extrait": "soc < 80)",
"source": "n8n:Victron - Auto Charge Manager V1.2"
},
{
"valeur": 80,
"extrait": "soc < 80)",
"source": "n8n:Victron - Auto Charge Manager V1.2"
}
],
"85": [
{
"valeur": 85,
"extrait": "soc >= 85 ",
"source": "n8n:HA Snapshot EVCS Surplus Controller V2.6"
},
{
"valeur": 85,
"extrait": "soc >= 85 ",
"source": "n8n:HA Snapshot EVCS Surplus Controller V2.6"
},
{
"valeur": 85,
"extrait": "soc < 85 ",
"source": "n8n:Victron - Auto Charge Manager V1.2"
},
{
"valeur": 85,
"extrait": "soc < 85 ",
"source": "n8n:Victron - Auto Charge Manager V1.2"
}
],
"90": [
{
"valeur": 90,
"extrait": "soc > 90 ",
"source": "n8n:HA Snapshot EVCS Surplus Controller V2.6"
},
{
"valeur": 90,
"extrait": "SOC > 90,",
"source": "n8n:HA Snapshot EVCS Surplus Controller V2.6"
},
{
"valeur": 90,
"extrait": "soc >= 90 ",
"source": "n8n:HA Snapshot EVCS Surplus Controller V2.6"
},
{
"valeur": 90,
"extrait": "soc >= 90 ",
"source": "n8n:HA Snapshot EVCS Surplus Controller V2.6"
},
{
"valeur": 90,
"extrait": "SOC >= 90\\",
"source": "n8n:HA Snapshot EVCS Surplus Controller V2.6"
},
{
"valeur": 90,
"extrait": "soc > 90 ",
"source": "n8n:HA Snapshot EVCS Surplus Controller V2.6"
},
{
"valeur": 90,
"extrait": "SOC > 90,",
"source": "n8n:HA Snapshot EVCS Surplus Controller V2.6"
},
{
"valeur": 90,
"extrait": "soc >= 90 ",
"source": "n8n:HA Snapshot EVCS Surplus Controller V2.6"
},
{
"valeur": 90,
"extrait": "soc >= 90 ",
"source": "n8n:HA Snapshot EVCS Surplus Controller V2.6"
},
{
"valeur": 90,
"extrait": "SOC >= 90\\",
"source": "n8n:HA Snapshot EVCS Surplus Controller V2.6"
},
{
"valeur": 90,
"extrait": "soc < 90)",
"source": "n8n:Victron - Auto Charge Manager V1.2"
},
{
"valeur": 90,
"extrait": "soc >= 90)",
"source": "n8n:Victron - Auto Charge Manager V1.2"
},
{
"valeur": 90,
"extrait": "soc >= 90 ",
"source": "n8n:Victron - Auto Charge Manager V1.2"
},
{
"valeur": 90,
"extrait": "soc < 90)",
"source": "n8n:Victron - Auto Charge Manager V1.2"
},
{
"valeur": 90,
"extrait": "soc >= 90)",
"source": "n8n:Victron - Auto Charge Manager V1.2"
},
{
"valeur": 90,
"extrait": "soc >= 90 ",
"source": "n8n:Victron - Auto Charge Manager V1.2"
}
],
"95": [
{
"valeur": 95,
"extrait": "soc < 95)",
"source": "n8n:Victron - Auto Charge Manager V1.2"
},
{
"valeur": 95,
"extrait": "soc < 95 ",
"source": "n8n:Victron - Auto Charge Manager V1.2"
},
{
"valeur": 95,
"extrait": "soc < 95 ",
"source": "n8n:Victron - Auto Charge Manager V1.2"
},
{
"valeur": 95,
"extrait": "soc < 95)",
"source": "n8n:Victron - Auto Charge Manager V1.2"
},
{
"valeur": 95,
"extrait": "soc < 95 ",
"source": "n8n:Victron - Auto Charge Manager V1.2"
},
{
"valeur": 95,
"extrait": "soc < 95 ",
"source": "n8n:Victron - Auto Charge Manager V1.2"
}
],
"100": [
{
"valeur": 100,
"extrait": "SocAtStartPct <= 100,",
"source": "n8n:EVCS Energy Balance V1"
},
{
"valeur": 100,
"extrait": "SocAtStartPct <= 100,",
"source": "n8n:EVCS Energy Balance V1"
}
]
}
}

160
tools/carte-decisions.mjs Normal file
View File

@@ -0,0 +1,160 @@
#!/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));