fix(sunspec): rendre la batterie SunSpec visible au moteur (energystorage)

sunspecStorage déclarait `battery` mais pas `energystorage`. Les trois
consommateurs qui filtrent par cette interface ne la trouvaient donc pas : le
bilan de puissance de nymea (`currentPowerStorage`, celui que le CLIENT lit sur
son tableau de bord), la réserve de stockage de la recharge VE, et celle du
waterfall. Vérifié sans authentification dans `energylogs.sqlite` de .75 : la
colonne `storage` de `powerBalance` est à 0.0 sur toutes les lignes, pendant
que la batterie affiche 50 % et « Holding ». Elle n'était pas à zéro, elle
était invisible.

Ce n'était pas une ligne de JSON. `energystorage` exige `currentPower` (W,
signé) et `capacity` (kWh), qu'aucun des quatorze états ne portait, et les
déclarer sans les câbler aurait donné une batterie éternellement inerte — pire
que son absence, puisque la réserve lirait « ni charge ni décharge » au lieu de
« je ne sais pas ».

D'où viennent les deux états. Le modèle 124 ne porte AUCUNE puissance : WChaMax
est un maximum, StorAval est en ampères-heures. La puissance instantanée vit
dans le modèle 160, réparti sur deux blocs `StCha` et `StDisCha` dont le `DCW`
est un uint16 toujours positif, l'inactif à zéro. D'où
`currentPower = DCW[StCha] - DCW[StDisCha]`, positif en charge, conformément à
libnymea/interfaces/energystorage.json (« positive is 'consumed' »). C'est
l'INVERSE de la convention constructeur du contrat §4, et
`FroniusRules::batteryPower()` est réécrite comme la négation stricte de la
nouvelle `storageCurrentPower()` pour qu'aucune des deux ne dérive seule.
`capacity` vient de WHRtg du modèle 120, et l'état est laissé tranquille plutôt
que mis à zéro quand le nameplate ne l'implémente pas.

Le signe est MESURÉ, pas déduit. Banc .211 unité 1, consigne batterie imposée
par MQTT, registres relus en lecture seule :
  +2000 W -> StCha 2000,0 W  StDisCha    0,0 W  ChaSt Charging
  -1500 W -> StCha    0,0 W  StDisCha 1500,0 W  ChaSt Discharging
Le même relevé a montré `DCW_SF` passant de -2 à -1 entre repos et charge : le
facteur d'échelle n'est jamais mis en cache d'un relevé à l'autre.

Écart assumé, D-9 dans DIVERGENCES.md : `sunspecStorage` est partagé avec le
vendor générique, et les interfaces se déclarent par thingClass. Un appareil
générique publiant le 124 sans le 160 porte désormais `energystorage` avec un
`currentPower` figé à zéro. Les trois alternatives sont pires — une thingClass
Fronius distincte détruirait le Thing existant et son historique. Le plugin
avertit donc UNE FOIS par thing quand il ne trouve pas de source de puissance,
en distinguant « pas de modèle 160 » de « modèle 160 sans bloc batterie ». Ça ne
remplace pas la mesure, mais ça retire le silence.

La cohérence des unités avec l'interface est vérifiée à la COMPILATION par
nymea-plugininfocompiler : mettre `KiloWattHour` sur `currentPower` fait échouer
le build. Onze tests `test_D9_*` figent le reste.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y3gzsuwENJfJtvuNDT3xGn
This commit is contained in:
Patrick Schurig 2026-08-26 14:02:37 +02:00
parent 2f94fd741f
commit 51a211e080
10 changed files with 611 additions and 4 deletions

32
debian/changelog vendored
View File

@ -1,3 +1,35 @@
etm-powersync-plugins-modbus (1.15.2+etm10) trixie; urgency=medium
* sunspec: make the SunSpec storage visible to the energy engine. The
sunspecStorage thing class declared "battery" but not "energystorage", so
the three consumers that filter by that interface never found it: nymea's
power balance (currentPowerStorage, the figure the customer reads on their
dashboard), the storage reserve of the EV surplus charging, and the reserve
of the whole waterfall. Observed on the bench: GetPowerBalance answering
currentPowerStorage 0 while the battery reported 50 % and "Holding". It was
not at zero, it was invisible.
* sunspec: publish currentPower and capacity on the storage, wired to
registers rather than declared empty. Model 124 carries no power at all -
WChaMax is a maximum, StorAval is in amp hours - so the instantaneous power
is read from model 160, whose StCha and StDisCha blocks each carry an
unsigned DCW with the inactive one at zero. currentPower is therefore
DCW[StCha] - DCW[StDisCha], positive when charging, per the convention of
libnymea/interfaces/energystorage.json. capacity comes from WHRtg of the
nameplate model 120, and is left alone rather than zeroed when the
nameplate does not implement it.
* sunspec: the sign is measured, not deduced. Read only on the bench with the
battery power driven over MQTT: a +2000 W charge order gives StCha 2000 W,
StDisCha 0 W and ChaSt Charging; a -1500 W discharge order gives StCha 0 W,
StDisCha 1500 W and ChaSt Discharging. The same run showed DCW_SF moving
from -2 to -1 between idle and load, which is why the scale factor is never
cached across a poll.
* sunspec: warn once per thing when a storage has no power source - no model
160 on its connection, or a model 160 with no StCha/StDisCha block. Such a
thing now claims energystorage while reporting a flat zero, and a silent
zero is what made the original defect expensive. See DIVERGENCES.md D-9.
-- Patrick Schurig <patrick@etm-schurig.eu> Wed, 26 Aug 2026 14:00:00 +0200
etm-powersync-plugins-modbus (1.15.2+etm9) trixie; urgency=medium
* sunspec: read Fronius float32 points big endian, the way the hardware

