Protocole pour comprendre pourquoi le Cerbo ne voit qu un pack sur deux

Le hub reconnait les deux groupes — LED verte et trois bips, son accuse de
reception — mais le Cerbo n en enumere qu un : 4 modules et 400 Ah la ou le parc
en compte 9 et 650.

Le releve de reference apporte le fait qui manquait : a 20:14 les quatre US5000
debitent 24 A au total pendant que le Cerbo annonce 41 A au parc. Les 17 A
d ecart sont le pack 2, qui porte donc bien du courant sans etre enumere. Le
courant mesure au shunt n est pas un indicateur d enumeration — seuls
nombre_de_modules_en_ligne et capacite_installee le sont.

Quatre etapes par risque croissant, la premiere ne touchant a aucun cable : un
redemarrage du Cerbo, l inventaire ayant deja change une fois le 13/09 a 21:35,
evenement isole verifie comme n etant pas un redemarrage de Home Assistant.

Regle de securite tiree de l incident d aujourd hui : fermer EDF AVANT toute
manoeuvre. Couper la communication du BMS arrete la gestion batterie, donc l
onduleur, donc la maison — 72 secondes dans le noir a 16:24. EDF deja ferme, la
maison passe en direct et ne voit rien.

L etape 4 renvoie a la documentation constructeur et n avance aucune theorie de
cablage : sur du materiel batterie je ne raisonne pas de memoire.

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-14 20:16:13 +02:00
parent 60165e139b
commit 0d597e7efc
14 changed files with 1509 additions and 6 deletions

View File

