Merge fix/fronius-meter-sign-and-battery-presence into experimental-silo

Aligne la ligne nightly sur le support Fronius GEN24 déjà monté en testing :
signe du compteur, batterie fantôme, ordre des mots des float32.
nymea-plugin-sunspec-etm01 passe de 1.15.2+etm6 à +etm9.

La branche descend d'experimental-silo, donc cette fusion n'apporte que les
huit commits du lot — rien de landing-silo ne remonte ici. En particulier
waveshare-relay-d8 reste PRÉSENT : la suppression f25b620 vit sur la ligne
landing/main et n'appartient pas à l'historique fusionné, conformément à
l'avertissement de ce commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-08-08 08:36:36 +02:00
commit f98542fe0c
8 changed files with 842 additions and 54 deletions

55
debian/changelog vendored
View File

@ -1,3 +1,58 @@
etm-powersync-plugins-modbus (1.15.2+etm9) trixie; urgency=medium
* sunspec: read Fronius float32 points big endian, the way the hardware
emits them. byteOrder() returned ByteOrderLittleEndian on the grounds that
"the SunSpec default is assumed"; that conflated the default of the
specification with libnymea-sunspec's own, which is the opposite. Every
float32 of the Fronius personality was therefore read with its two
registers swapped. Measured on a Symo, read only: the model 213 meter on
unit 200 serves Hz = 4248 0000, which is 50.0000 big endian and 2.3777e-41
swapped. Reproduced on the bench: a meter importing 2.7 kW reported as
exporting 107 MW, with the arbiter switching every load on, and the thing
still reporting itself connected with no Modbus error. The meter sign fix
only becomes observable once this is right. solarEdge already forces big
endian and the generic vendor keeps its user parameter; neither changes.
* sunspec: add tools/read-float-order.py, a read-only probe that decodes Hz
and PhVphA both ways so the word order can be settled by measurement
rather than assumption.
-- Patrick Schurig <patrick@etm-schurig.eu> Fri, 07 Aug 2026 07:00:00 +0200
etm-powersync-plugins-modbus (1.15.2+etm8) trixie; urgency=medium
* sunspec: ask the connection thing directly instead of resolving it by id.
The battery presence gate went through isFroniusChild(), which looks the
parent up in myThings(). During the initial setup the parent is not
registered yet, the lookup answers false, the Fronius gate is bypassed and
the phantom storage child is created after all. Caught on the bench, not
by the unit suite, which covers the decision and not the wiring. See
sunspec/DIVERGENCES.md D-7.
-- Patrick Schurig <patrick@etm-schurig.eu> Wed, 05 Aug 2026 19:20:00 +0200
etm-powersync-plugins-modbus (1.15.2+etm7) trixie; urgency=medium
* sunspec: correct the grid meter sign on Fronius. onMeterBlockUpdated()
negated the power of every meter model regardless of vendor, but the
Fronius Smart Meter already reports in the nymea convention, where a
negative value is feed-in. The negation is now undone for meters sitting
under a froniusConnection and only for those; generic and solarEdge keep
their previous behaviour. Established on a Primo GEN24 3.0, whose single
phase inverter gives the direction reference: the plugin reported the
inverter's own phase as importing while the two phases carrying no source
read as exporting. The arbiter concluded "insufficient surplus" and left
the water heater and the heat pump off while the site was exporting.
The energy counters were already correct and are left alone.
* sunspec: stop creating a phantom battery on Fronius. A hybrid GEN24
publishes model 124 whether or not a battery is wired to it, so a storage
child was created unconditionally and sat on batteryCritical forever. The
child is now created only when a physical quantity says a battery is
there. The state of charge is deliberately not used: 0% cannot be told
apart from a real but empty battery. See sunspec/DIVERGENCES.md D-6, the
battery-present side is still inferred.
-- Patrick Schurig <patrick@etm-schurig.eu> Wed, 05 Aug 2026 15:58:00 +0200
etm-powersync-plugins-modbus (1.15.2+etm6) trixie; urgency=medium
* sunspec: package the vendored SunSpec plugin as nymea-plugin-sunspec-etm01.

View File