View File

@ -243,3 +243,114 @@ l'utilisateur. Aucun des deux n'est modifié.
**Verrou.** `test_AC_6` affirme désormais `ByteOrderBigEndian`, et
`test_AC_18_swapped_words_yield_denormals` fige le raisonnement : les registres
réels lus à l'envers donnent un dénormal, jamais une mesure plausible.
---
## D-9 — `sunspecStorage` déclare `energystorage` mais tous ses porteurs ne savent pas mesurer (MESURÉ)
**Ce que demande le brief.** `BRIEF_sunspec_energystorage.md` : le Thing déclarait
`battery` + `connectable`, donc les trois consommateurs qui filtrent par
`filterByInterface("energystorage")` ne le trouvaient pas — le bilan de puissance
que le client lit, la réserve de recharge VE, la réserve du waterfall. Sur `.75`,
`currentPowerStorage` valait 0 pendant que la batterie affichait 50 % et
« Holding » : elle n'était pas à zéro, elle était invisible.
**Ce que fait le code.** `sunspecStorage` déclare désormais
`["battery", "energystorage", "connectable"]` et porte `currentPower` (W) et
`capacity` (kWh), câblés sur des registres.
### D'où vient `currentPower`
**Le modèle 124 ne porte aucune puissance.** Il décrit ce que la batterie *est*
— SoC, statut, consignes — pas ce qu'elle *fait* en watts. `WChaMax` est une
puissance **maximale**, `StorAval` est en ampères-heures. Vérifié registre par
registre sur le banc.
La puissance instantanée vit dans le **modèle 160**, réparti sur deux blocs
distincts que Fronius nomme `StCha` (charge) et `StDisCha` (décharge). `DCW` est
un `uint16` : chaque bloc est toujours positif, et le bloc inactif est à zéro.
D'où
```
currentPower = DCW[StCha] − DCW[StDisCha]
```
**Le signe est mesuré, pas déduit.** Relevé du 2026-08-26, banc `.211` unité 1,
modèle 160 à 40265, consigne batterie imposée par MQTT (`banc/cmd/manual`) et
registres relus en lecture seule :
| consigne banc | `banc/etat/batterie` | `StCha` | `StDisCha` | `ChaSt` (124) | `StCha − StDisCha` |
|---|---|---|---|---|---|
| +2000 W (charge) | +2000,0 W | **2000,0 W** | 0,0 W | Charging | **+2000,0 W** |
| −1500 W (décharge) | −1500,0 W | 0,0 W | **1500,0 W** | Discharging | **−1500,0 W** |
| 0 W | 0,0 W | 0,0 W | 0,0 W | Holding | 0,0 W |
`libnymea/interfaces/energystorage.json` : « positive is 'consumed' ». Une
batterie qui charge consomme, donc charge = positif. C'est **l'inverse** de la
convention constructeur du contrat §4 et de `FroniusRules::batteryPower()`. Les
deux fonctions coexistent, et `batteryPower()` est écrite comme la négation de
`storageCurrentPower()` pour qu'aucune des deux ne puisse dériver seule.
**Ce que le relevé a montré en plus.** `DCW_SF` est passé de **−2 à −1** entre la
lecture au repos et la lecture en charge, sans hystérésis (I5 du contrat). Un
plugin qui mettrait ce facteur en cache à la découverte lirait des puissances
fausses d'un facteur 10. libnymea-sunspec ne le met pas en cache : les 90
registres du modèle 160 sont lus en une seule transaction Modbus, et
`processBlockData()` relit les facteurs d'échelle du bloc fixe **avant** de
traiter les blocs répétés. La propriété est donc tenue, mais elle tient à cet
ordre — la casser en amont casserait cette lecture sans aucun signe extérieur.
### D'où vient `capacity`
`WHRtg` du modèle 120 (plaque signalétique), Wh → kWh.
`SunSpecStorageLogic::nameplateCapacityKwh()` rend une valeur **négative** quand
le nameplate n'en porte pas, et le plugin laisse alors l'état tranquille plutôt
que de publier un zéro qui se lirait comme une mesure.
**Non vérifiable au banc.** Le simulateur sert les modèles 120 à 123 en
**coquilles vides** (choix assumé du lot 1) : les 26 registres du 120 sont à
zéro, `WHRtg` compris. `capacity` reste donc à 0 sur `.75`, et le chemin de
lecture n'est éprouvé que par les tests unitaires. **Comment lever** : peupler
`WHRtg` dans la vue `sunspec-fronius` du banc, ou relever le modèle 120 d'un
GEN24 réel équipé d'une batterie. Aucun des trois consommateurs cités par le
brief ne lit `capacity` — la conséquence d'un 0 est bornée à l'affichage.
### L'écart assumé
**`sunspecStorage` est partagé entre les vendors `sunspec` générique et Fronius**,
et les interfaces se déclarent par thingClass, pas par thing. Un appareil SunSpec
générique qui publie le modèle 124 **sans** modèle 160 — c'est le cas du
simulateur générique `.210` — porte désormais l'interface `energystorage` avec un
`currentPower` qui ne bougera jamais de zéro. C'est exactement le défaut que le
brief décrit : un zéro se lit « ni charge ni décharge » là où l'absence se lisait
« je ne sais pas ».
**Pourquoi l'accepter quand même.** Les trois alternatives sont pires :
- créer une thingClass Fronius distincte (à la `solarEdgeBattery`) contredit le
contrat §A′ ligne 44, et **détruit le Thing existant** de `.75` avec son
historique, puisque le balayage des orphelins ferait disparaître l'ancien ;
- ne pas déclarer l'interface laisse les trois consommateurs aveugles, ce qui est
le défaut d'origine ;
- refuser de créer l'enfant storage quand la puissance n'est pas mesurable ferait
disparaître des appareils génériques qui fonctionnent aujourd'hui.
**Ce qui rend l'écart audible.** Le plugin **avertit une fois par thing** quand il
ne trouve pas de source de puissance, en distinguant les deux cas :
pas de modèle 160 du tout sur la connexion, ou modèle 160 sans bloc
`StCha`/`StDisCha` (un GEN24 sans batterie publie `N=2`). L'avertissement ne
remplace pas la mesure, mais il retire le silence — qui était le vrai coût du
défaut d'origine.
**Comment lever.** Que nymea permette de déclarer une interface par thing et non
par thingClass, ou que le vendor générique reçoive sa propre thingClass storage
au moment où un appareil générique avec batterie entre réellement au parc.
**Verrous.** `test_D9_*` dans `tests/logic` : la déclaration d'interface, les deux
états et leurs unités, le signe dans les deux sens, l'opposition stricte avec
`batteryPower()`, l'exclusion des strings PV, et la distinction entre « batterie
à l'arrêt » et « pas de bloc batterie ». La cohérence des unités avec l'interface
est en outre vérifiée **à la compilation** par `nymea-plugininfocompiler` :
essayer `KiloWattHour` sur `currentPower` fait échouer le build avec
« claims to implement interface "energystorage" but state "currentPower" has not
matching unit ».

