docs(sunspec): écrire la dépendance à l'ordre de lecture des SF là où elle porte

Le relevé du 2026-08-26 a montré DCW_SF passant de -2 à -1 entre une batterie à
l'arrêt et une batterie en charge, sur le même onduleur, à quelques secondes
d'intervalle. Un DCW brut de 20000 vaut 200,0 W avec le premier facteur et
2000,0 W avec le second.

Ce que ça casserait est de la même famille que d08cf50 mais plus discret : pas
de dénormal, pas d'erreur Modbus, un appareil qui se déclare connected, et une
puissance simplement dix fois fausse. La propriété tient aujourd'hui parce que
libnymea-sunspec lit les 90 registres du modèle 160 en une transaction et relit
les facteurs du bloc fixe AVANT les blocs répétés — deux propriétés qui vivent
en amont, que rien dans le compilateur ne tient, et qu'une optimisation de la
lecture ferait sauter sans aucun signe extérieur.

Le commentaire est donc à l'endroit qui en dépend : au-dessus de
storageModuleReadings(), qui consomme dcPower(), avec un pointeur au site de
readBlockData() et sur le champ ModuleReading::dcPower.

Le README documente en outre le côté exploitation de D-9 : un onduleur qui
publie le 124 sans le 160 porte energystorage avec un currentPower figé à zéro,
avec les deux lignes de journal exactes à chercher et la commande pour les
trouver.
This commit is contained in:
Patrick Schurig 2026-08-26 15:45:40 +02:00
parent 51a211e080
commit 122df17a98
3 changed files with 103 additions and 2 deletions

View File

@ -13,7 +13,9 @@ Connect nymea to SunSpec devices over Modbus TCP
* Model ID 202 & 212 Split phase meter
* Model ID 203 & 213 Three phase meter
* SunSpec Storage
* Model ID 124
* Model ID 124 (state of charge, status, setpoints)
* Model ID 160 (instantaneous power, `StCha` / `StDisCha` blocks)
* Model ID 120 (rated capacity, `WHRtg`)
## Tested connections
@ -27,3 +29,56 @@ Connect nymea to SunSpec devices over Modbus TCP
## More
https://sunspec.org
---
## Exploitation — batterie visible mais non mesurable
Depuis `1.15.2+etm10`, la thingClass `SunSpec Storage` déclare l'interface
**`energystorage`**. C'est ce qui la rend visible au bilan de puissance de nymea
(`currentPowerStorage`), à la réserve de recharge du véhicule, et à la réserve du
waterfall. Avant, elle ne déclarait que `battery` et **aucun des trois ne la
trouvait**.
L'état `currentPower` se lit dans le **modèle 160**, pas dans le modèle 124 qui
n'a aucun registre de puissance. Conséquence à connaître avant de diagnostiquer
une batterie « à zéro » :
> **Un onduleur SunSpec qui publie le modèle 124 sans modèle 160 porte
> l'interface `energystorage` avec un `currentPower` qui ne bougera jamais de
> zéro.** C'est le cas des appareils SunSpec génériques. La batterie apparaît
> alors dans le bilan, comptée pour 0 W — ce qui se lit « ni charge ni
> décharge », et non « je ne sais pas ».
Ce cas est **annoncé une fois par thing** dans le journal de `nymead`, au
démarrage ou au premier relevé qui suit la découverte. Deux formulations, selon
la cause :
```
W | SunSpec: Storage "<nom>" has no model 160 on its connection: currentPower
has no register to read and will stay at 0. This storage is visible to the
energy balance but not measurable.
W | SunSpec: Storage "<nom>" has a model 160 but no StCha/StDisCha block:
currentPower has nothing to read and will stay at 0.
```
La seconde forme est le cas d'un onduleur hybride **sans batterie câblée** : il
publie `N = 2` (les deux strings PV) et aucun bloc batterie.
À vérifier en premier sur un site où `currentPowerStorage` reste à zéro :
```bash
journalctl -u nymead | grep -i "will stay at 0"
```
Si la ligne est là, le défaut est dans ce que l'appareil publie, pas dans nymea.
Si elle est absente, la batterie est mesurée et le zéro est une vraie mesure.
L'état `capacity` (kWh) vient de `WHRtg` du modèle 120. Quand la plaque
signalétique ne l'implémente pas, l'état est **laissé tel quel** plutôt que remis
à zéro : un zéro se lirait comme une mesure. Aucune règle du moteur ne lit
`capacity` à ce jour.
Détail du raisonnement, de la mesure du signe et de l'écart assumé :
`DIVERGENCES.md`, entrée **D-9**.