@ -111,3 +111,135 @@ ajoutée par F3, elle, se retire proprement de sa table à la destruction.
**Comment lever.** Relier `QObject::destroyed` au retrait de la table pour les
deux connexions, dans un lot qui assume de toucher le chemin commun, avec une
couverture de test pour `solarEdge`.
---
## D-5 — le signe du compteur est corrigé côté Fronius seulement (MESURÉ)
**Contexte.** `onMeterBlockUpdated()` négociait le signe de façon **générique** :
tous les modèles compteur (201, 202, 203, 204, 211…214) faisaient
`-meter->watts()`. Il n'existait aucun mapping spécifique à un fabricant.
**L'écart.** Sur Fronius cette négation est fausse : le Smart Meter émet déjà
dans la convention nymea (négatif = injection). Le correctif n'annule la
négation que lorsque le compteur est enfant d'une `froniusConnection` ; les
vendors generic et solarEdge gardent la négation historique, verrouillée par
`test_AC_16_generic_meter_keeps_the_historical_negation`.
**Preuve.** Relevé du 02/08/2026 sur un Primo GEN24 3.0, onduleur **monophasé**
(modèle 101) et Smart Meter TS 65A-3 (modèle **203**, pas 213). L'onduleur
n'injecte que sur la phase A ; le plugin rapportait phase A = +1003 W
(soutirage) et phases B et C à −33 W et −262 W, soit une injection sur deux
phases ne portant aucune source. En inversant, le bilan se referme : production
1190 W = charge phase A 187 W + export 1003 W ; import B+C 294 W ; consommation
481 W ; export réseau 709 W contre 708 W mesuré. Corroboration indépendante :
sur la même fenêtre, le compteur d'énergie *import* de l'appareil reste à 0
pendant que l'*export* s'incrémente. Conséquence observée :
`[Arbitre] "chauffe-eau" → "Surplus insuffisant (-842 W)"` alors que le site
exportait ~842 W.
**Non touché.** Les compteurs d'énergie (`totalEnergyConsumed` /
`totalEnergyProduced`) sont **corrects** et ne sont pas permutés : ce sont eux
qui servent de témoin à charge ci-dessus.
**Comment lever.** Déterminer si la négation générique est fausse pour tous les
fabricants ou seulement pour Fronius demanderait un compteur d'un autre vendor
sur banc. Tant que ce n'est pas tranché, le générique ne bouge pas.
---
## D-6 — présence batterie : le côté « batterie présente » n'est pas validé (INFÉRÉ)
**Contexte.** Un GEN24 hybride publie le modèle 124 même sans batterie câblée.
Le plugin créait donc un enfant storage systématiquement, d'où batterie fantôme
et `batteryCritical` permanent.
**L'écart.** `FroniusRules::hasBattery()` exige désormais `InBatV > 0` ou
`StorAval > 0` pour créer l'enfant, et seulement sous une `froniusConnection`.
Le SoC est écarté (0 % ne se distingue pas d'une batterie vide) ainsi que les
registres de consigne (`WChaMax`… gardent leurs défauts sans batterie).
**Ce qui est établi.** Le côté « absente » est confirmé sur matériel : le GEN24
des beaux-parents, sans batterie, exposait le modèle 124 avec `ChaSt = Off` et
un SoC de 0, et nymea journalisait « There is no energy storage available to
consider » pendant que l'enfant restait visible.
**Ce qui ne l'est pas.** `InBatV` et `StorAval` n'étant pas exposés en states
nymea, leurs valeurs sur ce GEN24 sans batterie **n'ont pas pu être relevées**,
et aucun GEN24 **avec** batterie n'était joignable. L'hypothèse « une batterie
installée porte une tension au repos » est donc **INFÉRÉE des deux côtés**.
**Comment lever.** Relever en lecture seule le bloc 124 d'un GEN24 équipé d'une
batterie et vérifier `InBatV`/`StorAval` non nuls. **À faire avant tout
déploiement** : un discriminateur trop strict ferait disparaître une vraie
batterie chez un client.
---
## D-7 — la présence batterie ne peut pas être vérifiée par la suite logique (MESURÉ)
**Contexte.** La première version du garde de D-6 demandait
`isFroniusChild(thing->id())`, qui résout le parent par `myThings().findById()`.
**L'écart.** Pendant le setup initial, la connexion parente n'est **pas encore
enregistrée** dans `myThings()` : la recherche répond faux, le garde Fronius est
contourné, et l'enfant storage est créé malgré `InBatV = StorAval = 0`. Trace
nymead correspondante :
`ThingManager: Invalid parent thing id … Not adding thing to the system.`
Le garde interroge désormais directement le `thing` de connexion que la fonction
tient déjà (`thing->thingClassId() == froniusConnectionThingClassId`), sans
aucune recherche. Même traitement pour le signe compteur, qui utilise le
`parentThing` déjà résolu et non nul.
**Ce que cela apprend.** La suite `tests/logic` ne peut pas attraper ce défaut :
elle couvre la décision pure (`FroniusRules`), pas le câblage du plugin, c'est à
dire *quel prédicat est interrogé et à quel moment du cycle de vie*. Seul un
essai en boucle fermée contre un simulateur le révèle. Toute modification du
garde de présence doit donc être rejouée sur banc, pas seulement en unitaire.
**Comment lever.** Étendre `tests/plugin`, qui instancie réellement le plugin, à
un scénario de setup initial avec un modèle 124 à zéro. Hors périmètre ici.
---
## D-8 — ordre des mots des float32 : le défaut de la bibliothèque n'est pas celui de la spec (MESURÉ)
**Contexte.** `FroniusRules::byteOrder()` renvoyait `ByteOrderLittleEndian`, avec
pour justification « non documenté par le constructeur, on suppose le défaut
SunSpec ».
**L'écart.** La supposition confondait deux « défauts ». Un float32 SunSpec
occupe deux registres, **mot de poids fort d'abord**. Or
`SunSpecDataPoint::convertToUInt32` lit `registers.at(0)` puis `.at(1)` en
`ByteOrderBigEndian`, et l'**inverse** en `ByteOrderLittleEndian` — qui est le
défaut de libnymea-sunspec. Garder ce défaut revient à lire tous les float32 de
la personnalité Fronius avec leurs deux mots permutés.
**Mesure.** Relevé du 2026-08-07 sur un Fronius Symo réel, en lecture seule
(`sunspec/tools/read-float-order.py`) :
| point | registres bruts | big endian | mots inversés |
|---|---|---|---|
| compteur 213 u200 `Hz` | `4248 0000` | **50,0000** | 2,3777e−41 |
| compteur 213 u200 `PhVphA` | `436a 8000` | **234,5000** | −2,4184e−41 |
| onduleur 113 u1 `Hz` | `4247 fc48` | **49,9964** | −4,1592e+36 |
**Ce que cela coûtait.** Pas un décalage : une absurdité. Reproduit sur banc avec
le simulateur émettant l'ordre du matériel — un compteur **important 2,7 kW** a
été rapporté comme **exportant 107 MW**, `Current load on root meter:
-1.07621e+08W`, et l'arbitre a enclenché toutes les charges sur un « Surplus PV
107622496 W ». Un tel appareil se présente `connected = true`, sans aucune
erreur Modbus.
**Ordre des correctifs.** `meterPowerSign()` (D-5) reste juste, mais n'est
**observable** qu'une fois l'ordre des mots corrigé : corriger le signe d'un
dénormal ne produit rien.
**Non touché.** Le vendor solarEdge force déjà big endian sur son propre chemin ;
le vendor générique conserve son paramètre `endianness` réglable par
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.

View File

@ -35,9 +35,26 @@ bool matchesManufacturer(const QStringList &manufacturers)
return false;
}
double meterPowerSign(bool froniusMeter)
{
/* The Fronius meter is already in the nymea convention, so the generic
* negation has to be undone for it, and only for it. */
return froniusMeter ? 1.0 : -1.0;
}
bool hasBattery(double batteryVoltage, double availableCharge)
{
/* Strictly positive only: zero is the no-battery reading, and a negative
* value is the library's way of surfacing a not-implemented point rather
* than a real measurement. */
return batteryVoltage > 0.0 || availableCharge > 0.0;
}
SunSpecDataPoint::ByteOrder byteOrder()
{
return SunSpecDataPoint::ByteOrderLittleEndian;
/* Big endian is what the hardware emits, and what SunSpec specifies. The
* library's default is the opposite; see the header for the measurement. */
return SunSpecDataPoint::ByteOrderBigEndian;
}
MeterConnectionDecision decideMeterConnection(int meterSlaveId, int inverterSlaveId)

View File