View File

@ -182,7 +182,7 @@ double pvPower(const QList<ModuleReading> &readings)
return total;
}
double batteryPower(const QList<ModuleReading> &readings)
double storageCurrentPower(const QList<ModuleReading> &readings)
{
double charging = 0;
double discharging = 0;
@ -200,7 +200,25 @@ double batteryPower(const QList<ModuleReading> &readings)
}
}
return discharging - charging;
return charging - discharging;
}
double batteryPower(const QList<ModuleReading> &readings)
{
/* Written as the negation rather than as a second sum: two independent
* implementations of the same subtraction are two places for the sign to
* rot, and only one of them has a test watching it. */
return -storageCurrentPower(readings);
}
bool hasBatteryBlocks(const QList<ModuleReading> &readings)
{
foreach (const ModuleReading &reading, readings) {
const BlockRole role = classifyBlock(reading.inputIdString);
if (role == BlockRoleBatteryCharge || role == BlockRoleBatteryDischarge)
return true;
}
return false;
}
}

View File

@ -206,9 +206,35 @@ double pvPower(const QList<ModuleReading> &readings);
/* Discharge minus charge, so positive means discharging. DCW is a uint16 and
* therefore always positive: the sign is carried by whichever block is active,
* the inactive one reading zero. */
* the inactive one reading zero.
*
* This is the MANUFACTURER convention, the one the contract speaks in. It is
* the negation of storageCurrentPower() below, and is written as such so the
* two can never drift apart. */
double batteryPower(const QList<ModuleReading> &readings);
/* Charge minus discharge, so positive means charging. This is the NYMEA
* convention and the opposite of batteryPower() above.
*
* libnymea/interfaces/energystorage.json: "The currentPower state follows the
* usual convention that positive is 'consumed' energy and negative values
* represent 'produced' or in this case 'returned' energy." A battery that is
* charging consumes, so charging is positive.
*
* Getting this backwards is not a cosmetic error: the storage reserve of the
* arbiter would read a discharging battery as charging and free budget it does
* not have. Measured on the bench rather than deduced - see DIVERGENCES.md D-9. */
double storageCurrentPower(const QList<ModuleReading> &readings);
/* Whether the model 160 blocks carry a battery channel at all. A GEN24 with no
* battery publishes N=2 (the two PV strings) and no StCha/StDisCha at all, so
* the absence of both blocks is what "not measurable" looks like on the wire.
*
* The distinction matters because storageCurrentPower() answers 0 both for a
* battery sitting idle and for an inverter that never reports one, and those
* two zeros mean opposite things to a reserve rule. */
bool hasBatteryBlocks(const QList<ModuleReading> &readings);
}
#endif // FRONIUSRULES_H

