docs(brief): SunSpec Storage doit déclarer energystorage — et pourquoi ce n'est pas une ligne

Émis par l'agent moteur. Le Thing « Fronius Storage » déclare battery + connectable,
donc trois consommateurs distincts qui le cherchent par filterByInterface(energystorage)
ne le trouvent pas : le bilan de puissance de nymea (currentPowerStorage, que le CLIENT
lit sur son tableau de bord), la réserve de stockage du proxy VE, et celle du waterfall.
Vérifié sur .75 : currentPowerStorage vaut 0 pendant que la batterie affiche 50 % et
« Holding » — elle n'est pas à zéro, elle est invisible.

Ce n'est pas une ligne de JSON : energystorage exige currentPower (signé, W) et capacity
(kWh), que sunspecStorage ne fournit pas. Les déclarer sans les câbler donnerait deux
états à zéro — et un currentPower de batterie à zéro est PIRE que son absence, puisque
la réserve lirait « ni charge ni décharge » au lieu de « je ne sais pas ».

Rappel de vigilance : d08cf50 a montré ce que coûte un float32 mal ordonné sur ce même
plugin. Un currentPower de batterie mal signé produirait la même famille de dégât, en
plus discret : la réserve croirait la batterie en charge quand elle se vide.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
This commit is contained in:
Patrick Schurig 2026-08-26 11:57:26 +02:00
parent 366d332bf4
commit 2f94fd741f

View File

@ -0,0 +1,103 @@
# Brief — `SunSpec Storage` doit déclarer `energystorage`
**Émis par** : l'agent moteur (`etm-powersync-energy-plugin-etm`), 2026-08-26.
**Constaté sur** : banc `.75`, `nymea-plugin-sunspec-etm01 1.15.2+etm9`.
**Ce n'est pas une ligne de JSON** — voir §3. C'est la raison de ce brief.
---
## 1. Le constat
```
Thing « Fronius Storage » → classe « SunSpec Storage » (9a643ba8)
interfaces : ["battery", "connectable"] ← PAS "energystorage"
```
**Conséquence : cette batterie n'est comptée nulle part.** Trois consommateurs distincts la
cherchent par `filterByInterface("energystorage")`, et aucun ne la trouve :
| Qui | Ce qui est perdu |
|---|---|
| `EnergyManagerImpl::updatePowerBalance()` (experience-plugin nymea, l. 269) | `currentPowerStorage` et `totalFromStorage` — **le bilan de puissance que le client lit sur son tableau de bord** |
| `SmartChargingManager::planSurplusCharging()` (l. 1170) | la réserve de stockage pour la **recharge du véhicule** |
| `EnergyArbitrator::buildContext()` (moteur, 3g-1) | la réserve de stockage pour **toutes** les charges pilotées |
Vérifié sur `.75` : `Energy.GetPowerBalance` rend `"currentPowerStorage": 0` pendant que la
batterie affiche 50 % et l'état « Holding ». Elle n'est pas à zéro : **elle est invisible**.
> **Ce qui rend ce défaut coûteux, c'est son silence.** Un filtre par interface qui ne trouve
> rien est ambigu entre « il n'y a pas de batterie » et « il y en a une, mal déclarée ». Le
> second désactive une règle de sécurité sans produire ni erreur ni panne. Le moteur émet
> désormais un avertissement dans ce cas précis (« interface `battery` mais pas
> `energystorage` »), mais l'avertissement ne remplace pas la déclaration.
---
## 2. Ce que l'interface exige
`libnymea/interfaces/energystorage.json` :
```json
{ "extends": ["battery", "smartmeter"],
"states": [
{ "name": "currentPower", "type": "double", "unit": "Watt", "logged": true },
{ "name": "capacity", "type": "double", "unit": "KiloWattHour" } ] }
```
---
## 3. Pourquoi ce n'est PAS une ligne de JSON
`sunspecStorage` déclare aujourd'hui ces états :
```
batteryCritical · batteryLevel · chargeGradient · chargingRate · chargingState · connected
controlHealthy · dischargeGradient · dischargingRate · enableCharging · enableDischarging
gridCharging · storageStatus · version
```
**Ni `currentPower`, ni `capacity`.** Ajouter `"energystorage"` aux interfaces sans ajouter les
deux états produirait, au mieux, un refus de chargement du plugin ; au pire, deux états à zéro.
Et **un `currentPower` de batterie à zéro est pire que son absence** : la règle de réserve
lirait « la batterie ne charge ni ne décharge » au lieu de « je ne sais pas », et le bilan de
puissance du client afficherait un stockage éternellement inerte.
Il faut donc **câbler les deux états sur des registres** :
- **`currentPower`** — puissance instantanée du stockage, **signée** : positif = charge,
négatif = décharge (convention nymea, cf. la description de l'interface). À établir depuis
quel modèle SunSpec elle se lit sur un Symo — le modèle 124 porte l'état de charge, pas
nécessairement la puissance ; elle peut devoir se déduire des canaux DC (modèle 160).
- **`capacity`** — capacité nominale en kWh. Candidat : `WHRtg` du modèle 120 (plaque
signalétique), à convertir en kWh.
⚠️ **Attention au signe et à l'ordre des mots.** Le lot `d08cf50` a montré ce que coûte un
float32 lu à mots permutés sur ce même plugin — un compteur important 2,7 kW rapporté comme
exportant 107 MW, et des cumuls empoisonnés pendant dix-neuf jours. Un `currentPower` de
batterie mal signé produirait la même famille de dégât, en plus discret : la réserve croirait
la batterie en charge quand elle se vide.
---
## 4. Ce qui est demandé
1. **Garder `battery`** — ne pas la remplacer. D'autres choses peuvent en dépendre, et
`energystorage` l'étend de toute façon.
2. **Ajouter les états `currentPower` et `capacity`**, câblés sur les bons registres et avec le
bon signe.
3. **Puis ajouter `"energystorage"`** aux interfaces de `sunspecStorage`.
4. **Vérifier sur `.75`** que `Energy.GetPowerBalance` rend enfin un `currentPowerStorage` non
nul et cohérent avec l'état de la batterie.
Une fois cela fait, le moteur pourra éprouver sa règle de réserve pour la première fois — la
bascule `batteryLevelConsideration` 0,4 / 0,9 sur un banc dont la batterie est à 50 %.
---
## 5. Question ouverte, à trancher côté SunSpec
**Les autres classes du plugin sont-elles dans le même cas ?** Sur `.75`, l'onduleur déclare
bien `smartmeterproducer` + `smartmeter` et le compteur `smartmeter` — donc eux sont vus. Mais
le relevé du lot D avait noté que l'onduleur ne publie **aucun cumul** (`thingCache` à
`0.0 / 0.0`), ce qui gèle `totalProduction` côté nymea. Cause différente du présent brief —
il est trouvé, mais il ne publie pas `totalEnergyProduced` — et qui mérite le même examen.