Compare commits

...

15 Commits

Author SHA1 Message Date
Tallagrand Thierry
a7ebaabb8b Chemin d ecriture du chien de garde demontre, pas seulement simule
Sonde de 777 W reellement publiee sur le topic de consigne du GX,
relue, puis valeur d origine remise par le script. Le dry-run supprimait
exactement ce maillon.

Et la fin de session s est deroulee dans le bon ordre sans coordination :
consigne retombee a 50 W a 03:31:30 avec l etat de fin de charge, puis
contacteur ouvert 34 s plus tard. La demande baisse avant que le reseau
parte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
2026-09-01 03:36:09 +02:00
Tallagrand Thierry
32de4ffa35 Le dossier de journaux doit exister avant le premier up sur DSM
Docker de Synology refuse un bind-mount dont le dossier hote est absent,
la ou Docker standard le creerait. deploy/watchdog/logs est desormais
versionne vide, et le compose le dit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
2026-09-01 03:25:37 +02:00
Tallagrand Thierry
c3eeab2c92 Le journal du chien de garde n avait rien a faire dans le depot
Le garde grep -q sur node_modules avait fait sauter l ajout de logs/ au
gitignore : le journal s est retrouve suivi au commit precedent. Retire
du suivi, ignore desormais.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
2026-09-01 03:21:31 +02:00
Tallagrand Thierry
57c696928d Sauvegarde Home Assistant : configuree, poussee sur deux autres machines, verifiee
Aucune sauvegarde HA n avait jamais ete faite, et git ne la couvrait pas
-- un seul fichier HA y etait versionne. HA declarait pourtant trois
destinations Synology, dont 10.10.10.22 et une troisieme machine en
10.10.10.50 absente de toute la documentation du projet.

Quotidienne 04:45, sept copies, base d historique exclue (20 Mo au lieu
de 4,4 Go), poussee sur les trois machines. La configuration passe par
le websocket car l API REST ne l expose pas.

Verifiee archive en main : .storage, packages, dashboards, secrets et
custom_components presents, base d historique absente comme voulu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
2026-09-01 03:13:55 +02:00
Tallagrand Thierry
ccd764beb9 Chien de garde EVCS : remise en etat sur quand le controleur se tait
Le controleur pilote depuis cette nuit la consigne de puissance reseau.
S il meurt en pleine session, elle reste haute et le reseau reprend la
place du solaire toute la journee. Ce chien de garde repare ce risque.

Signal de vie : l API d executions n8n, pas un helper HA. Verifie que HA
ne rafraichit pas l horodatage d un input_text reecrit a l identique --
surplus_state paraissait fige depuis 40 min alors que tout tournait.

Il agit en MQTT direct sur le GX Venus, troisieme machine independante
des deux NAS, jamais via HA ni via le cloud Tuya. Il ne decide rien : il
remet la consigne au repos, arrete la borne, et se tait. Une seule
action par panne, verrou jusqu au retour du controleur.

Tests : controleur vivant, panne simulee, et verrou sur 5 cycles.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
2026-09-01 02:59:56 +02:00
Tallagrand Thierry
807ae45b7f Etat reel de la resilience : trois SPOF nommes, dont un cloud
Le depot git est deja sur le second Synology. Aucune sauvegarde HA
n existe. La cle de chiffrement n8n tient dans 56 octets sans copie.
Et le contacteur EDF est un Tuya CLOUD : couper le reseau depend
d internet, sur un site ou l orage emporte la connexion.

Le GX Venus (10.10.10.9) est en revanche une troisieme machine avec son
propre broker MQTT : consigne reseau, DVCC et arret de borne y sont
atteignables sans HA ni cloud. C est le socle d un chien de garde.

Meteo-France est installee : la reserve d orage tourne deja.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
2026-09-01 02:48:20 +02:00
Tallagrand Thierry
a57ff19ead La consigne de puissance reseau appartient au controleur
Fermer le contacteur ne suffit pas : au-dessus du SOC minimal, l ESS en
autoconsommation ne preleve que sa consigne et laisse la batterie porter
maison et voiture. Nuit du 01/09 : charge HC demarree a 01:25, arretee a
01:26 par le garde batterie, contacteur ferme et inerte jusqu a 02:00.

La consigne a 4200 W du proprietaire n etait pas decorative : c est elle
qui faisait fonctionner la prise de relais nocturne depuis le 07/08. La
retirer le 31/08 a supprime la substitution de journee et casse la nuit.

Le controleur ecrit desormais la consigne lui-meme, a partir du besoin
mesure, seulement pendant une session reseau deliberee, avec une bande
asymetrique : trop basse se corrige tout de suite, trop haute ne coute
rien. Deux ecritures en 25 minutes au lieu d une toutes les 30 s.

Golden 51 -> 56. Verifie en reel : batterie a 0 A, voiture a 3500 W.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
2026-09-01 02:14:57 +02:00
Tallagrand Thierry
92f07fe885 Etude de la frontiere de l excedent : la voiture est un delestage, pas une charge
Integration de l historique HA sur 8 jours : 64% de l energie livree au
vehicule vient d un excedent reel, 16% sort de la reserve batterie. La
frontiere se ferme entre 18h30 et 19h50 fin aout, franchement.

Cote reserve nocturne, le bilan repond deja et il repond mal a notre
avantage : sept jours sur huit, meme pleine, la batterie ne passe pas la
nuit. Le deficit est structurel, ce qui est evitable c est de l aggraver.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
2026-09-01 00:49:13 +02:00
Tallagrand Thierry
fc49e6c31a Sequence d ouverture : baisser le courant avant que le reseau parte
Le plafond majore (28 A en BOOST) n est legitime que tant que le reseau alimente
AC-IN : sinon les deux MultiPlus portent seuls la voiture plus la maison, le
mecanisme du W-105. Personne ne baissait avant l ouverture -- c est l hysteresis
qui ouvre, l EVCS l apprenait apres coup -- et quand il l apprenait, la reduction
attendait le cooldown de courant et descendait d un pas a la fois.

Correctif sans poignee de main ni helper nouveau : le plafond majore exige que
l EVCS DEMANDE lui-meme le reseau. Sa demande tombe au cycle ou la fenetre se
ferme (30 s), l hysteresis ne peut ouvrir qu ensuite (override disparu PUIS
min_on, cycle 1 min) : l EVCS baisse toujours avant, mecaniquement.

Second volet : un plafond n est pas une cible qu on approche. Reduction
immediate et directe au plafond, sans cooldown.

Le calcul du plafond a ete deplace apres sourceMode dans WINDOWS_SOURCEV2 ; rien
entre les deux ne le lisait, verifie avant de bouger.

Helper a 20 A par defaut : comportement inchange. Le correctif ne mord que sur
le BOOST 28 A, le cas dangereux.

Golden 48 -> 51, chaque cas apparie a son contrefactuel. Mutations des deux
volets separement : 1 puis 2 echecs cibles, restauration 51/51.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
2026-08-31 20:03:23 +02:00
Tallagrand Thierry
f0479945c4 Fenetre d etablissement : ne plus decider sur les mesures du transitoire
A la fermeture du contacteur, PV, import et courant batterie ne decrivent aucun
regime permanent pendant ~150 s (mesure du 31/08). La boucle de 30 s y prenait
cinq decisions, et la moyenne PV 10 min en gardait la trace dix minutes de plus
-- le systeme lisait "plus de soleil", en concluait qu il avait besoin du
reseau, et confirmait sa propre premisse.

Aucun seuil nouveau : le systeme publiait deja l horodatage de la transition et
personne ne le lisait. Pendant la fenetre, on ne demarre pas et on ne laisse pas
une charge s arreter sur ces mesures ; les step-down restent libres.

Deux asymetries voulues : la fenetre ne s ouvre qu a l ARRIVEE du reseau (a sa
disparition les onduleurs reprennent tout, les gardes doivent agir plus vite),
et elle suspend le confort, jamais le risque.

Golden 42 -> 48, chaque cas apparie a son contrefactuel hors fenetre. Mutation :
gridSettling force a false -> 2 echecs cibles, restauration -> 48/48.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
2026-08-31 18:18:03 +02:00
Tallagrand Thierry
f07d4cb0f1 Batterie pleine : l effondrement existe, mais il dure 2 min 30
Campagne decisive a SOC 90-92 %, batterie n absorbant plus que 22 A. La
fermeture du contacteur fait bien chuter la production de 79 % en un pas de
mesure (2663 -> 559 W), simultanement a l apparition de l import -- l
observation du proprietaire est exacte. Mais la recuperation est totale en
~2 min 30 : import 112 W et production 3158 W, au-dessus du niveau d avant.

Etat etabli hors transitoire : -7,0 % et +2,0 % contre les phases ouvertes
voisines. Aucune substitution permanente.

Meme mecanisme qu a batterie preneuse ; sans tampon, la seule variable d
ajustement est le bridage des MPPT, d ou l effet spectaculaire. Avec l ancienne
consigne a 4200 W le transitoire ne pouvait pas se terminer. Cout par
fermeture : ~150 Wh batterie pleine, ~50 Wh sinon.

Ni VEConfigure ni firmware justifies par ces mesures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
2026-08-31 13:20:30 +02:00
Tallagrand Thierry
40f34418d6 Le reseau ne prend plus la place du solaire, batterie preneuse
Nuit complete + deux campagnes alternees apres la correction de la consigne
reseau (4200 -> 50 W) : l'import colle a la consommation, la production monte
librement contacteur ferme, et les transitions locales donnent +0,5 % et
+13,1 % la ou le 27/08 mesurait -73 %.

Le transitoire de fermeture est caracterise : le reseau reprend toute la maison
et le solaire bascule vers la batterie, puis l'import retombe a la consigne en
~2 min 15 (25 W, 52 W). Cout ~50-60 Wh par fermeture.

Outillage : tools/test-edf-alterne.mjs (protocole alterne, restauration
garantie) et tools/analyse-test-alterne.mjs. Deux pieges d'instrumentation
corriges -- garde meteo quand les phases ouvertes divergent entre elles, et
biais du parseur qui laissait tomber les lignes ou le premier tracker etait
bride.

Reste ouvert : le cas batterie pleine, celui que le proprietaire designe comme
decisif, ou le solaire n'a plus de destination quand le reseau reprend la maison.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
2026-08-31 11:23:35 +02:00
Tallagrand Thierry
aa83b5c3d9 BatteryLife desactive : le plancher ESS agit enfin a 20 %
Le mode ESS passe en « Optimise sans BatteryLife » depuis l'ecran GX — le mode
n'est pas expose a Home Assistant, contrairement a tous les autres reglages ESS.
La limite de SOC active tombe immediatement de 40 a 20 %, au lieu de descendre
par paliers de 5 % sur quatre jours, et l'etat BatteryLife devient
self_consumption_soc_above_min.

Le GX cesse ainsi d'entretenir une limite dynamique que rien dans les workflows
ne lisait et qui montait de 5 % chaque nuit. C'est le troisieme acteur non lu de
la decision reseau neutralise en quatre jours, apres les charges programmees de
l'ESS et la consigne de puissance a 4200 W.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
2026-08-30 23:45:41 +02:00
Tallagrand Thierry
7a1775489f Consigne reseau a 50 W, plancher ESS a 20 %, parametres obsoletes restaures
Trois reglages GX modifies ce soir, journal rempli dans la procedure.

Le point de consigne reseau passe de 4200 a 50 W a 21:46 : c'est la correction
visee, et desormais la seule variable qui separe l'installation des mesures du
27/08. Le test alterne pourra donc etre interprete.

Deux parametres avaient ete pris pour des limites de SOC alors qu'ils sont des
pourcentages des courants declares par la batterie. A 20 % de DCL la decharge
aurait ete plafonnee a 30 A, soit 1,5 kW, quand la maison avait tire 73,7 A le
soir meme. Remis a 100 % / 100 %, leur valeur par defaut : Victron les range
lui-meme sous « parametres obsoletes », DVCC en est le proprietaire reel.

Le plancher ESS descend de 40 a 20 % pour cesser de concurrencer la cible du
matin du controleur. Mais la limite qui agit reellement reste a 40 % :
BatteryLife maintient sa propre limite dynamique, qui monte de 5 % chaque nuit
et ne redescend que de 5 % par journee de charge complete — l'historique de
sept jours ne la montre jamais sous 40. Soit on attend quatre jours, soit on
bascule le mode ESS sur « sans BatteryLife ». Les deux options sont
documentees, le choix revient au proprietaire.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
2026-08-30 22:01:27 +02:00
Tallagrand Thierry
457f4c8727 Consigne reseau a 4200 W : le proprietaire non lu de la substitution EDF
Le symptome prouve le 27/08 (73 % de la production solaire remplacee par du
reseau des que le contacteur se ferme) trouve une explication complete cote
GX, sans toucher aux onduleurs.

number.gx_device_ac_power_setpoint vaut 4200 W la ou le defaut Victron est
50 W. C'est le Grid setpoint de l'ESS : la puissance que le systeme s'efforce
en permanence de prelever sur le reseau. L'ESS regulant la puissance au point
de raccordement, son seul levier pour faire place a cet import quand la maison
et la batterie sont deja servies est de brider les MPPT via DVCC.

Les deux campagnes du 27/08 se reconcilient arithmetiquement : a 10:47 l'import
monte a 4113 W, soit la consigne ; a 11:38 la batterie en absorption n'accepte
que ~1070 W et conso 2258 + batterie 1070 - PV 1043 = 2285 W contre 2287
mesures. Le residu constant de ~1040 W etait la signature d'un plancher, pas
d'une reduction proportionnelle.

Seconde trouvaille : le SOC minimal de l'ESS est a 40 %, au-dessus de la cible
de 35 % du controleur. Le GX recharge donc sur le reseau de sa propre
initiative des que le controleur relache — etat `recharge` observe les 29 et
30/08. Troisieme automate contradictoire sur la meme batterie apres les charges
programmees. Arbitrage laisse au proprietaire.

L'assistant ESS tourne et aucune alarme VE.Bus n'est levee : l'hypothese d'un
assistant perdu au passage en parallele est affaiblie, pas eliminee. La partie
VEConfigure reste donc en seconde intention dans la procedure, avec sauvegarde
obligatoire et interdiction de relancer le System Configurator.

Aucun reglage n'est modifie ici : ce commit ne contient que le diagnostic et la
procedure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
2026-08-30 21:41:46 +02:00
28 changed files with 3689 additions and 36 deletions

6
.gitignore vendored
View File