@ -82,10 +82,82 @@ MeterConnectionDecision decideMeterConnection(int meterSlaveId, int inverterSlav
bool matchesManufacturer(const QStringList &manufacturers);
/* INFERRED: the byte order of the GEN24 is not documented by the manufacturer,
* so the SunSpec default is assumed. The forced big endian of the SolarEdge
* vendor is an anomaly specific to SolarEdge and must not be extended here.
* To be confirmed on the bench, then on hardware. */
/* Sign to apply to the raw SunSpec meter power registers (W, WphA...) to get
* the nymea smartmeter convention, where positive is import and negative is
* export.
*
* The Fronius Smart Meter already reports in that convention: negative W is
* feed-in. The generic path negates, so on a Fronius the surplus came out with
* the wrong sign and the arbiter refused to run loads while the site was
* exporting.
*
* Established on hardware, not from the manufacturer's documentation. On a
* Primo GEN24 3.0, a *single phase* inverter feeding phase A only, the plugin
* reported phase A at +1003 W (import) while phases B and C read -33 W and
* -262 W (export). Exporting on two phases carrying no source is impossible.
* With the sign flipped the balance closes exactly: production 1190 W = phase A
* load 187 W + phase A export 1003 W, B and C import 294 W, so the house draws
* 481 W and the site exports 709 W against 708 W measured. The meter's own
* energy counters agree: over the same window its import counter stayed at
* zero while its export counter advanced.
*
* Only the instantaneous power is affected. The energy counters map correctly
* already and must not be swapped.
*
* The generic default is left untouched: other vendors keep the negation. */
double meterPowerSign(bool froniusMeter);
/* Whether a model 124 block is backed by a physically installed battery.
*
* A hybrid GEN24 publishes model 124 whether or not a battery is wired to it,
* so the presence of the model proves nothing. Creating a storage child anyway
* gives the customer a phantom battery and a permanent batteryCritical alarm:
* observed on hardware, a GEN24 with no battery reported ChaSt = Off and a
* state of charge of 0, and nymea's own energy engine then logged "There is no
* energy storage available to consider" while the child stayed visible.
*
* The discriminator is deliberately a *physical* quantity. The state of charge
* is refused because 0% cannot be told apart from a real but empty battery, and
* the setpoint registers (WChaMax and friends) are refused because they keep
* their defaults with no battery attached.
*
* INFÉRÉ: an installed battery carries a DC voltage at rest, so InBatV or
* StorAval is expected to be strictly positive whenever one is present, and
* both zero when none is. The absent side matches what the battery-less GEN24
* showed and is reproduced on the simulator. The present side could NOT be
* confirmed: no GEN24 with a battery was reachable. Confirm InBatV/StorAval on
* a GEN24 with a battery before this ships. See DIVERGENCES.md. */
bool hasBattery(double batteryVoltage, double availableCharge);
/* Word order of the float32 points, measured on hardware.
*
* This used to read "INFERRED: not documented by the manufacturer, so the
* SunSpec default is assumed. To be confirmed on the bench, then on hardware."
* It has now been confirmed, and the assumption was wrong: what was assumed to
* be "the SunSpec default" was in fact libnymea-sunspec's own default, which is
* the opposite of the specification.
*
* A float32 spans two registers. SunSpec puts the high word first.
* SunSpecDataPoint::convertToUInt32 reads registers.at(0) then .at(1) for
* ByteOrderBigEndian, and the reverse for ByteOrderLittleEndian — and little
* endian is the library's default. Keeping that default on a Fronius means
* reading every float32 with its two words swapped.
*
* Measured 2026-08-07 on a Fronius Symo, read only:
* meter 213, unit 200 : Hz = 4248 0000 -> 50.0000 big endian
* PhVphA = 436a 8000 -> 234.5000
* inverter 113, unit 1: Hz = 4247 fc48 -> 49.9964
* Swapped, those same registers give 2.4e-41 and -4.2e+36: denormals, the
* signature of a wrong interpretation rather than a measurement.
*
* What it costs to get this wrong is not a small offset. Reproduced on the
* bench with the simulator emitting the hardware's order: a meter importing
* 2.7 kW was reported as exporting 107 MW, and the arbiter switched every load
* on. Correcting the SIGN of a denormal changes nothing, so meterPowerSign()
* only becomes observable once this is right.
*
* The SolarEdge vendor already forces big endian on its own path; the generic
* vendor keeps its user-settable parameter. Neither is affected. */
SunSpecDataPoint::ByteOrder byteOrder();
int clampUpdateInterval(int requestedSeconds);

View File

@ -835,9 +835,28 @@ void IntegrationPluginSunSpec::processDiscoveryResult(Thing *thing, SunSpecConne
case SunSpecModelFactory::ModelIdMeterThreePhaseDeltaConnect:
autocreateSunSpecModelThing(sunspecThreePhaseMeterThingClassId, QT_TR_NOOP("Three Phase Meter"), thing->id(), model);
break;
case SunSpecModelFactory::ModelIdStorage:
case SunSpecModelFactory::ModelIdStorage: {
/* A hybrid GEN24 publishes model 124 even with no battery
* wired to it. Creating the child anyway gives the customer a
* phantom battery stuck on batteryCritical. Only the Fronius
* path is gated; the generic and solarEdge vendors keep their
* previous behaviour. */
/* Asked of the connection thing we already hold, not through
* isFroniusChild(): during the initial setup the parent is not
* registered in myThings() yet, so a lookup answers false and
* the gate would be skipped exactly when it is needed. */
SunSpecStorageModel *storageModel = qobject_cast<SunSpecStorageModel *>(model);
if (storageModel && thing->thingClassId() == froniusConnectionThingClassId
&& !FroniusRules::hasBattery(storageModel->inBatV(), storageModel->storAval())) {
qCDebug(dcSunSpec()) << "Fronius model 124 found but no battery is installed"
<< "(InBatV" << storageModel->inBatV()
<< "StorAval" << storageModel->storAval()
<< "). Not creating a storage thing.";
break;
}
autocreateSunSpecModelThing(sunspecStorageThingClassId, QT_TR_NOOP("Storage"), thing->id(), model);
break;
}
default:
qCWarning(dcSunSpec()) << "Plugin has no implementation for detected" << model;
break;
@ -1835,15 +1854,21 @@ void IntegrationPluginSunSpec::onMeterBlockUpdated()
if (!parentThing)
return;
/* Sign applied to every power reading of this block. The generic vendors
* keep the historical negation; a meter sitting under a Fronius connection
* already reports in the nymea convention. See FroniusRules. */
const double powerSign = FroniusRules::meterPowerSign(
parentThing->thingClassId() == froniusConnectionThingClassId);
switch (model->modelId()) {
case SunSpecModelFactory::ModelIdMeterSinglePhase: {
SunSpecMeterSinglePhaseModel *meter = qobject_cast<SunSpecMeterSinglePhaseModel *>(model);
qCDebug(dcSunSpec()) << thing->name() << "block data updated";// << meter;
thing->setStateValue(sunspecSinglePhaseMeterConnectedStateTypeId, true);
thing->setStateValue(sunspecSinglePhaseMeterCurrentPowerStateTypeId, -meter->watts());
thing->setStateValue(sunspecSinglePhaseMeterCurrentPowerStateTypeId, powerSign * meter->watts());
thing->setStateValue(sunspecSinglePhaseMeterTotalEnergyProducedStateTypeId, meter->totalWattHoursExported() / 1000.0);
thing->setStateValue(sunspecSinglePhaseMeterTotalEnergyConsumedStateTypeId, meter->totalWattHoursImported() / 1000.0);
thing->setStateValue(sunspecSinglePhaseMeterCurrentPhaseAStateTypeId, -meter->ampsPhaseA());
thing->setStateValue(sunspecSinglePhaseMeterCurrentPhaseAStateTypeId, powerSign * meter->ampsPhaseA());
thing->setStateValue(sunspecSinglePhaseMeterVoltagePhaseAStateTypeId, meter->phaseVoltageAn());
thing->setStateValue(sunspecSinglePhaseMeterFrequencyStateTypeId, meter->hz());
thing->setStateValue(sunspecSinglePhaseMeterVersionStateTypeId, model->commonModelInfo().versionString);
@ -1853,10 +1878,10 @@ void IntegrationPluginSunSpec::onMeterBlockUpdated()
SunSpecMeterSinglePhaseFloatModel *meter = qobject_cast<SunSpecMeterSinglePhaseFloatModel *>(model);
qCDebug(dcSunSpec()) << thing->name() << "block data updated";// << meter;
thing->setStateValue(sunspecSinglePhaseMeterConnectedStateTypeId, true);
thing->setStateValue(sunspecSinglePhaseMeterCurrentPowerStateTypeId, -meter->watts());
thing->setStateValue(sunspecSinglePhaseMeterCurrentPowerStateTypeId, powerSign * meter->watts());
thing->setStateValue(sunspecSinglePhaseMeterTotalEnergyProducedStateTypeId, meter->totalWattHoursExported() / 1000.0);
thing->setStateValue(sunspecSinglePhaseMeterTotalEnergyConsumedStateTypeId, meter->totalWattHoursImported() / 1000.0);
thing->setStateValue(sunspecSinglePhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), -meter->watts()));
thing->setStateValue(sunspecSinglePhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), powerSign * meter->watts()));
thing->setStateValue(sunspecSinglePhaseMeterVoltagePhaseAStateTypeId, meter->phaseVoltageAn());
thing->setStateValue(sunspecSinglePhaseMeterFrequencyStateTypeId, meter->hz());
thing->setStateValue(sunspecSinglePhaseMeterVersionStateTypeId, model->commonModelInfo().versionString);
@ -1872,12 +1897,12 @@ void IntegrationPluginSunSpec::onMeterBlockUpdated()
thing->setStateValue(sunspecSplitPhaseMeterEnergyConsumedPhaseBStateTypeId, meter->totalWattHoursImportedPhaseB() / 1000.0);
thing->setStateValue(sunspecSplitPhaseMeterEnergyProducedPhaseAStateTypeId, meter->totalWattHoursExportedPhaseA() / 1000.0);
thing->setStateValue(sunspecSplitPhaseMeterEnergyProducedPhaseBStateTypeId, meter->totalWattHoursExportedPhaseB() / 1000.0);
thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerStateTypeId, -meter->watts());
thing->setStateValue(sunspecSplitPhaseMeterTotalCurrentStateTypeId, fixValueSign(meter->amps(), -meter->watts()));
thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerPhaseAStateTypeId, -meter->wattsPhaseA());
thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerPhaseBStateTypeId, -meter->wattsPhaseB());
thing->setStateValue(sunspecSplitPhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), -meter->wattsPhaseA()));
thing->setStateValue(sunspecSplitPhaseMeterCurrentPhaseBStateTypeId, fixValueSign(meter->ampsPhaseB(), -meter->wattsPhaseB()));
thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerStateTypeId, powerSign * meter->watts());
thing->setStateValue(sunspecSplitPhaseMeterTotalCurrentStateTypeId, fixValueSign(meter->amps(), powerSign * meter->watts()));
thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerPhaseAStateTypeId, powerSign * meter->wattsPhaseA());
thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerPhaseBStateTypeId, powerSign * meter->wattsPhaseB());
thing->setStateValue(sunspecSplitPhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), powerSign * meter->wattsPhaseA()));
thing->setStateValue(sunspecSplitPhaseMeterCurrentPhaseBStateTypeId, fixValueSign(meter->ampsPhaseB(), powerSign * meter->wattsPhaseB()));
thing->setStateValue(sunspecSplitPhaseMeterLnACVoltageStateTypeId, meter->voltageLn());
thing->setStateValue(sunspecSplitPhaseMeterVoltagePhaseAStateTypeId, meter->phaseVoltageAn());
thing->setStateValue(sunspecSplitPhaseMeterVoltagePhaseBStateTypeId, meter->phaseVoltageBn());
@ -1895,12 +1920,12 @@ void IntegrationPluginSunSpec::onMeterBlockUpdated()
thing->setStateValue(sunspecSplitPhaseMeterEnergyConsumedPhaseBStateTypeId, meter->totalWattHoursImportedPhaseB() / 1000.0);
thing->setStateValue(sunspecSplitPhaseMeterEnergyProducedPhaseAStateTypeId, meter->totalWattHoursExportedPhaseA() / 1000.0);
thing->setStateValue(sunspecSplitPhaseMeterEnergyProducedPhaseBStateTypeId, meter->totalWattHoursExportedPhaseB() / 1000.0);
thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerStateTypeId, -meter->watts());
thing->setStateValue(sunspecSplitPhaseMeterTotalCurrentStateTypeId, fixValueSign(meter->amps(), -meter->watts()));
thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerPhaseAStateTypeId, -meter->wattsPhaseA());
thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerPhaseBStateTypeId, -meter->wattsPhaseB());
thing->setStateValue(sunspecSplitPhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), -meter->wattsPhaseA()));
thing->setStateValue(sunspecSplitPhaseMeterCurrentPhaseBStateTypeId, fixValueSign(meter->ampsPhaseB(), -meter->wattsPhaseB()));
thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerStateTypeId, powerSign * meter->watts());
thing->setStateValue(sunspecSplitPhaseMeterTotalCurrentStateTypeId, fixValueSign(meter->amps(), powerSign * meter->watts()));
thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerPhaseAStateTypeId, powerSign * meter->wattsPhaseA());
thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerPhaseBStateTypeId, powerSign * meter->wattsPhaseB());
thing->setStateValue(sunspecSplitPhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), powerSign * meter->wattsPhaseA()));
thing->setStateValue(sunspecSplitPhaseMeterCurrentPhaseBStateTypeId, fixValueSign(meter->ampsPhaseB(), powerSign * meter->wattsPhaseB()));
thing->setStateValue(sunspecSplitPhaseMeterLnACVoltageStateTypeId, meter->voltageLn());
thing->setStateValue(sunspecSplitPhaseMeterVoltagePhaseAStateTypeId, meter->phaseVoltageAn());
thing->setStateValue(sunspecSplitPhaseMeterVoltagePhaseBStateTypeId, meter->phaseVoltageBn());
@ -1920,13 +1945,13 @@ void IntegrationPluginSunSpec::onMeterBlockUpdated()
thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseAStateTypeId, meter->totalWattHoursExportedPhaseA() / 1000.0);
thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseBStateTypeId, meter->totalWattHoursExportedPhaseB() / 1000.0);
thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseCStateTypeId, meter->totalWattHoursExportedPhaseC() / 1000.0);
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerStateTypeId, -meter->watts());
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseAStateTypeId, -meter->wattsPhaseA());
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseBStateTypeId, -meter->wattsPhaseB());
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseCStateTypeId, -meter->wattsPhaseC());
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), -meter->wattsPhaseA()));
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseBStateTypeId, fixValueSign(meter->ampsPhaseB(), -meter->wattsPhaseB()));
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseCStateTypeId, fixValueSign(meter->ampsPhaseC(), -meter->wattsPhaseC()));
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerStateTypeId, powerSign * meter->watts());
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseAStateTypeId, powerSign * meter->wattsPhaseA());
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseBStateTypeId, powerSign * meter->wattsPhaseB());
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseCStateTypeId, powerSign * meter->wattsPhaseC());
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), powerSign * meter->wattsPhaseA()));
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseBStateTypeId, fixValueSign(meter->ampsPhaseB(), powerSign * meter->wattsPhaseB()));
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseCStateTypeId, fixValueSign(meter->ampsPhaseC(), powerSign * meter->wattsPhaseC()));
thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseAStateTypeId, meter->phaseVoltageAn());
thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseBStateTypeId, meter->phaseVoltageBn());
thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseCStateTypeId, meter->phaseVoltageCn());
@ -1946,13 +1971,13 @@ void IntegrationPluginSunSpec::onMeterBlockUpdated()
thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseAStateTypeId, meter->totalWattHoursExportedPhaseA() / 1000.0);
thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseBStateTypeId, meter->totalWattHoursExportedPhaseB() / 1000.0);
thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseCStateTypeId, meter->totalWattHoursExportedPhaseC() / 1000.0);
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerStateTypeId, -meter->watts());
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseAStateTypeId, -meter->wattsPhaseA());
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseBStateTypeId, -meter->wattsPhaseB());
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseCStateTypeId, -meter->wattsPhaseC());
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), -meter->wattsPhaseA()));
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseBStateTypeId, fixValueSign(meter->ampsPhaseB(), -meter->wattsPhaseB()));
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseCStateTypeId, fixValueSign(meter->ampsPhaseC(), -meter->wattsPhaseC()));
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerStateTypeId, powerSign * meter->watts());
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseAStateTypeId, powerSign * meter->wattsPhaseA());
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseBStateTypeId, powerSign * meter->wattsPhaseB());
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseCStateTypeId, powerSign * meter->wattsPhaseC());
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), powerSign * meter->wattsPhaseA()));
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseBStateTypeId, fixValueSign(meter->ampsPhaseB(), powerSign * meter->wattsPhaseB()));
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseCStateTypeId, fixValueSign(meter->ampsPhaseC(), powerSign * meter->wattsPhaseC()));
thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseAStateTypeId, meter->phaseVoltageAn());
thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseBStateTypeId, meter->phaseVoltageBn());
thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseCStateTypeId, meter->phaseVoltageCn());
@ -1972,13 +1997,13 @@ void IntegrationPluginSunSpec::onMeterBlockUpdated()
thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseAStateTypeId, meter->totalWattHoursExportedPhaseA() / 1000.0);
thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseBStateTypeId, meter->totalWattHoursExportedPhaseB() / 1000.0);
thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseCStateTypeId, meter->totalWattHoursExportedPhaseC() / 1000.0);
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerStateTypeId, -meter->watts());
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseAStateTypeId, -meter->wattsPhaseA());
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseBStateTypeId, -meter->wattsPhaseB());
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseCStateTypeId, -meter->wattsPhaseC());
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), -meter->wattsPhaseA()));
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseBStateTypeId, fixValueSign(meter->ampsPhaseB(), -meter->wattsPhaseB()));
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseCStateTypeId, fixValueSign(meter->ampsPhaseC(), -meter->wattsPhaseC()));
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerStateTypeId, powerSign * meter->watts());
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseAStateTypeId, powerSign * meter->wattsPhaseA());
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseBStateTypeId, powerSign * meter->wattsPhaseB());
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseCStateTypeId, powerSign * meter->wattsPhaseC());
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), powerSign * meter->wattsPhaseA()));
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseBStateTypeId, fixValueSign(meter->ampsPhaseB(), powerSign * meter->wattsPhaseB()));
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseCStateTypeId, fixValueSign(meter->ampsPhaseC(), powerSign * meter->wattsPhaseC()));
thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseAStateTypeId, meter->phaseVoltageAn());
thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseBStateTypeId, meter->phaseVoltageBn());
thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseCStateTypeId, meter->phaseVoltageCn());
@ -1998,13 +2023,13 @@ void IntegrationPluginSunSpec::onMeterBlockUpdated()
thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseAStateTypeId, meter->totalWattHoursExportedPhaseA() / 1000.0);
thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseBStateTypeId, meter->totalWattHoursExportedPhaseB() / 1000.0);
thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseCStateTypeId, meter->totalWattHoursExportedPhaseC() / 1000.0);
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerStateTypeId, -meter->watts());
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseAStateTypeId, -meter->wattsPhaseA());
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseBStateTypeId, -meter->wattsPhaseB());
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseCStateTypeId, -meter->wattsPhaseC());
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), -meter->wattsPhaseA()));
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseBStateTypeId, fixValueSign(meter->ampsPhaseB(), -meter->wattsPhaseB()));
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseCStateTypeId, fixValueSign(meter->ampsPhaseC(), -meter->wattsPhaseC()));
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerStateTypeId, powerSign * meter->watts());
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseAStateTypeId, powerSign * meter->wattsPhaseA());
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseBStateTypeId, powerSign * meter->wattsPhaseB());
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseCStateTypeId, powerSign * meter->wattsPhaseC());
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), powerSign * meter->wattsPhaseA()));
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseBStateTypeId, fixValueSign(meter->ampsPhaseB(), powerSign * meter->wattsPhaseB()));
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseCStateTypeId, fixValueSign(meter->ampsPhaseC(), powerSign * meter->wattsPhaseC()));
thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseAStateTypeId, meter->phaseVoltageAn());
thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseBStateTypeId, meter->phaseVoltageBn());
thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseCStateTypeId, meter->phaseVoltageCn());