View File

@ -413,6 +413,7 @@ void IntegrationPluginSunSpec::setupThing(ThingSetupInfo *info)
if (model) {
m_sunSpecStorages.insert(thing, model);
connect(model, &SunSpecModel::blockUpdated, this, &IntegrationPluginSunSpec::onStorageBlockUpdated);
bindStorageCompanionModels(thing, model);
qCDebug(dcSunSpec()) << "Model initialized successfully for" << thing;
} else {
// Note: will be initialized once the connection is available and discovered.
@ -472,6 +473,9 @@ void IntegrationPluginSunSpec::thingRemoved(Thing *thing)
m_sunSpecMeters.remove(thing);
} else if (m_sunSpecStorages.contains(thing)) {
m_sunSpecStorages.remove(thing);
m_storageMpptModels.remove(thing);
m_storageNameplateModels.remove(thing);
m_storagesWarnedWithoutPowerSource.remove(thing->id());
} else {
Q_ASSERT_X(false, "thingRemoved", QString("Unhandled thingClassId: %1").arg(thing->thingClassId().toString()).toUtf8());
}
@ -800,6 +804,12 @@ void IntegrationPluginSunSpec::processDiscoveryResult(Thing *thing, SunSpecConne
connect(model, &SunSpecModel::blockUpdated, this, &IntegrationPluginSunSpec::onStorageBlockUpdated);
qCDebug(dcSunSpec()) << "Model initialized successfully for" << modelThing;
}
/* Outside the guard above: a storage thing bound to its model
* 124 before its connection finished discovering has no
* companion models yet, and would never get them if this only
* ran on the cycle that created the binding. */
bindStorageCompanionModels(modelThing, model);
}
} else {
@ -1612,6 +1622,12 @@ void IntegrationPluginSunSpec::markThingStatesDisconnected(Thing *thing)
thing->setStateValue(sunspecThreePhaseMeterFrequencyStateTypeId, 0);
} else if (thing->thingClassId() == sunspecStorageThingClassId) {
thing->setStateValue(sunspecStorageConnectedStateTypeId, false);
/* Zeroed, like every other power state of this plugin: nymea's power
* balance keeps summing a disconnected energystorage, and a stale
* value would be asserted as a measurement long after the inverter
* stopped answering. `capacity` is deliberately left alone - it is a
* nameplate figure, and losing the link does not shrink the battery. */
thing->setStateValue(sunspecStorageCurrentPowerStateTypeId, 0);
thing->setStateValue(sunspecStorageGridChargingStateTypeId, false);
thing->setStateValue(sunspecStorageChargingRateStateTypeId, 0);
thing->setStateValue(sunspecStorageDischargingRateStateTypeId, 0);
@ -1644,6 +1660,21 @@ 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. */
QSet<SunSpecModel *> storageCompanions;
foreach (SunSpecMpptModel *model, m_storageMpptModels.values())
storageCompanions.insert(model);
foreach (SunSpecNameplateModel *model, m_storageNameplateModels.values())
storageCompanions.insert(model);
foreach (SunSpecModel *model, storageCompanions) {
if (model->connection()->connected()) {
model->readBlockData();
}
}
// Update all other sunspec thing blocks
foreach (SunSpecThing *sunSpecThing, m_sunSpecThings) {
if (sunSpecThing->connection()->connected()) {
@ -2099,6 +2130,154 @@ void IntegrationPluginSunSpec::onStorageBlockUpdated()
}
}
/* Model 124 describes what the battery IS - its state of charge, its status,
* its setpoints - and nothing about what it is DOING in watts. There is no
* power point anywhere in that model. The instantaneous power of a Fronius
* storage lives in model 160, split over two blocks named `StCha` and
* `StDisCha`, and the rated capacity lives in WHRtg of model 120.
*
* Hence this binding: the storage thing stays keyed on its model 124, and
* borrows the two other models of the same connection to fill the two states
* the energystorage interface requires. */
void IntegrationPluginSunSpec::bindStorageCompanionModels(Thing *thing, SunSpecModel *storageModel)
{
if (!thing || !storageModel || !storageModel->connection())
return;
/* The same connection as the model 124, never a search across the parent:
* a Fronius thing carries two connections, and the model 160 of the
* inverter must not be paired with a storage read from the meter unit. */
SunSpecConnection *connection = storageModel->connection();
foreach (SunSpecModel *model, connection->models()) {
if (model->modelId() == SunSpecModelFactory::ModelIdMppt) {
SunSpecMpptModel *mppt = qobject_cast<SunSpecMpptModel *>(model);
if (mppt && m_storageMpptModels.value(thing) != mppt) {
m_storageMpptModels.insert(thing, mppt);
connect(mppt, &SunSpecModel::blockUpdated, this,
&IntegrationPluginSunSpec::onStorageMpptBlockUpdated, Qt::UniqueConnection);
qCDebug(dcSunSpec()) << "Storage" << thing->name() << "takes its power from" << mppt;
}
} else if (model->modelId() == SunSpecModelFactory::ModelIdNameplate) {
SunSpecNameplateModel *nameplate = qobject_cast<SunSpecNameplateModel *>(model);
if (nameplate && m_storageNameplateModels.value(thing) != nameplate) {
m_storageNameplateModels.insert(thing, nameplate);
connect(nameplate, &SunSpecModel::blockUpdated, this,
&IntegrationPluginSunSpec::onStorageNameplateBlockUpdated, Qt::UniqueConnection);
qCDebug(dcSunSpec()) << "Storage" << thing->name() << "takes its capacity from" << nameplate;
}
}
}
if (!m_storageMpptModels.contains(thing)) {
/* Said once, and said loudly. This thing now claims the energystorage
* interface, so nymea's power balance and the storage reserve of the
* arbiter will read its currentPower - and it will never leave zero.
* A filter that finds a battery reporting a flat zero is worse than a
* filter that finds none: the first reads as "neither charging nor
* discharging", the second as "no battery". */
if (!m_storagesWarnedWithoutPowerSource.contains(thing->id())) {
m_storagesWarnedWithoutPowerSource.insert(thing->id());
qCWarning(dcSunSpec()) << "Storage" << thing->name() << "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.";
}
return;
}
/* Publish straight away when the companion models already carry data, so a
* thing bound after a discovery does not wait a whole refresh cycle. */
if (!m_storageMpptModels.value(thing)->repeatingBlocks().isEmpty())
updateStorageCurrentPower(thing);
updateStorageCapacity(thing);
}
QList<FroniusRules::ModuleReading> IntegrationPluginSunSpec::storageModuleReadings(Thing *thing) const
{
QList<FroniusRules::ModuleReading> readings;
SunSpecMpptModel *mppt = m_storageMpptModels.value(thing);
if (!mppt)
return readings;
foreach (SunSpecModelRepeatingBlock *block, mppt->repeatingBlocks()) {
SunSpecMpptModelRepeatingBlock *module = qobject_cast<SunSpecMpptModelRepeatingBlock *>(block);
if (!module)
continue;
/* Keyed on the ID string, never on the block index: on a three or four
* MPPT inverter the battery blocks are not in position 3 and 4. */
readings.append({module->inputIdSting(), static_cast<double>(module->dcPower())});
}
return readings;
}
void IntegrationPluginSunSpec::updateStorageCurrentPower(Thing *thing)
{
const QList<FroniusRules::ModuleReading> readings = storageModuleReadings(thing);
if (!FroniusRules::hasBatteryBlocks(readings)) {
/* An inverter with no battery publishes N=2 and no StCha/StDisCha at
* all. Summing an empty set would answer a confident 0 W. */
if (!m_storagesWarnedWithoutPowerSource.contains(thing->id())) {
m_storagesWarnedWithoutPowerSource.insert(thing->id());
qCWarning(dcSunSpec()) << "Storage" << thing->name() << "has a model 160 but no StCha/StDisCha block:"
<< "currentPower has nothing to read and will stay at 0.";
}
return;
}
m_storagesWarnedWithoutPowerSource.remove(thing->id());
/* Charge minus discharge. The manufacturer counts the other way round and
* so does FroniusRules::batteryPower(); the interface this state belongs to
* counts a charging battery as consuming, hence positive. */
const double currentPower = FroniusRules::storageCurrentPower(readings);
thing->setStateValue(sunspecStorageCurrentPowerStateTypeId, currentPower);
}
void IntegrationPluginSunSpec::updateStorageCapacity(Thing *thing)
{
SunSpecNameplateModel *nameplate = m_storageNameplateModels.value(thing);
if (!nameplate)
return;
const double capacity = SunSpecStorageLogic::nameplateCapacityKwh(nameplate->whRtg());
if (capacity < 0) {
/* The nameplate does not carry a rated storage capacity. Left at its
* previous value rather than overwritten with a zero that would read
* as a measurement. */
qCDebug(dcSunSpec()) << "Storage" << thing->name() << "nameplate carries no WHRtg; capacity left untouched.";
return;
}
thing->setStateValue(sunspecStorageCapacityStateTypeId, capacity);
}
void IntegrationPluginSunSpec::onStorageMpptBlockUpdated()
{
SunSpecModel *model = qobject_cast<SunSpecModel *>(sender());
if (!model)
return;
/* Several storage things may share one model 160 - one connection, one
* inverter, and in theory more than one storage block. */
foreach (Thing *thing, m_storageMpptModels.keys(qobject_cast<SunSpecMpptModel *>(model)))
updateStorageCurrentPower(thing);
}
void IntegrationPluginSunSpec::onStorageNameplateBlockUpdated()
{
SunSpecModel *model = qobject_cast<SunSpecModel *>(sender());
if (!model)
return;
foreach (Thing *thing, m_storageNameplateModels.keys(qobject_cast<SunSpecNameplateModel *>(model)))
updateStorageCapacity(thing);
}
void IntegrationPluginSunSpec::onSolarEdgeBatteryBlockUpdated()
{
SolarEdgeBattery *battery = qobject_cast<SolarEdgeBattery *>(sender());

View File

@ -31,6 +31,8 @@
#include <sunspecconnection.h>
#include <models/sunspecmodelfactory.h>
#include <models/sunspecmpptmodel.h>
#include <models/sunspecnameplatemodel.h>
#include "sunspecthing.h"
#include "sunspecstoragelogic.h"
@ -108,6 +110,26 @@ private:
QHash<Thing *, SunSpecModel *> m_sunSpecMeters;
QHash<Thing *, SunSpecModel *> m_sunSpecStorages;
/* Companion models of a storage thing, on the same connection as its model
* 124. They are not things of their own and never will be: they describe
* the same battery, from the two registers model 124 does not carry.
*
* - model 160 gives the instantaneous power, through its StCha /
* StDisCha blocks. Without it the `currentPower` of the energystorage
* interface has no source at all;
* - model 120 gives the rated capacity in WHRtg.
*
* Both may legitimately be absent, and a storage thing without them keeps
* working exactly as it did before - minus the two states. */
QHash<Thing *, SunSpecMpptModel *> m_storageMpptModels;
QHash<Thing *, SunSpecNameplateModel *> m_storageNameplateModels;
/* Storage things already warned about a missing model 160. The warning has
* to be said once and not on every poll, but it does have to be said: an
* energystorage whose currentPower can never move is exactly the silent
* failure this whole change exists to remove. */
QSet<ThingId> m_storagesWarnedWithoutPowerSource;
/* Connections having completed at least one successful discovery. The
* orphan sweep needs to know whether it is looking at the whole picture or
* at half of it. Entries are removed when the connection is destroyed. */
@ -194,6 +216,19 @@ private:
void refreshStorageBlock(ThingActionInfo *info, SunSpecStorageModel *storage, const std::function<void()> &continuation);
void verifySetpointPlan(ThingActionInfo *info, SunSpecStorageModel *storage, const SunSpecStorageLogic::SetpointPlan &plan);
/* Binds a storage thing to the model 160 and model 120 of its own
* connection, and starts publishing currentPower and capacity from them.
* Idempotent: called from both paths that bind a storage thing to its model
* 124, and from either order. */
void bindStorageCompanionModels(Thing *thing, SunSpecModel *storageModel);
/* The model 160 blocks of a storage thing, in the shape the pure rules
* take. Empty when the thing has no model 160 bound. */
QList<FroniusRules::ModuleReading> storageModuleReadings(Thing *thing) const;
void updateStorageCurrentPower(Thing *thing);
void updateStorageCapacity(Thing *thing);
private slots:
void onRefreshTimer();
void onPluginConfigurationChanged(const ParamTypeId &paramTypeId, const QVariant &value);
@ -201,6 +236,8 @@ private slots:
void onInverterBlockUpdated();
void onMeterBlockUpdated();
void onStorageBlockUpdated();
void onStorageMpptBlockUpdated();
void onStorageNameplateBlockUpdated();
void onSolarEdgeBatteryBlockUpdated();
void evaluateEnergyProducedValue(Thing *inverterThing, float energyProduced);

View File

@ -1311,7 +1311,7 @@
"displayName": "SunSpec Storage",
"id": "9a643ba8-346c-4127-a2f8-956a7133d75e",
"createMethods": [ "Auto" ],
"interfaces": ["battery", "connectable"],
"interfaces": ["battery", "energystorage", "connectable"],
"paramTypes": [
{
"id": "219beb96-b9fe-4dd2-a386-ecfbbab8786d",
@ -1498,6 +1498,24 @@
"unit": "Percentage",
"defaultValue": 0
},
{
"id": "58083d0c-026f-4ad8-bdf4-587b33962e31",
"name": "currentPower",
"displayName": "Current power",
"displayNameEvent": "Current power changed",
"type": "double",
"unit": "Watt",
"defaultValue": 0
},
{
"id": "c02a512d-e9b4-480e-87cc-ed81fb023a66",
"name": "capacity",
"displayName": "Capacity",
"displayNameEvent": "Capacity changed",
"type": "double",
"unit": "KiloWattHour",
"defaultValue": 0
},
{
"id": "fca0c151-d8fa-4d6e-a5d7-b3bc965abc09",
"name": "version",

View File

@ -56,6 +56,14 @@ StorageStates readStates(SunSpecStorageModel *storage)
return states;
}
double nameplateCapacityKwh(double ratedEnergyWh)
{
if (ratedEnergyWh <= 0)
return -1;
return ratedEnergyWh / 1000.0;
}
bool isScaledRate(Register reg)
{
return reg == RegisterInWRte || reg == RegisterOutWRte;

View File

@ -103,6 +103,20 @@ const int MaximumRevertTimeSeconds = 28800;
StorageStates readStates(SunSpecStorageModel *storage);
/* Nameplate rated storage capacity, Wh -> kWh, for the `capacity` state of the
* energystorage interface.
*
* Model 124 carries no energy capacity: WChaMax is a power, StorAval is in amp
* hours and reads zero on every machine measured so far. The rated capacity of
* the storage lives in WHRtg of the nameplate model 120.
*
* Returns a NEGATIVE value when the nameplate carries no capacity, so that the
* caller can leave the state alone instead of publishing a fabricated zero. A
* nameplate that does not implement WHRtg reads 0xFFFF, which libnymea-sunspec
* reports as an invalid data point and leaves at 0 - indistinguishable here
* from a nameplate that genuinely says zero, and both mean "unknown". */
double nameplateCapacityKwh(double ratedEnergyWh);
/* True for the registers carried by InOutWRte_SF, i.e. those whose write should
* be preceded by a fresh read of that scale factor. */
bool isScaledRate(Register reg);

View File

@ -161,6 +161,19 @@ private slots:
void test_AC_15_battery_power_is_positive_when_discharging();
void test_AC_15_battery_power_is_negative_when_charging();
// D-9 - interface energystorage du sunspecStorage
void test_D9_storage_declares_energystorage_without_losing_battery();
void test_D9_storage_exposes_current_power_in_watt();
void test_D9_storage_exposes_capacity_in_kilowatthour();
void test_D9_current_power_is_positive_when_charging();
void test_D9_current_power_is_negative_when_discharging();
void test_D9_current_power_is_the_opposite_of_battery_power();
void test_D9_current_power_ignores_the_pv_strings();
void test_D9_battery_blocks_are_detected_by_id_string();
void test_D9_an_inverter_without_battery_has_no_battery_block();
void test_D9_capacity_converts_watthours_to_kilowatthours();
void test_D9_a_nameplate_without_capacity_is_refused();
void test_AC_16_fronius_meter_keeps_the_device_sign();
void test_AC_16_generic_meter_keeps_the_historical_negation();
void test_AC_16_field_case_export_is_reported_negative();
@ -911,6 +924,157 @@ void SunSpecFroniusTest::test_F3_meter_slave_id_is_not_marked_advanced()
// AC-16 - signe du compteur reseau
// ---------------------------------------------------------------------------
/* D-9 - Le Thing declare `battery` mais pas `energystorage`, donc les trois
* consommateurs qui filtrent par `energystorage` ne le trouvent pas : le bilan
* de puissance (currentPowerStorage, celui du tableau de bord client), la
* reserve de recharge VE, et la reserve du waterfall. Voir DIVERGENCES.md D-9. */
void SunSpecFroniusTest::test_D9_storage_declares_energystorage_without_losing_battery()
{
const QJsonObject storage = thingClassByName("sunspecStorage");
QVERIFY(!storage.isEmpty());
QStringList interfaces;
foreach (const QJsonValue &value, storage.value("interfaces").toArray())
interfaces.append(value.toString());
QVERIFY2(interfaces.contains("energystorage"),
"sans energystorage, filterByInterface ne trouve pas la batterie");
// `battery` est conservee : energystorage l'etend, et d'autres choses
// peuvent en dependre.
QVERIFY(interfaces.contains("battery"));
QVERIFY(interfaces.contains("connectable"));
}
void SunSpecFroniusTest::test_D9_storage_exposes_current_power_in_watt()
{
const QJsonObject storage = thingClassByName("sunspecStorage");
QVERIFY(!storage.isEmpty());
const QJsonObject state = stateByName(storage, "currentPower");
QVERIFY2(!state.isEmpty(), "energystorage exige currentPower");
// Type et unite sont verifies par PluginMetadata contre l'interface : s'ils
// divergent, le plugin ne se charge plus du tout.
QCOMPARE(state.value("type").toString(), QString("double"));
QCOMPARE(state.value("unit").toString(), QString("Watt"));
}
void SunSpecFroniusTest::test_D9_storage_exposes_capacity_in_kilowatthour()
{
const QJsonObject storage = thingClassByName("sunspecStorage");
QVERIFY(!storage.isEmpty());
const QJsonObject state = stateByName(storage, "capacity");
QVERIFY2(!state.isEmpty(), "energystorage exige capacity");
QCOMPARE(state.value("type").toString(), QString("double"));
QCOMPARE(state.value("unit").toString(), QString("KiloWattHour"));
}
/* Convention nymea, relevee dans libnymea/interfaces/energystorage.json :
* positif = consomme. Une batterie qui charge consomme.
*
* Mesure du 2026-08-26 sur le banc (.211 unit 1, modele 160 @40265), consigne
* batterie imposee par MQTT et registres relus en lecture seule :
* consigne +2000 W -> StCha 2000,0 W StDisCha 0,0 W ChaSt Charging
* consigne -1500 W -> StCha 0,0 W StDisCha 1500,0 W ChaSt Discharging
* Le signe n'est donc pas deduit de la documentation, il est constate. */
void SunSpecFroniusTest::test_D9_current_power_is_positive_when_charging()
{
using namespace FroniusRules;
const QList<ModuleReading> readings = {
{"MPPT 1", 496.5},
{"MPPT 2", 496.5},
{"StCha 3", 2000.0},
{"StDisCha 4", 0.0}
};
QCOMPARE(storageCurrentPower(readings), 2000.0);
}
void SunSpecFroniusTest::test_D9_current_power_is_negative_when_discharging()
{
using namespace FroniusRules;
const QList<ModuleReading> readings = {
{"MPPT 1", 621.8},
{"MPPT 2", 621.8},
{"StCha 3", 0.0},
{"StDisCha 4", 1500.0}
};
QCOMPARE(storageCurrentPower(readings), -1500.0);
}
/* Les deux conventions coexistent dans le plugin - celle du constructeur pour
* le contrat, celle de nymea pour l'etat. Ce test les tient dos a dos : une
* seule des deux peut changer de signe sans que rien ne le dise. */
void SunSpecFroniusTest::test_D9_current_power_is_the_opposite_of_battery_power()
{
using namespace FroniusRules;
const QList<ModuleReading> charging = {{"StCha 3", 2000.0}, {"StDisCha 4", 0.0}};
const QList<ModuleReading> discharging = {{"StCha 3", 0.0}, {"StDisCha 4", 1500.0}};
QCOMPARE(storageCurrentPower(charging), -batteryPower(charging));
QCOMPARE(storageCurrentPower(discharging), -batteryPower(discharging));
}
void SunSpecFroniusTest::test_D9_current_power_ignores_the_pv_strings()
{
using namespace FroniusRules;
// Les strings PV ont ete a 746,4 W chacun pendant la phase de repos du
// releve, batterie a l'arret. Les compter donnerait 1492,8 W de charge.
const QList<ModuleReading> readings = {
{"MPPT 1", 746.4},
{"MPPT 2", 746.4},
{"StCha 3", 0.0},
{"StDisCha 4", 0.0}
};
QCOMPARE(storageCurrentPower(readings), 0.0);
}
void SunSpecFroniusTest::test_D9_battery_blocks_are_detected_by_id_string()
{
using namespace FroniusRules;
QVERIFY(hasBatteryBlocks({{"MPPT 1", 1200.0}, {"StCha 3", 0.0}}));
QVERIFY(hasBatteryBlocks({{"MPPT 1", 1200.0}, {"StDisCha 4", 0.0}}));
}
/* La distinction que ce test protege : storageCurrentPower() repond 0 aussi
* bien pour une batterie a l'arret que pour un onduleur qui n'en a pas. Sans
* hasBatteryBlocks(), le plugin publierait un 0 confiant sur une machine qui
* n'a rien a mesurer - exactement le zero qui est pire que l'absence. */
void SunSpecFroniusTest::test_D9_an_inverter_without_battery_has_no_battery_block()
{
using namespace FroniusRules;
// Un GEN24 sans batterie publie N=2 : les deux strings PV, rien d'autre.
QVERIFY(!hasBatteryBlocks({{"MPPT 1", 1200.0}, {"MPPT 2", 800.0}}));
QVERIFY(!hasBatteryBlocks({}));
}
void SunSpecFroniusTest::test_D9_capacity_converts_watthours_to_kilowatthours()
{
QCOMPARE(SunSpecStorageLogic::nameplateCapacityKwh(10000.0), 10.0);
QCOMPARE(SunSpecStorageLogic::nameplateCapacityKwh(7500.0), 7.5);
}
/* WHRtg non implemente vaut 0xFFFF, que libnymea-sunspec rend comme un point de
* donnee invalide laisse a 0 - indistinguable d'un nameplate qui annonce
* vraiment zero. Les deux veulent dire "inconnu", et aucun des deux ne doit
* etre publie comme une mesure. */
void SunSpecFroniusTest::test_D9_a_nameplate_without_capacity_is_refused()
{
QVERIFY(SunSpecStorageLogic::nameplateCapacityKwh(0.0) < 0);
QVERIFY(SunSpecStorageLogic::nameplateCapacityKwh(-1.0) < 0);
}
void SunSpecFroniusTest::test_AC_16_fronius_meter_keeps_the_device_sign()
{
// Le Smart Meter Fronius emet deja dans la convention nymea : negatif =