View File

@ -198,6 +198,13 @@ BlockRole classifyBlock(const QString &inputIdString);
struct ModuleReading {
QString inputIdString;
/* Already scaled by DCW_SF, in watts. That factor is auto-scaled by the
* inverter at runtime - measured moving from -2 to -1 between an idle and a
* charging battery on the same machine - so it is only valid alongside the
* powers of the same Modbus transaction. Whoever fills this struct owns
* that guarantee; see the read order note above
* IntegrationPluginSunSpec::storageModuleReadings(). */
double dcPower;
};

View File

@ -1662,7 +1662,12 @@ void IntegrationPluginSunSpec::onRefreshTimer()
/* Companion models of the storage things. Deduplicated: two storage things
* on the same inverter share one model 160, and reading it twice per cycle
* would double the Modbus traffic for the same 90 registers. */
* would double the Modbus traffic for the same 90 registers.
*
* Whole model, one readBlockData() each, never a partial read: the power of
* the battery blocks is only meaningful next to the scale factor of the
* same transaction. See the read order note above
* storageModuleReadings(). */
QSet<SunSpecModel *> storageCompanions;
foreach (SunSpecMpptModel *model, m_storageMpptModels.values())
storageCompanions.insert(model);
@ -2193,6 +2198,40 @@ void IntegrationPluginSunSpec::bindStorageCompanionModels(Thing *thing, SunSpecM
updateStorageCapacity(thing);
}
/* ORDRE DE LECTURE - ne pas "optimiser" la lecture du modele 160.
*
* `dcPower()` rend une valeur DEJA multipliee par `DCW_SF`. Ce facteur d'echelle
* n'est pas une constante de l'appareil : Fronius l'auto-scale EN
* FONCTIONNEMENT, sans hysteresis. Mesure du 2026-08-26 sur le banc, meme
* onduleur, deux relevees a quelques secondes d'intervalle :
*
* batterie a l'arret, PV faible -> DCW_SF = -2
* batterie en charge 2000 W -> DCW_SF = -1
*
* Un `DCW` brut de 20000 vaut donc 200,0 W avec le premier facteur et 2000,0 W
* avec le second. Lire la puissance avec un facteur qui n'est pas celui de la
* MEME transaction, c'est se tromper d'un ordre de grandeur - meme famille de
* degat que l'ordre des mots des float32 (D-8), mais bien plus discret : la
* valeur reste plausible, elle est juste dix fois trop petite ou trop grande.
*
* Ce qui protege aujourd'hui :
*
* 1. `SunSpecModel::readBlockData()` lit les 90 registres du modele 160 en UNE
* transaction Modbus (la limite de decoupage est a 125 registres), donc les
* facteurs d'echelle et les puissances viennent du meme instantane ;
* 2. `SunSpecModel::handleNewBlockData()` appelle `processBlockData()` du bloc
* FIXE - qui relit `DCA_SF`/`DCV_SF`/`DCW_SF`/`DCWH_SF` - AVANT de traiter
* les blocs repetes, qui lisent ce facteur chez leur parent.
*
* Ces deux proprietes sont dans libnymea-sunspec, pas ici, et rien dans le
* compilateur ne les tient. Les casser en amont - decouper la lecture, mettre
* les facteurs en cache a la decouverte, traiter les blocs repetes en premier,
* ou lire un bloc repete isolement - casserait cette fonction SANS AUCUN SIGNE
* EXTERIEUR : pas d'erreur Modbus, pas de denormal, un appareil qui se declare
* `connected`. Toute reprise de la lecture du modele 160 doit etre rejouee au
* banc contre une puissance batterie connue, pas seulement compilee.
*
* Contrat Fronius I5, et D-9 dans DIVERGENCES.md. */
QList<FroniusRules::ModuleReading> IntegrationPluginSunSpec::storageModuleReadings(Thing *thing) const
{
QList<FroniusRules::ModuleReading> readings;