View File

@ -161,6 +161,18 @@ private slots:
void test_AC_15_battery_power_is_positive_when_discharging();
void test_AC_15_battery_power_is_negative_when_charging();
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();
void test_AC_16_three_phase_meter_export_on_every_phase();
void test_AC_18_swapped_words_yield_denormals();
void test_AC_17_no_battery_means_no_storage_child();
void test_AC_17_an_installed_battery_is_still_detected();
void test_AC_17_presence_does_not_depend_on_state_of_charge();
// F3 - multi unit ID
void test_F3_meter_slave_id_zero_disables_the_meter_connection();
void test_F3_meter_slave_id_out_of_range_is_refused();
@ -411,10 +423,44 @@ void SunSpecFroniusTest::test_AC_5_manufacturer_filter_rejects_other_manufacture
void SunSpecFroniusTest::test_AC_6_fronius_uses_sunspec_default_byte_order()
{
// INFERE : non documente par le constructeur, on suppose le defaut SunSpec.
// Le ByteOrderBigEndian force du vendor solarEdge est une anomalie propre a
// SolarEdge et ne doit pas etre etendue.
QCOMPARE(FroniusRules::byteOrder(), SunSpecDataPoint::ByteOrderLittleEndian);
// MESURE le 2026-08-07 sur un Symo reel, en lecture seule : l'appareil emet
// en big endian, mot de poids fort d'abord, conformement a la specification
// SunSpec.
//
// compteur 213 unite 200 : Hz = 4248 0000 -> 50.0000
// onduleur 113 unite 1 : Hz = 4247 fc48 -> 49.9964
//
// Ce test affirmait l'inverse, avec pour justification « on suppose le
// defaut SunSpec ». La supposition confondait le defaut de la SPEC avec
// celui de libnymea-sunspec, qui est son contraire. Le nom du test est
// conserve pour ne pas casser les references, mais « sunspec_default »
// designe desormais le defaut de la specification, pas celui de la lib.
QCOMPARE(FroniusRules::byteOrder(), SunSpecDataPoint::ByteOrderBigEndian);
}
void SunSpecFroniusTest::test_AC_18_swapped_words_yield_denormals()
{
// Antidote : verrouille le RAISON du choix, pas seulement le choix.
// Les registres ci-dessous sont ceux releves sur le Symo. Lus dans le bon
// ordre ils donnent une frequence reseau ; lus mots inverses, un denormal.
// Si quelqu'un rebascule byteOrder() en little endian, c'est cette lecture
// absurde que le plugin produira sur du materiel reel.
const quint16 hi = 0x4248, lo = 0x0000; // Hz du compteur 213
auto asFloat = [](quint16 first, quint16 second) {
const quint32 raw = (quint32(first) << 16) | second;
float value;
memcpy(&value, &raw, sizeof(raw));
return value;
};
// Big endian : le registre de poids fort d'abord.
QVERIFY(qAbs(asFloat(hi, lo) - 50.0f) < 0.01f);
// Mots inverses : un denormal, jamais une mesure plausible.
const float swapped = asFloat(lo, hi);
QVERIFY(swapped > 0.0f);
QVERIFY(swapped < 1.0e-40f);
}
// ---------------------------------------------------------------------------
@ -862,6 +908,91 @@ void SunSpecFroniusTest::test_F3_meter_slave_id_is_not_marked_advanced()
"Voir sunspec/DIVERGENCES.md.");
}
// AC-16 - signe du compteur reseau
// ---------------------------------------------------------------------------
void SunSpecFroniusTest::test_AC_16_fronius_meter_keeps_the_device_sign()
{
// Le Smart Meter Fronius emet deja dans la convention nymea : negatif =
// injection. La negation generique doit donc etre annulee pour lui.
QCOMPARE(FroniusRules::meterPowerSign(true), 1.0);
}
void SunSpecFroniusTest::test_AC_16_generic_meter_keeps_the_historical_negation()
{
// Antidote de non regression : le chemin generique et solarEdge ne bougent
// pas. Ce correctif est cible Fronius, et rien d'autre.
QCOMPARE(FroniusRules::meterPowerSign(false), -1.0);
}
void SunSpecFroniusTest::test_AC_16_field_case_export_is_reported_negative()
{
// Cas terrain du 02/08/2026, Primo GEN24 3.0 monophase (modele 101) avec un
// Smart Meter TS 65A-3 (modele 203). L'onduleur n'injecte que sur la phase
// A. Le plugin rapportait phase A = +1003 W (soutirage) et les phases B et
// C negatives, soit une injection sur deux phases sans aucune source : le
// signe etait inverse. L'arbitre voyait "Surplus insuffisant (-842 W)" et
// laissait chauffe-eau et PAC eteints alors que le site exportait.
const double sign = FroniusRules::meterPowerSign(true);
const double wattsTotal = -708.0; // registre brut du compteur
const double wattsPhaseA = -1003.1;
const double wattsPhaseB = 32.8;
const double wattsPhaseC = 261.6;
// Export vu comme export, et non plus comme soutirage.
QVERIFY(sign * wattsTotal < 0.0);
// La phase portant l'onduleur exporte ; les deux autres soutirent.
QVERIFY(sign * wattsPhaseA < 0.0);
QVERIFY(sign * wattsPhaseB > 0.0);
QVERIFY(sign * wattsPhaseC > 0.0);
}
void SunSpecFroniusTest::test_AC_16_three_phase_meter_export_on_every_phase()
{
// Reproduit sur le simulateur, profil Symo triphase (onduleur modele 103,
// compteur modele 203). Le compteur emet -500 / -100 / -100 W, soit un
// export sur les trois phases. Le plugin etm6 rapportait +500 / +100 /
// +100, c'est a dire du soutirage sur les trois. Le chemin triphase est
// celui du materiel terrain : il merite son propre verrou.
const double sign = FroniusRules::meterPowerSign(true);
QCOMPARE(sign * -500.0, -500.0);
QCOMPARE(sign * -100.0, -100.0);
// Et le total suit la meme regle, quel que soit son signe.
QCOMPARE(sign * -700.0, -700.0);
QCOMPARE(sign * 3262.0, 3262.0);
}
// AC-17 - presence physique de la batterie
// ---------------------------------------------------------------------------
void SunSpecFroniusTest::test_AC_17_no_battery_means_no_storage_child()
{
// Cas fantome : le GEN24 hybride publie le modele 124 sans batterie cablee.
// Constate sur le materiel des beaux-parents, ou l'enfant batterie etait
// cree et restait bloque sur batteryCritical.
QVERIFY(!FroniusRules::hasBattery(0.0, 0.0));
}
void SunSpecFroniusTest::test_AC_17_an_installed_battery_is_still_detected()
{
// Antidote symetrique : le correctif ne doit pas faire disparaitre une
// vraie batterie. Une batterie installee porte une tension au repos.
QVERIFY(FroniusRules::hasBattery(230.4, 0.0)); // tension seule
QVERIFY(FroniusRules::hasBattery(0.0, 12.5)); // capacite dispo seule
QVERIFY(FroniusRules::hasBattery(230.4, 12.5)); // les deux
}
void SunSpecFroniusTest::test_AC_17_presence_does_not_depend_on_state_of_charge()
{
// Le SoC est volontairement hors du discriminateur : 0 % ne se distingue
// pas d'une vraie batterie vide. Une batterie a plat mais cablee garde sa
// tension, donc reste detectee.
QVERIFY(FroniusRules::hasBattery(230.4, 0.0));
}
void SunSpecFroniusTest::test_F3_generic_vendors_have_no_meter_slave_id()
{
// Le multi unit ID est une particularite Fronius. Les vendors generique et

View File

@ -0,0 +1,189 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-3.0-or-later
"""Quel ordre de mots un onduleur SunSpec émet-il pour ses float32 ?
La spécification SunSpec dit big endian, mot de poids fort en premier. Mais
libnymea-sunspec décode par défaut `ByteOrderLittleEndian`, c'est à dire
`registers.at(1)` PUIS `registers.at(0)` — mot de poids faible d'abord
(sunspecdatapoint.cpp, convertToUInt32). Le vendor Fronius du plugin garde ce
défaut. Si l'appareil émet en big endian, le plugin lit des dénormaux (~1e-41)
et aucune correction de SIGNE n'y changera quoi que ce soit.
Ce script tranche la question par la mesure, sur deux points dont la valeur
attendue est connue d'avance et non ambiguë :
- Hz : ~50 Hz
- PhVphA : ~230..240 V
Un ordre donne ces valeurs, l'autre donne des dénormaux. Il n'y a pas de doute
possible entre les deux.
LECTURE SEULE : ce script n'émet QUE des fonctions Modbus 3 (read holding
registers). Aucune écriture, aucun code fonction d'écriture n'est implémenté.
python3 read-float-order.py 192.168.3.230
python3 read-float-order.py 192.168.3.230 --inverter-unit 1 --meter-unit 200
Bibliothèque standard uniquement : rien à installer.
"""
import argparse
import socket
import struct
import sys
SUNS = (0x5375, 0x6E53)
# Offsets comptés depuis le registre ID du modèle (les données commencent à +2).
# Chaque point float32 occupe DEUX registres, d'où le pas de 2.
INV_F = {"W": 22, "Hz": 24, "PhVphA": 16, "PhVphB": 18, "PhVphC": 20, "DCW": 38}
MET_F = {"W": 28, "WphA": 30, "WphB": 32, "WphC": 34,
"Hz": 26, "PhV": 10, "PhVphA": 12, "PhVphB": 14, "PhVphC": 16}
INVERTER_FLOAT = (111, 112, 113)
METER_FLOAT = (211, 212, 213, 214)
INVERTER_INT = (101, 102, 103)
METER_INT = (201, 202, 203, 204)
def read_holding(sock, unit, addr, count):
"""Fonction 3, et rien d'autre."""
sock.sendall(struct.pack(">HHHBBHH", 1, 0, 6, unit, 3, addr, count))
hdr = b""
while len(hdr) < 8:
chunk = sock.recv(8 - len(hdr))
if not chunk:
raise IOError("connexion fermée (en-tête)")
hdr += chunk
_, _, length, _, fc = struct.unpack(">HHHBB", hdr)
body = b""
while len(body) < length - 2:
chunk = sock.recv(length - 2 - len(body))
if not chunk:
raise IOError("connexion fermée (corps)")
body += chunk
if fc & 0x80:
raise IOError("exception modbus %d en lisant %d registres à %d"
% (body[0], count, addr))
return list(struct.unpack(">%dH" % (body[0] // 2), body[1:1 + body[0]]))
def decode(r0, r1, low_word_first):
hi, lo = (r1, r0) if low_word_first else (r0, r1)
return struct.unpack(">f", struct.pack(">HH", hi, lo))[0]
def find_base(sock, unit):
for base in (40000, 50000, 0):
try:
if tuple(read_holding(sock, unit, base, 2)) == SUNS:
return base
except IOError:
continue
return None
def walk(sock, unit, base):
"""(model_id, adresse du registre ID, longueur) pour toute la chaîne."""
out, addr = [], base + 2
while addr < base + 5000:
mid, mlen = read_holding(sock, unit, addr, 2)
if mid == 0xFFFF:
return out
out.append((mid, addr, mlen))
addr += 2 + mlen
return out
def verdict(hz_low, hz_big):
"""Quel ordre donne une fréquence réseau plausible ?"""
def plausible(v):
return 45.0 <= v <= 65.0
if plausible(hz_low) and not plausible(hz_big):
return "MOT DE POIDS FAIBLE D'ABORD (ce que le plugin lit déjà)"
if plausible(hz_big) and not plausible(hz_low):
return ("BIG ENDIAN, conforme à la spec SunSpec — un plugin qui garde le "
"défaut de libnymea-sunspec lira des dénormaux")
if hz_low == 0.0 and hz_big == 0.0:
return "INDÉCIDABLE : registres à zéro, aucun ordre ne se distingue"
return "INDÉCIDABLE : aucune des deux lectures ne donne 45..65 Hz"
def probe(host, port, unit, wanted_float, wanted_int, table, points, role):
print("=" * 74)
print("%s — unité %d" % (role, unit))
print("=" * 74)
sock = socket.create_connection((host, port), timeout=10)
try:
base = find_base(sock, unit)
if base is None:
print(" aucun marqueur « SunS » : rien à cette unité.\n")
return
chain = walk(sock, unit, base)
print(" base %d · chaîne : %s" % (
base, ", ".join("%d@%d(L=%d)" % (m, a, l) for m, a, l in chain)))
found = [c for c in chain if c[0] in wanted_float]
if not found:
ints = [c for c in chain if c[0] in wanted_int]
if ints:
print(" modèle %d : ENTIER+SF, pas de float32 — la question de "
"l'ordre des mots ne se pose pas ici.\n" % ints[0][0])
else:
print(" aucun modèle attendu dans cette chaîne.\n")
return
mid, id_addr, mlen = found[0]
print(" modèle float %d, registre ID %d, longueur %d\n" % (mid, id_addr, mlen))
print(" %-8s %-9s %-14s %14s %16s %s" % (
"point", "registre", "brut (hex)", "mot faible 1er", "big endian", "lecture"))
print(" " + "-" * 86)
hz = None
for name in points:
off = table[name]
addr = id_addr + off
r0, r1 = read_holding(sock, unit, addr, 2)
low, big = decode(r0, r1, True), decode(r0, r1, False)
if name == "Hz":
hz = (low, big)
note = "nul — n'apprend rien" if (r0 == 0 and r1 == 0) else ""
print(" %-8s %-9d %-14s %14.4f %16.4e %s" % (
name, addr, "%04X %04X" % (r0, r1), low, big, note))
print()
if hz:
print(" >>> %s" % verdict(*hz))
print()
finally:
sock.close()
def main():
ap = argparse.ArgumentParser(
description="Ordre des mots des float32 SunSpec — LECTURE SEULE.")
ap.add_argument("host")
ap.add_argument("--port", type=int, default=502)
ap.add_argument("--inverter-unit", type=int, default=1)
ap.add_argument("--meter-unit", type=int, default=200)
args = ap.parse_args()
# Le COMPTEUR d'abord : c'est lui qui tranche. Il mesure le réseau en
# permanence, donc ses registres portent des valeurs même onduleur en veille.
# Un onduleur au repos sert un bloc à zéro, et zéro se décode en 0.0 dans les
# DEUX ordres : ça n'apprend rien.
probe(args.host, args.port, args.meter_unit, METER_FLOAT, METER_INT,
MET_F, ("Hz", "PhVphA", "PhVphB", "PhVphC", "W", "WphA"),
"COMPTEUR — mesure décisive")
probe(args.host, args.port, args.inverter_unit, INVERTER_FLOAT, INVERTER_INT,
INV_F, ("Hz", "PhVphA", "PhVphB", "PhVphC", "W"),
"ONDULEUR — confirmation, nul si en veille")
print("Lecture : la colonne qui donne ~50 Hz et ~230..240 V est l'ordre que")
print("l'appareil émet. L'autre affiche des dénormaux (~1e-41) : c'est la")
print("signature d'une mauvaise interprétation, pas une vraie mesure.")
print()
print("Si « big endian » est la bonne colonne, tout lecteur qui garde le défaut")
print("de libnymea-sunspec (mot faible d'abord) lira ces modèles de travers.")
print("Le vendor Fronius du plugin force big endian depuis 1.15.2+etm9.")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,167 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-3.0-or-later
"""Read-only dump of the SunSpec storage block (model 124) of an inverter.
Answers one question: does this inverter have a battery physically installed?
See sunspec/DIVERGENCES.md D-6 — the "battery present" side of the presence
discriminator is still inferred and needs a reading from real hardware.
Standard library only: no pymodbus, no pysunspec2, nothing to install.
It only ever issues function code 3 (read holding registers). It never writes.
python3 read-model124.py 192.168.1.50
python3 read-model124.py fronius.local --unit 1
Report back the InBatV, StorAval and ChaSt lines.
"""
import argparse
import socket
import struct
import sys
SUNS = (0x5375, 0x6E53) # "SunS"
# Model 124, in order, from the SunSpec storage model definition.
# (name, offset, kind, scale-factor name or None)
POINTS = [
("WChaMax", 0, "u16", "WChaMax_SF"),
("WChaGra", 1, "u16", "WChaDisChaGra_SF"),
("WDisChaGra", 2, "u16", "WChaDisChaGra_SF"),
("StorCtl_Mod", 3, "u16", None),
("VAChaMax", 4, "u16", "VAChaMax_SF"),
("MinRsvPct", 5, "u16", "MinRsvPct_SF"),
("ChaState", 6, "u16", "ChaState_SF"),
("StorAval", 7, "u16", "StorAval_SF"),
("InBatV", 8, "u16", "InBatV_SF"),
("ChaSt", 9, "u16", None),
("OutWRte", 10, "i16", "InOutWRte_SF"),
("InWRte", 11, "i16", "InOutWRte_SF"),
("ChaGriSet", 15, "u16", None),
]
SCALE_FACTORS = {
"WChaMax_SF": 16, "WChaDisChaGra_SF": 17, "VAChaMax_SF": 18,
"MinRsvPct_SF": 19, "ChaState_SF": 20, "StorAval_SF": 21,
"InBatV_SF": 22, "InOutWRte_SF": 23,
}
CHA_ST = {1: "Off", 2: "Empty", 3: "Discharging", 4: "Charging",
5: "Full", 6: "Holding", 7: "Testing"}
NOT_IMPLEMENTED_U16 = 0xFFFF
NOT_IMPLEMENTED_I16 = 0x8000
def read_holding(sock, unit, addr, count):
req = struct.pack(">HHHBBHH", 1, 0, 6, unit, 3, addr, count)
sock.sendall(req)
hdr = b""
while len(hdr) < 8:
chunk = sock.recv(8 - len(hdr))
if not chunk:
raise IOError("connection closed while reading header")
hdr += chunk
_, _, length, _, fc = struct.unpack(">HHHBB", hdr)
body = b""
while len(body) < length - 2:
chunk = sock.recv(length - 2 - len(body))
if not chunk:
raise IOError("connection closed while reading body")
body += chunk
if fc & 0x80:
raise IOError("modbus exception %d reading %d registers at %d"
% (body[0], count, addr))
return list(struct.unpack(">%dH" % (body[0] // 2), body[1:1 + body[0]]))
def find_base(sock, unit):
for base in (40000, 50000, 0):
try:
if tuple(read_holding(sock, unit, base, 2)) == SUNS:
return base
except IOError:
continue
return None
def find_model(sock, unit, base, wanted):
addr = base + 2
chain = []
while addr < base + 5000:
mid, mlen = read_holding(sock, unit, addr, 2)
if mid == 0xFFFF:
return None, chain
chain.append(mid)
if mid == wanted:
return addr + 2, chain
addr += 2 + mlen
return None, chain
def scaled(raw, kind, exponent):
if kind == "i16" and raw > 0x7FFF:
raw -= 0x10000
if exponent is None:
return float(raw)
if exponent > 0x7FFF:
exponent -= 0x10000
return raw * (10.0 ** exponent)
def main():
ap = argparse.ArgumentParser(description="Read-only dump of SunSpec model 124.")
ap.add_argument("host")
ap.add_argument("--port", type=int, default=502)
ap.add_argument("--unit", type=int, default=1)
args = ap.parse_args()
sock = socket.create_connection((args.host, args.port), timeout=10)
try:
base = find_base(sock, args.unit)
if base is None:
print("No SunSpec 'SunS' marker at 40000, 50000 or 0 on unit %d." % args.unit)
return 2
print("SunSpec base register: %d (unit %d)" % (base, args.unit))
data, chain = find_model(sock, args.unit, base, 124)
print("Models present: %s" % ", ".join(str(m) for m in chain))
if data is None:
print("\nNo model 124 on this inverter: it publishes no storage block at all.")
return 0
regs = read_holding(sock, args.unit, data, 24)
finally:
sock.close()
print("\nModel 124 raw block at %d:" % data)
print(" " + " ".join("%04X" % r for r in regs))
print("\n%-14s %10s %s" % ("point", "value", "raw"))
for name, off, kind, sf in POINTS:
raw = regs[off]
sentinel = NOT_IMPLEMENTED_I16 if kind == "i16" else NOT_IMPLEMENTED_U16
if raw == sentinel:
print("%-14s %10s 0x%04X" % (name, "n/impl", raw))
continue
value = scaled(raw, kind, regs[SCALE_FACTORS[sf]] if sf else None)
extra = " (%s)" % CHA_ST.get(raw, "?") if name == "ChaSt" else ""
print("%-14s %10.3f 0x%04X%s" % (name, value, raw, extra))
in_bat_v = scaled(regs[8], "u16", regs[SCALE_FACTORS["InBatV_SF"]])
stor_aval = scaled(regs[7], "u16", regs[SCALE_FACTORS["StorAval_SF"]])
print("\n--- what this decides -------------------------------------------")
print("InBatV = %.3f V" % in_bat_v)
print("StorAval = %.3f Ah" % stor_aval)
if in_bat_v > 0 or stor_aval > 0:
print("=> reads as BATTERY PRESENT")
else:
print("=> reads as NO BATTERY")
print("\nOn an inverter that really does have a battery, both being zero would")
print("mean the discriminator is wrong and would hide a real battery. That is")
print("exactly the case to report back.")
return 0
if __name__ == "__main__":
sys.exit(main())