diff --git a/debian/changelog b/debian/changelog index 295e208..2811bec 100644 --- a/debian/changelog +++ b/debian/changelog @@ -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 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 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 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. diff --git a/sunspec/DIVERGENCES.md b/sunspec/DIVERGENCES.md index bdfdbd6..264d1cf 100644 --- a/sunspec/DIVERGENCES.md +++ b/sunspec/DIVERGENCES.md @@ -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. diff --git a/sunspec/froniusrules.cpp b/sunspec/froniusrules.cpp index bd0bb50..659438e 100644 --- a/sunspec/froniusrules.cpp +++ b/sunspec/froniusrules.cpp @@ -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) diff --git a/sunspec/froniusrules.h b/sunspec/froniusrules.h index f98fd4d..2730876 100644 --- a/sunspec/froniusrules.h +++ b/sunspec/froniusrules.h @@ -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); diff --git a/sunspec/integrationpluginsunspec.cpp b/sunspec/integrationpluginsunspec.cpp index dc27726..390a5b5 100644 --- a/sunspec/integrationpluginsunspec.cpp +++ b/sunspec/integrationpluginsunspec.cpp @@ -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(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(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(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()); diff --git a/sunspec/tests/logic/tst_sunspec.cpp b/sunspec/tests/logic/tst_sunspec.cpp index 04fe1c1..ad7b433 100644 --- a/sunspec/tests/logic/tst_sunspec.cpp +++ b/sunspec/tests/logic/tst_sunspec.cpp @@ -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 diff --git a/sunspec/tools/read-float-order.py b/sunspec/tools/read-float-order.py new file mode 100644 index 0000000..0ef4045 --- /dev/null +++ b/sunspec/tools/read-float-order.py @@ -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()) diff --git a/sunspec/tools/read-model124.py b/sunspec/tools/read-model124.py new file mode 100644 index 0000000..b6564a5 --- /dev/null +++ b/sunspec/tools/read-model124.py @@ -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())