@@ -8,3 +8,9 @@ node_modules/
# Jeton HA local (ne jamais versionner)
.env
deploy/watchdog/watchdog.env
deploy/watchdog/logs/*
!deploy/watchdog/logs/.gitkeep
# Journal du chien de garde (churn permanent, aucune valeur en historique)
logs/

View File

@@ -1601,3 +1601,754 @@ du bilan énergétique partagé plutôt qu'un garde de plus.
---
*Document mis à jour le : 24/08/2026 (~12:50) — Fix G retiré (golden-hysteresis 7/7, seuils réels 20/30), inversion démarrage/maintien corrigée (golden 40/40), diagnostic des deux oscillateurs*
## 27. Session du 30/08/2026 — Deux propriétaires GX de la décision réseau, jamais lus
Le propriétaire demandait une procédure détaillée avant d'intervenir sur les
deux MultiPlus-II en parallèle (piste VEConfigure de la §26). Les lectures
préalables côté GX déplacent la priorité : **il est probable qu'aucune
intervention VEConfigure ne soit nécessaire.**
Procédure complète : `docs/PROCEDURE_ESS_PARALLELE.md`.
### Trouvaille 1 — Consigne de puissance réseau à 4200 W (défaut : 50 W)
`number.gx_device_ac_power_setpoint` = **4200 W**, miroir per-phase
`number.airtria_mpii_v1_ac_power_setpoint_l1` = 4200 W. C'est le *Grid setpoint*
ESS (`/Settings/CGwacs/AcPowerSetPoint`) : la puissance que le système
s'efforce en permanence de **prélever sur le réseau** quand AC-IN est présent.
L'ESS régulant la puissance au point de raccordement, le seul levier pour faire
place à cet import quand maison et batterie sont déjà servies est de **brider
les MPPT via DVCC**. C'est le mécanisme exact de la substitution prouvée le
27/08, et l'explication du résidu constant (~1040 W).
Les deux campagnes se réconcilient arithmétiquement :
- 10:47, SOC 71 %, conso 877 W → import monte à **4113 W** ≈ consigne ;
- 11:38, SOC 86 %, batterie en absorption n'acceptant que ~1070 W →
`conso 2258 + batterie 1070 − PV 1043 = 2285 W` contre **2287 W** mesurés.
Valeur inchangée sur tout l'historique disponible (7 j) ; `user_id: null` sur le
dernier changement = simple recréation d'entité au redémarrage HA du 24/08.
Aucun code du dépôt n'écrit cette entité (grep). Hypothèse la plus probable :
ancien contournement manuel, même famille que les charges programmées ESS
désactivées le 27/08. **À confirmer sur l'écran du GX avant de conclure** — ne
pas se fier au seul nom de l'entité HA.
Correction proposée : **50 W** (défaut Victron, légèrement positif pour ne
jamais injecter). Réversible en dix secondes, sans VEConfigure.
### Trouvaille 2 — SOC minimal ESS à 40 %, au-dessus de notre cible de nuit
`number.gx_device_ess_min_soc_limit` = 40 %, `sensor.gx_device_active_soc_limit`
= 40 % (BatteryLife ne fait donc pas dériver la limite au-dessus).
Sous 40 %, l'ESS recharge la batterie sur le réseau **de sa propre initiative**.
Ce n'est pas théorique : `sensor.gx_device_ess_batterylife_state` passe à
`recharge` les 29/08 04:08 et 30/08 01:21, précédé de
`soc_below_battery_life_dynamic_soc_limit`.
Or le contrôleur vise **35 %** le matin en été : le nôtre relâche à 35, le GX
reprend à 40. **Troisième automate contradictoire sur la même batterie**, après
les charges programmées. Arbitrage à rendre avec le propriétaire — piste
préférée : laisser ce plancher comme filet de sécurité bas (~20 %) et confier la
vraie cible au bilan énergétique. Rien changé sans son accord.
### Trouvaille 3 — L'assistant ESS tourne, aucune erreur VE.Bus
États BatteryLife vivants et cohérents → la boucle ESS fonctionne, elle n'est
pas absente. Toutes les alarmes VE.Bus à `no_alarm`, aucune erreur
d'installation. L'hypothèse « assistant perdu ou dépareillé entre les deux
unités après le passage en parallèle » est **affaiblie** sans être éliminée (un
assistant présent mais mal paramétré ne lève aucune alarme) — d'où le maintien
de la partie VEConfigure en seconde intention dans la procédure.
### Méthode retenue dans la procédure
Étapes classées par risque croissant : lectures gratuites → réglages GX
réversibles → **rejeu du protocole alterné du 27/08** comme critère objectif →
VEConfigure seulement en cas d'échec, avec sauvegarde `.rvms` obligatoire, une
correction à la fois, et interdiction absolue de relancer le VE.Bus System
Configurator. Firmware : toujours pas justifié par les mesures, et à ne pas
mêler au diagnostic.
---
*Document mis à jour le : 30/08/2026 — consigne réseau 4200 W et SOC minimal ESS 40 % identifiés comme propriétaires non lus de la décision réseau ; procédure d'intervention rédigée*
## 28. Session du 31/08/2026 — La substitution ne se reproduit plus (batterie preneuse). Cas « batterie pleine » encore ouvert
Première vérification de la correction de la consigne réseau (4200 → 50 W,
§27), sur une nuit complète puis deux campagnes alternées.
### La nuit 30-31/08 : l'import colle à la consommation
Une seule session réseau, `switch.edf_general_ok_switch` ON à **05:04:10**
(hystérésis, SOC 21 ≤ seuil 20) et OFF à **10:08:04** (SOC 30 ≥ seuil OFF).
Aucun rebond, aucun créneau.
| heure | import réseau | conso maison | courant batterie |
|---|---:|---:|---:|
| 05:15 | 780 W | 732 W | 0 A |
| 06:30 | 507 W | 477 W | −1 A |
| 07:45 | 781 W | 751 W | +4 A |
À comparer au test du 27/08 10:47 où, pour 877 W de consommation, l'import
montait en rampe jusqu'à **4113 W** pour gaver la batterie. Le comportement a
disparu. Détail de la fermeture : de 05:04 à 05:13 l'import est resté à
**24-56 W** (la consigne) pendant que la batterie continuait de servir la maison
à −15 A ; à 05:14, SOC atteignant le plancher ESS de 20 %, l'ESS a basculé —
import = charge maison, batterie à 0 A, SOC plat à 20 % jusqu'à 08:00. Manuel.
### Le matin : réseau présent, production libre
De 08:40 à la coupure de 10:08, contacteur toujours fermé :
| heure | PV | import | conso | I batterie |
|---|---:|---:|---:|---:|
| 08:45 | 1 303 W | **18 W** | 1 109 W | +2 A |
| 09:25 | 3 425 W | **39 W** | 805 W | +50 A |
| 10:05 | **5 244 W** | **47 W** | 952 W | +83 A |
`sensor.mppt_450_200_hq2449yr9pt_mppt_operation_mode` est resté `mppt_active`
sur toute la fenêtre : le chargeur déclare lui-même chercher son point de
puissance maximale pendant que le réseau est présent.
### Campagnes alternées (`tools/test-edf-alterne.mjs`)
Journaux : `docs/mesures/20260831-1043-test-edf-alterne.log` (phases 180 s) et
`20260831-1101-test-edf-alterne.log` (phases 240 s).
Transitions exploitables, chaque phase fermée contre ses voisines ouvertes :
| phase | PV fermée | PV voisines ouvertes | écart | trackers bridés |
|---|---:|---:|---:|---:|
| 10:47 ON_1 | 7 168 W | 7 131 W | **+0,5 %** | 2,7/7 (DVCC, I=124 A) |
| 11:13 ON_2 | 7 920 W (max 8 759) | 7 002 W | **+13,1 %** | 4,5/7 (DVCC, I=125 A) |
Le 27/08, la même manœuvre faisait tomber la PV de 3 885 à 1 045 W en 90 s avec
six trackers sur sept en `voltage_current_limited`. **La substitution ne se
reproduit plus à batterie preneuse.** Le bridage résiduel observé est le bridage
NORMAL : il apparaît quand le courant batterie approche la limite DVCC (125 sur
130 A) et il s'accompagne d'un import réseau nul — critère de discrimination
utilisable : bridage normal ⇒ import nul ; bridage anormal ⇒ l'import remplace
la production.
### Mécanisme identifié : un transitoire de reprise, qui retombe à la consigne
À chaque fermeture du contacteur, le réseau reprend d'un coup **toute** la charge
de la maison et la totalité du solaire bascule vers la batterie. Bilan vérifié au
watt près (10:48:15 : PV 7222 + grid 2120 − conso 2249 = 7093 W contre 7107 W de
charge batterie mesurée). Puis l'import **décroît jusqu'à la consigne** :
| campagne | import à la fermeture | après ~2 min 15 |
|---|---:|---:|
| 11:05 ON_1 | 1 985 W | **25 W**, stable |
| 11:13 ON_2 | 2 600 W | **52 W** puis −41 W |
Coût ≈ 50-60 Wh par fermeture. Avec l'ancienne consigne à 4200 W, cette reprise
n'avait aucune raison de redescendre : elle se stabilisait sur « le réseau
alimente tout, on bride le solaire pour faire de la place ». C'est
vraisemblablement le même mécanisme, dont la correction de consigne a rétabli
l'extinction. Corollaire : chaque fermeture coûte, ce qui plaide pour une
session réseau unique et dimensionnée plutôt que des créneaux répétés.
Note : aucun transitoire à la fermeture de 05:04 (PV nulle) — il n'y a rien à
rediriger quand il n'y a pas de solaire.
### CE QUI N'EST TOUJOURS PAS TESTÉ — batterie pleine (à faire)
Tout ce qui précède s'est mesuré à SOC 40-54 %, batterie absorbant 60 à 125 A.
Le propriétaire maintient, sur plusieurs mois d'observation, que le cas décisif
est **batterie pleine** : plus de tampon, donc le solaire qui alimentait la
maison n'a plus de destination quand le réseau la reprend. Discrimination
attendue, contacteur fermé, maison ~2 kW, SOC ≥ 90 % :
| observation | conclusion |
|---|---|
| PV ≈ conso (~2 kW), import ≈ 50 W | corrigé — écrêtage normal, le solaire sert la maison |
| PV ≈ 0-1 kW, import ≈ conso | non corrigé — éviction ; VEConfigure (§27, `docs/PROCEDURE_ESS_PARALLELE.md` étape 1) |
Le 27/08 à 11:38, SOC 86-88 %, batterie n'acceptant plus que ~1 kW, on avait
PV 1043 W pour une maison à 2258 W et un import de 2287 W : le solaire SOUS la
consommation et le réseau AU-DESSUS. C'est le point le plus proche du cas
décisif dont on dispose, et il est du mauvais côté. Trois minutes de protocole
alterné à SOC ≥ 90 suffiront à trancher.
### Outillage ajouté
- `tools/test-edf-alterne.mjs` — protocole alterné OFF/ON/OFF/ON/OFF, phases et
pas d'échantillonnage paramétrables. Mesure PV, tension et courant batterie,
conso, import, SOC, limite DVCC, présence physique AC-IN et le mode des 7
chargeurs solaires. **Désactive l'hystérésis pendant le test et restaure tout
à la fin, sur Ctrl-C et sur erreur.** Journal horodaté dans `docs/mesures/`.
- `tools/analyse-test-alterne.mjs` — résumé par phase et verdict.
Deux pièges d'instrumentation corrigés en cours de route, à ne pas réintroduire :
1. **Garde météo.** Les phases OUVERTES voient toutes la même ressource : si
elles divergent entre elles (33 % et 42 % sur les deux campagnes du 31/08),
la moyenne ON contre OFF est ininterprétable. L'outil refuse désormais de
conclure dans ce cas et compare chaque phase fermée à ses voisines
immédiates. C'est ce piège qui avait pollué le premier test du 27/08.
Contrôle interne utile : si la PV chute AVANT l'apparition de l'import, ce
sont des nuages (cas de la phase ON_2 du 31/08 10:53, chute 6995 → 3607 W
avec l'import encore à zéro).
2. **Biais du parseur.** La lecture des modes MPPT était ancrée sur `MPPT `, ce
qui faisait tomber en silence toute ligne dont le PREMIER tracker était bridé
— précisément les échantillons à mesurer — et biaisait le résultat vers
« aucun bridage ». Le bloc de modes est le dernier champ : l'ancrer sur
`edf on|off`, jamais sur le mot `MPPT`.
### Signal indépendant : la jauge de SOC s'effondre en bas d'échelle
Entre 04:00 et 04:20, le SOC est passé de **30 à 22 %** alors que la batterie ne
débitait que ~11 A (≈ 550 W), tension stable à 49 V. Huit points sur un parc de
~15 kWh représentent 1,2 kWh, soit plus de deux heures à ce débit — pas vingt
minutes. La jauge ne mesure plus, elle réestime. Comportement classique d'un
parc vieillissant près du genou de décharge, cohérent avec le module en panne
connu (7 modules actifs sur 8, cf. MANUEL §0).
Conséquence : tous les seuils des deux workflows (20, 30, 22, 35) sont exprimés
dans une grandeur non linéaire en bas d'échelle. À verser au chantier du bilan
énergétique ; ne pas « corriger » par un seuil de plus.
### LE CAS DÉCISIF, TESTÉ — batterie pleine (campagne 12:58, SOC 90-92 %)
Journal : `docs/mesures/20260831-1319-test-edf-alterne.log`. Conditions de
départ exactement celles que le propriétaire désignait : SOC 90 %, courant
batterie **+22,8 A**, DVCC retombée à 80 A, production déjà écrêtée à
3 691 W (2 301 W de maison + ~1,1 kW de batterie) alors que la même
installation produisait 9 112 W le matin. Plus de tampon.
**Le phénomène qu'il décrit se produit — et il se résorbe.** ON_2, le cas le
plus net :
| heure | PV | import | conso |
|---|---:|---:|---:|
| 13:11:57 | 2 663 W | 0 | 1 899 W |
| **13:12:12** | **559 W** | **1 777 W** | 1 886 W |
| 13:12:57 | 1 405 W | 1 136 W | 1 877 W |
| 13:13:27 | 1 912 W | 683 W | 1 891 W |
| 13:14:27 | 3 008 W | 258 W | 2 362 W |
| 13:14:42 | **3 158 W** | **112 W** | 2 373 W |
Effondrement de **79 %** en un seul pas de mesure, simultané à l'apparition de
l'import (et non avant, contrairement aux passages nuageux du matin : le
critère de discrimination fonctionne). Puis récupération en miroir, watt pour
watt, sur ~2 min 30 : l'import redescend à 112 W et la production revient à
3 158 W, **au-dessus** de son niveau d'avant fermeture. Même séquence en ON_1
(3 369 → 1 890 W avec import 2 083 W, retour à 3 315 W avec import 8 W).
Une fois le transitoire exclu (165 s), l'état établi ne montre aucune
substitution : **−7,0 %** et **+2,0 %** contre les phases ouvertes voisines,
import résiduel 443 et 479 W et toujours décroissant. Les phases ouvertes
déclinent d'elles-mêmes (3 505 → 2 808 → 2 488 W, milieu d'après-midi), ce qui
explique le −7 %.
**Conclusion.** L'observation de plusieurs mois du propriétaire est exacte : à
batterie pleine, fermer le contacteur tue la production. Ce qui était faux,
c'est la permanence. Le mécanisme est le même transitoire de reprise qu'à
batterie preneuse — le réseau reprend la maison, le solaire cherche une
destination — sauf que sans tampon la seule variable d'ajustement est le
bridage des MPPT, donc l'effet devient spectaculaire au lieu d'être invisible.
Avec l'ancienne consigne à 4200 W, ce transitoire n'avait aucune raison de
se terminer : le régulateur ne pouvait atteindre sa cible qu'en maintenant le
solaire bridé. **C'est l'explication complète de « pas de grid sinon le
solaire ne sert à rien », et la correction de consigne y met fin.**
Coût par fermeture du contacteur, à batterie pleine : ~50 Wh d'import
transitoire **plus** ~100 Wh de production perdue pendant les 2 min 30 de
bridage, soit environ **150 Wh**. À batterie preneuse, seulement les ~50 Wh.
C'est l'argument chiffré en faveur d'une **session réseau unique et
dimensionnée** plutôt que de créneaux répétés : chaque ouverture-fermeture se
paie, et se paie trois fois plus cher quand la batterie est pleine.
Ce qui reste vrai et inchangé : à batterie pleine et régime établi, la
production est écrêtée à la consommation (6 à 7 trackers sur 7 en
`voltage_current_limited` dans TOUTES les phases, ouvertes comme fermées).
C'est le bridage normal d'une installation sans injection, il ne dépend pas du
réseau, et il ne doit pas être confondu avec l'éviction.
**Aucune intervention VEConfigure ni firmware n'est justifiée par ces mesures.**
L'étape 1 de `docs/PROCEDURE_ESS_PARALLELE.md` reste en réserve, sans objet
actuel.
---
*Document mis à jour le : 31/08/2026 (~13:30) — cas batterie pleine testé : l'effondrement existe mais dure 2 min 30 et se résorbe entierement ; la substitution permanente est morte avec la consigne a 4200 W ; ni VEConfigure ni firmware justifies*
## 29. Session du 31/08/2026 (suite) — Fenêtre d'établissement du réseau
### Le défaut
Mesure du 31/08 (§28) : à la fermeture du contacteur, le réseau reprend d'un
coup toute la charge de la maison et le solaire bascule vers la batterie — ou se
fait brider s'il n'y a plus de tampon (2 663 → 559 W). L'import redescend
ensuite à la consigne en **~150 s**, deux fois mesuré (1 985 → 25 W, 2 600 → 52 W).
Pendant cette phase, PV, import et courant batterie ne décrivent **aucun régime
permanent**. Or la boucle EVCS tourne toutes les 30 s : elle y prend cinq
décisions. Pire, `INPUT_PARSE` lit `pv_power_10m_avg_w`, une moyenne glissante
de dix minutes : un creux de 2,5 kW pendant 2 min 30 la tire d'environ 600 W et
l'effet persiste une dizaine de minutes après le retour à la normale. Les seuils
de démarrage et de maintien solaire se jouent dans cette plage.
Boucle de rattrapage qui en résulte : réseau présent → le système lit « plus de
soleil » → il conclut qu'il a besoin du réseau → il le garde. La conclusion
confirme sa prémisse. **Quatrième oscillateur**, même famille que les trois du
24/08.
### Le correctif
Aucun seuil nouveau : le système publiait déjà l'information et personne ne la
lisait — l'horodatage de la dernière transition du réseau.
- **Gabarit snapshot** : `acin1_last_changed_ts`, `grid_switch_last_changed_ts`
(`as_timestamp(...last_changed, 0)`, avec garde `is not none` pour une entité
absente), plus `grid_settle_seconds` (helper **optionnel**
`input_number.evcs_grid_settle_seconds` ; absent → 180 s).
⚠️ Un commentaire Jinja `{# #}` est **interdit** à l'intérieur de l'expression
`{{ {...} | tojson }}` : `TemplateSyntaxError: unexpected char '#'`.
- **INPUT_PARSE** : `parsed.gridTransitionAgeSec` = âge de la **plus récente**
des deux transitions (AC-IN physique et contacteur). Horodatage absent ou nul
→ `Infinity` → aucune fenêtre, comportement historique. On ne devine jamais
une transition qu'on n'a pas observée. `params.gridSettleSeconds` borné [0,900].
- **WINDOWS_SOURCEV2** : `derived.gridSettling = gridAvailable && age < seuil`.
- **BATTERY_CLOUD_LOCKS** : pendant la fenêtre, `rawSolarStartOk` est faux (on ne
démarre pas sur des mesures fausses), les deux termes de mesure de
`rawSolarHoldOk` (plancher de décharge, seuil PV) sont neutralisés (on ne laisse
pas une charge en cours s'arrêter à cause d'elles), `probeFailedNow` est
suspendu (le réseau masque la production, la sonde n'échoue pas) et
`solarShouldStepUp` est bloqué. **Les step-down ne sont pas bridés** : c'est la
direction sûre.
- **HA_SERVICE_CALLS_OUTPUT** : trace `ETABLISSEMENT reseau Xs/Ys` dans la raison,
sinon une lecture du logbook pendant le transitoire donne l'impression que le
système décide n'importe quoi — alors qu'il décide de ne pas décider.
### ASYMÉTRIE VOULUE (le point à ne pas casser)
La fenêtre ne s'ouvre qu'à l'**arrivée** du réseau. À sa disparition, les
onduleurs reprennent instantanément toute la charge : les gardes doivent agir
**plus vite, jamais moins vite**. `gridSettling` exige donc `parsed.gridAvailable`.
De même, la fenêtre suspend le **confort**, jamais le **risque** :
`commonSafety*`, planchers SOC, surcharge et arrêt dur batterie restent évalués
normalement — application directe de la règle « lissage pour le confort,
instantané pour la sécurité ».
### Golden 42 → 48
Chaque cas est apparié à son **contrefactuel hors fenêtre** ; sans cet
appariement le test ne prouverait rien (première rédaction : les trois scénarios
donnaient le même résultat, le maintien passant par la tolérance nuageuse).
| scénario | attendu |
|---|---|
| `etablissement-maintien-charge` (30 s, PV 600 W, batt −15 A) | `charging_step_down_battery` 14 A — continue |
| `etablissement-termine-arret-normal` (400 s, idem) | `stop_conditions_lost` |
| `etablissement-securite-non-suspendue` (30 s, batt −40 A) | `battery_current_hard_stop` |
| `etablissement-ouverture-aucune-fenetre` (réseau disparu 30 s) | `stop_conditions_lost` |
| `etablissement-pas-de-demarrage` (30 s, PV 4,5 kW) | `idle_wait_conditions` |
| `etablissement-demarrage-hors-fenetre` (400 s, idem) | `start_min_current` |
Les 42 scénarios existants sont **inchangés** : leur fixture ne porte pas les
horodatages, donc `Infinity` → aucune fenêtre. Le repli est sûr par construction.
Preuve par mutation : `gridSettling = false` → échec des deux scénarios qui
testent le correctif (`maintien-charge`, `pas-de-demarrage`), les quatre autres
restant verts puisqu'ils n'en dépendent pas. Restauration → 48/48.
### Suite prévue (ordre convenu avec le propriétaire)
1. ✅ fenêtre d'établissement ;
2. séquence d'ouverture — baisser la consigne de la borne **avant** d'ouvrir le
contacteur, jamais après. Aujourd'hui l'hystérésis ouvre et le contrôleur
EVCS l'apprend jusqu'à 30 s plus tard, pendant lesquelles les deux MultiPlus
portent la voiture au courant majoré (jusqu'à 28 A en boost) plus la maison :
c'est le mécanisme du W-105 déjà consigné ;
3. priorité solaire pour la voiture à batterie pleine (coût marginal nul :
6-7 trackers sur 7 bridés = ressource refusée) et interdiction de fermer le
contacteur pour charger une voiture en journée batterie pleine (~150 Wh).
---
*Document mis à jour le : 31/08/2026 (~18:20) — fenêtre d'établissement déployée dans src/, golden 48/48, PUT n8n en attente de validation*
## 30. Session du 31/08/2026 (suite) — Séquence d'ouverture du contacteur
### Le défaut, en deux morceaux
Le plafond de courant EVCS majoré (P5, `input_number.evcs_plafond_courant_edf_on`,
jusqu'à **28 A en BOOST**) n'est légitime que tant que le réseau alimente AC-IN :
hors réseau, les deux MultiPlus portent seuls la voiture **plus** la maison.
C'est le mécanisme du W-105 déjà consigné (§3, maintenance du 09/07).
1. **Personne ne baissait avant l'ouverture.** C'est l'hystérésis (cycle 1 min)
qui ouvre le contacteur ; le contrôleur EVCS ne l'apprenait qu'après coup, via
`parsed.acInAvailable`. Les deux automates n'ont aucune poignée de main.
2. **Et quand il l'apprenait, il ne baissait pas vite.** La branche
`charging_step_down_system_cap` de `DECISION_ENGINEV2` exigeait
`lifecycle.currentCooldownElapsed` — un compteur de **confort** — et ne
descendait que de `currentStepDownA` par cycle. De 28 A à 20 A : plusieurs
cooldowns. L'exposition ne se comptait donc pas en secondes mais en minutes.
### Le correctif
**Aucune poignée de main entre les deux automates, aucun helper nouveau.** Le
plafond majoré exige désormais que **l'EVCS demande lui-même le réseau** :
```js
const evcsGridCeilingAllowed =
parsed.acInAvailable &&
(sourceMode === 'grid_forced' || sourceMode === 'grid_hc_allowed' || forceGridRequested);
```
L'ordonnancement devient mécanique : la demande de l'EVCS tombe au cycle où la
fenêtre se ferme (30 s), alors que l'hystérésis ne peut ouvrir qu'**ensuite**
(sa branche `turn_off_recovered` exige justement que l'override ait disparu, puis
min_on écoulé, sur un cycle d'une minute). L'EVCS baisse donc toujours avant,
sans que rien ne soit coordonné.
⚠️ **Le calcul du plafond a été DÉPLACÉ après le calcul de `sourceMode`** dans
`WINDOWS_SOURCEV2` (juste avant `const operatingMode = profile;`). Vérifié avant
de bouger : `physicalCapA`, `canRunAtMinCurrent` et les quatre variables
intermédiaires n'étaient utilisés qu'à leur définition et dans l'export — rien
entre les deux ne les lisait. Ne pas les remonter.
Second volet, dans `DECISION_ENGINEV2` : **un plafond n'est pas une cible qu'on
approche, c'est une limite qu'on respecte.** La réduction ne dépend plus du
cooldown et descend directement au plafond (`decision: 'step_down_fast'`, valeur
déjà utilisée par la branche surcharge). Réduire est toujours sûr ; c'est la
montée qui doit être temporisée, et elle l'est.
### Portée réelle, et ce qui reste exposé
Avec le helper à sa valeur par défaut (20 A), **le comportement est strictement
inchangé** : le plafond valait déjà 20 A dans les deux branches. Le correctif ne
mord que sur le BOOST 28 A, c'est-à-dire exactement le cas dangereux.
Reste irréductible : une ouverture **manuelle** (EDF URGENCE) ou une perte
physique du réseau ne se laissent pas anticiper. L'exposition y passe de
plusieurs minutes à **≤ 30 s plus le temps de rampe de la borne**, ce qui est le
minimum atteignable sans signal matériel.
### Golden 48 → 51
| scénario | attendu |
|---|---|
| `ouverture-plafond-tombe-hors-demande-reseau` (AC-IN présent, BOOST 28, matin solaire, cooldown NON écoulé) | `charging_step_down_system_cap` → **20 A immédiatement** |
| `ouverture-plafond-maintenu-en-demande-reseau` (CONTREFACTUEL, `force_grid_now`) | `charging_hold_grid` **28 A**, BOOST préservé |
| `ouverture-reseau-disparu-reduction-immediate` (AC-IN absent, cooldown non écoulé) | réduction immédiate à 20 A |
Preuve par mutation, les deux volets séparément :
- plafond majoré reconditionné au seul `acInAvailable` → 1 échec ciblé ;
- réduction remise sous `currentCooldownElapsed` → 2 échecs ciblés ;
- restauration → 51/51.
### Suite
3. priorité solaire pour la voiture à batterie pleine (coût marginal nul :
6-7 trackers sur 7 bridés = ressource refusée) et interdiction de fermer le
contacteur pour charger une voiture en journée batterie pleine (~150 Wh).
---
*Document mis à jour le : 31/08/2026 (~19:00) — séquence d'ouverture corrigée, golden 51/51, PUT n8n en attente de validation*
## 31. Session du 01/09/2026 (nuit) — La consigne réseau appartient désormais au contrôleur
### L'incident : charge HC démarrée puis morte en soixante secondes
Fenêtre HC ouverte normalement, tout notre pilotage a fait son travail :
| 01:24:03-04 | latch hystérésis ON, EDF fermé, AC-IN présent |
| 01:24:00 | takeover nuit ON, source `grid_hc_allowed` (P7) |
| 01:25:15 | ACM en `evcs_takeover_hold`, DVCC à 2 A |
| 01:25:30 | `start_grid_hc_min_current` — la charge démarre |
| 01:25:42 | le réseau prend la voiture : import **1 450 W** |
| 01:26:33 | import retombé à 763 W puis **négatif**, batterie de −7 à **−25 A** |
| 01:26:30 | garde « batterie trop sollicitée (−21,5 A) » → arrêt + verrou 17 min |
| 01:44 → 01:47 | `battery_current_hard_stop` puis `idle_wait_conditions` |
### Cause : l'ESS n'utilise le réseau que si on le lui demande
`ac_power_setpoint` = **50 W** (corrigé le 31/08 depuis 4 200 W),
`ess_min_soc_limit` = 20 %, SOC = 50 % ⇒
`ess_batterylife_state` = `self_consumption_soc_above_min`. Au-dessus du SOC
minimal, l'ESS fait servir la batterie et ignore le réseau. Le pic à 1 450 W
était le transitoire de reprise, pas le régime : en 50 s le régulateur a rendu
la charge à la batterie en convergeant vers les 50 W.
Preuve indépendante, à 01:49 sans voiture : contacteur fermé, maison 761 W,
tuile Réseau « – », **batterie −803 W**. Le réseau était branché et inerte.
**Le garde a eu raison** : la pente était monotone, sa fin était « la batterie
porte la voiture toute la nuit ». Le neutraliser aurait été pire.
**Et la consigne à 4 200 W du propriétaire était PORTEUSE** : c'est elle qui
faisait fonctionner toute la mécanique de prise de relais nocturne (P4/P7).
J'avais écrit en la retirant que le besoin couvert restait entier et devait être
repris ailleurs ; je ne l'avais pas repris. Règle qui en découle, désormais en
mémoire : ne jamais retirer un de ces contournements avant d'avoir mis son
remplaçant adaptable en production.
### Correctif (déployé 01/09 02:10, golden 56/56)
Le contrôleur écrit lui-même `number.gx_device_ac_power_setpoint`.
- **Snapshot** : `grid_setpoint_w`, `grid_setpoint_idle_w` (helper optionnel
`input_number.evcs_grid_setpoint_idle_w`, absent → 50 W, le défaut Victron).
- **INPUT_PARSE** : `parsed.gridSetpointW`, `params.gridSetpointIdleW` [0, 500].
- **HA_SERVICE_CALLS_OUTPUT** :
`gridSessionActive = acInAvailable && sourceMode ∈ {grid_forced, grid_hc_allowed}`
— donc P7 nuit, P9 départ et `force_grid_now`, et rien d'autre.
Besoin = `houseNonEvcsW + physicalCapA × acVoltageRef` : une grandeur
**mesurée**, pas une constante. Hors session, retour à la valeur de repos.
- **BUILD_ACTIONS** : appel `grid_power_setpoint` (`number.set_value`).
- Trace `Consigne reseau=XW` dans la raison pendant une session.
**La bande d'écriture est ASYMÉTRIQUE, et c'est le point.** Une consigne trop
basse fait porter la différence par la batterie — c'est le défaut de cette nuit,
corrigé sans attendre. Une consigne trop haute ne coûte rien : l'ESS ne prélève
que ce qu'il peut placer. On écrit donc avec une marge d'**un pas de courant
EVCS** (`currentStepUpA × acVoltageRef` ≈ 230 W — grandeur du système, pas un
chiffre inventé) et on ne redescend qu'au-delà de deux marges. Première version
sans cette asymétrie : réécriture toutes les 30 s en poursuivant le bruit de la
maison. Après : **deux écritures en 25 minutes**.
⚠️ **Deux leviers, deux rôles, à ne pas confondre** : la consigne dit ce que le
réseau a le DROIT de servir ; **DVCC (Auto Charge Manager) dit ce que la batterie
a le droit de prendre**. C'est DVCC à 2 A qui empêche une consigne généreuse de
gaver la batterie pendant un `evcs_takeover_hold`, et c'est DVCC à 30 A qui
autorise la recharge pendant un `evcs_takeover_recover`. Le couple se compose
correctement, mais la générosité de la consigne DÉPEND de ce cap : ne pas
supprimer l'un sans revoir l'autre.
### Golden 51 → 56
`summarize()` expose `gridSetpointW` (valeur écrite ce cycle, `null` si aucune
écriture). Scénarios : session HC → 5 525 W · contrefactuel hors session → 50 W ·
consigne déjà généreuse (5 600) → aucune écriture · **consigne 45 W sous le
besoin → correction immédiate** (le défaut de la nuit) · dérive externe à
4 000 W hors session → ramenée à 50 W.
Preuve par mutation : consigne figée au repos → 2 échecs ciblés ; branche
« trop basse » neutralisée → 1 échec ciblé ; restauration → 56/56.
### Vérification en réel
02:10 → 02:13, contacteur fermé : grid 4 350 W, **batterie ~0 A**, EVCS 3 500 W,
SOC stable à 49 %, consigne écrite à 5 276 puis 5 616 W. La voiture charge sur
le réseau en HC sans toucher à la réserve — ce que P7 était censé faire depuis
le 07/08 et qui ne tenait que par un réglage manuel.
### Risque résiduel assumé
Si le contrôleur s'arrête en pleine session (SPOF Synology, cf. §3), la consigne
reste haute et la substitution de journée peut revenir. C'est le même mode de
défaillance que le reste de la chaîne, mais il est nouveau pour ce paramètre :
à reprendre le jour où le SPOF sera traité.
### Non fait volontairement
La fenêtre d'établissement (§29) ne couvre que le chemin solaire ; les gardes
réseau ne sont pas gardés. Ce n'est pas la cause de cet incident et, la consigne
étant désormais correcte, le transitoire observé au redémarrage de 01:58 était
de 20 s avec la batterie à −1,6 A. À traiter séparément si un cas se présente.
---
*Document mis à jour le : 01/09/2026 (~02:15) — consigne de puissance réseau pilotée par le contrôleur, golden 56/56, charge HC vérifiée en réel*
## 32. Session du 01/09/2026 (nuit) — État réel de la résilience, faits vérifiés
Relevés avant toute proposition de secours (second Synology 10.10.10.22).
### Ce qui est déjà hors du NAS principal
`/mnt/projects` est monté depuis **10.10.10.22** : le dépôt git (src/nodes,
golden, tools, docs) ne dépend donc PAS de 10.10.10.17. Gitea vit sur la même
machine. La moitié du travail de résilience est faite sans qu'on l'ait cherché.
`/mnt/ARCH3/docker` (HA + n8n) est sur **10.10.10.17** uniquement, non répliqué.
### Aucune sauvegarde Home Assistant n'existe
Pas de dossier `backups/` dans `/mnt/ARCH3/docker/HA` (vérifié : seuls `SAVE/`
et `SAVE_CONFIG/`, créés à la main lors de nos sessions). La sauvegarde intégrée
de HA n'a jamais produit d'archive ici.
### ⚠️ La clé de chiffrement n8n tient dans 56 octets et n'a aucune copie
`N8N_ENCRYPTION_KEY` est **absent de `compose.yaml`** : n8n l'a générée au
premier démarrage et elle vit uniquement dans `/mnt/ARCH3/docker/n8n/data/config`
(56 octets, `encryptionKey`, 06/02/2026). Sans ce fichier, une copie parfaite de
`database.sqlite` (1,69 Go) ne redonne **aucun credential** — OpenRouter compris.
C'est l'objet le plus fragile de toute l'installation.
### Volumétrie, et c'est elle qui commande la stratégie
| | taille | nature |
|---|---:|---|
| `.storage` + `packages` + `dashboards` + yaml | **~6 Mo** | identité : entités, helpers, cartes |
| `custom_components` | 52 Mo | réinstallable (HACS), versions à figer |
| `home-assistant_v2.db` | 4,39 Go | historique |
| `n8n/data/database.sqlite` | 1,69 Go | workflows (déjà en git) + credentials |
Les deux bases sont en **mode WAL** (`-wal`/`-shm` présents) : un `cp` à chaud
sur NFS produit une sauvegarde corrompue **en silence**. Il faut
`sqlite3 .backup` / `VACUUM INTO`, ou conteneur arrêté.
### ⚠️ Le contacteur EDF est un Tuya CLOUD — SPOF distinct du NAS
`switch.edf_general_ok_switch` : `platform: tuya`,
`unique_id: tuya.bf3d7928e069594e5fzfi9switch` (intégration `tuya`, projet
cloud `gg-1060...`, pas `localtuya`). Ouvrir ou fermer le réseau **passe donc
par internet**. Or les coupures d'orage de ce site emportent souvent la
connexion : le contacteur peut devenir incommandable exactement quand il faut
agir, et le tableau « EDF Urgence » avec lui.
### En revanche, le GX est une troisième machine, indépendante des deux NAS
Intégration `victron_gx` = **Venus OS `c0619ab34ac5` sur 10.10.10.9:1883**, et
le broker MQTT est sur cette même adresse. Consigne de puissance réseau, DVCC,
courant EVCS, arrêt de la borne, SOC, PV, états MPPT : tout est lisible et
écrivable **en MQTT local, sans HA et sans cloud**. C'est là-dessus qu'un chien
de garde doit s'appuyer — pas sur HA, et surtout pas sur Tuya.
### Backlog levé sans qu'on le remarque : Météo-France est installée
`meteo_france` — Pleumeur-Bodou, Bretagne (22).
`sensor.meteo_france_alert_for_department_22_22_weather_alert` = `Vert`, et la
réserve d'orage (§26) tourne : `storm_level` vert, réserve **4,95 kWh**
(SOC 55 %), manque 0,9 kWh au SOC actuel de 49 %. L'action utilisateur en
attente depuis le 27/08 est donc caduque.
### Risque nouveau introduit le 01/09
Le contrôleur écrit maintenant la consigne réseau (§31). S'il s'arrête en pleine
session, elle reste haute et la substitution de journée revient. Ce risque
n'existait pas avant cette nuit : il justifie à lui seul un chien de garde.
## 33. Session du 01/09/2026 (nuit) — Chien de garde EVCS sur le second Synology
### Ce qu'il protège
Le contrôleur pilote depuis §31 la consigne de puissance réseau de l'ESS. S'il
s'arrête **en pleine session**, elle reste haute et le réseau se substitue au
solaire toute la journée suivante. Ce risque est né cette nuit ; le chien de
garde existe d'abord pour lui. Il couvre au passage l'incident du 09/07
(NAS éteint la nuit, EDF laissé fermé jusqu'au matin).
### Le signal de vie : surtout PAS un helper Home Assistant
Piège trouvé en le construisant, et vérifié : **HA ne rafraîchit pas
l'horodatage d'un `input_text` réécrit à l'identique.** À 02:52,
`victron_evcs_surplus_state` paraissait figé depuis 2 440 s alors que le
contrôleur tournait normalement (`charging_hold_grid` stable depuis 02:12, et la
raison tronquée à 255 caractères perdait justement les champs variables). Un
helper HA est un mauvais battement de cœur.
Signal retenu : **l'API d'exécutions de n8n**
(`GET /api/v1/executions?workflowId=...&limit=3`). Elle prouve que la boucle
s'exécute, et son absence couvre indifféremment « n8n arrêté », « NAS éteint »
et « réseau coupé ». Zéro modification du contrôleur, zéro helper à créer.
Cas supplémentaire traité : trois exécutions consécutives en `error` = le
contrôleur tourne à vide (typiquement HA inaccessible) → considéré mort.
### Ce qu'il fait, et surtout ce qu'il ne fait pas
- Il ne prend **aucune** décision d'énergie : ni seuil, ni démarrage. Il remet
l'état sûr par défaut (consigne → 50 W, borne arrêtée) et se tait.
- Il ne touche **pas** au contacteur EDF. C'est un Tuya *cloud* (§32), donc un
actionneur non fiable en secours ; et ce n'est pas nécessaire : contacteur
fermé + consigne au repos = autoconsommation normale, la maison passe sur la
batterie jusqu'au SOC minimal puis le réseau reprend seul. État sûr.
- Il n'utilise **ni HA ni le cloud** pour agir : MQTT direct sur le GX Venus
(10.10.10.9, portail `c0619ab34ac5`), troisième machine indépendante des deux
NAS. Topics vérifiés en lecture avant tout code :
`settings/0/Settings/CGwacs/AcPowerSetPoint`, `evcharger/40/StartStop`,
`evcharger/40/Ac/Power`, `battery/512/Soc`, `system/0/Ac/Grid/L1/Power`.
Broker sans authentification, keepalive `R/<portail>/keepalive` obligatoire.
- **Une seule action par panne**, puis verrou jusqu'au retour d'un signe de vie.
Sans ce verrou, une coupure réseau entre les deux NAS ferait rejouer l'action
à chaque cycle pendant que le contrôleur, vivant, la défait : un oscillateur
de plus, dans un projet qui en a déjà corrigé quatre.
### Réglages
Seuil de silence **600 s** (20 cycles manqués) : assez long pour ignorer un
hoquet, assez court pour qu'une panne nocturne ne coûte pas une journée de
production. Période 60 s. Consigne de repos 50 W (défaut Victron).
### Tests passés
1. Contrôleur vivant → aucune écriture, une ligne de veille par heure.
2. Panne simulée (`--simulate-dead --dry-run`) → détection, deux écritures
annoncées et supprimées, message d'alerte complet avec l'état du GX.
3. Verrou : 5 cycles de panne consécutifs → **1 alerte, 2 écritures**, puis
silence.
### Déploiement (action utilisateur)
`deploy/watchdog/compose.yaml`, à lancer **sur 10.10.10.22 uniquement** :
```
cd /volume2/projects/EVCS/EVCS/deploy/watchdog
cp watchdog.env.exemple watchdog.env # y coller la clé API n8n
docker compose up -d && docker compose logs -f
```
Le dépôt est monté en **lecture seule** ; seul le journal est écrit, hors dépôt.
`node_modules` (14 Mo, `mqtt` 5.15.2) est **sans dépendance native** — le
répertoire installé depuis le poste fonctionne tel quel dans le conteneur
alpine, et il vit déjà physiquement sur 10.10.10.22 (`/mnt/projects` = export
`10.10.10.22:/volume2/projects`).
⚠️ **Ne jamais le lancer sur 10.10.10.17** : un chien de garde hébergé par la
machine qu'il surveille meurt avec elle.
### Ce qu'il ne couvre pas encore
Le contacteur EDF reste incommandable si internet tombe (Tuya cloud). Un
contacteur local — ou un `localtuya` — est le prochain vrai gain de résilience.
La sauvegarde (6 Mo d'identité vers Gitea, 6,1 Go d'historique en hebdomadaire
avec `sqlite3 .backup`) et le secours froid n8n restent à faire.
## 34. Session du 01/09/2026 (nuit) — Sauvegarde Home Assistant : elle n'existait pas, elle existe
### Le point de départ
Aucune sauvegarde HA n'avait jamais été faite (§32). Et **non, git ne la
couvrait pas** : le dépôt contient le code du contrôleur, pas Home Assistant.
Un seul fichier HA y était versionné (`dashboards/bilan_energetique.yaml`).
Les helpers, les entités, les paquets, les cartes, les intégrations n'avaient
aucune copie nulle part.
### La trouvaille qui a simplifié le problème
`backup/agents/info` déclare **quatre destinations**, et le croisement avec les
entrées `synology_dsm` de `.storage/core.config_entries` donne leur hôte réel —
le nom seul ne dit pas où chacune écrit :
| agent | nom | hôte |
|---|---|---|
| `backup.local` | local | 10.10.10.17 |
| `synology_dsm.1660NZN396007` | ARCHIVE2 | **10.10.10.22** |
| `synology_dsm.14B0MPN914514` | ARCHIVE | **10.10.10.50** |
| `synology_dsm.20A0SBRVM9PF2` | ARCHIVE3 | 10.10.10.17 ⚠️ à ne PAS utiliser |
Home Assistant sait donc **pousser lui-même ses sauvegardes vers deux autres
machines**. Aucun script de recopie, aucune tâche planifiée, aucun montage NFS
supplémentaire : il n'y avait qu'à le configurer. (Troisième machine 10.10.10.50
découverte au passage — elle n'apparaissait dans aucun document du projet.)
### Configuration appliquée (`tools/ha-backup-config.mjs`, rejouable)
La configuration de sauvegarde **n'est pas exposée par l'API REST** : elle vit
dans `.storage/backup` et ne se règle que par l'interface ou le **websocket**.
Le script passe par le websocket, ce qui la rend reproductible et relisible.
- **Destinations** : local + 10.10.10.22 + 10.10.10.50. ARCHIVE3 (10.10.10.17)
volontairement exclu : une sauvegarde qui ne survit pas à la panne qu'elle
couvre ne protège rien.
- **Base de données EXCLUE.** Ce qu'il faut restaurer vite, c'est l'identité du
système — 20 Mo compressés. L'historique pèse 4,4 Go, change en permanence et
n'a aucune valeur d'urgence : il se sauvegarde à part, plus rarement, et avec
les précautions SQLite (mode WAL, jamais un `cp` à chaud).
- **Quotidienne à 04:45**, rétention **7 copies**.
- **Sans mot de passe**, choix assumé : ces archives restent sur le réseau local
du propriétaire, et une sauvegarde restaurable vaut mieux qu'une sauvegarde
chiffrée dont la clé est perdue le jour où elle sert.
### Vérifiée, pas seulement déclenchée
Première archive : `Automatic backup 2026.8.1` (a9d35ac9), 01/09 03:11,
**20,3 Mo, présente sur les trois machines**. Contenu contrôlé archive en main :
`.storage` 39 fichiers · `packages` 18 · `dashboards` 30 ·
`custom_components` 2 283 · `configuration.yaml`, `secrets.yaml`,
`automations.yaml`, `scripts.yaml` · et **zéro occurrence** de
`home-assistant_v2.db`, comme voulu.
`tools/ha-backup-list.mjs` affiche pour chaque sauvegarde les hôtes où elle est
réellement présente — c'est cette colonne-là qui compte.
### Ce que cette sauvegarde ne couvre PAS
1. **n8n.** Ni ses workflows (déjà en git, donc couverts autrement), ni sa base
`database.sqlite` (1,69 Go), ni surtout **la clé de chiffrement de 56 octets**
(§32) sans laquelle aucun credential n'est récupérable. C'est le prochain
trou à boucher, et le plus petit.
2. **L'historique HA** (4,4 Go) : perdu en cas de restauration. Conséquence
réelle pour le chantier du bilan énergétique, qui s'appuie sur les
statistiques long terme. À traiter séparément, en hebdomadaire.
### Validation de bout en bout, nuit du 01/09 (fin de session)
**Chemin d'écriture du chien de garde DÉMONTRÉ**, pas seulement simulé
(`tools/watchdog-selftest.mjs`, rejouable) : écriture réelle d'une sonde de
777 W sur `W/<portail>/settings/0/Settings/CGwacs/AcPowerSetPoint`, relecture
conforme, remise de la valeur d'origine par le script lui-même. Le `--dry-run`
supprimait justement ce maillon-là ; sans cette preuve, le chien de garde
n'était que probablement fonctionnel. Test fait EDF ouvert, donc consigne
inerte : risque nul.
**Et la séquence de fin de session s'est déroulée dans le bon ordre**, sans
que personne l'ait orchestrée :
| 03:31:30 | fin de charge → `night_recovery_done_idle`, et **la consigne retombe à 50 W dans le même cycle** |
| 03:32:04 | 34 s plus tard, l'hystérésis ouvre le contacteur (`turn_off_recovery_done`) |
La demande baisse AVANT que le réseau parte — le principe de la séquence
d'ouverture (§30), obtenu ici par construction et non par coordination. C'est
la garantie que le risque introduit en §31 (consigne laissée haute) ne se
matérialise pas en fonctionnement normal ; le chien de garde ne couvre plus que
le cas de la panne.
**Bilan de la nuit HC** : charge démarrée à 01:58 après correction, menée à son
terme à 03:31 sans jamais entamer la réserve (batterie tenue entre −2 et +1 A
pendant toute la session), SOC stable à 49 %.
**Clé API n8n** : désormais aussi dans `.env` à la racine (convention du
propriétaire), en plus de `~/.config/opencode/opencode.json`. Le repli du chien
de garde sur `.env` est vérifié — utile pour tout déploiement hors poste de
travail.

View File

@@ -0,0 +1,45 @@
# Chien de garde EVCS — à déployer sur le SECOND Synology (10.10.10.22).
#
# Il surveille le contrôleur qui tourne sur 10.10.10.17 et, s'il se tait,
# remet la consigne de puissance réseau de l'ESS à sa valeur de repos et
# arrête la borne — en MQTT direct sur le GX Venus (10.10.10.9), donc sans
# passer par Home Assistant ni par le cloud Tuya.
#
# ⚠️ NE JAMAIS le lancer sur 10.10.10.17 : un chien de garde hébergé par la
# machine qu'il surveille meurt avec elle.
#
# Mise en service :
# cd /volume2/projects/EVCS/EVCS/deploy/watchdog
# cp watchdog.env.exemple watchdog.env puis y coller la clé API n8n
# docker compose up -d
# docker compose logs -f
#
# Vérification sans risque, avant de le laisser en service :
# docker compose run --rm evcs-watchdog node tools/watchdog-evcs.mjs --once
# docker compose run --rm evcs-watchdog node tools/watchdog-evcs.mjs --once --dry-run --simulate-dead
services:
evcs-watchdog:
image: node:22-alpine
container_name: evcs-watchdog
restart: always
working_dir: /app
network_mode: bridge
volumes:
# Le dépôt en LECTURE SEULE : le chien de garde n'a aucune raison
# d'écrire dans le code qu'il exécute.
- /volume2/projects/EVCS/EVCS:/app:ro
# Son journal, seul point d'écriture, hors du code.
# ⚠️ Le dossier ./logs doit EXISTER avant `up` : le démon Docker de
# Synology DSM refuse un bind-mount dont le dossier hôte est absent,
# là où Docker standard le créerait. Il est versionné vide pour ça.
- ./logs:/var/log/evcs-watchdog:rw
env_file:
- watchdog.env
environment:
- TZ=Europe/Paris
- WATCHDOG_LOG=/var/log/evcs-watchdog/watchdog-evcs.log
command: ["node", "tools/watchdog-evcs.mjs"]
healthcheck:
test: ["CMD", "node", "-e", "process.exit(0)"]
interval: 5m

View File

@@ -0,0 +1,14 @@
# Copier en `watchdog.env` (exclu de git) et renseigner la clé.
# La clé API n8n se trouve dans ~/.config/opencode/opencode.json
# (mcp.n8n-mcp.environment.N8N_API_KEY) sur le poste de travail.
N8N_API_KEY=
# Tout le reste a des valeurs par défaut correctes ; à ne changer qu'en
# connaissance de cause.
# N8N_URL=https://n8n.airtria.pro
# EVCS_WORKFLOW_ID=ATswvGfG4KJUls0K
# GX_MQTT_HOST=10.10.10.9
# GX_PORTAL_ID=c0619ab34ac5
# WATCHDOG_MAX_AGE_SEC=600 # 20 cycles manqués avant de conclure à la panne
# WATCHDOG_PERIOD_SEC=60
# WATCHDOG_IDLE_SETPOINT_W=50 # défaut Victron

View File

@@ -599,3 +599,129 @@ Ordre de travail qui en découle, avant tout câblage :
- [ ] **États zombies** `stop_source_lost` / `idle_wait_source` — noms
transitoires peu lisibles dans le journal et le tableau de bord (cf. HANDOFF
§24, hors périmètre à l'époque).
## 01/09/2026 — La frontière de l'excédent, mesurée sur 8 jours
Question du propriétaire : faut-il charger la voiture le soir ? Avec la règle
qu'il pose et qui gouverne désormais le sujet — **la voiture est un délestage de
l'excédent PV, pas une charge prioritaire qui vide les batteries solaires**, et
la frontière se détecte à la **puissance de décharge batterie**, pas à la
production PV ni à un seuil de SOC.
### Méthode
`tools/etude-frontiere-excedent.mjs` intègre l'historique HA (résolution native
~2 s, rétention 90 jours effective depuis le 24/08 — vérifié) et répartit
l'énergie **réellement livrée au véhicule**, instant par instant, entre trois
origines physiques :
```
batterie : min(P_evcs, décharge batterie) -> réserve entamée
réseau : min(reste, import réseau) -> énergie payante
excédent : le solde -> production qui aurait été
refusée ou simplement stockée
```
Aucune écriture, aucune décision : le script lit et intègre. Convention
d'attribution assumée : sans la voiture, la décharge batterie aurait été
réduite d'au plus `P_evcs` — l'attribution est donc une borne haute honnête de
ce que la voiture a coûté à la réserve.
### Ce que les 8 jours disent (24/08 → 31/08)
| | kWh | part |
|---|---:|---:|
| Livré au véhicule | 80,13 | 100 % |
| dont **excédent** | 51,18 | **64 %** |
| dont **batterie** | 13,22 | **16 %** |
| dont réseau (HC nuit, voulu) | 15,73 | 20 % |
Part d'excédent par heure, cumul sur la période :
| h | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | **19** | **20** |
|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|
| part excédent | 94 % | 99 % | 86 % | 80 % | 83 % | 90 % | 77 % | 92 % | 81 % | **38 %** | **4 %** |
**La frontière existe, elle est franche, et elle tombe entre 18 h 30 et 19 h 50**
(heure du dernier instant où le véhicule chargeait sans faire décharger la
batterie) :
| jour | fermeture | SOC max du jour |
|---|---|---:|
| 24/08 | 19:26 | 95 % |
| 25/08 | 19:29 | 95 % |
| 28/08 | 19:50 | 93 % |
| 29/08 | 18:47 | 94 % |
| 30/08 | 18:31 | 94 % |
| 31/08 | 20:06 (0 % d'excédent) | 95 % |
### Pourquoi la charge de journée, elle, est gratuite
La batterie **sature à 93-96 % tous les jours**. L'énergie détournée vers la
voiture entre 10 h et 18 h n'a donc coûté aucune réserve : le plateau était
atteint de toute façon, et la production au-delà était refusée (§ censure). Ce
n'est vrai que *tant que la batterie sature* — c'est le second terme de la
règle, et c'est lui qui bougera avec la saison.
### Le coût réel de la charge du soir
13,22 kWh pris à la réserve sur 8 jours, soit **1,65 kWh/jour**, rachetés au
réseau la nuit suivante — avec en plus le rendement aller-retour de la batterie
(~10 %) et un cycle de charge/décharge pour rien. Le tarif est le même (HC dans
les deux cas) : le surcoût est purement en pertes et en usure. Autrement dit,
charger le soir sur la batterie **revient à charger la voiture au réseau, en
plus cher et en abîmant le parc**.
### La deuxième question : combien garder pour la nuit ?
Elle est déjà résolue, et pas par un seuil. Le bilan publie
`evcs_balance_next_deficit_kwh` et `..._required_soc` — l'énergie de la
prochaine traversée déficitaire et le niveau à atteindre avant. Validation du
26/08 : point bas de la nuit prédit **à un point de SOC près sur douze heures**.
Mesure à 18 h, SOC requis pour franchir la nuit **sans réseau** :
| 24/08 | 25/08 | 26/08 | 27/08 | 28/08 | 29/08 | 30/08 | 31/08 |
|---:|---:|---:|---:|---:|---:|---:|---:|
| 113,7 % | 119,4 % | 22,3 % ⚠️ | 114,3 % | 112,9 % | 112,3 % | 109,5 % | 117,9 % |
**Sept jours sur huit, même pleine, la batterie ne passe pas la nuit.** Déficit
structurel de 10 à 20 points, soit 1,5 à 3 kWh — c'est `storage_shortfall_kwh`,
le chiffre du dimensionnement, et il concorde avec les 11,8 kWh/jour jetés faute
de puits. L'import de nuit n'est donc pas évitable aujourd'hui ; ce qui est
évitable, c'est de l'**aggraver** en vidant la réserve le soir.
⚠️ Le 22,3 % du 26/08 est à vérifier (statut du bilan ce jour-là) avant d'être
cité.
### La règle qui en découle — deux termes, tous deux mesurés
```
charger le véhicule <=> (a) la batterie ne se décharge pas pour le nourrir
ET (b) elle atteindra quand même le niveau exigé par la
prochaine traversée déficitaire
```
- **(a)** est instantané, physique, déjà mesuré (`battery_power`), et se pilote
en asservissement : monter le courant tant que la batterie ne décharge pas,
redescendre dès qu'elle décharge. Ce n'est pas un seuil, c'est un servo.
- **(b)** est le bilan, déjà calculé toutes les 5 minutes
(`nextDeficit.reachable`). C'est lui qui portera la saison : quand les jours
raccourciront, la batterie cessera de saturer et (b) se fermera de lui-même,
de plus en plus tôt — sans qu'aucune constante n'ait à être touchée.
Ce couple remplace `batteryAssistSolarStartOk` (SOC ≥ 90), `soc_start_min`,
`midday_soc_min` et les planchers de profil : tous sont des mandataires de « la
production est refusée », justes à midi et faux au crépuscule.
### L'étude longue : rien à instrumenter
Constat utile : **l'historique HA suffit**. 2 s de résolution sur les puissances,
90 jours de rétention, et le bilan qui publie ses helpers toutes les 5 minutes
depuis le 24/08. La grandeur à suivre semaine après semaine est l'**heure de
fermeture de la frontière** et le **SOC max du jour** ; 8 jours ne montrent
aucune dérive saisonnière exploitable — il en faut plusieurs semaines, ce qui
est précisément l'objet de l'étude.
Bénéfice second, non cherché : les mêmes relevés donnent la courbe
énergie ↔ point de SOC par bande, c'est-à-dire le point (a) de l'ordre de
travail du 26/08, qui bloque tout câblage de la cible du matin.

View File

@@ -0,0 +1,303 @@
# Procédure — corriger la substitution EDF↔solaire sur les 2 MultiPlus-II en parallèle
> Rédigé le 30/08/2026, avant intervention. À suivre dans l'ordre : les étapes
> sont classées par **risque croissant**, et il est très possible que tout soit
> réglé avant d'arriver à VEConfigure.
>
> Rappel du symptôme à corriger (prouvé le 27/08, protocole alterné
> `docs/mesures/20260827-test-edf-mppt-alterne.log`) : dès que le contacteur EDF
> se ferme, la production solaire tombe de ~3 900 W à ~1 050 W et le réseau
> reprend la charge au watt près, à consommation inchangée. Quatre fois de
> suite, transitions en 75-90 s. Perte : **73 % de la production**.
---
## Étape 0 — Trois lectures gratuites (à faire AVANT tout le reste)
Aucune de ces trois vérifications ne touche à VEConfigure. Deux d'entre elles
sont des réglages du GX, modifiables depuis l'écran ou la console à distance,
réversibles en dix secondes.
### 0.1 — La consigne de puissance réseau est à 4200 W (défaut Victron : 50 W)
**C'est le suspect numéro un.** Relevé le 30/08 dans Home Assistant :
```
number.gx_device_ac_power_setpoint = 4200 W (défaut : 50 W)
number.airtria_mpii_v1_ac_power_setpoint_l1 = 4200 W
```
C'est le paramètre ESS *Grid setpoint* (`/Settings/CGwacs/AcPowerSetPoint`) :
**la puissance que le système s'efforce en permanence de prélever sur le
réseau** quand AC-IN est présent. À 4200 W, on ne demande pas au système de
s'équilibrer sur le solaire : on lui demande d'importer 4,2 kW.
Et comme l'ESS régule *la puissance au point de raccordement*, il n'a qu'un
levier pour faire de la place à cet import quand les puits (maison + batterie)
sont déjà servis : **brider les chargeurs solaires via DVCC**. D'où la
substitution, et d'où le résidu remarquablement constant.
Les deux campagnes de mesure se réconcilient exactement avec cette hypothèse :
| Test | Conso | PV pendant EDF ON | Réseau | Vérification |
|---|---:|---:|---:|---|
| 27/08 10:47, SOC 71 % | 877 W | — | monte à **4113 W** | ≈ la consigne 4200 W, le surplus part en batterie (le courant batterie s'effondre de 104,6 → 25,7 A à la coupure) |
| 27/08 11:38, SOC 86 % | 2258 W | 1043 W | 2287 W | batterie en absorption, elle n'accepte plus que ~1070 W : `2258 + 1070 − 1043 = 2285` ✔ |
Dans le second cas la consigne de 4200 W n'est pas atteignable (la batterie
sature), mais le système fait tout ce qu'il peut dans cette direction — et ce
« tout ce qu'il peut » consiste précisément à effacer le solaire.
**Vérification (aucun risque)** — console GX / Remote Console :
`Paramètres → ESS → Point de consigne réseau`. Confirmer qu'il affiche bien
4200 W. *Ne pas conclure sur le seul nom de l'entité HA : il faut le voir à
l'écran du GX.*
**Correction** : ramener à **50 W**. Valeur par défaut Victron, légèrement
positive pour garantir qu'on n'injecte jamais — ce qui est exactement le
régime voulu ici.
- soit sur le GX : même menu ;
- soit depuis HA : `number.gx_device_ac_power_setpoint` → 50.
**Noter la valeur d'origine (4200) avant de changer.** Retour arrière = remettre
4200.
⚠️ Il est très probable que ce 4200 soit un ancien contournement manuel, de la
même famille que les trois charges programmées de l'ESS désactivées le 27/08 —
mises en place à l'époque où le système ne tenait plus ses seuils. À confirmer
de mémoire avant de supposer qu'il s'agit d'un accident.
### 0.2 — Le SOC minimal de l'ESS est à 40 %, au-dessus de notre cible de nuit
```
number.gx_device_ess_min_soc_limit = 40 %
sensor.gx_device_active_soc_limit = 40.0 %
```
En dessous de 40 %, l'ESS **recharge la batterie sur le réseau de sa propre
initiative**, sans rien demander à personne. Ce n'est pas une hypothèse :
l'historique montre l'état `recharge` deux nuits sur les sept derniers jours
(29/08 04:08, 30/08 01:21), précédé à chaque fois de
`soc_below_battery_life_dynamic_soc_limit`.
Or notre contrôleur vise **35 %** le matin en été. **Deux automates avec deux
consignes contradictoires sur la même batterie** : le nôtre relâche à 35, le GX
reprend à 40. C'est le même cas de figure que les charges programmées.
Ce n'est pas la cause de la substitution (les deux tests étaient à SOC 71 % et
86 %), mais c'est une source d'import réseau non commandé qu'il faut trancher.
**Décision à prendre** (pas d'urgence, pas de risque) : soit descendre ce
plancher sous la cible du contrôleur, soit — plus cohérent avec la direction du
projet — le laisser comme **filet de sécurité bas** (par exemple 20 %) et
laisser le bilan énergétique piloter la vraie cible. À arbitrer ensemble ;
je ne le change pas sans votre accord.
### 0.3 — L'assistant ESS tourne bien, et il n'y a aucune erreur VE.Bus
Deux indices concordants, relevés le 30/08 :
- `sensor.gx_device_ess_batterylife_state` produit des états vivants et
cohérents (`self_consumption`, `self_consumption_soc_exceeds_85`, `recharge`) —
la boucle de régulation ESS **tourne**, elle n'est pas absente ;
- toutes les alarmes VE.Bus sont à `no_alarm`, aucune erreur d'installation
n'est signalée.
**Conséquence** : l'hypothèse « l'assistant ESS a été perdu ou dépareillé entre
les deux unités lors du passage en parallèle » est **affaiblie** — sans être
éliminée, car un assistant présent mais mal paramétré ne lève aucune alarme.
C'est ce qui justifie de garder la partie VEConfigure ci-dessous en réserve,
mais **en seconde intention**.
### 0.4 — Le test de validation (30 minutes, à faire après 0.1)
Ne pas se fier à une impression. Rejouer **exactement** le protocole alterné du
27/08, qui a déjà fait ses preuves :
- OFF 3 min / ON 3 min / OFF 3 min / ON 3 min, échantillon toutes les 15 s ;
- grandeurs relevées : PV, courant **et tension** batterie, conso, import
réseau, limite DVCC, mode ACM, modes de fonctionnement des MPPT.
- Conditions : plein soleil, batterie **non pleine** (SOC < 80 % de préférence),
et de quoi consommer (chauffe-eau en marche par exemple).
Critère de réussite, sans ambiguïté possible : **la PV ne bouge pas d'une phase
à l'autre**, et le réseau reste proche de zéro pendant les phases ON.
L'alternance est indispensable : elle interdit à une dérive météo de se faire
passer pour un effet, ce qui avait ruiné la première campagne du 27/08 à 10:47.
Je peux relancer ce test dès que vous me dites que le setpoint est corrigé.
---
## Étape 1 — VEConfigure, seulement si l'étape 0 n'a pas suffi
### 1.1 — Ce qu'il ne faut surtout PAS faire
1. **Ne jamais relancer le VE.Bus System Configurator.** C'est l'outil qui
déclare le nombre d'unités et les phases. Le relancer **efface la
configuration des deux onduleurs** et impose de tout refaire. On vient
modifier des réglages, on ne reconstruit pas le système.
2. **Ne pas mettre à jour le firmware dans la foulée.** Voir §2.
3. **Ne pas travailler sans sauvegarde préalable** (§1.3).
4. **Ne pas enchaîner plusieurs corrections.** Une modification, une
vérification, puis la suivante — sinon on ne saura pas laquelle a agi.
### 1.2 — Matériel et accès : deux chemins possibles
**Chemin A — Remote VEConfigure via VRM (recommandé, pas de câble)**
- Prérequis : le GX est en ligne sur VRM, et la **communication bidirectionnelle**
est activée (VRM → Paramètres de l'installation).
- VRM → votre installation → *Liste des appareils* → **Remote VEConfigure**.
- « Télécharger le fichier de réglages » : VRM interroge le GX, qui lit **les
deux unités** et produit un fichier de configuration **système** (extension
`.rvms` pour un système multi-unités, `.rvsc` pour une unité seule).
- Ce fichier s'ouvre dans **VEConfigure3** (Windows — paquet *VE Configuration
Tools*, téléchargeable gratuitement sur le site Victron). Un sélecteur permet
de passer de l'unité maître à l'unité parallèle.
- Puis « Envoyer et appliquer le fichier de réglages ».
**Chemin B — Câble MK3-USB (si VRM n'est pas accessible)**
- Interface **MK3-USB** branchée sur un port VE.Bus (RJ45) de **l'onduleur
maître** ; VEConfigure3 détecte le système complet, pas une unité isolée.
⚠️ **Dans les deux cas, l'écriture des réglages provoque un redémarrage bref du
VE.Bus** : les charges de la maison sont coupées quelques secondes. À faire à un
moment où c'est acceptable, et surtout **pas** pendant une charge de véhicule.
### 1.3 — Sauvegarde (obligatoire, avant toute écriture)
1. Télécharger le fichier de réglages **et le conserver tel quel**, sans
l'ouvrir en écriture : c'est le point de retour.
2. Le nommer explicitement, par exemple
`SAVE_CONFIG/vebus-parallele-AVANT-20260830.rvms`, et le mettre à l'abri
**hors du GX et hors du NAS** (le Synology est déjà un point unique de
défaillance connu du projet).
3. Photographier ou noter, depuis la console GX : point de consigne réseau,
SOC minimal, mode ESS, limite de courant d'entrée CA. Ces valeurs-là ne sont
pas dans le fichier VEConfigure — elles vivent dans le GX.
### 1.4 — Inspection en lecture seule (rien ne s'écrit à cette étape)
Ouvrir le fichier dans VEConfigure3 et **relever**, pour **chacune des deux
unités** (basculer avec le sélecteur d'appareil) :
| Onglet | Point à vérifier | Ce qu'on cherche |
|---|---|---|
| **Assistants** | Liste des assistants installés | **ESS présent sur les DEUX unités**, même version. Un assistant présent sur le maître seul est la panne classique après un passage en parallèle. |
| **Assistants → ESS** (rejouer l'assistant en lecture) | « Y a-t-il un compteur réseau ? » | Doit correspondre à la réalité. Si l'assistant croit qu'un compteur externe existe alors qu'il n'y en a pas (ou qu'il a été désaffecté lors du passage en parallèle), la boucle de régulation lit une puissance réseau fausse et se comporte exactement comme observé. |
| **Assistants → ESS** | Type de batterie / tensions / *sustain* | Cohérence avec les Pylontech pilotées en CAN-bus. |
| **Grid** | Code réseau / pays | Un code réseau réinitialisé après reconstruction du parallèle peut imposer des comportements non voulus. |
| **Inverter** | PowerAssist, limite de courant d'entrée CA | Doit être cohérent entre les deux unités. |
| **Virtual switch** | Doit être **désactivé** | L'assistant ESS et le *virtual switch* sont incompatibles. |
| **Charger** | Tensions de charge | Largement outrepassé par DVCC avec une batterie managée — à noter, pas à corriger en priorité. |
**Toute divergence entre les deux unités est un résultat en soi** : la noter
avant de toucher à quoi que ce soit.
### 1.5 — Corrections, une seule à la fois
Ordre recommandé, du plus probable au moins probable :
1. assistant ESS manquant ou dépareillé entre les deux unités ;
2. réponse « compteur réseau » de l'assistant ESS incohérente avec l'installation ;
3. limite de courant d'entrée CA / PowerAssist incohérentes.
Après **chaque** écriture : attendre le retour du VE.Bus, vérifier que les
alarmes restent à `no_alarm`, puis **rejouer le test §0.4**. Si le comportement
ne change pas, revenir au fichier de sauvegarde avant d'essayer la correction
suivante — on ne veut pas empiler des modifications non concluantes.
### 1.6 — Retour arrière
Renvoyer le fichier `…-AVANT-20260830.rvms` par le même chemin (VRM ou MK3),
et restaurer à la main les réglages GX notés en §1.3. C'est pour cela qu'il faut
les avoir notés : ils ne sont pas dans le fichier.
---
## Étape 2 — Mise à jour du firmware : à ne pas faire maintenant
Si « mise à jour » désignait le **firmware** des MultiPlus-II, la réponse reste
celle du 27/08 : **aucune des mesures effectuées ne justifie une mise à jour**.
Les raisons de ne pas la faire dans le même mouvement :
- sur un système en parallèle, **les deux unités doivent porter exactement le
même firmware** ; une divergence casse le parallèle ;
- une mise à jour firmware peut réinitialiser des réglages et **imposer de
refaire la configuration du système** — c'est précisément l'opération à
risque qu'on cherche à éviter ;
- on ne diagnostique pas deux variables à la fois : si l'étape 0 corrige le
symptôme, une mise à jour n'aura servi qu'à brouiller la piste.
Si elle devient nécessaire un jour, elle se fait **avec accès physique aux
onduleurs**, jamais à distance, et après la sauvegarde du §1.3.
---
## Journal d'intervention (à remplir au fur et à mesure)
| Date/heure | Action | Valeur avant | Valeur après | Résultat du test §0.4 |
|---|---|---|---|---|
| 30/08 21:46 | Consigne réseau GX | 4200 W | **50 W** | à faire (plein soleil) |
| 30/08 ~21:55 | Limite de charge (% de CCL) — *paramètre obsolète* | 95 % | **100 %** | remis au défaut |
| 30/08 ~21:55 | Limite de décharge (% de DCL) — *paramètre obsolète* | 20 % | **100 %** | remis au défaut |
| 30/08 22:0x | SOC minimal ESS | 40 % | **20 %** | ⚠ limite *active* encore à 40 % (BatteryLife) |
| | (VEConfigure, si nécessaire) | | | non entamé |
### Note sur l'épisode « paramètres obsolètes » (30/08)
Les lignes « Limite de charge/décharge de la batterie (% de CCL / % de DCL) »
de l'écran ESS ne sont **pas des limites de SOC** : ce sont des pourcentages des
courants que la batterie déclare accepter et fournir (*Charge/Discharge Current
Limit*). Portées à 95 % / 20 %, elles auraient plafonné la décharge à 20 % de
150 A = **30 A ≈ 1,5 kW**, là où la maison avait tiré 73,7 A le soir même et
108 A dans l'après-midi. Réseau ouvert — le régime habituel ici — la batterie
n'aurait plus pu servir la maison ; réseau fermé, l'écart serait revenu au
réseau, soit exactement la substitution qu'on cherche à supprimer.
Victron les range lui-même sous « Paramètres obsolètes » : DVCC (`forced_on`
avec une batterie managée en CAN-bus) en est le vrai propriétaire.
**Valeur correcte : 100 % / 100 %.**
### Note sur le SOC minimal et BatteryLife (30/08)
`MinimumSocLimit` a été ramené de 40 % à **20 %**, pour en faire un filet de
sécurité bas cohérent avec les seuils du contrôleur (plancher dur 22 %,
hystérésis réseau ON à 20 %) au lieu d'un concurrent de la cible du matin
(35 % en été).
⚠️ **La limite qui agit réellement n'a pas suivi immédiatement** :
`sensor.gx_device_active_soc_limit` reste à 40 %. Avec BatteryLife actif, le GX
maintient une limite *dynamique* propre, qui monte de 5 % quand la batterie
passe sous elle et ne redescend que de 5 % par jour de charge complète.
L'historique le montre sans ambiguïté : 40 → 45 chaque nuit, 45 → 40 chaque
jour, jamais en dessous de 40, sept jours durant.
Deux options :
1. **attendre** : la limite descendra 40 → 35 → 30 → 25 → 20 au rythme d'un
palier par journée de charge complète, soit environ quatre jours ;
2. **basculer le mode ESS** sur « Optimisé (sans BatteryLife) » depuis l'écran
GX (ligne *Mode*, au-dessus de *État BatteryLife*) : la limite active devient
alors immédiatement égale au minimum réglé.
L'option 2 est la plus cohérente avec la direction du projet — une limite
dynamique cachée que rien ne lit est précisément le type d'acteur non lu qu'on
est en train d'éliminer.
**Retenue et appliquée le 30/08 au soir** (mode ESS → « Optimisé sans
BatteryLife », depuis l'écran GX : le mode n'est PAS exposé à Home Assistant).
Vérification immédiate :
```
sensor.gx_device_active_soc_limit : 40 % → 20 % ✔
sensor.gx_device_ess_batterylife_state : self_consumption
→ self_consumption_soc_above_min ✔
```
⚠️ Faux ami à connaître : `select.gx_device_dess_mode` (options
`off/auto_vrm/buy/sell/node_red`) n'est PAS le mode ESS — c'est le *Dynamic
ESS*, piloté par les prix de l'électricité. Il doit rester sur `off`.
---
*Document créé le 30/08/2026. Relevés HA à l'appui : consigne 4200 W et SOC
minimal 40 % lus le 30/08 ; historique `recharge` des 29 et 30/08 ; alarmes
VE.Bus toutes à `no_alarm`.*

View File

@@ -0,0 +1,72 @@
TEST ALTERNE EDF <-> MPPT — 31/08/2026 10:43:54
phases 180s, echantillon 15s, sequence OFF_1 / ON_1 / OFF_2 / ON_2 / FIN_OFF
etat initial : edf off | latch off | hysteresis on
consigne reseau 50 W | SOC min ESS 20 % | limite active 20.0 %
MPPT ordre: mppt1 rs_1 rs_2 rs_3 rs_4 rs_g v1 (MPPT=cherche le max, lim_=se retient)
10:43:56 | OFF_1 | PV 7353 | U 50.13 | I 97.3 | conso 2270 | grid 0 | SOC 39 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
10:44:11 | OFF_1 | PV 6985 | U 50.15 | I 95.8 | conso 2258 | grid 0 | SOC 40 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
10:44:26 | OFF_1 | PV 6710 | U 50.16 | I 91.3 | conso 2276 | grid 0 | SOC 40 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
10:44:41 | OFF_1 | PV 6216 | U 50.12 | I 75.5 | conso 2309 | grid 0 | SOC 40 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
10:44:56 | OFF_1 | PV 6749 | U 50.10 | I 70.7 | conso 2246 | grid 0 | SOC 40 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
10:45:11 | OFF_1 | PV 6683 | U 50.17 | I 91.3 | conso 2263 | grid 0 | SOC 40 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
10:45:26 | OFF_1 | PV 5462 | U 50.10 | I 67.8 | conso 2252 | grid 0 | SOC 40 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
10:45:42 | OFF_1 | PV 5423 | U 50.08 | I 64.4 | conso 2270 | grid 0 | SOC 40 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
10:45:57 | OFF_1 | PV 6697 | U 50.10 | I 74.8 | conso 2268 | grid 0 | SOC 40 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
10:46:12 | OFF_1 | PV 6911 | U 50.12 | I 78.3 | conso 2264 | grid 0 | SOC 40 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
10:46:27 | OFF_1 | PV 7168 | U 50.18 | I 97.2 | conso 2246 | grid 0 | SOC 40 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
10:46:42 | OFF_1 | PV 7498 | U 50.19 | I 96.0 | conso 2272 | grid 0 | SOC 40 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
>>> EDF ON
10:47:00 | ON_1 | PV 7278 | U 50.21 | I 98.9 | conso 2252 | grid 0 | SOC 41 | DVCC 130A/51.5V | acin 1 | edf on | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
10:47:15 | ON_1 | PV 7169 | U 50.23 | I 99.0 | conso 2244 | grid 0 | SOC 41 | DVCC 130A/51.5V | acin 1 | edf on | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
10:47:30 | ON_1 | PV 7447 | U 50.22 | I 95.3 | conso 2286 | grid 0 | SOC 41 | DVCC 130A/51.5V | acin 1 | edf on | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
10:47:45 | ON_1 | PV 7327 | U 50.22 | I 94.5 | conso 2300 | grid 0 | SOC 41 | DVCC 130A/51.5V | acin 1 | edf on | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
10:48:00 | ON_1 | PV 7221 | U 50.22 | I 95.4 | conso 2252 | grid 0 | SOC 41 | DVCC 130A/51.5V | acin 1 | edf on | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
10:48:15 | ON_1 | PV 7222 | U 50.37 | I 141.1 | conso 2249 | grid 2120 | SOC 41 | DVCC 130A/51.5V | acin 1 | edf on | volt volt volt volt MPPT volt volt
10:48:30 | ON_1 | PV 6884 | U 50.36 | I 129.1 | conso 2245 | grid 1914 | SOC 41 | DVCC 130A/51.5V | acin 1 | edf on | volt volt volt volt MPPT volt volt
10:48:45 | ON_1 | PV 7087 | U 50.37 | I 126.8 | conso 2252 | grid 1727 | SOC 41 | DVCC 130A/51.5V | acin 1 | edf on | volt MPPT MPPT MPPT MPPT volt volt
10:49:00 | ON_1 | PV 7331 | U 50.37 | I 126.0 | conso 2309 | grid 1556 | SOC 42 | DVCC 130A/51.5V | acin 1 | edf on | volt volt volt volt MPPT volt volt
10:49:15 | ON_1 | PV 7458 | U 50.37 | I 124.4 | conso 2228 | grid 1248 | SOC 42 | DVCC 130A/51.5V | acin 1 | edf on | volt MPPT MPPT MPPT MPPT MPPT MPPT
10:49:30 | ON_1 | PV 7520 | U 50.37 | I 122.8 | conso 2293 | grid 1117 | SOC 42 | DVCC 130A/51.5V | acin 1 | edf on | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
10:49:45 | ON_1 | PV 6730 | U 50.35 | I 115.5 | conso 2271 | grid 886 | SOC 42 | DVCC 130A/51.5V | acin 1 | edf on | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
>>> EDF OFF
10:50:04 | OFF_2 | PV 7329 | U 50.32 | I 108.6 | conso 2254 | grid 0 | SOC 42 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
10:50:19 | OFF_2 | PV 6540 | U 50.28 | I 99.0 | conso 2397 | grid 0 | SOC 42 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
10:50:34 | OFF_2 | PV 7382 | U 50.25 | I 93.0 | conso 2338 | grid 0 | SOC 42 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
10:50:49 | OFF_2 | PV 7356 | U 50.28 | I 99.0 | conso 2240 | grid 0 | SOC 42 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
10:51:04 | OFF_2 | PV 7639 | U 50.28 | I 99.8 | conso 2243 | grid 0 | SOC 42 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
10:51:19 | OFF_2 | PV 7381 | U 50.28 | I 101.0 | conso 2240 | grid 0 | SOC 43 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
10:51:34 | OFF_2 | PV 7508 | U 50.27 | I 98.2 | conso 2257 | grid 0 | SOC 43 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
10:51:49 | OFF_2 | PV 7700 | U 50.27 | I 100.2 | conso 2331 | grid 0 | SOC 43 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
10:52:04 | OFF_2 | PV 7879 | U 50.28 | I 101.6 | conso 2305 | grid 0 | SOC 43 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
10:52:19 | OFF_2 | PV 7570 | U 50.29 | I 103.3 | conso 2328 | grid 0 | SOC 43 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
10:52:34 | OFF_2 | PV 7853 | U 50.29 | I 103.3 | conso 2311 | grid 0 | SOC 43 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
10:52:49 | OFF_2 | PV 7899 | U 50.30 | I 106.8 | conso 2309 | grid 0 | SOC 43 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
>>> EDF ON
10:53:07 | ON_2 | PV 6995 | U 50.30 | I 103.0 | conso 2329 | grid 0 | SOC 43 | DVCC 130A/51.5V | acin 1 | edf on | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
10:53:22 | ON_2 | PV 5384 | U 50.22 | I 80.6 | conso 2822 | grid 0 | SOC 43 | DVCC 130A/51.5V | acin 1 | edf on | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
10:53:37 | ON_2 | PV 4032 | U 50.01 | I 23.6 | conso 2793 | grid 0 | SOC 44 | DVCC 130A/51.5V | acin 1 | edf on | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
10:53:52 | ON_2 | PV 3607 | U 49.91 | I 6.9 | conso 2825 | grid 0 | SOC 44 | DVCC 130A/51.5V | acin 1 | edf on | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
10:54:07 | ON_2 | PV 3686 | U 49.87 | I 11.4 | conso 2804 | grid 0 | SOC 44 | DVCC 130A/51.5V | acin 1 | edf on | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
10:54:22 | ON_2 | PV 3707 | U 50.03 | I 70.3 | conso 2874 | grid 2717 | SOC 44 | DVCC 130A/51.5V | acin 1 | edf on | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
10:54:37 | ON_2 | PV 3794 | U 50.06 | I 63.8 | conso 2926 | grid 2598 | SOC 44 | DVCC 130A/51.5V | acin 1 | edf on | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
10:54:52 | ON_2 | PV 3037 | U 50.05 | I 57.4 | conso 2719 | grid 2221 | SOC 44 | DVCC 130A/51.5V | acin 1 | edf on | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
10:55:07 | ON_2 | PV 2968 | U 50.01 | I 43.8 | conso 2731 | grid 1956 | SOC 44 | DVCC 130A/51.5V | acin 1 | edf on | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
10:55:22 | ON_2 | PV 3078 | U 49.98 | I 39.8 | conso 2739 | grid 1760 | SOC 44 | DVCC 130A/51.5V | acin 1 | edf on | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
10:55:37 | ON_2 | PV 3603 | U 49.98 | I 43.8 | conso 2767 | grid 1555 | SOC 44 | DVCC 130A/51.5V | acin 1 | edf on | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
10:55:52 | ON_2 | PV 4702 | U 50.00 | I 50.1 | conso 2741 | grid 1343 | SOC 44 | DVCC 130A/51.5V | acin 1 | edf on | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
>>> EDF OFF
10:56:11 | FIN_OFF| PV 4788 | U 50.07 | I 65.7 | conso 2664 | grid 0 | SOC 44 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
10:56:26 | FIN_OFF| PV 5765 | U 50.02 | I 51.7 | conso 2682 | grid 0 | SOC 44 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
10:56:41 | FIN_OFF| PV 5249 | U 50.00 | I 44.5 | conso 2634 | grid 0 | SOC 44 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
10:56:56 | FIN_OFF| PV 4821 | U 49.96 | I 36.0 | conso 2664 | grid 0 | SOC 44 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
10:57:11 | FIN_OFF| PV 6658 | U 49.98 | I 45.1 | conso 2642 | grid 0 | SOC 44 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
10:57:26 | FIN_OFF| PV 7882 | U 50.15 | I 93.1 | conso 2644 | grid 0 | SOC 44 | DVCC 130A/51.5V | acin 0 | edf off | volt MPPT MPPT MPPT MPPT MPPT MPPT
10:57:41 | FIN_OFF| PV 5740 | U 50.26 | I 114.8 | conso 2360 | grid 0 | SOC 44 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
10:57:56 | FIN_OFF| PV 5774 | U 50.12 | I 61.2 | conso 2327 | grid 0 | SOC 45 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
10:58:11 | FIN_OFF| PV 4755 | U 50.10 | I 61.2 | conso 2336 | grid 0 | SOC 45 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
10:58:26 | FIN_OFF| PV 4824 | U 50.04 | I 48.1 | conso 2350 | grid 0 | SOC 45 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
10:58:41 | FIN_OFF| PV 5987 | U 50.05 | I 55.6 | conso 2345 | grid 0 | SOC 45 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
10:58:56 | FIN_OFF| PV 6074 | U 50.09 | I 67.5 | conso 2381 | grid 0 | SOC 45 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
>>> RESTAURATION (fin de campagne)
>>> etat restaure : edf off | latch off | hysteresis on

View File

@@ -0,0 +1,92 @@
TEST ALTERNE EDF <-> MPPT — 31/08/2026 11:01:00
phases 240s, echantillon 15s, sequence OFF_1 / ON_1 / OFF_2 / ON_2 / FIN_OFF
etat initial : edf off | latch off | hysteresis on
consigne reseau 50 W | SOC min ESS 20 % | limite active 20.0 %
MPPT ordre: mppt1 rs_1 rs_2 rs_3 rs_4 rs_g v1 (MPPT=cherche le max, lim_=se retient)
11:01:02 | OFF_1 | PV 5200 | U 50.09 | I 48.2 | conso 2275 | grid 0 | SOC 46 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
11:01:17 | OFF_1 | PV 6109 | U 50.11 | I 60.3 | conso 2279 | grid 0 | SOC 46 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
11:01:32 | OFF_1 | PV 5632 | U 50.19 | I 84.3 | conso 1983 | grid 0 | SOC 46 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
11:01:47 | OFF_1 | PV 8639 | U 50.35 | I 131.3 | conso 2108 | grid 0 | SOC 46 | DVCC 130A/51.5V | acin 0 | edf off | volt volt volt volt MPPT volt volt
11:02:02 | OFF_1 | PV 8510 | U 50.38 | I 126.4 | conso 2372 | grid 0 | SOC 46 | DVCC 130A/51.5V | acin 0 | edf off | volt MPPT MPPT MPPT MPPT MPPT MPPT
11:02:18 | OFF_1 | PV 8540 | U 50.33 | I 100.9 | conso 2452 | grid 0 | SOC 46 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
11:02:33 | OFF_1 | PV 7535 | U 50.30 | I 89.8 | conso 2424 | grid 0 | SOC 47 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
11:02:48 | OFF_1 | PV 6464 | U 50.30 | I 97.3 | conso 2393 | grid 0 | SOC 47 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
11:03:03 | OFF_1 | PV 7018 | U 50.22 | I 74.3 | conso 2400 | grid 0 | SOC 47 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
11:03:18 | OFF_1 | PV 7233 | U 50.28 | I 93.0 | conso 2381 | grid 0 | SOC 47 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
11:03:33 | OFF_1 | PV 8269 | U 50.32 | I 104.7 | conso 2405 | grid 0 | SOC 47 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
11:03:48 | OFF_1 | PV 6161 | U 50.29 | I 84.4 | conso 2395 | grid 0 | SOC 47 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
11:04:03 | OFF_1 | PV 5965 | U 50.20 | I 58.6 | conso 2404 | grid 0 | SOC 47 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
11:04:18 | OFF_1 | PV 6338 | U 50.22 | I 77.2 | conso 2397 | grid 0 | SOC 47 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
11:04:33 | OFF_1 | PV 6878 | U 50.22 | I 75.6 | conso 2409 | grid 0 | SOC 47 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
11:04:48 | OFF_1 | PV 6276 | U 50.20 | I 66.7 | conso 2394 | grid 0 | SOC 48 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
>>> EDF ON
11:05:06 | ON_1 | PV 5262 | U 50.16 | I 61.4 | conso 2452 | grid 0 | SOC 48 | DVCC 130A/51.5V | acin 1 | edf on | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
11:05:21 | ON_1 | PV 5674 | U 50.13 | I 57.5 | conso 2458 | grid 0 | SOC 48 | DVCC 130A/51.5V | acin 1 | edf on | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
11:05:36 | ON_1 | PV 5740 | U 50.10 | I 54.3 | conso 2410 | grid 0 | SOC 48 | DVCC 130A/51.5V | acin 1 | edf on | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
11:05:51 | ON_1 | PV 6539 | U 50.18 | I 78.4 | conso 2083 | grid 0 | SOC 48 | DVCC 130A/51.5V | acin 1 | edf on | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
11:06:06 | ON_1 | PV 7018 | U 50.23 | I 80.1 | conso 2107 | grid 0 | SOC 48 | DVCC 130A/51.5V | acin 1 | edf on | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
11:06:21 | ON_1 | PV 5203 | U 50.35 | I 114.4 | conso 2113 | grid 1985 | SOC 48 | DVCC 130A/51.5V | acin 1 | edf on | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
11:06:36 | ON_1 | PV 5126 | U 50.31 | I 93.4 | conso 2107 | grid 1811 | SOC 48 | DVCC 130A/51.5V | acin 1 | edf on | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
11:06:52 | ON_1 | PV 4853 | U 50.29 | I 90.0 | conso 2131 | grid 1603 | SOC 48 | DVCC 130A/51.5V | acin 1 | edf on | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
11:07:07 | ON_1 | PV 4935 | U 50.26 | I 78.6 | conso 2184 | grid 1418 | SOC 48 | DVCC 130A/51.5V | acin 1 | edf on | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
11:07:22 | ON_1 | PV 5147 | U 50.23 | I 74.6 | conso 2140 | grid 1135 | SOC 48 | DVCC 130A/51.5V | acin 1 | edf on | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
11:07:37 | ON_1 | PV 5535 | U 50.26 | I 84.6 | conso 2120 | grid 939 | SOC 48 | DVCC 130A/51.5V | acin 1 | edf on | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
11:07:52 | ON_1 | PV 4455 | U 50.21 | I 62.1 | conso 2079 | grid 659 | SOC 48 | DVCC 130A/51.5V | acin 1 | edf on | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
11:08:07 | ON_1 | PV 4105 | U 50.14 | I 50.5 | conso 2086 | grid 445 | SOC 49 | DVCC 130A/51.5V | acin 1 | edf on | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
11:08:22 | ON_1 | PV 4116 | U 50.08 | I 42.0 | conso 2160 | grid 307 | SOC 49 | DVCC 130A/51.5V | acin 1 | edf on | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
11:08:37 | ON_1 | PV 5153 | U 50.09 | I 50.3 | conso 2070 | grid 25 | SOC 49 | DVCC 130A/51.5V | acin 1 | edf on | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
11:08:52 | ON_1 | PV 4488 | U 50.09 | I 48.3 | conso 2067 | grid 25 | SOC 49 | DVCC 130A/51.5V | acin 1 | edf on | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
>>> EDF OFF
11:09:10 | OFF_2 | PV 4483 | U 50.06 | I 38.3 | conso 2314 | grid 0 | SOC 49 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
11:09:25 | OFF_2 | PV 4953 | U 50.05 | I 41.6 | conso 2305 | grid 0 | SOC 49 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
11:09:40 | OFF_2 | PV 6915 | U 50.10 | I 58.7 | conso 2304 | grid 0 | SOC 49 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
11:09:55 | OFF_2 | PV 6850 | U 50.19 | I 76.7 | conso 2310 | grid 0 | SOC 49 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
11:10:10 | OFF_2 | PV 6174 | U 50.22 | I 76.9 | conso 2309 | grid 0 | SOC 49 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
11:10:25 | OFF_2 | PV 5937 | U 50.16 | I 59.6 | conso 2374 | grid 0 | SOC 49 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
11:10:40 | OFF_2 | PV 6519 | U 50.17 | I 64.4 | conso 2319 | grid 0 | SOC 49 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
11:10:55 | OFF_2 | PV 5987 | U 50.22 | I 74.4 | conso 2340 | grid 0 | SOC 49 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
11:11:10 | OFF_2 | PV 4922 | U 50.15 | I 51.5 | conso 2381 | grid 0 | SOC 49 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
11:11:25 | OFF_2 | PV 5392 | U 50.10 | I 43.9 | conso 2361 | grid 0 | SOC 49 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
11:11:40 | OFF_2 | PV 5504 | U 50.16 | I 62.3 | conso 2347 | grid 0 | SOC 49 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
11:11:55 | OFF_2 | PV 6235 | U 50.17 | I 67.1 | conso 2324 | grid 0 | SOC 49 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
11:12:11 | OFF_2 | PV 7208 | U 50.26 | I 84.7 | conso 2330 | grid 0 | SOC 49 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
11:12:26 | OFF_2 | PV 5227 | U 50.24 | I 69.9 | conso 2350 | grid 0 | SOC 50 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
11:12:41 | OFF_2 | PV 4526 | U 50.14 | I 44.0 | conso 2371 | grid 0 | SOC 50 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
11:12:56 | OFF_2 | PV 3986 | U 50.11 | I 40.1 | conso 4229 | grid 0 | SOC 50 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
>>> EDF ON
11:13:14 | ON_2 | PV 4963 | U 50.05 | I 38.8 | conso 2398 | grid 0 | SOC 50 | DVCC 130A/51.5V | acin 1 | edf on | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
11:13:29 | ON_2 | PV 5666 | U 50.07 | I 41.0 | conso 2369 | grid 0 | SOC 50 | DVCC 130A/51.5V | acin 1 | edf on | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
11:13:44 | ON_2 | PV 5415 | U 50.14 | I 59.1 | conso 2374 | grid 0 | SOC 50 | DVCC 130A/51.5V | acin 1 | edf on | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
11:13:59 | ON_2 | PV 6240 | U 50.14 | I 62.6 | conso 2380 | grid 0 | SOC 50 | DVCC 130A/51.5V | acin 1 | edf on | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
11:14:14 | ON_2 | PV 7346 | U 50.24 | I 87.1 | conso 2635 | grid 0 | SOC 50 | DVCC 130A/51.5V | acin 1 | edf on | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
11:14:29 | ON_2 | PV 7398 | U 50.47 | I 142.9 | conso 2730 | grid 2600 | SOC 50 | DVCC 130A/51.5V | acin 1 | edf on | volt volt volt volt MPPT volt volt
11:14:44 | ON_2 | PV 6944 | U 50.48 | I 129.5 | conso 2495 | grid 2190 | SOC 50 | DVCC 130A/51.5V | acin 1 | edf on | volt MPPT MPPT MPPT MPPT MPPT volt
11:14:59 | ON_2 | PV 7122 | U 50.50 | I 127.0 | conso 2498 | grid 1949 | SOC 51 | DVCC 130A/51.5V | acin 1 | edf on | volt volt volt volt MPPT volt volt
11:15:14 | ON_2 | PV 7312 | U 50.52 | I 126.2 | conso 2540 | grid 1806 | SOC 51 | DVCC 130A/51.5V | acin 1 | edf on | volt volt volt volt MPPT volt volt
11:15:29 | ON_2 | PV 7540 | U 50.53 | I 126.8 | conso 2482 | grid 1536 | SOC 51 | DVCC 130A/51.5V | acin 1 | edf on | volt volt volt volt MPPT volt volt
11:15:44 | ON_2 | PV 7966 | U 50.54 | I 123.9 | conso 2515 | grid 1237 | SOC 51 | DVCC 130A/51.5V | acin 1 | edf on | volt volt volt volt MPPT volt volt
11:15:59 | ON_2 | PV 8017 | U 50.54 | I 121.3 | conso 2503 | grid 1094 | SOC 51 | DVCC 130A/51.5V | acin 1 | edf on | volt volt volt volt MPPT volt MPPT
11:16:14 | ON_2 | PV 8456 | U 50.56 | I 123.5 | conso 2499 | grid 828 | SOC 51 | DVCC 130A/51.5V | acin 1 | edf on | volt volt volt volt MPPT volt volt
11:16:29 | ON_2 | PV 8592 | U 50.57 | I 125.9 | conso 2116 | grid 277 | SOC 52 | DVCC 130A/51.5V | acin 1 | edf on | volt volt volt volt MPPT volt volt
11:16:44 | ON_2 | PV 8488 | U 50.59 | I 126.8 | conso 2089 | grid 52 | SOC 52 | DVCC 130A/51.5V | acin 1 | edf on | volt MPPT MPPT MPPT MPPT MPPT MPPT
11:16:59 | ON_2 | PV 8759 | U 50.57 | I 121.0 | conso 2092 | grid -41 | SOC 52 | DVCC 130A/51.5V | acin 1 | edf on | volt MPPT MPPT MPPT MPPT MPPT MPPT
>>> EDF OFF
11:17:17 | FIN_OFF| PV 8921 | U 50.58 | I 127.5 | conso 2058 | grid 0 | SOC 52 | DVCC 130A/51.5V | acin 0 | edf off | volt volt volt volt MPPT volt volt
11:17:32 | FIN_OFF| PV 8483 | U 50.58 | I 126.6 | conso 2054 | grid 0 | SOC 52 | DVCC 130A/51.5V | acin 0 | edf off | volt MPPT MPPT MPPT MPPT MPPT MPPT
11:17:47 | FIN_OFF| PV 8296 | U 50.57 | I 121.9 | conso 2024 | grid 0 | SOC 52 | DVCC 130A/51.5V | acin 0 | edf off | volt MPPT MPPT MPPT MPPT MPPT MPPT
11:18:02 | FIN_OFF| PV 7904 | U 50.57 | I 122.9 | conso 2024 | grid 0 | SOC 53 | DVCC 130A/51.5V | acin 0 | edf off | volt MPPT MPPT MPPT MPPT MPPT MPPT
11:18:17 | FIN_OFF| PV 8785 | U 50.59 | I 128.9 | conso 2025 | grid 0 | SOC 53 | DVCC 130A/51.5V | acin 0 | edf off | volt volt volt volt MPPT volt volt
11:18:32 | FIN_OFF| PV 8128 | U 50.60 | I 125.2 | conso 2028 | grid 0 | SOC 53 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
11:18:48 | FIN_OFF| PV 8287 | U 50.61 | I 127.3 | conso 2018 | grid 0 | SOC 53 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
11:19:03 | FIN_OFF| PV 6198 | U 50.50 | I 90.7 | conso 2021 | grid 0 | SOC 53 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
11:19:18 | FIN_OFF| PV 8660 | U 50.47 | I 89.1 | conso 2012 | grid 0 | SOC 53 | DVCC 130A/51.5V | acin 0 | edf off | volt volt volt volt MPPT volt MPPT
11:19:33 | FIN_OFF| PV 8310 | U 50.58 | I 120.2 | conso 2019 | grid 0 | SOC 54 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
11:19:48 | FIN_OFF| PV 8015 | U 50.59 | I 118.9 | conso 2317 | grid 0 | SOC 54 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT MPPT MPPT MPPT MPPT
11:20:03 | FIN_OFF| PV 8740 | U 50.55 | I 111.1 | conso 2364 | grid 0 | SOC 54 | DVCC 130A/51.5V | acin 0 | edf off | MPPT MPPT MPPT volt volt volt MPPT
11:20:18 | FIN_OFF| PV 9047 | U 50.61 | I 121.6 | conso 2392 | grid 0 | SOC 54 | DVCC 130A/51.5V | acin 0 | edf off | volt MPPT MPPT MPPT MPPT MPPT MPPT
11:20:33 | FIN_OFF| PV 9009 | U 50.64 | I 124.3 | conso 2343 | grid 0 | SOC 54 | DVCC 130A/51.5V | acin 0 | edf off | volt MPPT MPPT MPPT MPPT MPPT MPPT
11:20:48 | FIN_OFF| PV 9155 | U 50.64 | I 122.4 | conso 2459 | grid 0 | SOC 54 | DVCC 130A/51.5V | acin 0 | edf off | volt MPPT MPPT MPPT MPPT MPPT volt
11:21:03 | FIN_OFF| PV 9115 | U 50.66 | I 127.1 | conso 2343 | grid 0 | SOC 54 | DVCC 130A/51.5V | acin 0 | edf off | MPPT volt volt volt MPPT volt volt
>>> RESTAURATION (fin de campagne)
>>> etat restaure : edf off | latch off | hysteresis on

View File

@@ -0,0 +1,92 @@
TEST ALTERNE EDF <-> MPPT — 31/08/2026 12:58:43
phases 240s, echantillon 15s, sequence OFF_1 / ON_1 / OFF_2 / ON_2 / FIN_OFF
etat initial : edf off | latch off | hysteresis on
consigne reseau 50 W | SOC min ESS 20 % | limite active 20.0 %
MPPT ordre: mppt1 rs_1 rs_2 rs_3 rs_4 rs_g v1 (MPPT=cherche le max, lim_=se retient)
12:58:46 | OFF_1 | PV 3692 | U 50.93 | I 22.6 | conso 2323 | grid 0 | SOC 90 | DVCC 110A/51.2V | acin 0 | edf off | volt volt volt volt MPPT volt volt
12:59:01 | OFF_1 | PV 3680 | U 50.93 | I 23.3 | conso 2271 | grid 0 | SOC 90 | DVCC 110A/51.2V | acin 0 | edf off | volt volt volt volt MPPT volt volt
12:59:16 | OFF_1 | PV 3653 | U 50.93 | I 23.5 | conso 2281 | grid 0 | SOC 90 | DVCC 110A/51.2V | acin 0 | edf off | volt volt volt volt MPPT volt volt
12:59:31 | OFF_1 | PV 3621 | U 50.93 | I 23.0 | conso 2264 | grid 0 | SOC 90 | DVCC 110A/51.2V | acin 0 | edf off | volt volt volt volt MPPT volt volt
12:59:46 | OFF_1 | PV 3627 | U 50.92 | I 23.1 | conso 2266 | grid 0 | SOC 90 | DVCC 110A/51.2V | acin 0 | edf off | volt volt volt volt MPPT volt volt
13:00:01 | OFF_1 | PV 3625 | U 50.92 | I 22.5 | conso 2273 | grid 0 | SOC 90 | DVCC 110A/51.2V | acin 0 | edf off | volt volt volt volt MPPT volt volt
13:00:16 | OFF_1 | PV 3611 | U 50.92 | I 22.8 | conso 2266 | grid 0 | SOC 90 | DVCC 80A/51.3V | acin 0 | edf off | volt volt volt volt MPPT volt volt
13:00:31 | OFF_1 | PV 3632 | U 50.92 | I 23.5 | conso 2265 | grid 0 | SOC 90 | DVCC 80A/51.3V | acin 0 | edf off | volt volt volt volt MPPT volt volt
13:00:46 | OFF_1 | PV 3659 | U 50.93 | I 23.0 | conso 2269 | grid 0 | SOC 90 | DVCC 80A/51.3V | acin 0 | edf off | volt volt volt volt MPPT volt volt
13:01:01 | OFF_1 | PV 3649 | U 50.93 | I 22.8 | conso 2270 | grid 0 | SOC 90 | DVCC 80A/51.3V | acin 0 | edf off | volt volt volt volt MPPT volt volt
13:01:16 | OFF_1 | PV 3643 | U 50.94 | I 23.6 | conso 2250 | grid 0 | SOC 90 | DVCC 80A/51.3V | acin 0 | edf off | volt volt volt volt MPPT volt volt
13:01:31 | OFF_1 | PV 3638 | U 50.96 | I 23.2 | conso 2268 | grid 0 | SOC 90 | DVCC 80A/51.3V | acin 0 | edf off | volt volt volt volt MPPT volt volt
13:01:46 | OFF_1 | PV 3675 | U 50.97 | I 22.3 | conso 2290 | grid 0 | SOC 90 | DVCC 80A/51.3V | acin 0 | edf off | volt volt volt volt MPPT volt volt
13:02:01 | OFF_1 | PV 3438 | U 50.97 | I 21.7 | conso 2270 | grid 0 | SOC 90 | DVCC 80A/51.3V | acin 0 | edf off | volt volt volt volt MPPT volt volt
13:02:16 | OFF_1 | PV 3397 | U 50.95 | I 18.3 | conso 2256 | grid 0 | SOC 90 | DVCC 80A/51.3V | acin 0 | edf off | volt volt volt volt MPPT volt volt
13:02:31 | OFF_1 | PV 3377 | U 50.94 | I 17.6 | conso 2378 | grid 0 | SOC 90 | DVCC 80A/51.3V | acin 0 | edf off | volt volt volt volt MPPT volt volt
>>> EDF ON
13:02:49 | ON_1 | PV 3487 | U 50.93 | I 17.6 | conso 2308 | grid 0 | SOC 90 | DVCC 80A/51.3V | acin 1 | edf on | volt volt volt volt MPPT volt volt
13:03:04 | ON_1 | PV 3430 | U 50.92 | I 18.5 | conso 2284 | grid 0 | SOC 90 | DVCC 80A/51.3V | acin 1 | edf on | volt volt volt volt MPPT volt volt
13:03:19 | ON_1 | PV 3430 | U 50.92 | I 18.8 | conso 2269 | grid 0 | SOC 90 | DVCC 80A/51.3V | acin 1 | edf on | volt volt volt volt MPPT volt volt
13:03:34 | ON_1 | PV 3390 | U 50.93 | I 18.1 | conso 2259 | grid 0 | SOC 90 | DVCC 80A/51.3V | acin 1 | edf on | volt volt volt volt MPPT volt volt
13:03:49 | ON_1 | PV 3369 | U 50.93 | I 18.2 | conso 2266 | grid 0 | SOC 90 | DVCC 80A/51.3V | acin 1 | edf on | volt volt volt volt MPPT volt volt
13:04:04 | ON_1 | PV 1890 | U 51.14 | I 42.6 | conso 2245 | grid 2083 | SOC 91 | DVCC 80A/51.3V | acin 1 | edf on | volt volt volt volt volt volt volt
13:04:20 | ON_1 | PV 1503 | U 51.11 | I 25.4 | conso 2251 | grid 1915 | SOC 91 | DVCC 80A/51.3V | acin 1 | edf on | off volt volt volt volt volt volt
13:04:35 | ON_1 | PV 1502 | U 51.06 | I 17.9 | conso 2318 | grid 1790 | SOC 91 | DVCC 80A/51.3V | acin 1 | edf on | volt volt volt volt volt volt volt
13:04:50 | ON_1 | PV 1809 | U 51.03 | I 16.1 | conso 2328 | grid 1535 | SOC 91 | DVCC 80A/51.3V | acin 1 | edf on | volt volt volt volt volt volt volt
13:05:05 | ON_1 | PV 2004 | U 51.01 | I 17.0 | conso 2337 | grid 1343 | SOC 91 | DVCC 80A/51.3V | acin 1 | edf on | volt volt volt volt volt volt volt
13:05:20 | ON_1 | PV 2239 | U 50.99 | I 17.2 | conso 2309 | grid 1093 | SOC 91 | DVCC 80A/51.3V | acin 1 | edf on | volt volt volt volt volt volt volt
13:05:35 | ON_1 | PV 2401 | U 50.98 | I 16.6 | conso 2320 | grid 866 | SOC 91 | DVCC 80A/51.3V | acin 1 | edf on | volt volt volt volt MPPT volt volt
13:05:50 | ON_1 | PV 2698 | U 50.97 | I 17.3 | conso 2300 | grid 637 | SOC 91 | DVCC 80A/51.3V | acin 1 | edf on | volt volt volt volt MPPT volt volt
13:06:05 | ON_1 | PV 2846 | U 50.95 | I 16.1 | conso 2332 | grid 462 | SOC 91 | DVCC 80A/51.3V | acin 1 | edf on | volt volt volt volt MPPT volt volt
13:06:20 | ON_1 | PV 3411 | U 50.98 | I 17.7 | conso 2317 | grid 244 | SOC 91 | DVCC 80A/51.3V | acin 1 | edf on | volt volt volt volt MPPT volt volt
13:06:35 | ON_1 | PV 3315 | U 51.02 | I 16.4 | conso 2326 | grid 8 | SOC 91 | DVCC 80A/51.3V | acin 1 | edf on | volt volt volt volt MPPT volt volt
>>> EDF OFF
13:06:53 | OFF_2 | PV 3481 | U 51.03 | I 18.1 | conso 2329 | grid 0 | SOC 91 | DVCC 80A/51.3V | acin 0 | edf off | volt volt volt volt MPPT volt volt
13:07:08 | OFF_2 | PV 3459 | U 51.03 | I 18.3 | conso 2345 | grid 0 | SOC 91 | DVCC 80A/51.3V | acin 0 | edf off | volt volt volt volt MPPT volt volt
13:07:23 | OFF_2 | PV 3404 | U 51.04 | I 18.1 | conso 2255 | grid 0 | SOC 91 | DVCC 80A/51.3V | acin 0 | edf off | volt volt volt volt MPPT volt volt
13:07:38 | OFF_2 | PV 3363 | U 51.06 | I 17.9 | conso 2273 | grid 0 | SOC 92 | DVCC 80A/51.3V | acin 0 | edf off | volt volt volt volt MPPT volt volt
13:07:53 | OFF_2 | PV 3409 | U 51.07 | I 17.8 | conso 2299 | grid 0 | SOC 92 | DVCC 80A/51.3V | acin 0 | edf off | volt volt volt volt MPPT volt volt
13:08:08 | OFF_2 | PV 3190 | U 51.12 | I 26.0 | conso 1941 | grid 0 | SOC 92 | DVCC 80A/51.3V | acin 0 | edf off | off volt volt volt volt volt volt
13:08:23 | OFF_2 | PV 2974 | U 51.14 | I 20.6 | conso 1896 | grid 0 | SOC 92 | DVCC 80A/51.3V | acin 0 | edf off | off volt volt volt MPPT volt volt
13:08:38 | OFF_2 | PV 3016 | U 51.15 | I 19.3 | conso 1895 | grid 0 | SOC 92 | DVCC 80A/51.3V | acin 0 | edf off | volt volt volt volt MPPT volt volt
13:08:53 | OFF_2 | PV 2975 | U 51.16 | I 19.0 | conso 1916 | grid 0 | SOC 92 | DVCC 80A/51.3V | acin 0 | edf off | volt volt volt volt volt volt volt
13:09:08 | OFF_2 | PV 2931 | U 51.18 | I 18.0 | conso 1893 | grid 0 | SOC 92 | DVCC 80A/51.3V | acin 0 | edf off | volt volt volt volt volt volt volt
13:09:23 | OFF_2 | PV 2719 | U 51.15 | I 14.4 | conso 1907 | grid 0 | SOC 92 | DVCC 80A/51.3V | acin 0 | edf off | volt volt volt volt volt volt volt
13:09:39 | OFF_2 | PV 2795 | U 51.13 | I 15.0 | conso 1908 | grid 0 | SOC 92 | DVCC 80A/51.3V | acin 0 | edf off | volt volt volt volt volt volt volt
13:09:54 | OFF_2 | PV 2922 | U 51.11 | I 11.7 | conso 1950 | grid 0 | SOC 92 | DVCC 80A/51.3V | acin 0 | edf off | volt volt volt volt volt volt volt
13:10:09 | OFF_2 | PV 2827 | U 51.12 | I 17.4 | conso 1895 | grid 0 | SOC 92 | DVCC 80A/51.3V | acin 0 | edf off | volt volt volt volt volt volt volt
13:10:24 | OFF_2 | PV 2770 | U 51.10 | I 13.3 | conso 1907 | grid 0 | SOC 92 | DVCC 80A/51.3V | acin 0 | edf off | volt volt volt volt volt volt volt
13:10:39 | OFF_2 | PV 2724 | U 51.09 | I 13.7 | conso 1911 | grid 0 | SOC 92 | DVCC 80A/51.3V | acin 0 | edf off | volt volt volt volt volt volt volt
>>> EDF ON
13:10:57 | ON_2 | PV 2760 | U 51.09 | I 13.8 | conso 1967 | grid 0 | SOC 92 | DVCC 80A/51.3V | acin 1 | edf on | volt volt volt volt volt volt volt
13:11:12 | ON_2 | PV 2796 | U 51.10 | I 13.7 | conso 1966 | grid 0 | SOC 92 | DVCC 80A/51.3V | acin 1 | edf on | volt volt volt volt volt volt volt
13:11:27 | ON_2 | PV 2959 | U 51.14 | I 18.3 | conso 1904 | grid 0 | SOC 92 | DVCC 80A/51.3V | acin 1 | edf on | volt volt volt volt volt volt volt
13:11:42 | ON_2 | PV 2734 | U 51.19 | I 16.4 | conso 1902 | grid 0 | SOC 92 | DVCC 80A/51.3V | acin 1 | edf on | volt volt volt volt volt volt volt
13:11:57 | ON_2 | PV 2663 | U 51.15 | I 12.9 | conso 1899 | grid 0 | SOC 92 | DVCC 80A/51.3V | acin 1 | edf on | volt volt volt volt volt volt volt
13:12:12 | ON_2 | PV 559 | U 51.16 | I 14.3 | conso 1886 | grid 1777 | SOC 92 | DVCC 80A/51.3V | acin 1 | edf on | volt volt volt volt volt volt volt
13:12:27 | ON_2 | PV 800 | U 51.07 | I 4.4 | conso 1935 | grid 1613 | SOC 92 | DVCC 80A/51.3V | acin 1 | edf on | volt volt volt volt volt volt volt
13:12:42 | ON_2 | PV 1112 | U 51.06 | I 9.4 | conso 1923 | grid 1361 | SOC 92 | DVCC 80A/51.3V | acin 1 | edf on | volt volt MPPT MPPT MPPT volt volt
13:12:57 | ON_2 | PV 1405 | U 51.06 | I 10.5 | conso 1877 | grid 1136 | SOC 92 | DVCC 80A/51.3V | acin 1 | edf on | volt volt volt volt volt volt volt
13:13:12 | ON_2 | PV 1707 | U 51.05 | I 12.0 | conso 1941 | grid 852 | SOC 92 | DVCC 80A/51.3V | acin 1 | edf on | volt volt volt MPPT MPPT volt volt
13:13:27 | ON_2 | PV 1912 | U 51.06 | I 12.1 | conso 1891 | grid 683 | SOC 92 | DVCC 80A/51.3V | acin 1 | edf on | volt volt volt volt volt volt volt
13:13:42 | ON_2 | PV 2071 | U 51.05 | I 8.9 | conso 2263 | grid 788 | SOC 92 | DVCC 80A/51.3V | acin 1 | edf on | volt volt volt volt volt volt volt
13:13:57 | ON_2 | PV 2378 | U 51.03 | I 9.2 | conso 2335 | grid 693 | SOC 92 | DVCC 80A/51.3V | acin 1 | edf on | volt volt volt volt volt volt volt
13:14:12 | ON_2 | PV 2885 | U 51.01 | I 7.4 | conso 2428 | grid 542 | SOC 92 | DVCC 80A/51.3V | acin 1 | edf on | volt volt MPPT MPPT volt volt volt
13:14:27 | ON_2 | PV 3008 | U 51.11 | I 15.0 | conso 2362 | grid 258 | SOC 92 | DVCC 80A/51.3V | acin 1 | edf on | volt volt volt volt volt volt volt
13:14:42 | ON_2 | PV 3158 | U 51.14 | I 13.8 | conso 2373 | grid 112 | SOC 92 | DVCC 80A/51.3V | acin 1 | edf on | volt volt volt volt volt volt volt
>>> EDF OFF
13:15:01 | FIN_OFF| PV 3293 | U 51.14 | I 11.5 | conso 2270 | grid 0 | SOC 92 | DVCC 80A/51.3V | acin 0 | edf off | volt volt volt volt volt volt volt
13:15:16 | FIN_OFF| PV 3016 | U 51.17 | I 14.0 | conso 2293 | grid 0 | SOC 92 | DVCC 80A/51.3V | acin 0 | edf off | volt volt volt volt volt volt volt
13:15:31 | FIN_OFF| PV 3031 | U 51.13 | I 12.4 | conso 2268 | grid 0 | SOC 92 | DVCC 80A/51.3V | acin 0 | edf off | volt volt volt volt volt volt volt
13:15:46 | FIN_OFF| PV 3022 | U 51.14 | I 11.8 | conso 2268 | grid 0 | SOC 92 | DVCC 80A/51.3V | acin 0 | edf off | volt volt volt volt volt volt volt
13:16:01 | FIN_OFF| PV 2643 | U 51.15 | I 11.9 | conso 1900 | grid 0 | SOC 92 | DVCC 80A/51.3V | acin 0 | edf off | volt volt volt volt volt volt volt
13:16:16 | FIN_OFF| PV 2420 | U 51.13 | I 8.0 | conso 1949 | grid 0 | SOC 92 | DVCC 80A/51.3V | acin 0 | edf off | volt volt volt volt volt volt volt
13:16:31 | FIN_OFF| PV 2669 | U 51.12 | I 10.1 | conso 1976 | grid 0 | SOC 92 | DVCC 80A/51.3V | acin 0 | edf off | volt volt volt volt volt volt volt
13:16:46 | FIN_OFF| PV 2478 | U 51.12 | I 9.0 | conso 1901 | grid 0 | SOC 92 | DVCC 80A/51.3V | acin 0 | edf off | volt volt volt volt volt volt volt
13:17:01 | FIN_OFF| PV 2616 | U 51.09 | I 8.5 | conso 1973 | grid 0 | SOC 92 | DVCC 80A/51.3V | acin 0 | edf off | volt volt volt volt volt volt volt
13:17:16 | FIN_OFF| PV 2835 | U 51.10 | I 9.8 | conso 1996 | grid 0 | SOC 92 | DVCC 80A/51.3V | acin 0 | edf off | volt volt volt volt volt volt volt
13:17:31 | FIN_OFF| PV 2653 | U 51.17 | I 14.3 | conso 2004 | grid 0 | SOC 92 | DVCC 80A/51.3V | acin 0 | edf off | volt volt volt volt volt volt volt
13:17:46 | FIN_OFF| PV 2436 | U 51.15 | I 8.1 | conso 1955 | grid 0 | SOC 92 | DVCC 80A/51.3V | acin 0 | edf off | volt volt volt volt volt volt volt
13:18:01 | FIN_OFF| PV 2477 | U 51.13 | I 9.4 | conso 1965 | grid 0 | SOC 92 | DVCC 80A/51.3V | acin 0 | edf off | volt volt volt volt volt volt volt
13:18:16 | FIN_OFF| PV 2514 | U 51.13 | I 8.4 | conso 1956 | grid 0 | SOC 92 | DVCC 80A/51.3V | acin 0 | edf off | volt volt volt volt volt volt volt
13:18:31 | FIN_OFF| PV 2514 | U 51.14 | I 8.7 | conso 1964 | grid 0 | SOC 93 | DVCC 80A/51.3V | acin 0 | edf off | volt volt volt volt volt volt volt
13:18:46 | FIN_OFF| PV 2498 | U 51.13 | I 7.8 | conso 1955 | grid 0 | SOC 93 | DVCC 80A/51.3V | acin 0 | edf off | volt volt volt volt volt volt volt
>>> RESTAURATION (fin de campagne)
>>> etat restaure : edf off | latch off | hysteresis on

488
package-lock.json generated Normal file
View File

@@ -0,0 +1,488 @@
{
"name": "evcs-tools",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "evcs-tools",
"dependencies": {
"mqtt": "^5.10.1"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@types/node": {
"version": "26.4.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.0.tgz",
"integrity": "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==",
"dependencies": {
"undici-types": "~8.3.0"
}
},
"node_modules/@types/readable-stream": {
"version": "4.0.24",
"resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.24.tgz",
"integrity": "sha512-NRvUNC/JFGPJvqdAfEve8oginbM6V08u5NzLWpG8MwA2kTPOLnqk+wpwuPT+mp3aUsxyuT6m2gnrPuHYCruzEg==",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"dependencies": {
"event-target-shim": "^5.0.0"
},
"engines": {
"node": ">=6.5"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"node_modules/bl": {
"version": "6.1.6",
"resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz",
"integrity": "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==",
"dependencies": {
"@types/readable-stream": "^4.0.0",
"buffer": "^6.0.3",
"inherits": "^2.0.4",
"readable-stream": "^4.2.0"
}
},
"node_modules/broker-factory": {
"version": "3.1.15",
"resolved": "https://registry.npmjs.org/broker-factory/-/broker-factory-3.1.15.tgz",
"integrity": "sha512-ko+aWvgNuP49meGrdjUu7rC+Y+Wai3cCPxP3xWwHsHfehFjOh5ZQM2yC4gEB2UddeZ/YXhm0K1eG/L6fxym2Og==",
"dependencies": {
"@babel/runtime": "^7.29.7",
"fast-unique-numbers": "^9.0.27",
"tslib": "^2.8.1",
"worker-factory": "^7.0.50"
}
},
"node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="
},
"node_modules/commist": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/commist/-/commist-3.2.0.tgz",
"integrity": "sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw=="
},
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/concat-stream/node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
"engines": {
"node": ">=6"
}
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/fast-unique-numbers": {
"version": "9.0.27",
"resolved": "https://registry.npmjs.org/fast-unique-numbers/-/fast-unique-numbers-9.0.27.tgz",
"integrity": "sha512-nDA9ADeINN8SA2u2wCtU+siWFTTDqQR37XvgPIDDmboWQeExz7X0mImxuaN+kJddliIqy2FpVRmnvRZ+j8i1/A==",
"dependencies": {
"@babel/runtime": "^7.29.2",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=18.2.0"
}
},
"node_modules/help-me": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz",
"integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"node_modules/ip-address": {
"version": "10.7.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz",
"integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==",
"engines": {
"node": ">= 12"
}
},
"node_modules/js-sdsl": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.3.0.tgz",
"integrity": "sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/js-sdsl"
}
},
"node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mqtt": {
"version": "5.15.2",
"resolved": "https://registry.npmjs.org/mqtt/-/mqtt-5.15.2.tgz",
"integrity": "sha512-VWZU2CSUY3U3oN0PSBRDE5SNsFi4zqqNeQ/uv3pZWqY3CrBXD/dhd0ZLjlsk5YnebGlrapi4lRVNJPUNQ5aZ3w==",
"dependencies": {
"@types/readable-stream": "^4.0.21",
"@types/ws": "^8.18.1",
"commist": "^3.2.0",
"concat-stream": "^2.0.0",
"debug": "^4.4.1",
"help-me": "^5.0.0",
"lru-cache": "^10.4.3",
"minimist": "^1.2.8",
"mqtt-packet": "^9.0.2",
"number-allocator": "^1.0.14",
"readable-stream": "^4.7.0",
"rfdc": "^1.4.1",
"socks": "^2.8.6",
"split2": "^4.2.0",
"worker-timers": "^8.0.23",
"ws": "^8.18.3"
},
"bin": {
"mqtt": "build/bin/mqtt.js",
"mqtt_pub": "build/bin/pub.js",
"mqtt_sub": "build/bin/sub.js"
},
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/mqtt-packet": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-9.0.2.tgz",
"integrity": "sha512-MvIY0B8/qjq7bKxdN1eD+nrljoeaai+qjLJgfRn3TiMuz0pamsIWY2bFODPZMSNmabsLANXsLl4EMoWvlaTZWA==",
"dependencies": {
"bl": "^6.0.8",
"debug": "^4.3.4",
"process-nextick-args": "^2.0.1"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/number-allocator": {
"version": "1.0.14",
"resolved": "https://registry.npmjs.org/number-allocator/-/number-allocator-1.0.14.tgz",
"integrity": "sha512-OrL44UTVAvkKdOdRQZIJpLkAdjXGTRda052sN4sO77bKEzYYqWKMBjQvrJFzqygI99gL6Z4u2xctPW1tB8ErvA==",
"dependencies": {
"debug": "^4.3.1",
"js-sdsl": "4.3.0"
}
},
"node_modules/process": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
},
"node_modules/readable-stream": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
"dependencies": {
"abort-controller": "^3.0.0",
"buffer": "^6.0.3",
"events": "^3.3.0",
"process": "^0.11.10",
"string_decoder": "^1.3.0"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/rfdc": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"node_modules/smart-buffer": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
"engines": {
"node": ">= 6.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/socks": {
"version": "2.8.9",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz",
"integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==",
"dependencies": {
"ip-address": "^10.1.1",
"smart-buffer": "^4.2.0"
},
"engines": {
"node": ">= 10.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
},
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="
},
"node_modules/undici-types": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
"node_modules/worker-factory": {
"version": "7.0.50",
"resolved": "https://registry.npmjs.org/worker-factory/-/worker-factory-7.0.50.tgz",
"integrity": "sha512-hhwc0G+sFwM4qBuhJIUBn2p1Jf8v/FwmLUANBf/Q+Lt2uI8mfIZQhXaZQACodQD4R7Zp6cn/6702bIvNn2puJQ==",
"dependencies": {
"@babel/runtime": "^7.29.7",
"fast-unique-numbers": "^9.0.27",
"tslib": "^2.8.1"
}
},
"node_modules/worker-timers": {
"version": "8.0.34",
"resolved": "https://registry.npmjs.org/worker-timers/-/worker-timers-8.0.34.tgz",
"integrity": "sha512-WXL+Dqsm0G6dnC66rQsvM3tPT2adhbqSirWUCZGglkALOr8ocS0KlpU0iuKbjflJOlu3ydZikMEVrYnHOM9suw==",
"dependencies": {
"@babel/runtime": "^7.29.7",
"tslib": "^2.8.1",
"worker-timers-broker": "^8.0.18",
"worker-timers-worker": "^9.0.15"
}
},
"node_modules/worker-timers-broker": {
"version": "8.0.18",
"resolved": "https://registry.npmjs.org/worker-timers-broker/-/worker-timers-broker-8.0.18.tgz",
"integrity": "sha512-FrjzDVX1wKfZN0gRbCFqv8VHuTncG4sbI/WGEg4tSSQeIsnwqg4YBYWMAHYLJtUDEYYmiK65UKFrVMVk2irDSg==",
"dependencies": {
"@babel/runtime": "^7.29.7",
"broker-factory": "^3.1.15",
"fast-unique-numbers": "^9.0.27",
"tslib": "^2.8.1",
"worker-timers-worker": "^9.0.15"
}
},
"node_modules/worker-timers-worker": {
"version": "9.0.15",
"resolved": "https://registry.npmjs.org/worker-timers-worker/-/worker-timers-worker-9.0.15.tgz",
"integrity": "sha512-KKUe7lZ/Aignr51H6hOUik8LwTnIgojH/1lwhli8A8qIEIyewogZTpNpMW5B6BF7nmwBOkUoTYgf1H3QShcjSA==",
"dependencies": {
"@babel/runtime": "^7.29.7",
"tslib": "^2.8.1",
"worker-factory": "^7.0.50"
}
},
"node_modules/ws": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}

9
package.json Normal file
View File

@@ -0,0 +1,9 @@
{
"name": "evcs-tools",
"private": true,
"type": "module",
"description": "Outillage EVCS. Seul le chien de garde a une dependance externe (client MQTT vers le GX Venus) ; tout le reste tourne sur le Node de base.",
"dependencies": {
"mqtt": "^5.10.1"
}
}

View File

@@ -563,8 +563,17 @@ const batteryAssistSolarHoldOk =
// =====================================================
// START / HOLD CONDITIONS
// =====================================================
// FENETRE D'ETABLISSEMENT (31/08/2026) — cf. WINDOWS_SOURCEV2 pour la mesure.
// Pendant les ~150 s qui suivent l'arrivee du reseau, PV et courant batterie ne
// mesurent rien d'exploitable. On NE DEMARRE PAS sur ces valeurs, et on ne
// laisse pas une charge en cours s'arreter a cause d'elles. Les gardes de
// securite (commonSafety*, planchers SOC, surcharge, pas de tampon) restent
// evaluees normalement : c'est le risque qui prime, jamais le confort.
const gridSettling = derived.gridSettling === true;
const rawSolarStartOk =
(context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') &&
!gridSettling &&
commonSafetyStartOk &&
parsed.soc >= effectiveSocStartFloor &&
parsed.batteryCurrentA >= profileBatteryDischargeFloorA &&
@@ -580,8 +589,12 @@ const rawSolarHoldOk =
(context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') &&
commonSafetyHoldOk &&
parsed.soc >= effectiveSocHoldFloor &&
parsed.batteryCurrentA >= profileBatteryDischargeFloorA &&
(
gridSettling ||
parsed.batteryCurrentA >= profileBatteryDischargeFloorA
) &&
(
gridSettling ||
parsed.pvPowerW >= params.pvHoldMinW ||
cloudToleranceOk
);
@@ -667,9 +680,13 @@ const probeSuccessNow =
parsed.batteryCurrentA >= Math.min(0, batteryDischargeSoftDynamicA)
);
// Une sonde ne peut pas etre declaree ratee sur une mesure prise pendant
// l'etablissement : c'est le reseau qui masque la production, pas la sonde qui
// echoue. On differe le verdict, on ne l'invente pas.
const probeFailedNow =
probeModeActive &&
probeElapsedReached &&
!gridSettling &&
!probeSuccessNow;
// =====================================================
@@ -689,7 +706,10 @@ const stepUpByForecastMode =
derived.projectedEndSocAfterCharge >= (derived.endDayGuardSoc - 5) &&
cloudToleranceOk;
// Pas de montee en courant sur des mesures d'etablissement. La descente, elle,
// n'est pas bridee : c'est la direction sure.
const solarShouldStepUp =
!gridSettling &&
lifecycle.currentCooldownElapsed &&
derived.currentReferenceA < derived.physicalCapA &&
derived.physicalCapA >= params.currentMinA &&
@@ -906,6 +926,8 @@ return [{
solarStartOk,
solarHoldOk,
gridSettling,
gridTransitionAgeSec: parsed.gridTransitionAgeSec,
gridStartOk,
gridHoldOk,

View File

@@ -124,6 +124,17 @@ pushAction(
context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced'
);
// =====================================================
// CONSIGNE DE PUISSANCE RESEAU
// Fermer le contacteur ne suffit pas : sans consigne, l'ESS laisse la
// batterie porter la maison et la voiture (nuit du 01/09/2026).
// =====================================================
pushAction(
'grid_power_setpoint',
calls.grid_power_setpoint,
!!calls.grid_power_setpoint?.execute
);
// =====================================================
// AUTO START OFF SI BESOIN
// =====================================================

View File

@@ -834,18 +834,20 @@ else if (cloud.sessionObservedActive) {
}
}
else if ((derived.currentReferenceA > derived.physicalCapA) && lifecycle.currentCooldownElapsed) {
// SÉQUENCE D'OUVERTURE (31/08/2026) : un plafond n'est pas une cible qu'on
// approche, c'est une limite qu'on respecte. Cette réduction attendait le
// cooldown de courant — un compteur de confort — et ne descendait que d'un
// pas à la fois : de 28 A à 20 A elle prenait plusieurs cooldowns, pendant
// lesquels les onduleurs portaient un courant qu'ils n'ont plus le droit de
// porter (le plafond retombe à la disparition du réseau). Désormais :
// descente IMMÉDIATE et DIRECTE au plafond. Réduire est toujours sûr ; c'est
// la montée qui doit être temporisée, et elle l'est.
else if (derived.currentReferenceA > derived.physicalCapA) {
setOn({
stateName: 'charging_step_down_system_cap',
decisionName: 'step_down',
currentA: Math.max(
params.currentMinA,
Math.min(
derived.currentReferenceA - Math.max(params.currentStepDownA, 2),
derived.physicalCapA
)
),
reasonText: 'Réduction pour respecter la capacité système',
decisionName: 'step_down_fast',
currentA: Math.max(params.currentMinA, derived.physicalCapA),
reasonText: `Réduction immédiate au plafond système ${derived.physicalCapA}A`,
dynamic: true,
mustStopFlag: false,
});

View File

@@ -1,3 +1,10 @@
// Bornes de la consigne de puissance reseau. Le maximum est celui declare par
// l'entite elle-meme (number.airtria_mpii_v1_ac_power_setpoint_l1, max 10000 W),
// pas un chiffre choisi ici. La bande morte evite de reecrire la meme valeur
// toutes les 30 s sans empecher la correction d'une derive externe.
const GRID_SETPOINT_MAX_W = 10000;
const GRID_SETPOINT_DEADBAND_W = 100;
function round1(v) {
return Math.round(v * 10) / 10;
}
@@ -309,7 +316,77 @@ reason.push(`SOC=${round1(parsed.soc)}%`);
reason.push(`Batt=${round1(parsed.batteryCurrentA)}A`);
reason.push(`PV=${Math.round(parsed.pvPowerW)}W`);
reason.push(`PV10=${Math.round(parsed.pvPowerAvg10mW)}W`);
// =====================================================
// CONSIGNE DE PUISSANCE RESEAU (01/09/2026)
// =====================================================
// Fermer le contacteur ne suffit PAS. Au-dessus du SOC minimal, l'ESS en
// autoconsommation ne preleve que `ac_power_setpoint` et laisse la batterie
// porter tout le reste : nuit du 01/09, contacteur ferme, consigne 50 W,
// batterie a -803 W pour 761 W de maison, voiture arretee par le garde
// "batterie trop sollicitee". La consigne est donc le levier qui rend une
// session reseau REELLE, et il doit appartenir au controleur, pas rester un
// reglage manuel permanent (celui-ci provoquait la substitution du solaire
// par le reseau en journee, cf. HANDOFF 27/28).
//
// La valeur n'est pas une constante : c'est le besoin mesure du moment, la
// maison telle qu'elle consomme plus la voiture au courant que le controleur
// s'autorise. Le plafond joue le role de marge : quand la voiture n'est pas a
// son plafond, la consigne est deja genereuse, et l'exces ne peut pas partir
// dans la batterie (DVCC tenue a 2 A par l'Auto Charge Manager pendant un
// takeover). Hors session, retour a la valeur de repos.
const gridSessionActive =
parsed.acInAvailable &&
(context.sourceMode === 'grid_forced' || context.sourceMode === 'grid_hc_allowed');
const gridSetpointNeedW = gridSessionActive
? clamp(
Math.round(derived.houseNonEvcsW + derived.physicalCapA * derived.acVoltageRef),
params.gridSetpointIdleW,
GRID_SETPOINT_MAX_W
)
: params.gridSetpointIdleW;
// La bande est ASYMETRIQUE, et c'est le point. Une consigne trop BASSE fait
// porter la difference par la batterie : c'est le defaut de la nuit du 01/09,
// il se corrige immediatement. Une consigne trop HAUTE ne coute rien : l'ESS
// ne preleve que ce qu'il peut placer, et le courant de charge de la batterie
// est tenu par ailleurs (DVCC, Auto Charge Manager). Deux leviers, deux roles
// -- la consigne dit ce que le reseau a le DROIT de servir, DVCC dit ce que la
// batterie a le droit de prendre. Ne pas confondre les deux.
//
// On ecrit donc avec une marge d'un pas de courant EVCS (grandeur du systeme,
// pas une constante inventee) pour que le bruit de la maison ne rende pas la
// consigne insuffisante au cycle suivant, et on ne redescend que si elle est
// devenue franchement inutile. Sans cela la boucle 30 s reecrivait a chaque
// cycle en poursuivant le bruit.
const gridSetpointSlackW = gridSessionActive
? Math.max(GRID_SETPOINT_DEADBAND_W, Math.round(params.currentStepUpA * derived.acVoltageRef))
: 0;
const gridSetpointTargetW = clamp(
gridSetpointNeedW + gridSetpointSlackW,
0,
GRID_SETPOINT_MAX_W
);
const gridSetpointCurrentW = Number.isFinite(parsed.gridSetpointW)
? parsed.gridSetpointW
: null;
const gridSetpointNeedsWrite =
gridSetpointCurrentW === null ||
gridSetpointCurrentW < gridSetpointNeedW ||
gridSetpointCurrentW > gridSetpointNeedW + 2 * gridSetpointSlackW + GRID_SETPOINT_DEADBAND_W;
reason.push(`Grid=${parsed.gridAvailable ? 'on' : 'off'} (${Math.round(parsed.gridPowerW)}W)`);
// Trace de la fenetre d'etablissement : sans elle, une lecture du logbook
// pendant le transitoire donne l'impression que le systeme decide n'importe
// quoi. Il decide, au contraire, de ne PAS decider sur ces mesures.
if (derived.gridSettling) {
reason.push(`ETABLISSEMENT reseau ${Math.round(parsed.gridTransitionAgeSec)}s/${params.gridSettleSeconds}s (mesures PV/batterie non fiables)`);
}
if (gridSessionActive) {
reason.push(`Consigne reseau=${gridSetpointTargetW}W`);
}
reason.push(`Cap=${derived.physicalCapA}A`);
reason.push(`Ref=${round1(derived.currentReferenceA)}A`);
reason.push(`Target=${round1(desiredCurrentA)}A`);
@@ -995,6 +1072,16 @@ return [{
}
},
grid_power_setpoint: {
execute: gridSetpointNeedsWrite,
domain: 'number',
service: 'set_value',
data: {
entity_id: 'number.gx_device_ac_power_setpoint',
value: gridSetpointTargetW
}
},
evcs_auto_start_off: {
execute: autoStartAction === 'turn_off',
domain: 'switch',

View File

@@ -13,6 +13,13 @@
"grid_state": states("switch.edf_general_ok_switch"),
"victron_acin1_available": states("sensor.victron_ac_in1_available"),
"acin1_last_changed_ts": as_timestamp(states.sensor.victron_ac_in1_available.last_changed, 0) if states.sensor.victron_ac_in1_available is not none else 0,
"grid_switch_last_changed_ts": as_timestamp(states.switch.edf_general_ok_switch.last_changed, 0) if states.switch.edf_general_ok_switch is not none else 0,
"grid_settle_seconds": states("input_number.evcs_grid_settle_seconds"),
"grid_setpoint_w": states("number.gx_device_ac_power_setpoint"),
"grid_setpoint_idle_w": states("input_number.evcs_grid_setpoint_idle_w"),
"today_remaining_kwh": states("sensor.energy_production_today_remaining"),
"tomorrow_kwh": states("sensor.energy_production_tomorrow"),

View File

@@ -245,10 +245,32 @@ const parsed = {
gridIsOn: low(input.grid_state || 'off') === 'on',
acInAvailable: num(input.victron_acin1_available ?? input.ac_in1_available, 0) === 1,
// Consigne de puissance réseau de l'ESS, lue telle qu'elle est appliquée.
// C'est ELLE qui décide si un contacteur fermé sert à quelque chose : au-dessus
// du SOC minimal, l'ESS en autoconsommation ne prélève que cette puissance et
// laisse la batterie porter le reste. Nuit du 01/09 : contacteur fermé,
// consigne 50 W, batterie à -803 W pour 761 W de maison.
gridSetpointW: num(input.grid_setpoint_w, NaN),
gridAvailable:
(low(input.grid_state || 'off') === 'on') ||
(num(input.victron_acin1_available ?? input.ac_in1_available, 0) === 1),
// FENETRE D'ETABLISSEMENT (31/08/2026) — age, en secondes, de la derniere
// transition du reseau. On retient la plus RECENTE des deux sources : la
// presence physique AC-IN (celle qui declenche reellement le transitoire) et
// le contacteur (qui peut bouger sans qu'AC-IN suive, disjoncteur ouvert).
// Horodatage absent ou nul => age tres grand => aucune fenetre, comportement
// historique. On ne devine jamais une transition qu'on n'a pas observee.
gridTransitionAgeSec: (() => {
const ts = Math.max(
num(input.acin1_last_changed_ts, 0),
num(input.grid_switch_last_changed_ts, 0)
);
if (!(ts > 0)) return Number.POSITIVE_INFINITY;
return Math.max(0, (nowMs / 1000) - ts);
})(),
todayRemainingKwh: Math.max(0, num(input.today_remaining_kwh, 0)),
tomorrowKwh: Math.max(0, num(input.tomorrow_kwh, 0)),
@@ -398,6 +420,16 @@ const params = {
advisorStatusOk: cleanText(input.advisor_status).toLowerCase() === 'ok',
advisorLastRunMs: parseAdvisorRunToMs(cleanText(input.advisor_last_run)),
// Duree de la fenetre d'etablissement. Valeur mesuree le 31/08/2026 : le
// transitoire de fermeture dure ~150 s (import 1985 -> 25 W et 2600 -> 52 W,
// deux campagnes), on prend 180 s de marge. Reglable sans redeploiement via
// input_number.evcs_grid_settle_seconds ; absent => 180.
gridSettleSeconds: clamp(num(input.grid_settle_seconds, 180), 0, 900),
// Consigne réseau au repos. 50 W est le défaut Victron (légèrement positif
// pour ne jamais injecter), pas une valeur choisie ici.
gridSetpointIdleW: clamp(num(input.grid_setpoint_idle_w, 50), 0, 500),
probeEnabled: low(input.probe_enabled || 'off') === 'on',
probeMinutes: Math.max(1, num(input.probe_minutes, 3)),
probeRetryMinutes: Math.max(5, num(input.probe_retry_minutes, 45)),

View File

@@ -117,24 +117,27 @@ const vebusNonEvcsW = Math.max(0, parsed.vebusAcOutPowerW - parsed.evcsAcPowerW)
const totalVebusSoftCeilingW = Math.max(6500, params.acLoadsMaxSafeW || 8500);
const totalVebusHardCeilingW = totalVebusSoftCeilingW + 400;
// P5 : plafond EVCS configurable quand le réseau alimente réellement AC-IN
// (input_number.evcs_plafond_courant_edf_on). Hors réseau : plafond câblé prudent.
const evcsDedicatedCapBaseW = Math.max(0, cfg.evcsDedicatedMaxW - cfg.systemReserveW);
const safeEvcsByDedicatedCapW = parsed.acInAvailable
? Math.max(0, params.gridCurrentMaxA * acVoltageRef)
: evcsDedicatedCapBaseW;
const safeEvcsByVebusW = Math.max(0, totalVebusSoftCeilingW - vebusNonEvcsW - cfg.systemReserveW);
const safeEvcsCombinedW = Math.max(0, Math.min(safeEvcsByDedicatedCapW, safeEvcsByVebusW));
const systemCapCurrentA = Math.max(0, Math.floor(safeEvcsCombinedW / acVoltageRef));
const physicalCapA = clamp(
systemCapCurrentA,
0,
parsed.acInAvailable
? Math.max(params.currentMaxAutoA, params.gridCurrentMaxA)
: params.currentMaxAutoA
);
const canRunAtMinCurrent = physicalCapA >= params.currentMinA;
// =====================================================
// FENETRE D'ETABLISSEMENT DU RESEAU (31/08/2026)
// =====================================================
// Mesure du 31/08 : a la fermeture du contacteur, le reseau reprend d'un coup
// TOUTE la charge de la maison et le solaire bascule vers la batterie. Batterie
// preneuse, la production ne bouge pas ; batterie pleine, elle s'effondre (2663
// -> 559 W) faute de destination. Dans les deux cas l'import decroit ensuite
// jusqu'a la consigne en ~150 s (1985 -> 25 W, puis 2600 -> 52 W).
//
// Pendant cette phase, PV, import et courant batterie ne decrivent aucun regime
// permanent : la boucle de 30 s y prend cinq decisions, et la moyenne PV 10 min
// en garde la trace une dizaine de minutes de plus. Le systeme lit "plus de
// soleil", en conclut qu'il a besoin du reseau, et confirme ainsi sa propre
// premisse -- une boucle de rattrapage, comme les trois du 24/08.
//
// ASYMETRIE VOULUE : la fenetre ne s'ouvre qu'a l'ARRIVEE du reseau. A sa
// disparition, les onduleurs reprennent instantanement toute la charge : les
// gardes doivent agir plus vite, jamais moins vite.
const gridSettling =
parsed.gridAvailable &&
parsed.gridTransitionAgeSec < params.gridSettleSeconds;
const endDayGuardSoc = Math.max(params.endOfDaySocGuard, params.endOfDaySocTarget);
@@ -757,6 +760,50 @@ if (dayVehicleFullIdle && profile !== 'night_hc') {
sourceMode = 'blocked';
}
// =====================================================
// PLAFOND DE COURANT EVCS — calculé APRÈS sourceMode (31/08/2026)
// =====================================================
// P5 : plafond majoré (input_number.evcs_plafond_courant_edf_on, jusqu'à 28 A
// en BOOST) quand le réseau alimente réellement AC-IN ; sinon plafond câblé
// prudent, celui que les deux MultiPlus peuvent porter SEULS.
//
// SÉQUENCE D'OUVERTURE (31/08/2026) — pourquoi ce bloc a été déplacé ici.
// Le plafond majoré n'est légitime que tant que le réseau est là. Or c'est
// l'hystérésis (cycle 1 min) qui ouvre le contacteur, et le contrôleur EVCS ne
// l'apprenait qu'après coup : pendant ce temps les deux onduleurs portaient
// seuls la voiture au courant majoré plus la maison — le mécanisme du W-105.
//
// Correctif sans poignée de main entre les deux automates : le plafond majoré
// exige désormais que l'EVCS DEMANDE lui-même le réseau. Sa demande tombe au
// cycle où la fenêtre se ferme (30 s), alors que l'hystérésis ne peut ouvrir
// qu'ensuite (override disparu ET min_on écoulé, cycle 1 min). L'EVCS baisse
// donc AVANT, mécaniquement, sans rien coordonner.
// Le calcul doit venir après sourceMode ; rien entre les deux ne l'utilisait.
const evcsGridCeilingAllowed =
parsed.acInAvailable &&
(
sourceMode === 'grid_forced' ||
sourceMode === 'grid_hc_allowed' ||
forceGridRequested
);
const evcsDedicatedCapBaseW = Math.max(0, cfg.evcsDedicatedMaxW - cfg.systemReserveW);
const safeEvcsByDedicatedCapW = evcsGridCeilingAllowed
? Math.max(0, params.gridCurrentMaxA * acVoltageRef)
: evcsDedicatedCapBaseW;
const safeEvcsByVebusW = Math.max(0, totalVebusSoftCeilingW - vebusNonEvcsW - cfg.systemReserveW);
const safeEvcsCombinedW = Math.max(0, Math.min(safeEvcsByDedicatedCapW, safeEvcsByVebusW));
const systemCapCurrentA = Math.max(0, Math.floor(safeEvcsCombinedW / acVoltageRef));
const physicalCapA = clamp(
systemCapCurrentA,
0,
evcsGridCeilingAllowed
? Math.max(params.currentMaxAutoA, params.gridCurrentMaxA)
: params.currentMaxAutoA
);
const canRunAtMinCurrent = physicalCapA >= params.currentMinA;
const operatingMode = profile;
// =====================================================
@@ -805,6 +852,9 @@ return [{
systemCapCurrentA,
physicalCapA,
canRunAtMinCurrent,
evcsGridCeilingAllowed,
gridSettling,
gridTransitionAgeSec: parsed.gridTransitionAgeSec,
endDayGuardSoc,
energyNeededForEndDayGuardKwh,
forecastBufferKwh,

View File

@@ -116,6 +116,10 @@ function summarize(out) {
takeoverNeeded: !!c.night_grid_takeover_needed,
edfOnAction: !!calls.edf_general_ok_on?.execute,
takeoverHelperService: calls.helper_night_grid_takeover_needed?.service || null,
// Consigne de puissance reseau ecrite ce cycle (null = aucune ecriture).
gridSetpointW: calls.grid_power_setpoint?.execute
? calls.grid_power_setpoint.data.value
: null,
};
}

View File

@@ -14,6 +14,11 @@ const MIDDAY = '2026-08-08T13:00:00+02:00';
const AFTERNOON = '2026-08-08T15:30:00+02:00';
const EVENING = '2026-08-08T18:00:00+02:00';
// Fenêtre d'établissement du réseau (31/08/2026) : horodatage de la dernière
// transition d'AC-IN, en secondes epoch. Le harnais fige l'horloge sur `time`,
// donc on exprime l'âge de la transition par rapport à cet instant simulé.
const MORNING_TS = Date.parse(MORNING) / 1000;
const VEHICLE_NIGHT = {
evcs_vehicle_connected: 'on',
evcs_device_connected: 'on',
@@ -1019,4 +1024,374 @@ export const scenarios = [
takeoverHelperService: 'turn_off',
},
},
// ======= FENÊTRE D'ÉTABLISSEMENT DU RÉSEAU (31/08/2026) =======
// Mesuré ce jour-là : à la fermeture du contacteur, le réseau reprend toute la
// charge de la maison, le solaire bascule vers la batterie (ou se fait brider
// s'il n'y a plus de tampon : 2663 -> 559 W), puis l'import redescend à la
// consigne en ~150 s. Pendant ce temps la boucle de 30 s prend cinq décisions
// sur des mesures qui ne décrivent aucun régime permanent.
// Chaque cas est apparié à son contrefactuel hors fenêtre : sans cet appariement
// le test ne prouverait rien.
{
id: 'etablissement-maintien-charge',
label: 'Réseau fermé il y a 30 s, PV effondrée à 600 W et batterie à -15 A : la charge en cours NE DOIT PAS être arrêtée',
time: MORNING,
overrides: {
evcs_vehicle_connected: 'on', evcs_device_connected: 'on',
evcs_charging_switch: 'on', control_latch: 'on', charge_permission_now: 'on',
last_start_at: '2026-08-08 08:00:00',
soc_percent: '60',
evcs_current_a: '16', evcs_set_current_a: '16', evcs_command_current_a: '16',
evcs_ac_power_w: '3680',
house_ac_power_w: '4400', vebus_ac_out_l1_power_w: '4400',
pv_power_w: '600', pv_power_10m_avg_w: '3900', pv_trend_w: '-2000',
grid_state: 'on', victron_acin1_available: '1', grid_power_w: '1800',
battery_current_a: '-15',
acin1_last_changed_ts: String(MORNING_TS - 30),
grid_switch_last_changed_ts: String(MORNING_TS - 30),
},
expect: {
profile: "morning_solar",
sourceMode: "solar_only",
state: "charging_step_down_battery",
decision: "step_down",
desiredCharging: true,
currentA: 14,
takeoverNeeded: false,
edfOnAction: false,
takeoverHelperService: "turn_off",
},
},
{
id: 'etablissement-termine-arret-normal',
label: 'CONTREFACTUEL : mêmes mesures, transition vieille de 400 s → fenêtre fermée, la garde de maintien reprend la main et arrête',
time: MORNING,
overrides: {
evcs_vehicle_connected: 'on', evcs_device_connected: 'on',
evcs_charging_switch: 'on', control_latch: 'on', charge_permission_now: 'on',
last_start_at: '2026-08-08 08:00:00',
soc_percent: '60',
evcs_current_a: '16', evcs_set_current_a: '16', evcs_command_current_a: '16',
evcs_ac_power_w: '3680',
house_ac_power_w: '4400', vebus_ac_out_l1_power_w: '4400',
pv_power_w: '600', pv_power_10m_avg_w: '3900', pv_trend_w: '-2000',
grid_state: 'on', victron_acin1_available: '1', grid_power_w: '1800',
battery_current_a: '-15',
acin1_last_changed_ts: String(MORNING_TS - 400),
grid_switch_last_changed_ts: String(MORNING_TS - 400),
},
expect: {
profile: "morning_solar",
sourceMode: "solar_only",
state: "stop_conditions_lost",
decision: "stop",
desiredCharging: false,
currentA: 6,
takeoverNeeded: false,
edfOnAction: false,
takeoverHelperService: "turn_off",
},
},
{
id: 'etablissement-securite-non-suspendue',
label: "SÉCURITÉ : dans la fenêtre, batterie à -40 A → l'arrêt dur reste actif. La fenêtre suspend le confort, jamais le risque",
time: MORNING,
overrides: {
evcs_vehicle_connected: 'on', evcs_device_connected: 'on',
evcs_charging_switch: 'on', control_latch: 'on', charge_permission_now: 'on',
last_start_at: '2026-08-08 08:00:00',
soc_percent: '60',
evcs_current_a: '16', evcs_set_current_a: '16', evcs_command_current_a: '16',
evcs_ac_power_w: '3680',
house_ac_power_w: '4400', vebus_ac_out_l1_power_w: '4400',
pv_power_w: '600', pv_power_10m_avg_w: '3900', pv_trend_w: '-2000',
grid_state: 'on', victron_acin1_available: '1', grid_power_w: '1800',
battery_current_a: '-40',
acin1_last_changed_ts: String(MORNING_TS - 30),
grid_switch_last_changed_ts: String(MORNING_TS - 30),
},
expect: {
profile: "morning_solar",
sourceMode: "solar_only",
state: "battery_current_hard_stop",
decision: "stop_hard",
desiredCharging: false,
currentA: 6,
takeoverNeeded: false,
edfOnAction: false,
takeoverHelperService: "turn_off",
},
},
{
id: 'etablissement-ouverture-aucune-fenetre',
label: "ASYMÉTRIE : réseau DISPARU il y a 30 s → aucune fenêtre. Les onduleurs reprennent tout, les gardes doivent agir plus vite, pas moins",
time: MORNING,
overrides: {
evcs_vehicle_connected: 'on', evcs_device_connected: 'on',
evcs_charging_switch: 'on', control_latch: 'on', charge_permission_now: 'on',
last_start_at: '2026-08-08 08:00:00',
soc_percent: '60',
evcs_current_a: '16', evcs_set_current_a: '16', evcs_command_current_a: '16',
evcs_ac_power_w: '3680',
house_ac_power_w: '4400', vebus_ac_out_l1_power_w: '4400',
pv_power_w: '600', pv_power_10m_avg_w: '3900', pv_trend_w: '-2000',
grid_state: 'on', victron_acin1_available: '1', grid_power_w: '1800',
battery_current_a: '-15',
grid_state: 'off', victron_acin1_available: '0', grid_power_w: '0',
acin1_last_changed_ts: String(MORNING_TS - 30),
grid_switch_last_changed_ts: String(MORNING_TS - 30),
},
expect: {
profile: "morning_solar",
sourceMode: "solar_only",
state: "stop_conditions_lost",
decision: "stop",
desiredCharging: false,
currentA: 6,
takeoverNeeded: false,
edfOnAction: false,
takeoverHelperService: "turn_off",
},
},
{
id: 'etablissement-pas-de-demarrage',
label: "Réseau fermé il y a 30 s, rien en charge : on ne démarre pas non plus sur des mesures d'établissement",
time: MORNING,
overrides: {
evcs_vehicle_connected: 'on', evcs_device_connected: 'on',
soc_percent: '60', pv_power_w: '4500', pv_power_10m_avg_w: '4200',
pv_trend_w: '100', battery_current_a: '10',
house_ac_power_w: '900', vebus_ac_out_l1_power_w: '900',
grid_state: 'on', victron_acin1_available: '1', grid_power_w: '1800',
acin1_last_changed_ts: String(MORNING_TS - 30),
grid_switch_last_changed_ts: String(MORNING_TS - 30),
},
expect: {
profile: "morning_solar",
sourceMode: "solar_only",
state: "idle_wait_conditions",
decision: "hold",
desiredCharging: false,
currentA: 6,
takeoverNeeded: false,
edfOnAction: false,
takeoverHelperService: "turn_off",
},
},
{
id: 'etablissement-demarrage-hors-fenetre',
label: 'CONTREFACTUEL : mêmes mesures, transition vieille de 400 s → le démarrage solaire a bien lieu',
time: MORNING,
overrides: {
evcs_vehicle_connected: 'on', evcs_device_connected: 'on',
soc_percent: '60', pv_power_w: '4500', pv_power_10m_avg_w: '4200',
pv_trend_w: '100', battery_current_a: '10',
house_ac_power_w: '900', vebus_ac_out_l1_power_w: '900',
grid_state: 'on', victron_acin1_available: '1', grid_power_w: '1800',
acin1_last_changed_ts: String(MORNING_TS - 400),
grid_switch_last_changed_ts: String(MORNING_TS - 400),
},
expect: {
profile: "morning_solar",
sourceMode: "solar_only",
state: "start_min_current",
decision: "start",
desiredCharging: true,
currentA: 6,
takeoverNeeded: false,
edfOnAction: false,
takeoverHelperService: "turn_off",
},
},
// ======= SÉQUENCE D'OUVERTURE DU CONTACTEUR (31/08/2026) =======
// Le plafond majoré (28 A en BOOST) n'est légitime que tant que le réseau est
// là. C'est l'hystérésis qui ouvre, une minute après que la demande réseau de
// l'EVCS a disparu : l'EVCS doit donc baisser AVANT, de lui-même.
{
id: 'ouverture-plafond-tombe-hors-demande-reseau',
label: "Matin solaire, AC-IN encore présent mais l'EVCS ne demande plus le réseau : le plafond retombe à 20 A et la voiture est réduite IMMÉDIATEMENT",
time: MORNING,
overrides: {
evcs_vehicle_connected: 'on', evcs_device_connected: 'on',
evcs_charging_switch: 'on', control_latch: 'on', charge_permission_now: 'on',
last_start_at: '2026-08-08 08:00:00',
// BOOST EDF : plafond réseau 28 A, voiture effectivement à 28 A.
grid_current_max_a: '28', current_max_auto_a: '22',
evcs_current_a: '28', evcs_set_current_a: '28', evcs_command_current_a: '28',
evcs_ac_power_w: '6440',
house_ac_power_w: '8440', vebus_ac_out_l1_power_w: '8440',
grid_state: 'on', victron_acin1_available: '1', grid_power_w: '2000',
soc_percent: '60', pv_power_w: '4500', pv_power_10m_avg_w: '4200',
battery_current_a: '10',
// Cooldown de courant NON écoulé : la réduction ne doit pas l'attendre.
last_current_change_at: '2026-08-08 08:59:50',
acin1_last_changed_ts: String(MORNING_TS - 4000),
grid_switch_last_changed_ts: String(MORNING_TS - 4000),
},
expect: {
profile: "morning_solar",
sourceMode: "solar_only",
state: "charging_step_down_system_cap",
decision: "step_down_fast",
desiredCharging: true,
currentA: 20,
takeoverNeeded: false,
edfOnAction: false,
takeoverHelperService: "turn_off",
},
},
{
id: 'ouverture-plafond-maintenu-en-demande-reseau',
label: "CONTREFACTUEL : force_grid_now, l'EVCS demande le réseau → le plafond BOOST 28 A est conservé, aucune réduction",
time: MORNING,
overrides: {
evcs_vehicle_connected: 'on', evcs_device_connected: 'on',
evcs_charging_switch: 'on', control_latch: 'on', charge_permission_now: 'on',
last_start_at: '2026-08-08 08:00:00',
// BOOST EDF : plafond réseau 28 A, voiture effectivement à 28 A.
grid_current_max_a: '28', current_max_auto_a: '22',
evcs_current_a: '28', evcs_set_current_a: '28', evcs_command_current_a: '28',
evcs_ac_power_w: '6440',
house_ac_power_w: '8440', vebus_ac_out_l1_power_w: '8440',
grid_state: 'on', victron_acin1_available: '1', grid_power_w: '2000',
victron_evcs_strategy: 'force_grid_now',
soc_percent: '60', pv_power_w: '4500', pv_power_10m_avg_w: '4200',
battery_current_a: '10',
last_current_change_at: '2026-08-08 08:59:50',
acin1_last_changed_ts: String(MORNING_TS - 4000),
grid_switch_last_changed_ts: String(MORNING_TS - 4000),
},
expect: {
profile: "morning_solar",
sourceMode: "grid_forced",
state: "charging_hold_grid",
decision: "hold",
desiredCharging: true,
currentA: 28,
takeoverNeeded: false,
edfOnAction: false,
takeoverHelperService: "turn_off",
},
},
{
id: 'ouverture-reseau-disparu-reduction-immediate',
label: 'Réseau DISPARU : plafond câblé 20 A, voiture encore à 28 A, cooldown non écoulé → réduction immédiate, sans attendre le compteur de confort',
time: MORNING,
overrides: {
evcs_vehicle_connected: 'on', evcs_device_connected: 'on',
evcs_charging_switch: 'on', control_latch: 'on', charge_permission_now: 'on',
last_start_at: '2026-08-08 08:00:00',
// BOOST EDF : plafond réseau 28 A, voiture effectivement à 28 A.
grid_current_max_a: '28', current_max_auto_a: '22',
evcs_current_a: '28', evcs_set_current_a: '28', evcs_command_current_a: '28',
evcs_ac_power_w: '6440',
house_ac_power_w: '8440', vebus_ac_out_l1_power_w: '8440',
grid_state: 'on', victron_acin1_available: '1', grid_power_w: '2000',
grid_state: 'off', victron_acin1_available: '0', grid_power_w: '0',
soc_percent: '60', pv_power_w: '4500', pv_power_10m_avg_w: '4200',
battery_current_a: '10',
last_current_change_at: '2026-08-08 08:59:50',
acin1_last_changed_ts: String(MORNING_TS - 20),
grid_switch_last_changed_ts: String(MORNING_TS - 20),
},
expect: {
profile: "morning_solar",
sourceMode: "solar_only",
state: "charging_step_down_system_cap",
decision: "step_down_fast",
desiredCharging: true,
currentA: 20,
takeoverNeeded: false,
edfOnAction: false,
takeoverHelperService: "turn_off",
},
},
// ===================================================================
// CONSIGNE DE PUISSANCE RESEAU (01/09/2026)
// Fermer le contacteur ne suffit pas : au-dessus du SOC minimal, l'ESS en
// autoconsommation ne preleve que `ac_power_setpoint` et laisse la batterie
// porter maison + voiture. Incident de la nuit du 01/09 : consigne a 50 W,
// charge HC demarree puis arretee en 60 s par le garde batterie.
// ===================================================================
{
id: 'consigne-session-hc-reseau-porte-la-charge',
label: 'Nuit HC, reseau present, session reseau : la consigne monte au besoin mesure (maison hors EVCS + voiture au plafond)',
time: NIGHT,
overrides: {
...VEHICLE_NIGHT,
soc_percent: '50',
allow_grid_hc_night: 'on',
grid_state: 'on',
victron_acin1_available: '1',
grid_setpoint_w: '50',
},
expect: {
sourceMode: 'grid_hc_allowed',
gridSetpointW: 5525,
},
},
{
id: 'consigne-hors-session-retour-au-repos',
label: 'CONTREFACTUEL — meme nuit sans session reseau : la consigne redescend a la valeur de repos',
time: NIGHT,
overrides: {
...VEHICLE_NIGHT,
soc_percent: '50',
allow_grid_hc_night: 'off',
grid_state: 'off',
victron_acin1_available: '0',
grid_setpoint_w: '4000',
},
expect: {
gridSetpointW: 50,
},
},
{
id: 'consigne-deja-genereuse-aucune-ecriture',
label: 'Session reseau, consigne a 5600 W pour un besoin de 5295 W : deja suffisante, aucune reecriture (le bruit de la maison ne la fait pas courir)',
time: NIGHT,
overrides: {
...VEHICLE_NIGHT,
soc_percent: '50',
allow_grid_hc_night: 'on',
grid_state: 'on',
victron_acin1_available: '1',
grid_setpoint_w: '5600',
},
expect: {
gridSetpointW: null,
},
},
{
id: 'consigne-insuffisante-corrigee-immediatement',
label: "LE DEFAUT DE LA NUIT DU 01/09 — consigne 45 W sous le besoin : la batterie porterait la difference, correction sans attendre",
time: NIGHT,
overrides: {
...VEHICLE_NIGHT,
soc_percent: '50',
allow_grid_hc_night: 'on',
grid_state: 'on',
victron_acin1_available: '1',
grid_setpoint_w: '5250',
},
expect: {
sourceMode: 'grid_hc_allowed',
gridSetpointW: 5525,
},
},
{
id: 'consigne-derive-externe-corrigee',
label: 'Hors session mais consigne laissee a 4000 W par un tiers : corrigee au cycle suivant',
time: MORNING,
overrides: {
soc_percent: '60',
grid_state: 'off',
victron_acin1_available: '0',
grid_setpoint_w: '4000',
},
expect: {
gridSetpointW: 50,
},
},
];

View File

@@ -0,0 +1,119 @@
#!/usr/bin/env node
// Resume par phase d'un journal produit par tools/test-edf-alterne.mjs.
// Usage: node tools/analyse-test-alterne.mjs docs/mesures/<fichier>.log [secondesIgnorees]
//
// Les N premieres secondes de chaque phase sont ignorees par defaut (90 s) :
// la bascule du contacteur met ~30 a 90 s a s'etablir, et inclure le transitoire
// ecraserait l'ecart qu'on cherche a mesurer.
import fs from 'node:fs';
const file = process.argv[2];
const SKIP = Number(process.argv[3] ?? 90);
const lines = fs.readFileSync(file, 'utf8').split('\n');
const rows = [];
for (const l of lines) {
const m = l.match(
// Le bloc des modes MPPT est le DERNIER champ : on l'ancre sur "edf on|off",
// jamais sur le mot MPPT. Ancrer sur "MPPT " faisait tomber en silence toute
// ligne dont le PREMIER tracker etait bride -- c'est-a-dire precisement les
// echantillons a mesurer -- et biaisait le resultat vers "aucun bridage".
/^(\d{2}:\d{2}:\d{2}) \| (\w+)\s*\| PV\s+(-?\d+) \| U\s+([\d.]+) \| I\s+(-?[\d.]+) \| conso\s+(-?\d+) \| grid\s+(-?\d+) \| SOC\s+(\d+).*\| edf (?:on|off) \| (.*)$/
);
if (!m) continue;
const [, t, phase, pv, u, i, conso, grid, soc, modes] = m;
rows.push({
t, phase,
pv: +pv, u: +u, i: +i, conso: +conso, grid: +grid, soc: +soc,
lim: modes.trim().split(/\s+/).filter((m) => m !== 'MPPT').length,
sec: (+t.slice(0, 2)) * 3600 + (+t.slice(3, 5)) * 60 + (+t.slice(6, 8)),
});
}
const order = [];
const byPhase = new Map();
for (const r of rows) {
if (!byPhase.has(r.phase)) { byPhase.set(r.phase, []); order.push(r.phase); }
byPhase.get(r.phase).push(r);
}
const avg = (a, k) => (a.length ? a.reduce((s, x) => s + x[k], 0) / a.length : NaN);
const n = (x, d = 0) => (Number.isFinite(x) ? x.toFixed(d) : '—');
console.log(`Journal : ${file} (${rows.length} echantillons, ${SKIP}s de transitoire ignorees par phase)\n`);
console.log('phase n | PV moy | PV max | conso | grid | I bat | SOC | trackers brides');
console.log('---------------------------------------------------------------------------------');
const summary = [];
for (const p of order) {
const all = byPhase.get(p);
const t0 = all[0].sec;
const a = all.filter((r) => r.sec - t0 >= SKIP);
const use = a.length ? a : all;
const s = {
phase: p, n: use.length,
pv: avg(use, 'pv'), pvmax: Math.max(...use.map((r) => r.pv)),
conso: avg(use, 'conso'), grid: avg(use, 'grid'), i: avg(use, 'i'),
soc: avg(use, 'soc'), lim: avg(use, 'lim'),
};
summary.push(s);
console.log(
`${p.padEnd(8)}${String(s.n).padStart(3)} |${n(s.pv).padStart(9)} |${n(s.pvmax).padStart(8)} |` +
`${n(s.conso).padStart(8)} |${n(s.grid).padStart(7)} |${n(s.i, 1).padStart(7)} |${n(s.soc, 1).padStart(5)} |` +
`${n(s.lim, 1).padStart(9)} / 7`
);
}
const off = summary.filter((s) => !s.phase.startsWith('ON'));
const on = summary.filter((s) => s.phase.startsWith('ON'));
// --- garde meteo -------------------------------------------------------------
// Les phases OUVERTES sont toutes censees voir la meme ressource solaire. Si
// elles ne s'accordent pas entre elles, c'est le ciel qui a change pendant la
// campagne : la moyenne ON contre moyenne OFF devient ininterpretable, et il ne
// faut surtout pas la presenter comme un resultat (piege rencontre le 27/08 au
// premier test, et de nouveau le 31/08).
const pvOffs = off.map((s) => s.pv);
const spread = Math.max(...pvOffs) - Math.min(...pvOffs);
const spreadPct = (spread / (pvOffs.reduce((a, b) => a + b, 0) / pvOffs.length)) * 100;
console.log('\n--- STABILITE DE LA RESSOURCE ---');
console.log(`PV moyenne des phases OUVERTES : ${off.map((s) => `${s.phase} ${n(s.pv)} W`).join(' | ')}`);
console.log(`Ecart entre phases ouvertes : ${n(spread)} W (${n(spreadPct, 1)} % de la moyenne)`);
const meteoStable = spreadPct < 15;
console.log(meteoStable
? '=> ressource stable : la comparaison globale ON/OFF est exploitable.'
: '=> RESSOURCE INSTABLE (nuages) : la comparaison globale ON/OFF n est PAS exploitable.\n Se fier uniquement aux transitions ci-dessous, phase par phase.');
// --- comparaison locale : chaque ON contre ses voisines OFF -------------------
console.log('\n--- TRANSITIONS (chaque phase fermee contre ses voisines ouvertes) ---');
for (const s of on) {
const idx = order.indexOf(s.phase);
const voisins = summary.filter((x) => Math.abs(order.indexOf(x.phase) - idx) === 1 && !x.phase.startsWith('ON'));
if (!voisins.length) continue;
const ref = voisins.reduce((a, x) => a + x.pv, 0) / voisins.length;
const d = s.pv - ref;
const pct = (d / ref) * 100;
console.log(
`${s.phase} : PV ${n(s.pv)} W contre ${n(ref)} W chez ${voisins.map((v) => v.phase).join('+')}` +
` => ${d >= 0 ? '+' : ''}${n(d)} W (${pct >= 0 ? '+' : ''}${n(pct, 1)} %)` +
` | import ${n(s.grid)} W | trackers brides ${n(s.lim, 1)}/7`
);
}
console.log('\n--- VERDICT ---');
if (!meteoStable) {
console.log('Campagne partiellement inexploitable (ressource instable).');
console.log('Verifier a la main, sur le journal brut, si la chute de PV precede ou suit');
console.log('l apparition de l import reseau : si elle la PRECEDE, ce sont des nuages.');
} else {
const pvOff = pvOffs.reduce((a, b) => a + b, 0) / pvOffs.length;
const pvOn = on.reduce((a, s) => a + s.pv, 0) / on.length;
const gOn = on.reduce((a, s) => a + s.grid, 0) / on.length;
const pct = ((pvOn - pvOff) / pvOff) * 100;
console.log(`PV ouverte ${n(pvOff)} W | PV fermee ${n(pvOn)} W | ecart ${pct >= 0 ? '+' : ''}${n(pct, 1)} %`);
console.log(Math.abs(pct) < 10
? '=> PAS DE SUBSTITUTION : la production ne depend pas de la presence du reseau.'
: '=> SUBSTITUTION : la production chute quand le reseau est la.');
console.log(`Import moyen en phase fermee : ${n(gOn)} W (consigne ESS ~50 W attendue si le solaire couvre tout).`);
}

View File

@@ -0,0 +1,228 @@
#!/usr/bin/env node
// Étude de la frontière « excédent » pour la charge du véhicule.
//
// Question posée par le propriétaire (31/08/2026) : la voiture doit être un
// délestage de l'excédent PV, pas une charge prioritaire qui vide les
// batteries. La frontière se détecte à la PUISSANCE DE DÉCHARGE BATTERIE,
// pas à la production PV ni à un seuil de SOC.
//
// Ce script ne décide rien et n'écrit rien. Il intègre l'historique HA
// (résolution native ~2 s) et répartit l'énergie réellement livrée au
// véhicule entre trois origines physiques, instant par instant :
//
// batterie : min(P_evcs, décharge batterie) -> réserve entamée
// réseau : min(reste, import réseau) -> énergie payante
// excédent : le solde -> production qui aurait
// été refusée ou stockée
//
// Usage: node tools/etude-frontiere-excedent.mjs [debut] [fin]
// node tools/etude-frontiere-excedent.mjs 2026-08-24 2026-09-01
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const HA_URL = process.env.HA_URL || 'http://10.10.10.17:8123';
function loadToken() {
if (process.env.HA_TOKEN) return process.env.HA_TOKEN;
const envPath = path.join(ROOT, '.env');
for (const line of fs.readFileSync(envPath, 'utf8').split('\n')) {
const m = line.match(/^\s*HA_TOKEN\s*=\s*(.+?)\s*$/);
if (m) return m[1].replace(/^["']|["']$/g, '');
}
console.error('HA_TOKEN absent de .env');
process.exit(1);
}
const TOKEN = loadToken();
const ENT = {
evcs: 'sensor.victron_evcs_ac_power',
batt: 'sensor.victron_battery_power',
pv: 'sensor.victron_pv_power_system',
conso:'sensor.victron_consumption_power_l1',
grid: 'sensor.victron_grid_power_l1',
soc: 'sensor.victron_battery_soc',
};
const KEYS = Object.keys(ENT);
async function fetchDay(dayStart, dayEnd) {
const url =
`${HA_URL}/api/history/period/${dayStart.toISOString()}` +
`?end_time=${encodeURIComponent(dayEnd.toISOString())}` +
`&filter_entity_id=${encodeURIComponent(Object.values(ENT).join(','))}` +
`&minimal_response`;
const res = await fetch(url, { headers: { Authorization: `Bearer ${TOKEN}` } });
if (!res.ok) throw new Error(`HTTP ${res.status} sur ${dayStart.toISOString()}`);
return res.json();
}
// Fusionne les séries en un flux d'événements trié.
function toEvents(series) {
const byEntity = new Map();
for (const rows of series) {
if (!rows.length) continue;
byEntity.set(rows[0].entity_id, rows);
}
const ev = [];
for (const [key, id] of Object.entries(ENT)) {
for (const r of byEntity.get(id) || []) {
const v = Number(r.state);
if (!Number.isFinite(v)) continue;
ev.push({ t: Date.parse(r.last_changed || r.last_updated), key, v });
}
}
ev.sort((a, b) => a.t - b.t);
return ev;
}
const MAX_GAP_MS = 300e3; // au-delà, on considère une lacune et on n'intègre pas
const HOURS = 24;
function blankDay() {
return {
evcsKwh: 0, fromBattKwh: 0, fromGridKwh: 0, fromSurplusKwh: 0,
pvKwh: 0, consoKwh: 0, gridImportKwh: 0, battDischargeKwh: 0,
gapSec: 0,
hour: Array.from({ length: HOURS }, () => ({
evcsKwh: 0, fromBattKwh: 0, fromGridKwh: 0, fromSurplusKwh: 0,
})),
socMin: null, socMax: null,
lastSurplusMs: null, // dernier instant ou la voiture chargeait SANS decharger la batterie
firstSurplusMs: null,
};
}
const args = process.argv.slice(2);
const endArg = args[1] ? new Date(args[1]) : new Date();
const startArg = args[0] ? new Date(args[0]) : new Date(endArg.getTime() - 8 * 864e5);
const days = new Map(); // 'YYYY-MM-DD' (Paris) -> agrégats
const parisParts = (ms) => {
const d = new Date(ms);
const f = new Intl.DateTimeFormat('fr-CA', {
timeZone: 'Europe/Paris', year: 'numeric', month: '2-digit',
day: '2-digit', hour: '2-digit', hour12: false,
}).formatToParts(d);
const g = (t) => f.find((p) => p.type === t).value;
return { day: `${g('year')}-${g('month')}-${g('day')}`, hour: Number(g('hour')) % 24 };
};
let cursor = new Date(startArg);
const cur = {}; // dernières valeurs connues
let lastT = null;
while (cursor < endArg) {
const chunkEnd = new Date(Math.min(cursor.getTime() + 864e5, endArg.getTime()));
process.stderr.write(` lecture ${cursor.toISOString().slice(0, 10)}...\n`);
const ev = toEvents(await fetchDay(cursor, chunkEnd));
for (const e of ev) {
if (lastT !== null) {
const dtMs = e.t - lastT;
if (dtMs > 0) {
const { day, hour } = parisParts(lastT);
if (!days.has(day)) days.set(day, blankDay());
const D = days.get(day);
if (dtMs > MAX_GAP_MS) {
D.gapSec += dtMs / 1000;
} else if (KEYS.every((k) => cur[k] !== undefined)) {
const h = dtMs / 3.6e6; // heures
const evcs = Math.max(0, cur.evcs);
const battDis = Math.max(0, -cur.batt); // W déchargés
const gridImp = Math.max(0, cur.grid); // W importés
const fromBatt = Math.min(evcs, battDis);
const fromGrid = Math.min(evcs - fromBatt, gridImp);
const fromSurp = Math.max(0, evcs - fromBatt - fromGrid);
D.evcsKwh += evcs * h / 1000;
D.fromBattKwh += fromBatt * h / 1000;
D.fromGridKwh += fromGrid * h / 1000;
D.fromSurplusKwh += fromSurp * h / 1000;
D.pvKwh += Math.max(0, cur.pv) * h / 1000;
D.consoKwh += Math.max(0, cur.conso) * h / 1000;
D.gridImportKwh += gridImp * h / 1000;
D.battDischargeKwh += battDis * h / 1000;
if (evcs > 200 && fromSurp > 0.6 * evcs) {
if (D.firstSurplusMs === null) D.firstSurplusMs = lastT;
D.lastSurplusMs = lastT;
}
const H = D.hour[hour];
H.evcsKwh += evcs * h / 1000;
H.fromBattKwh += fromBatt * h / 1000;
H.fromGridKwh += fromGrid * h / 1000;
H.fromSurplusKwh += fromSurp * h / 1000;
}
if (cur.soc !== undefined) {
D.socMin = D.socMin === null ? cur.soc : Math.min(D.socMin, cur.soc);
D.socMax = D.socMax === null ? cur.soc : Math.max(D.socMax, cur.soc);
}
}
}
cur[e.key] = e.v;
lastT = e.t;
}
cursor = chunkEnd;
}
const r1 = (x) => x.toFixed(1);
const r2 = (x) => x.toFixed(2);
console.log('\n=== ORIGINE DE L\'ENERGIE LIVREE AU VEHICULE (kWh/jour) ===\n');
console.log('jour EVCS excedent batterie reseau | PV conso import SOC');
let tot = { e: 0, s: 0, b: 0, g: 0 };
const sorted = [...days.keys()].sort();
for (const d of sorted) {
const D = days.get(d);
if (D.gapSec > 3600) console.log(` (lacune historique ${Math.round(D.gapSec / 60)} min le ${d})`);
tot.e += D.evcsKwh; tot.s += D.fromSurplusKwh; tot.b += D.fromBattKwh; tot.g += D.fromGridKwh;
console.log(
`${d} ${r2(D.evcsKwh).padStart(6)} ${r2(D.fromSurplusKwh).padStart(7)} ` +
`${r2(D.fromBattKwh).padStart(7)} ${r2(D.fromGridKwh).padStart(7)} | ` +
`${r1(D.pvKwh).padStart(5)} ${r1(D.consoKwh).padStart(6)} ${r1(D.gridImportKwh).padStart(6)} ` +
`${D.socMin ?? '?'}-${D.socMax ?? '?'}`
);
}
console.log(`\nTOTAL ${r2(tot.e).padStart(6)} ${r2(tot.s).padStart(7)} ${r2(tot.b).padStart(7)} ${r2(tot.g).padStart(7)}`);
if (tot.e > 0) {
console.log(`part 100% ${(100 * tot.s / tot.e).toFixed(0).padStart(6)}% ` +
`${(100 * tot.b / tot.e).toFixed(0).padStart(6)}% ${(100 * tot.g / tot.e).toFixed(0).padStart(6)}%`);
}
const hhmm = (ms) => ms === null ? ' -- ' : new Date(ms).toLocaleTimeString('fr-FR',
{ timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit' });
console.log('\n=== FRONTIERE DE L\'EXCEDENT, JOUR PAR JOUR ===\n');
console.log('jour ouverture fermeture SOC max part excedent 16h 17h 18h 19h 20h');
for (const d of sorted) {
const D = days.get(d);
const sh = (h) => {
const H = D.hour[h];
return H.evcsKwh < 0.05 ? ' . ' : `${(100 * H.fromSurplusKwh / H.evcsKwh).toFixed(0).padStart(3)}%`;
};
console.log(
`${d} ${hhmm(D.firstSurplusMs)} ${hhmm(D.lastSurplusMs)} ` +
`${String(D.socMax ?? '?').padStart(3)}% ` +
`${sh(16)} ${sh(17)} ${sh(18)} ${sh(19)} ${sh(20)}`
);
}
console.log('\n=== PAR HEURE DE LA JOURNEE, cumul sur la periode (kWh) ===\n');
console.log('h EVCS excedent batterie reseau part excedent');
for (let h = 0; h < HOURS; h++) {
let e = 0, s = 0, b = 0, g = 0;
for (const d of sorted) {
const H = days.get(d).hour[h];
e += H.evcsKwh; s += H.fromSurplusKwh; b += H.fromBattKwh; g += H.fromGridKwh;
}
if (e < 0.01) continue;
console.log(
`${String(h).padStart(2)}h ${r2(e).padStart(7)} ${r2(s).padStart(7)} ${r2(b).padStart(7)} ` +
`${r2(g).padStart(7)} ${(100 * s / e).toFixed(0).padStart(3)}%`
);
}

134
tools/ha-backup-config.mjs Normal file
View File

@@ -0,0 +1,134 @@
#!/usr/bin/env node
/**
* Configure la sauvegarde automatique de Home Assistant (installation Core /
* Container, HA 2026.8.1 — pas de Supervisor ici).
*
* POURQUOI CE SCRIPT PLUTÔT QUE L'INTERFACE
* La configuration de sauvegarde n'est PAS exposée par l'API REST : elle vit
* dans `.storage/backup` et ne se règle que par l'interface ou par le
* websocket. Passer par le websocket la rend reproductible, versionnée, et
* relisible dans six mois — ce qu'un clic dans l'interface n'est pas.
*
* CE QUI EST RÉGLÉ, ET POURQUOI
* - Destinations : les agents Synology **ARCHIVE2 (10.10.10.22)** et
* **ARCHIVE (10.10.10.50)**, plus une copie locale. Le NAS principal
* (ARCHIVE3, 10.10.10.17) est délibérément EXCLU comme destination : une
* sauvegarde qui ne survit pas à la panne qu'elle couvre ne sert à rien.
* - Base de données EXCLUE. Ce qu'il faut pouvoir restaurer vite, c'est
* l'identité du système (entités, helpers, cartes, paquets, intégrations) :
* environ 60 Mo. L'historique, lui, pèse 4,4 Go, change en permanence, et
* n'a aucune valeur d'urgence — il se sauvegarde séparément, plus rarement,
* et avec les précautions SQLite (mode WAL : jamais un `cp` à chaud).
* - Rétention 7 copies : une semaine pour s'apercevoir d'une bêtise.
* - Pas de mot de passe. Choix assumé : ces archives restent sur le réseau
* local du propriétaire, et une sauvegarde restaurable vaut mieux qu'une
* sauvegarde chiffrée dont la clé est perdue le jour où elle sert.
*
* Usage :
* node tools/ha-backup-config.mjs --dry-run (affiche avant/après, n'écrit rien)
* node tools/ha-backup-config.mjs (applique)
* node tools/ha-backup-config.mjs --run-now (applique puis déclenche une sauvegarde)
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const HA_URL = process.env.HA_URL || 'http://10.10.10.17:8123';
const ARGS = new Set(process.argv.slice(2));
const DRY = ARGS.has('--dry-run');
const RUN_NOW = ARGS.has('--run-now');
function loadToken() {
if (process.env.HA_TOKEN) return process.env.HA_TOKEN;
const m = fs.readFileSync(path.join(ROOT, '.env'), 'utf8').match(/^\s*HA_TOKEN\s*=\s*(.+?)\s*$/m);
if (!m) { console.error('HA_TOKEN absent de .env'); process.exit(1); }
return m[1].replace(/^["']|["']$/g, '');
}
// Agents de destination. Identifiants relevés via `backup/agents/info` et
// croisés avec les entrées `synology_dsm` de `.storage/core.config_entries`
// pour connaître l'hôte réel de chacun — le nom seul ne dit pas où il écrit.
const AGENTS = {
local: 'backup.local', // 10.10.10.17, restauration rapide
archive2: 'synology_dsm.1660NZN396007', // 10.10.10.22 — second Synology
archive: 'synology_dsm.14B0MPN914514', // 10.10.10.50 — troisième machine
// 'synology_dsm.20A0SBRVM9PF2' = ARCHIVE3 = 10.10.10.17 : NE PAS UTILISER
// comme destination, c'est la machine dont on se protège.
};
const DESIRED = {
create_backup: {
agent_ids: [AGENTS.local, AGENTS.archive2, AGENTS.archive],
include_database: false,
include_folders: null,
include_all_addons: false,
include_addons: null,
name: null,
password: null,
},
retention: { copies: 7, days: null },
schedule: { recurrence: 'daily', time: '04:45:00', days: [] },
};
const token = loadToken();
const ws = new WebSocket(HA_URL.replace(/^http/, 'ws') + '/api/websocket');
let seq = 1;
const pending = new Map();
function call(msg) {
return new Promise((resolve, reject) => {
const id = seq++;
pending.set(id, { resolve, reject });
ws.send(JSON.stringify({ id, ...msg }));
});
}
ws.onmessage = async (ev) => {
const m = JSON.parse(ev.data);
if (m.type === 'auth_required') return ws.send(JSON.stringify({ type: 'auth', access_token: token }));
if (m.type === 'auth_invalid') { console.error('jeton HA refusé'); process.exit(1); }
if (m.type === 'auth_ok') return main().catch(e => { console.error(e.message); process.exit(1); });
if (m.type === 'result') {
const p = pending.get(m.id); pending.delete(m.id);
if (!p) return;
m.success ? p.resolve(m.result) : p.reject(new Error(JSON.stringify(m.error)));
}
};
ws.onerror = () => { console.error(`websocket injoignable sur ${HA_URL}`); process.exit(1); };
async function main() {
const before = (await call({ type: 'backup/config/info' })).config;
console.log('=== AVANT ===');
console.log(` configurée : ${before.automatic_backups_configured}`);
console.log(` récurrence : ${before.schedule.recurrence} à ${before.schedule.time ?? '—'}`);
console.log(` destinations : ${before.create_backup.agent_ids.join(', ') || '— aucune —'}`);
console.log(` base incluse : ${before.create_backup.include_database}`);
console.log(` rétention : ${before.retention.copies ?? '—'} copies`);
console.log('\n=== APRÈS (souhaité) ===');
console.log(` récurrence : ${DESIRED.schedule.recurrence} à ${DESIRED.schedule.time}`);
console.log(' destinations :');
console.log(` ${AGENTS.local} → 10.10.10.17 (copie locale, restauration rapide)`);
console.log(` ${AGENTS.archive2} → 10.10.10.22 (second Synology)`);
console.log(` ${AGENTS.archive} → 10.10.10.50 (troisième machine)`);
console.log(` base incluse : ${DESIRED.create_backup.include_database} (les 4,4 Go d'historique se sauvegardent à part)`);
console.log(` rétention : ${DESIRED.retention.copies} copies`);
if (DRY) { console.log('\n[DRY-RUN] aucune écriture.'); ws.close(); process.exit(0); }
await call({ type: 'backup/config/update', ...DESIRED });
const after = (await call({ type: 'backup/config/info' })).config;
console.log(`\n✅ appliqué — configurée=${after.automatic_backups_configured}, ` +
`prochaine=${after.next_automatic_backup ?? '?'}`);
if (RUN_NOW) {
console.log('\nDéclenchement d\'une sauvegarde de vérification…');
await call({ type: 'backup/generate_with_automatic_settings' });
console.log(' demandée. Suivre avec: node tools/ha-backup-list.mjs');
}
ws.close(); process.exit(0);
}
setTimeout(() => { console.error('délai dépassé'); process.exit(1); }, 60000);

48
tools/ha-backup-list.mjs Normal file
View File

@@ -0,0 +1,48 @@
#!/usr/bin/env node
// Liste les sauvegardes Home Assistant et, pour chacune, les destinations où
// elle est RÉELLEMENT présente. C'est cette dernière colonne qui compte : une
// sauvegarde qui n'existe que sur la machine dont on se protège ne protège rien.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const HA_URL = process.env.HA_URL || 'http://10.10.10.17:8123';
const token = process.env.HA_TOKEN ||
fs.readFileSync(path.join(ROOT, '.env'), 'utf8').match(/^\s*HA_TOKEN\s*=\s*(.+?)\s*$/m)[1].replace(/^["']|["']$/g, '');
const HOST = {
'backup.local': '10.10.10.17 (local)',
'synology_dsm.1660NZN396007': '10.10.10.22',
'synology_dsm.14B0MPN914514': '10.10.10.50',
'synology_dsm.20A0SBRVM9PF2': '10.10.10.17',
};
const ws = new WebSocket(HA_URL.replace(/^http/, 'ws') + '/api/websocket');
ws.onmessage = (ev) => {
const m = JSON.parse(ev.data);
if (m.type === 'auth_required') return ws.send(JSON.stringify({ type: 'auth', access_token: token }));
if (m.type === 'auth_ok') return ws.send(JSON.stringify({ id: 1, type: 'backup/info' }));
if (m.type === 'result') {
const r = m.result || {};
if (r.backups?.length) {
for (const b of r.backups) {
const mo = (b.agents ? Object.entries(b.agents) : []);
console.log(`\n${b.name} (${b.backup_id})`);
console.log(` créée : ${new Date(b.date).toLocaleString('fr-FR', { timeZone: 'Europe/Paris' })}`);
console.log(` base incluse : ${b.database_included} automatique : ${b.with_automatic_settings}`);
for (const [agent, info] of mo) {
const mb = info.size ? (info.size / 1048576).toFixed(1) + ' Mo' : '?';
console.log(` → ${(HOST[agent] || agent).padEnd(22)} ${mb.padStart(9)}`);
}
}
} else {
console.log('Aucune sauvegarde.');
}
if (r.next_automatic_backup) {
console.log(`\nProchaine automatique : ${new Date(r.next_automatic_backup).toLocaleString('fr-FR', { timeZone: 'Europe/Paris' })}`);
}
ws.close(); process.exit(0);
}
};
setTimeout(() => { console.error('délai dépassé'); process.exit(1); }, 30000);

157
tools/test-edf-alterne.mjs Normal file
View File

@@ -0,0 +1,157 @@
#!/usr/bin/env node
// Campagne de test EDF <-> MPPT, protocole ALTERNE.
//
// Usage: node tools/test-edf-alterne.mjs [phaseSec] [sampleSec]
// node tools/test-edf-alterne.mjs 180 15 (defaut)
//
// Protocole : OFF / ON / OFF / ON / OFF, phases de duree egale, echantillon
// regulier. L'alternance est ce qui rend la mesure interpretable : une derive
// meteo monotone ne peut pas produire un signal qui suit le contacteur quatre
// fois de suite.
//
// Ce que le script mesure a chaque echantillon : PV totale, tension et courant
// batterie, consommation maison, import reseau, SOC, limite DVCC, presence
// physique AC-IN, et le mode de fonctionnement des 7 chargeurs solaires
// (mppt_active = cherche son point de puissance maximale ; sinon il se retient).
//
// SECURITE : l'hysteresis reseau est desactivee pendant le test puis restauree,
// ainsi que le latch et l'etat du contacteur. La restauration est faite aussi
// sur Ctrl-C et sur erreur.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const HA = process.env.HA_URL || 'http://10.10.10.17:8123';
function loadToken() {
if (process.env.HA_TOKEN) return process.env.HA_TOKEN;
const m = fs.readFileSync(path.join(ROOT, '.env'), 'utf8').match(/^\s*HA_TOKEN\s*=\s*(.+?)\s*$/m);
if (!m) { console.error('HA_TOKEN absent de .env'); process.exit(1); }
return m[1].replace(/^["']|["']$/g, '');
}
const TOKEN = loadToken();
const H = { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' };
const get = async (id) => {
const r = await fetch(`${HA}/api/states/${id}`, { headers: H });
if (!r.ok) return null;
return (await r.json()).state;
};
const call = async (svc, data) => {
const [d, s] = svc.split('.');
const r = await fetch(`${HA}/api/services/${d}/${s}`, { method: 'POST', headers: H, body: JSON.stringify(data) });
if (!r.ok) throw new Error(`${svc} -> HTTP ${r.status} ${await r.text()}`);
};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// --- entites -----------------------------------------------------------------
const MPPT = [
['mppt1', 'sensor.airtria_mppt1_mppt_operation_mode'],
['rs_1', 'sensor.mppt_450_200_hq2449yr9pt_pv_tracker_solar_1_operation_mode'],
['rs_2', 'sensor.mppt_450_200_hq2449yr9pt_pv_tracker_solar_2_operation_mode'],
['rs_3', 'sensor.mppt_450_200_hq2449yr9pt_pv_tracker_solar_3_operation_mode'],
['rs_4', 'sensor.mppt_450_200_hq2449yr9pt_pv_tracker_solar_4_operation_mode'],
['rs_g', 'sensor.mppt_450_200_hq2449yr9pt_mppt_operation_mode'],
['v1', 'sensor.v1_mppt_operation_mode'],
];
const M = {
pv: 'sensor.victron_pv_power_system',
u: 'sensor.victron_battery_voltage',
i: 'sensor.victron_battery_current',
conso: 'sensor.victron_consumption_power_l1',
grid: 'sensor.victron_grid_power_l1',
soc: 'sensor.victron_battery_soc',
dvcc_a: 'sensor.victron_max_charge_current_setting',
dvcc_v: 'sensor.victron_max_charge_voltage_setting',
acin: 'sensor.victron_ac_in1_available',
edf: 'switch.edf_general_ok_switch',
mode: 'input_text.victron_charge_mode',
};
const HYST = 'input_boolean.victron_grid_hysteresis_enabled';
const LATCH = 'input_boolean.victron_grid_hysteresis_latch';
const PHASE_SEC = Number(process.argv[2] || 180);
const SAMPLE_SEC = Number(process.argv[3] || 15);
const PHASES = ['OFF_1', 'ON_1', 'OFF_2', 'ON_2', 'FIN_OFF'];
const stamp = () => new Date().toLocaleTimeString('fr-FR', { timeZone: 'Europe/Paris' });
const out = [];
const say = (l) => { console.log(l); out.push(l); };
// --- restauration ------------------------------------------------------------
let initial = null;
let restored = false;
async function restore(why) {
if (restored || !initial) return;
restored = true;
say(`>>> RESTAURATION (${why})`);
try {
await call('switch.turn_off', { entity_id: M.edf });
await call(`input_boolean.turn_${initial.latch}`, { entity_id: LATCH });
await call(`input_boolean.turn_${initial.hyst}`, { entity_id: HYST });
say(`>>> etat restaure : edf off | latch ${initial.latch} | hysteresis ${initial.hyst}`);
} catch (e) {
say(`!!! ECHEC RESTAURATION : ${e.message} — VERIFIER A LA MAIN ${M.edf}, ${LATCH}, ${HYST}`);
}
}
process.on('SIGINT', async () => { await restore('Ctrl-C'); process.exit(130); });
// --- deroule -----------------------------------------------------------------
initial = {
hyst: (await get(HYST)) === 'on' ? 'on' : 'off',
latch: (await get(LATCH)) === 'on' ? 'on' : 'off',
edf: await get(M.edf),
};
say(`TEST ALTERNE EDF <-> MPPT — ${new Date().toLocaleString('fr-FR', { timeZone: 'Europe/Paris' })}`);
say(`phases ${PHASE_SEC}s, echantillon ${SAMPLE_SEC}s, sequence ${PHASES.join(' / ')}`);
say(`etat initial : edf ${initial.edf} | latch ${initial.latch} | hysteresis ${initial.hyst}`);
say(`consigne reseau ${await get('number.gx_device_ac_power_setpoint')} W | SOC min ESS ${await get('number.gx_device_ess_min_soc_limit')} % | limite active ${await get('sensor.gx_device_active_soc_limit')} %`);
say(`MPPT ordre: ${MPPT.map((m) => m[0]).join(' ')} (MPPT=cherche le max, lim_=se retient)`);
say('');
try {
await call('input_boolean.turn_off', { entity_id: HYST });
await call('switch.turn_off', { entity_id: M.edf });
await sleep(2000);
for (const phase of PHASES) {
const want = phase.startsWith('ON') ? 'on' : 'off';
if ((await get(M.edf)) !== want) {
say(`>>> EDF ${want.toUpperCase()}`);
await call(`switch.turn_${want}`, { entity_id: M.edf });
await sleep(3000);
}
const t0 = Date.now();
while (Date.now() - t0 < PHASE_SEC * 1000) {
const v = {};
await Promise.all(Object.entries(M).map(async ([k, id]) => { v[k] = await get(id); }));
const modes = await Promise.all(MPPT.map(async ([, id]) => {
const s = await get(id);
return s === 'mppt_active' ? 'MPPT' : (s || 'n/a').slice(0, 4).padEnd(4);
}));
const n = (x, d = 0) => (Number.isFinite(Number(x)) ? Number(x).toFixed(d) : String(x));
say(
`${stamp()} | ${phase.padEnd(7)}| PV ${n(v.pv).padStart(5)} | U ${n(v.u, 2).padStart(5)}` +
` | I ${n(v.i, 1).padStart(6)} | conso ${n(v.conso).padStart(5)} | grid ${n(v.grid).padStart(5)}` +
` | SOC ${n(v.soc)} | DVCC ${n(v.dvcc_a)}A/${n(v.dvcc_v, 1)}V | acin ${v.acin} | edf ${v.edf}` +
` | ${modes.join(' ')}`
);
await sleep(SAMPLE_SEC * 1000);
}
}
} catch (e) {
say(`!!! ERREUR : ${e.message}`);
} finally {
await restore('fin de campagne');
}
// Le nom porte la date ET l'heure : deux campagnes le meme jour ne doivent pas
// s'ecraser l'une l'autre (piege rencontre le 31/08).
const now = new Date();
const hhmm = now.toLocaleTimeString('fr-FR', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit' }).replace(':', '');
const dest = path.join(ROOT, 'docs/mesures', `${now.toISOString().slice(0, 10).replace(/-/g, '')}-${hhmm}-test-edf-alterne.log`);
fs.writeFileSync(dest, out.join('\n') + '\n');
console.log(`\nJournal : ${dest}`);

289
tools/watchdog-evcs.mjs Normal file
View File

@@ -0,0 +1,289 @@
#!/usr/bin/env node
/**
* CHIEN DE GARDE EVCS — remise en état sûr quand le contrôleur ne répond plus.
*
* POURQUOI
* Depuis le 01/09/2026 le contrôleur EVCS pilote lui-même la consigne de
* puissance réseau de l'ESS (HANDOFF §31). C'est ce qui rend une session
* réseau réelle — mais c'est aussi un risque nouveau : s'il s'arrête EN PLEINE
* SESSION, la consigne reste haute et le réseau se substitue au solaire toute
* la journée suivante (HANDOFF §26/§28). Ce chien de garde existe d'abord pour
* ça. Il couvre au passage l'incident du 09/07/2026 (NAS éteint la nuit, EDF
* laissé fermé jusqu'au matin).
*
* OÙ IL TOURNE
* Sur le SECOND Synology (10.10.10.22), pas sur celui qui héberge HA et n8n.
* Un chien de garde hébergé par la machine qu'il surveille ne sert à rien.
*
* CE QU'IL NE FAIT PAS, ET POURQUOI
* 1. Il ne prend JAMAIS de décision d'énergie. Il ne démarre rien, ne charge
* rien, ne calcule aucun seuil. Il remet le système dans son état sûr par
* défaut et s'arrête là. Deux automates qui décident sur le même contacteur,
* c'est la pathologie que ce projet passe son temps à retirer.
* 2. Il ne touche PAS au contacteur EDF. D'abord parce que c'est un Tuya
* *cloud* (HANDOFF §32) : le commander dépend d'internet, donc ce n'est pas
* un actionneur fiable en secours. Ensuite parce que ce n'est pas
* nécessaire : avec la consigne ramenée à sa valeur de repos, l'ESS
* retrouve son autoconsommation normale — la maison passe sur la batterie
* jusqu'au SOC minimal, puis le réseau prend le relais tout seul. Contacteur
* fermé + consigne au repos est un état SÛR.
* 3. Il ne passe ni par Home Assistant ni par le cloud pour agir : il écrit en
* MQTT directement sur le GX Venus (10.10.10.9), une TROISIÈME machine,
* indépendante des deux NAS. C'est ce qui lui permet d'agir quand HA est
* mort avec le reste.
*
* SIGNAL DE VIE
* L'API d'exécutions de n8n, pas un helper HA. Vérifié le 01/09 : HA ne
* rafraîchit PAS l'horodatage d'un `input_text` réécrit à l'identique, donc
* `victron_evcs_surplus_state` paraissait figé depuis 40 min alors que le
* contrôleur tournait normalement. Un helper HA est un mauvais battement de
* cœur. Une exécution n8n horodatée en est un bon : elle prouve que la boucle
* s'exécute, et son absence couvre aussi bien « n8n arrêté » que « NAS éteint »
* que « réseau coupé ».
*
* UNE SEULE ACTION PAR PANNE
* Après avoir agi, il se verrouille jusqu'au retour d'un signe de vie. Sans ce
* verrou, une simple coupure réseau entre les deux NAS ferait rejouer l'action
* à chaque cycle pendant que le contrôleur, bien vivant, la défait — un
* oscillateur de plus. Une écriture, puis silence.
*
* Usage :
* node tools/watchdog-evcs.mjs (boucle, mode réel)
* node tools/watchdog-evcs.mjs --once (un seul cycle)
* node tools/watchdog-evcs.mjs --dry-run (n'écrit jamais sur le GX)
* node tools/watchdog-evcs.mjs --once --dry-run --simulate-dead
* (rejoue une panne, sans risque)
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import mqtt from 'mqtt';
const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..');
const ARGS = new Set(process.argv.slice(2));
const ONCE = ARGS.has('--once');
const DRY = ARGS.has('--dry-run');
const SIMULATE_DEAD = ARGS.has('--simulate-dead');
const CFG = {
n8nUrl: process.env.N8N_URL || 'https://n8n.airtria.pro',
workflowId: process.env.EVCS_WORKFLOW_ID || 'ATswvGfG4KJUls0K',
gxHost: process.env.GX_MQTT_HOST || '10.10.10.9',
gxPort: Number(process.env.GX_MQTT_PORT || 1883),
portalId: process.env.GX_PORTAL_ID || 'c0619ab34ac5',
evChargerInstance: process.env.GX_EVCHARGER_INSTANCE || '40',
// Un contrôleur vivant s'exécute toutes les 30 s. Dix minutes = vingt cycles
// manqués : assez pour ne pas réagir à un hoquet, assez court pour qu'une
// panne nocturne ne coûte pas une journée de production.
livenessMaxAgeSec: Number(process.env.WATCHDOG_MAX_AGE_SEC || 600),
periodSec: Number(process.env.WATCHDOG_PERIOD_SEC || 60),
// Valeur de repos de la consigne réseau : le défaut Victron, pas un choix.
idleSetpointW: Number(process.env.WATCHDOG_IDLE_SETPOINT_W || 50),
// Au-delà de quoi la consigne est jugée « laissée haute ». Un pas de courant
// EVCS à 230 V, la granularité à laquelle le contrôleur déplace la puissance.
setpointSlackW: Number(process.env.WATCHDOG_SETPOINT_SLACK_W || 230),
logFile: process.env.WATCHDOG_LOG || path.join(ROOT, 'logs', 'watchdog-evcs.log'),
};
// ---------------------------------------------------------------- journal
function log(level, msg) {
const line = `${new Date().toISOString()} ${level.padEnd(5)} ${msg}`;
console.log(line);
try {
fs.mkdirSync(path.dirname(CFG.logFile), { recursive: true });
// Rotation naïve : le chien de garde ne doit jamais remplir le disque
// qu'il est censé protéger.
try {
if (fs.statSync(CFG.logFile).size > 5e6) {
fs.renameSync(CFG.logFile, CFG.logFile + '.1');
}
} catch { /* pas encore de fichier */ }
fs.appendFileSync(CFG.logFile, line + '\n');
} catch (e) {
console.error('journal indisponible :', e.message);
}
}
// ---------------------------------------------------------------- clé n8n
function loadApiKey() {
if (process.env.N8N_API_KEY) return process.env.N8N_API_KEY;
try {
const cfg = JSON.parse(
fs.readFileSync(path.join(os.homedir(), '.config/opencode/opencode.json'), 'utf8')
);
const key = cfg?.mcp?.['n8n-mcp']?.environment?.N8N_API_KEY;
if (key) return key;
} catch { /* absent */ }
// Repli pour un déploiement hors poste de travail (conteneur sur 10.10.10.22) :
// le .env du dépôt, exclu de git comme le jeton HA.
try {
const envPath = path.join(ROOT, '.env');
const m = fs.readFileSync(envPath, 'utf8').match(/^\s*N8N_API_KEY\s*=\s*(.+?)\s*$/m);
if (m) return m[1].replace(/^["']|["']$/g, '');
} catch { /* absent */ }
log('FATAL', 'Clé API n8n introuvable (N8N_API_KEY ou opencode.json).');
process.exit(1);
}
const API_KEY = loadApiKey();
// ------------------------------------------------------------- signal de vie
/**
* Renvoie { alive, ageSec, detail }. Toute panne de lecture vaut « mort » :
* si le chien de garde ne peut pas joindre n8n, c'est déjà une anomalie, et
* l'action qu'il déclenche est de toute façon un retour à l'état sûr.
*/
async function controllerLiveness() {
if (SIMULATE_DEAD) return { alive: false, ageSec: null, detail: 'panne simulée (--simulate-dead)' };
try {
const ctl = AbortSignal.timeout(10000);
const r = await fetch(
`${CFG.n8nUrl}/api/v1/executions?workflowId=${encodeURIComponent(CFG.workflowId)}&limit=3`,
{ headers: { 'X-N8N-API-KEY': API_KEY }, signal: ctl }
);
if (!r.ok) return { alive: false, ageSec: null, detail: `n8n HTTP ${r.status}` };
const runs = (await r.json()).data || [];
if (!runs.length) return { alive: false, ageSec: null, detail: 'aucune exécution connue' };
const ageSec = (Date.now() - Date.parse(runs[0].startedAt)) / 1000;
if (!(ageSec <= CFG.livenessMaxAgeSec)) {
return { alive: false, ageSec, detail: `dernière exécution il y a ${Math.round(ageSec)}s` };
}
// Le contrôleur s'exécute mais échoue à chaque fois : il tourne à vide
// (typiquement Home Assistant inaccessible). Trois de suite pour ne pas
// réagir à une erreur isolée.
if (runs.length >= 3 && runs.every(e => e.status === 'error')) {
return { alive: false, ageSec, detail: '3 dernières exécutions en erreur' };
}
return { alive: true, ageSec, detail: `exécution il y a ${Math.round(ageSec)}s` };
} catch (e) {
return { alive: false, ageSec: null, detail: `n8n injoignable (${e.message})` };
}
}
// ------------------------------------------------------------------- GX MQTT
const T = {
setpoint: `settings/0/Settings/CGwacs/AcPowerSetPoint`,
evStartStop: `evcharger/${CFG.evChargerInstance}/StartStop`,
evPower: `evcharger/${CFG.evChargerInstance}/Ac/Power`,
soc: `battery/512/Soc`,
gridPower: `system/0/Ac/Grid/L1/Power`,
};
const READ = (p) => `N/${CFG.portalId}/${p}`;
const WRITE = (p) => `W/${CFG.portalId}/${p}`;
const gx = { client: null, cache: new Map(), connected: false };
function gxConnect() {
return new Promise((resolve) => {
const c = mqtt.connect(`mqtt://${CFG.gxHost}:${CFG.gxPort}`, {
connectTimeout: 8000,
reconnectPeriod: 5000,
clientId: `evcs-watchdog-${Math.random().toString(16).slice(2, 8)}`,
});
c.on('connect', () => {
gx.connected = true;
for (const p of Object.values(T)) c.subscribe(READ(p));
// Venus ne publie que si on l'entretient : keepalive obligatoire.
c.publish(`R/${CFG.portalId}/keepalive`, '');
log('INFO', `GX ${CFG.gxHost}:${CFG.gxPort} — connecté`);
resolve();
});
c.on('reconnect', () => { gx.connected = false; });
c.on('close', () => { gx.connected = false; });
c.on('error', (e) => log('WARN', `MQTT: ${e.message}`));
c.on('message', (t, m) => {
try {
const v = JSON.parse(m.toString());
if (v && typeof v === 'object' && 'value' in v) gx.cache.set(t, v.value);
} catch { /* charge utile inattendue : ignorée */ }
});
gx.client = c;
setTimeout(resolve, 9000); // ne jamais bloquer la boucle sur le broker
});
}
function gxKeepalive() {
if (gx.client && gx.connected) gx.client.publish(`R/${CFG.portalId}/keepalive`, '');
}
const gxRead = (p) => gx.cache.get(READ(p));
function gxWrite(p, value) {
if (DRY) { log('DRY', `écriture SUPPRIMÉE ${WRITE(p)} = ${value}`); return; }
if (!gx.client || !gx.connected) { log('ERROR', `GX injoignable : ${p} = ${value} NON écrit`); return; }
gx.client.publish(WRITE(p), JSON.stringify({ value }));
log('ACT', `${WRITE(p)} = ${value}`);
}
// -------------------------------------------------------------------- boucle
let armed = true; // faux = action déjà faite, on attend un signe de vie
let lastAliveLog = 0;
async function cycle() {
gxKeepalive();
const live = await controllerLiveness();
if (live.alive) {
if (!armed) log('INFO', `contrôleur revenu (${live.detail}) — chien de garde réarmé`);
armed = true;
// Trace discrète : une ligne par heure suffit à prouver qu'il veille.
if (Date.now() - lastAliveLog > 3600e3) {
lastAliveLog = Date.now();
log('INFO', `veille — contrôleur vivant (${live.detail}), consigne ${gxRead(T.setpoint) ?? '?'} W`);
}
return;
}
const setpointW = gxRead(T.setpoint);
const evPowerW = gxRead(T.evPower);
const evOn = gxRead(T.evStartStop);
const dangerousSetpoint =
Number.isFinite(setpointW) && setpointW > CFG.idleSetpointW + CFG.setpointSlackW;
const evActive = evOn === 1 || (Number.isFinite(evPowerW) && evPowerW > 100);
if (!dangerousSetpoint && !evActive) {
log('WARN', `contrôleur muet (${live.detail}) — mais rien à reprendre ` +
`(consigne ${setpointW ?? '?'} W, borne ${evActive ? 'active' : 'arrêtée'})`);
return;
}
if (!armed) return; // déjà agi pour cette panne : on se tait
log('ALERT', `CONTRÔLEUR MUET — ${live.detail}. ` +
`Consigne ${setpointW ?? '?'} W, borne ${evPowerW ?? '?'} W, SOC ${gxRead(T.soc) ?? '?'}%, ` +
`réseau ${gxRead(T.gridPower) ?? '?'} W. Retour à l'état sûr.`);
if (dangerousSetpoint) gxWrite(T.setpoint, CFG.idleSetpointW);
if (evActive) gxWrite(T.evStartStop, 0);
armed = false;
log('ALERT', 'Action unique effectuée. Aucune autre écriture avant le retour du contrôleur. ' +
'Le contacteur EDF n\'est PAS touché (Tuya cloud, et consigne au repos = état sûr).');
}
// ---------------------------------------------------------------------- main
log('INFO', `démarrage — n8n ${CFG.n8nUrl} (workflow ${CFG.workflowId}), GX ${CFG.gxHost}, ` +
`seuil ${CFG.livenessMaxAgeSec}s, période ${CFG.periodSec}s` +
`${DRY ? ' [DRY-RUN]' : ''}${SIMULATE_DEAD ? ' [PANNE SIMULÉE]' : ''}`);
await gxConnect();
if (ONCE) {
await new Promise(r => setTimeout(r, 3000)); // laisser le cache se remplir
await cycle();
gx.client?.end();
process.exit(0);
}
await new Promise(r => setTimeout(r, 3000));
await cycle();
setInterval(() => { cycle().catch(e => log('ERROR', `cycle: ${e.message}`)); }, CFG.periodSec * 1000);
for (const sig of ['SIGINT', 'SIGTERM']) {
process.on(sig, () => { log('INFO', `arrêt (${sig})`); gx.client?.end(); process.exit(0); });
}

View File

@@ -0,0 +1,90 @@
#!/usr/bin/env node
/**
* Preuve de bout en bout du CHEMIN D'ÉCRITURE du chien de garde.
*
* Le `--dry-run` valide la détection, le verrou et le journal, mais il
* supprime précisément ce qui compte le jour d'une panne : la publication
* MQTT vers le GX. Si le broker refusait les topics `W/`, le chien de garde
* serait décoratif — et on ne l'apprendrait qu'au pire moment. Ce test écrit
* réellement, relit, puis REMET la valeur d'origine lui-même.
*
* Sûreté : il n'écrit que sur la consigne de puissance réseau, il mémorise la
* valeur trouvée avant de la modifier et la restaure quoi qu'il arrive
* (y compris sur erreur ou Ctrl-C). La valeur sonde est volontairement
* absurde (777 W) pour être reconnaissable dans un journal.
*
* Usage : node tools/watchdog-selftest.mjs
*/
import mqtt from 'mqtt';
const HOST = process.env.GX_MQTT_HOST || '10.10.10.9';
const PORT = Number(process.env.GX_MQTT_PORT || 1883);
const P = process.env.GX_PORTAL_ID || 'c0619ab34ac5';
const EV = process.env.GX_EVCHARGER_INSTANCE || '40';
const PROBE_W = 777;
const SETPOINT = 'settings/0/Settings/CGwacs/AcPowerSetPoint';
const STARTSTOP = `evcharger/${EV}/StartStop`;
const ACIN = 'system/0/Ac/Grid/L1/Power';
const cache = new Map();
const c = mqtt.connect(`mqtt://${HOST}:${PORT}`, { connectTimeout: 8000 });
const read = (p) => cache.get(`N/${P}/${p}`);
const write = (p, value) => c.publish(`W/${P}/${p}`, JSON.stringify({ value }));
const wait = (ms) => new Promise(r => setTimeout(r, ms));
let original = null;
async function restore(why) {
if (original === null) return;
write(SETPOINT, original);
await wait(1500);
console.log(` restauration (${why}) → consigne ${read(SETPOINT)} W`);
}
process.on('SIGINT', async () => { await restore('interruption'); process.exit(1); });
c.on('connect', async () => {
for (const t of [SETPOINT, STARTSTOP, ACIN]) c.subscribe(`N/${P}/${t}`);
c.publish(`R/${P}/keepalive`, '');
await wait(2500);
original = read(SETPOINT);
console.log(`GX ${HOST}:${PORT} — connecté`);
console.log(`état trouvé : consigne ${original} W · borne StartStop=${read(STARTSTOP)} · réseau ${read(ACIN)} W\n`);
if (typeof original !== 'number') {
console.log('❌ consigne illisible : le test ne peut pas garantir la restauration. ABANDON.');
c.end(); process.exit(1);
}
try {
console.log(`1) écriture réelle de la sonde : ${PROBE_W} W`);
write(SETPOINT, PROBE_W);
await wait(3000);
const seen = read(SETPOINT);
console.log(` relecture : ${seen} W`);
const ok = seen === PROBE_W;
console.log(ok
? ' ✅ le GX accepte les écritures du chien de garde\n'
: ` ❌ écriture SANS EFFET (attendu ${PROBE_W}) — le chien de garde serait décoratif\n`);
console.log(`2) remise de la valeur d'origine : ${original} W`);
write(SETPOINT, original);
await wait(3000);
const back = read(SETPOINT);
console.log(` relecture : ${back} W ${back === original ? '✅' : '❌'}\n`);
console.log(ok && back === original
? 'VERDICT : chemin d\'écriture DÉMONTRÉ, état d\'origine rétabli.'
: 'VERDICT : ANOMALIE — voir ci-dessus.');
process.exitCode = ok && back === original ? 0 : 1;
} catch (e) {
console.error('erreur :', e.message);
await restore('erreur');
process.exitCode = 1;
}
c.end();
setTimeout(() => process.exit(process.exitCode ?? 0), 500);
});
c.on('message', (t, m) => { try { const v = JSON.parse(m.toString()); if (v && 'value' in v) cache.set(t, v.value); } catch {} });
c.on('error', (e) => { console.error('MQTT:', e.message); process.exit(1); });
setTimeout(() => { console.error('délai dépassé'); process.exit(1); }, 30000);

File diff suppressed because one or more lines are too long