@@ -0,0 +1,79 @@
#!/usr/bin/env node
// Releve le plafond de courant de charge : pv_boost 150 -> 180 A, pv_mid 130 -> 160 A.
//
// MESURE DU 14/09/2026 11:45, neuf releves consecutifs a douze secondes :
// courant 148-150 A, colle au plafond de 150, jamais en dessous de 148
// tension 50,38-50,41 V pour une consigne de 51,5 V — 1,1 V de marge, donc
// AUCUNE phase d absorption : ce n est pas la tension qui limite
// cellules ecart 2 mV, aucune alarme, zero module bloquant la charge
// solaire 4 a 6 chargeurs sur 6 declarent se retenir, en continu
// bilan batterie 7,56 kW + maison 0,75 = 8,31 kW pour 8,4 kW produits
// — la production se cale sur ce que le systeme absorbe : ecretage
//
// C est donc NOTRE plafond qui limite, pas le materiel.
//
// ⚠️ LE PARC N EST PAS CELUI QUE LE CERBO DECLARE. Il compte quatre US5000 et
// cinq US2000, soit TREIZE equivalents US2000 ; le Cerbo ne voit que les quatre
// US5000 (d ou `nombre_de_modules_en_ligne` = 4 et `capacite_installee` =
// 400 Ah). Les 250 A qu il autorise sont donc calcules sur quatre modules et
// s appliquent a treize equivalents. A 180 A cela fait moins de 14 A par
// equivalent 2000. Corrobore par la mesure d energie : 0,312 kWh par point de
// SOC, quand treize equivalents (~650 Ah a 50 V) en predisent 0,325.
//
// Le haut de charge n est PAS touche : au-dela de 90 % c est la tension de
// cellule qui devient le facteur limitant, et le BMS l arbitre bien mieux qu un
// palier de courant — il a demontre le 05/09 une reaction en CINQ SECONDES.
import fs from 'fs';
const cible = process.argv[2] || 'workflows/victron-auto-charge-manager-v1.2.json';
const wf = JSON.parse(fs.readFileSync(cible, 'utf8'));
const node = wf.nodes.find((n) => n.name === 'Compute Charge Targets');
if (!node) { console.error('noeud « Compute Charge Targets » introuvable'); process.exit(1); }
let code = node.parameters.jsCode;
const remplacements = [
// Les deux plafonds de la table.
['pv_boost 150 -> 180 A', /(pv_boost:\s*\{\s*current_a:\s*)150/, '$1180'],
['pv_mid 130 -> 160 A', /(pv_mid:\s*\{\s*current_a:\s*)130/, '$1160'],
// ⚠️ ET LE RELEVEMENT ANTI-BRIDAGE, QUI EST MORT — constat, pas correctif.
//
// Le node porte un mecanisme cense detecter EXACTEMENT la situation du
// 14/09 : « courant batterie a moins de 3 A du plafond, PV au-dessus de
// 6 kW, SOC sous 95 » — et relever alors le plafond. La condition a bien ete
// remplie ce matin, pendant une heure. Elle n'a rien releve.
//
// Parce que son relevement vaut `Math.max(targetCurrentA, soc >= 90 ? 80 :
// 130)` et que le plafond du profil est deja au-dessus de ce plancher dans
// TOUTES les bandes :
//
// SOC 61 pv_boost 180 vs plancher 130 -> max = 180, inchange
// SOC 85 pv_mid 160 vs plancher 130 -> max = 160, inchange
// SOC 92 pv_topoff 80 vs plancher 80 -> max = 80, inchange
//
// On remplace la constante par la valeur de la table dont elle a pris du
// retard — un lecteur ne croira plus a un plancher a 130 A qui n'existe pas.
// ⚠️ MAIS CELA NE LE RANIME PAS : le mecanisme reste un no-op dans les deux
// formes, et c'est VOULU de ne pas le reveiller ici. Un plafond de securite
// qui se releve tout seul parce qu'on bute dessus est precisement la forme
// de boucle qui a produit six oscillateurs dans ce projet. Le ressusciter
// demande sa propre reflexion, ses propres scenarios, et une decision du
// proprietaire — pas un effet de bord d'un changement de palier.
['plancher anti-bridage : constante perimee -> valeur de la table',
/targetCurrentA = Math\.max\(targetCurrentA, soc >= 90 \? 80 : 130\);/,
`targetCurrentA = Math.max(targetCurrentA,
// Valeurs de la table, et non des constantes qui prennent du retard.
// ⚠️ Ce Math.max est un NO-OP : le plafond du profil est au-dessus du
// plancher dans toutes les bandes. Constate le 14/09/2026 ; le
// mecanisme n'a pas ete ranime deliberement (un plafond qui se releve
// parce qu'on bute dessus est une boucle de retroaction).
soc >= 90 ? cfg.profiles.pv_topoff.current_a : cfg.profiles.pv_mid.current_a);`],
];
let n = 0;
for (const [quoi, motif, par] of remplacements) {
if (!motif.test(code)) { console.error(`motif introuvable pour ${quoi} — deja applique ?`); process.exit(1); }
code = code.replace(motif, par); n++;
}
node.parameters.jsCode = code;
fs.writeFileSync(cible, JSON.stringify(wf, null, 2) + '\n');
console.log(`${n} plafond(s) releve(s) dans ${cible}`);

View File

@@ -0,0 +1,107 @@
#!/usr/bin/env node
// RELEVE DE L ETAT DU PARC BATTERIE — LECTURE SEULE, rejouable.
//
// node tools/releve-parc-batterie.mjs "etape 0 - reference"
// node tools/releve-parc-batterie.mjs "etape 1 - apres reboot cerbo" --console /dev/ttyUSB0
//
// Prend une photo complete de ce que le Cerbo voit du parc, et si une console
// serie est branchee, de ce que la chaine declare de son cote. Ecrit un journal
// horodate dans docs/mesures/ pour que deux etapes se comparent ligne a ligne.
//
// AUCUNE ECRITURE vers Home Assistant, vers le Cerbo ou vers les modules.
import fs from 'fs';
import { execFileSync } from 'child_process';
const etiquette = process.argv[2] || 'releve';
const port = process.argv.includes('--console')
? process.argv[process.argv.indexOf('--console') + 1] : null;
const env = fs.readFileSync('.env', 'utf8');
const jeton = (env.match(/^HA_TOKEN=(.*)$/m)?.[1] || '').trim().replace(/^["']|["']$/g, '');
const HA = 'http://10.10.10.17:8123';
// Ce que le CERBO declare du parc. Les deux premieres lignes sont le critere de
// reussite du protocole : 9 modules et 650 Ah (4 x 100 + 5 x 50) diraient qu il
// voit enfin les deux groupes.
const CERBO = [
['sensor.airtria_8_us2000c_nombre_de_modules_en_ligne', 'modules en ligne'],
['sensor.airtria_8_us2000c_capacite_installee', 'capacite installee'],
['sensor.airtria_8_us2000c_maximum_allowed_charge_current', 'charge autorisee'],
['sensor.airtria_8_us2000c_maximum_allowed_discharge_current', 'decharge autorisee'],
['sensor.airtria_8_us2000c_maximum_allowed_charging_voltage', 'tension autorisee'],
['sensor.airtria_8_us2000c_nombre_de_modules_bloquant_la_charge', 'modules bloquant charge'],
['sensor.airtria_8_us2000c_nombre_de_modules_hors_ligne', 'modules hors ligne'],
['sensor.airtria_8_us2000c_tension_minimale_de_la_cellule', 'cellule la plus basse'],
['sensor.airtria_8_us2000c_tension_maximale_de_la_cellule', 'cellule la plus haute'],
['sensor.airtria_8_us2000c_id_de_la_cellule_a_tension_maximale', 'id cellule haute'],
['sensor.victron_battery_soc', 'SOC'],
['sensor.victron_battery_voltage', 'tension parc'],
['sensor.victron_battery_current', 'courant parc'],
['sensor.victron_max_charge_current_setting', 'DVCC appliquee'],
['switch.edf_general_ok_switch', 'contacteur EDF'],
['sensor.victron_consumption_power_l1', 'maison'],
['sensor.victron_pv_power_system', 'production'],
];
const etat = async (id) => {
try {
const r = await fetch(`${HA}/api/states/${id}`, { headers: { Authorization: `Bearer ${jeton}` } });
if (!r.ok) return null;
const s = await r.json();
return { v: s.state, depuis: new Date(s.last_changed).toLocaleString('fr-FR', { timeZone: 'Europe/Paris' }) };
} catch { return null; }
};
const lignes = [];
const dire = (s) => { lignes.push(s); console.log(s); };
const maintenant = new Date();
dire(`RELEVE PARC BATTERIE — ${etiquette}`);
dire(maintenant.toLocaleString('fr-FR', { timeZone: 'Europe/Paris' }));
dire('='.repeat(72));
dire('\nCE QUE LE CERBO DECLARE');
for (const [id, libelle] of CERBO) {
const e = await etat(id);
dire(' ' + libelle.padEnd(26) + '= ' + String(e ? e.v : '(absent)').padEnd(22) + (e ? '(depuis ' + e.depuis + ')' : ''));
}
dire('\nALARMES');
try {
const tout = await (await fetch(`${HA}/api/states`, { headers: { Authorization: `Bearer ${jeton}` } })).json();
const mauvais = tout.filter((x) => /alarm|alarme|error|erreur|bloquant|hors_ligne/i.test(x.entity_id)
&& !/^(no_alarm|no_error|off|0|0\.0|unavailable|unknown|normal|ok)$/i.test(String(x.state)));
if (!mauvais.length) dire(' aucune');
for (const x of mauvais.slice(0, 14)) dire(' ' + x.entity_id.slice(0, 58).padEnd(60) + '= ' + x.state);
} catch (e) { dire(' lecture impossible : ' + e.message); }
if (port) {
dire(`\nCE QUE LA CHAINE DECLARE (console sur ${port})`);
for (const cmd of ['pwr', 'info 1', 'info 2', 'info 3', 'info 4', 'info 5']) {
try {
const out = execFileSync('node', ['tools/pylontech-console.mjs', '--port', port, '--commande', cmd],
{ encoding: 'utf8', timeout: 45000 });
if (cmd === 'pwr') {
const l = out.split('\n').filter((x) => /^\d+\s/.test(x));
const presents = l.filter((x) => !/Absent/.test(x));
dire(` modules qui repondent : ${presents.length}`);
for (const x of presents) dire(' ' + x.trim().slice(0, 100));
} else {
const b = out.match(/Barcode\s*:\s*(\S+)/)?.[1];
const d = out.match(/Device name\s*:\s*(\S+)/)?.[1];
const f = out.match(/Main Soft version\s*:\s*(\S+)/)?.[1];
if (b) dire(` ${cmd.padEnd(8)} ${String(d).padEnd(9)} ${b} firmware ${f}`);
}
} catch { if (cmd === 'pwr') dire(' console injoignable'); }
}
} else {
dire('\n(pas de console branchee — passer --console /dev/ttyUSB0 pour lire la chaine)');
}
dire('\n' + '='.repeat(72));
dire('CRITERE DE REUSSITE : 9 modules en ligne et 650 Ah (4 x 100 + 5 x 50).');
dire('Tant que le Cerbo annonce 4 modules et 400 Ah, il ne voit qu un groupe.');
const nom = `docs/mesures/${maintenant.toISOString().slice(0, 16).replace(/[-:T]/g, '')}-parc-${etiquette.replace(/[^a-z0-9]+/gi, '-').toLowerCase()}.log`;
fs.writeFileSync(nom, lignes.join('\n') + '\n');
console.log(`\n--- journalise dans ${nom}`);