From 89f7bf0199be7bad49c2c2903b2f2b843b93ac35 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Wed, 5 Aug 2026 08:29:16 +0200 Subject: [PATCH 1/8] =?UTF-8?q?fix(sunspec):=20signe=20du=20compteur=20Fro?= =?UTF-8?q?nius=20et=20batterie=20fant=C3=B4me?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux défauts constatés sur le GEN24 des beaux-parents (Primo GEN24 3.0, Smart Meter TS 65A-3, sans batterie). Signe du compteur. onMeterBlockUpdated() négociait le signe de tous les modèles compteur sans distinction de fabricant. Sur Fronius la négation est fausse : le Smart Meter émet déjà dans la convention nymea. L'onduleur étant monophasé, il sert de référence de direction : le plugin rapportait la phase portant l'onduleur en soutirage et les deux autres en injection, ce qui est impossible sans source. L'arbitre en concluait « Surplus insuffisant (-842 W) » et laissait chauffe-eau et PAC éteints en plein export. La décision vit dans FroniusRules::meterPowerSign() et n'est annulée que sous une froniusConnection. Les vendors generic et solarEdge sont inchangés, ce qu'un antidote verrouille. Les compteurs d'énergie sont corrects et ne sont pas permutés. Batterie fantôme. Un GEN24 hybride publie le modèle 124 sans batterie câblée, d'où un enfant storage bloqué sur batteryCritical. FroniusRules::hasBattery() exige une grandeur physique non nulle (InBatV ou StorAval), le SoC étant écarté car 0 % ne se distingue pas d'une batterie vide. Le côté « batterie présente » reste INFÉRÉ : voir D-6, à valider sur un GEN24 équipé avant tout déploiement. Suite logique : 50 tests verts, dont six antidotes AC-16 et AC-17. Co-Authored-By: Claude Opus 5 (1M context) --- sunspec/DIVERGENCES.md | 62 +++++++++++++++ sunspec/froniusrules.cpp | 15 ++++ sunspec/froniusrules.h | 47 ++++++++++++ sunspec/integrationpluginsunspec.cpp | 110 ++++++++++++++++----------- sunspec/tests/logic/tst_sunspec.cpp | 76 ++++++++++++++++++ 5 files changed, 265 insertions(+), 45 deletions(-) diff --git a/sunspec/DIVERGENCES.md b/sunspec/DIVERGENCES.md index bdfdbd6..cfef716 100644 --- a/sunspec/DIVERGENCES.md +++ b/sunspec/DIVERGENCES.md @@ -111,3 +111,65 @@ 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. diff --git a/sunspec/froniusrules.cpp b/sunspec/froniusrules.cpp index bd0bb50..56bc5d8 100644 --- a/sunspec/froniusrules.cpp +++ b/sunspec/froniusrules.cpp @@ -35,6 +35,21 @@ 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; diff --git a/sunspec/froniusrules.h b/sunspec/froniusrules.h index f98fd4d..269ff3b 100644 --- a/sunspec/froniusrules.h +++ b/sunspec/froniusrules.h @@ -82,6 +82,53 @@ MeterConnectionDecision decideMeterConnection(int meterSlaveId, int inverterSlav bool matchesManufacturer(const QStringList &manufacturers); +/* 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); + /* 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. diff --git a/sunspec/integrationpluginsunspec.cpp b/sunspec/integrationpluginsunspec.cpp index dc27726..94ccdeb 100644 --- a/sunspec/integrationpluginsunspec.cpp +++ b/sunspec/integrationpluginsunspec.cpp @@ -835,9 +835,24 @@ 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. */ + SunSpecStorageModel *storageModel = qobject_cast(model); + if (storageModel && isFroniusChild(thing->id()) + && !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 +1850,20 @@ 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(isFroniusChild(thing->parentId())); + 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 +1873,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 +1892,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 +1915,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 +1940,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 +1966,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 +1992,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 +2018,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..7b40fdd 100644 --- a/sunspec/tests/logic/tst_sunspec.cpp +++ b/sunspec/tests/logic/tst_sunspec.cpp @@ -161,6 +161,14 @@ 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_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(); @@ -862,6 +870,74 @@ 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); +} + +// 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 From 05d68117cdb6ec6753b11e8f37a1ede437681906 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Wed, 5 Aug 2026 15:57:36 +0200 Subject: [PATCH 2/8] debian: changelog entry 1.15.2+etm7 for the SunSpec meter sign and battery presence fixes Co-Authored-By: Claude Opus 5 (1M context) --- debian/changelog | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/debian/changelog b/debian/changelog index 295e208..e489138 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,26 @@ +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. From c459ba2f9b830c5c7ba6205112929f1791b8dd43 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Wed, 5 Aug 2026 16:06:08 +0200 Subject: [PATCH 3/8] =?UTF-8?q?sunspec:=20antidote=20triphas=C3=A9=20et=20?= =?UTF-8?q?outil=20de=20relev=C3=A9=20du=20mod=C3=A8le=20124?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'antidote AC-16 triphasé verrouille le chemin réellement emprunté par le matériel terrain, reproduit sur le simulateur en profil Symo (onduleur modèle 103, compteur modèle 203) : compteur à -500/-100/-100 W, que le plugin etm6 rapportait en +500/+100/+100. tools/read-model124.py relève en lecture seule le bloc storage d'un onduleur, sans aucune dépendance à installer. Il sert à lever D-6 : le côté « batterie présente » du discriminateur reste inféré faute d'un GEN24 équipé. Sur le simulateur il montre pourquoi ni le SoC ni les consignes ne peuvent servir de discriminateur : le bloc annonce un SoC de 50 %, un statut Holding et WChaMax à 10 kW alors qu'InBatV et StorAval sont nuls. Co-Authored-By: Claude Opus 5 (1M context) --- sunspec/tests/logic/tst_sunspec.cpp | 19 ++++ sunspec/tools/read-model124.py | 167 ++++++++++++++++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 sunspec/tools/read-model124.py diff --git a/sunspec/tests/logic/tst_sunspec.cpp b/sunspec/tests/logic/tst_sunspec.cpp index 7b40fdd..5ecf481 100644 --- a/sunspec/tests/logic/tst_sunspec.cpp +++ b/sunspec/tests/logic/tst_sunspec.cpp @@ -165,6 +165,8 @@ private slots: 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_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(); @@ -910,6 +912,23 @@ void SunSpecFroniusTest::test_AC_16_field_case_export_is_reported_negative() 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 // --------------------------------------------------------------------------- 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()) From ea3b9bdd519abac5877bcd129c147a678aae7aac Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Wed, 5 Aug 2026 20:05:13 +0200 Subject: [PATCH 4/8] fix(sunspec): interroger le thing de connexion, pas une recherche par id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le garde de présence batterie demandait isFroniusChild(thing->id()), qui résout le parent par myThings().findById(). Pendant le setup initial la connexion n'est pas encore enregistrée : la recherche répond faux, le garde est contourné, et l'enfant storage est créé alors qu'InBatV et StorAval sont nuls. Constaté en boucle fermée sur le simulateur, avec la trace nymead "Invalid parent thing id ... Not adding thing to the system". Le garde interroge maintenant directement le thing de connexion que la fonction tient déjà. Le signe compteur passe de même par le parentThing déjà résolu, dont la non-nullité est garantie quelques lignes plus haut. La suite logique ne pouvait pas attraper ce défaut : elle couvre la décision pure, pas le câblage. Consigné en D-7. Co-Authored-By: Claude Opus 5 (1M context) --- sunspec/DIVERGENCES.md | 27 +++++++++++++++++++++++++++ sunspec/integrationpluginsunspec.cpp | 9 +++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/sunspec/DIVERGENCES.md b/sunspec/DIVERGENCES.md index cfef716..3d50d93 100644 --- a/sunspec/DIVERGENCES.md +++ b/sunspec/DIVERGENCES.md @@ -173,3 +173,30 @@ installée porte une tension au repos » est donc **INFÉRÉE des deux côtés** 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. diff --git a/sunspec/integrationpluginsunspec.cpp b/sunspec/integrationpluginsunspec.cpp index 94ccdeb..390a5b5 100644 --- a/sunspec/integrationpluginsunspec.cpp +++ b/sunspec/integrationpluginsunspec.cpp @@ -841,8 +841,12 @@ void IntegrationPluginSunSpec::processDiscoveryResult(Thing *thing, SunSpecConne * 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 && isFroniusChild(thing->id()) + 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() @@ -1853,7 +1857,8 @@ void IntegrationPluginSunSpec::onMeterBlockUpdated() /* 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(isFroniusChild(thing->parentId())); + const double powerSign = FroniusRules::meterPowerSign( + parentThing->thingClassId() == froniusConnectionThingClassId); switch (model->modelId()) { case SunSpecModelFactory::ModelIdMeterSinglePhase: { From 63abc15b448cf0b6eeb443d57b56551f923b80f9 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Wed, 5 Aug 2026 20:05:25 +0200 Subject: [PATCH 5/8] debian: changelog entry 1.15.2+etm8 Co-Authored-By: Claude Opus 5 (1M context) --- debian/changelog | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/debian/changelog b/debian/changelog index e489138..2778197 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,15 @@ +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() From d08cf506ee0a20709769b29b57c080d9cbb9a9a7 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Fri, 7 Aug 2026 07:30:14 +0200 Subject: [PATCH 6/8] =?UTF-8?q?fix(sunspec):=20lire=20les=20float32=20Fron?= =?UTF-8?q?ius=20en=20big=20endian,=20comme=20le=20mat=C3=A9riel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit byteOrder() renvoyait ByteOrderLittleEndian au motif qu'on « supposait le défaut SunSpec ». La supposition confondait le défaut de la SPÉCIFICATION avec celui de libnymea-sunspec, qui est son contraire : convertToUInt32 lit registers.at(1) puis .at(0) en little endian. Garder ce défaut revient à lire tous les float32 avec leurs deux mots permutés. Mesuré le 2026-08-07 sur un Symo réel, en lecture seule : le compteur 213 sur l'unité 200 sert Hz = 4248 0000, soit 50,0000 en big endian et 2,3777e-41 en mots inversés. L'onduleur 113 concorde. Le coût n'était pas un décalage mais une absurdité. Reproduit sur banc avec le simulateur émettant l'ordre du matériel : un compteur important 2,7 kW rapporté comme exportant 107 MW, et l'arbitre enclenchant toutes les charges sur un « Surplus PV 107622496 W ». Le thing reste connected, sans erreur Modbus. Conséquence sur l'ordre des correctifs : meterPowerSign() reste juste mais n'est observable qu'une fois celui-ci appliqué — corriger le signe d'un dénormal ne produit rien. solarEdge force déjà big endian de son côté et le générique garde son paramètre utilisateur : aucun des deux chemins n'est touché. test_AC_18 fige le raisonnement plutôt que la seule valeur. Voir DIVERGENCES.md D-8. Co-Authored-By: Claude Opus 5 (1M context) --- sunspec/DIVERGENCES.md | 43 ++++++++++++++++++++++++++++ sunspec/froniusrules.cpp | 4 ++- sunspec/froniusrules.h | 33 +++++++++++++++++++--- sunspec/tests/logic/tst_sunspec.cpp | 44 ++++++++++++++++++++++++++--- 4 files changed, 115 insertions(+), 9 deletions(-) diff --git a/sunspec/DIVERGENCES.md b/sunspec/DIVERGENCES.md index 3d50d93..264d1cf 100644 --- a/sunspec/DIVERGENCES.md +++ b/sunspec/DIVERGENCES.md @@ -200,3 +200,46 @@ 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 56bc5d8..659438e 100644 --- a/sunspec/froniusrules.cpp +++ b/sunspec/froniusrules.cpp @@ -52,7 +52,9 @@ bool hasBattery(double batteryVoltage, double availableCharge) 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 269ff3b..2730876 100644 --- a/sunspec/froniusrules.h +++ b/sunspec/froniusrules.h @@ -129,10 +129,35 @@ double meterPowerSign(bool froniusMeter); * a GEN24 with a battery before this ships. See DIVERGENCES.md. */ bool hasBattery(double batteryVoltage, double availableCharge); -/* 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. */ +/* 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/tests/logic/tst_sunspec.cpp b/sunspec/tests/logic/tst_sunspec.cpp index 5ecf481..ad7b433 100644 --- a/sunspec/tests/logic/tst_sunspec.cpp +++ b/sunspec/tests/logic/tst_sunspec.cpp @@ -167,6 +167,8 @@ private slots: 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(); @@ -421,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); } // --------------------------------------------------------------------------- From 99bc6061985a5b40f1cac35013c74cacf9d1388f Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Fri, 7 Aug 2026 07:30:34 +0200 Subject: [PATCH 7/8] debian: changelog entry 1.15.2+etm9 Co-Authored-By: Claude Opus 5 (1M context) --- debian/changelog | 20 ++++ sunspec/tools/read-float-order.py | 187 ++++++++++++++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 sunspec/tools/read-float-order.py diff --git a/debian/changelog b/debian/changelog index 2778197..2811bec 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,23 @@ +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. diff --git a/sunspec/tools/read-float-order.py b/sunspec/tools/read-float-order.py new file mode 100644 index 0000000..7da351a --- /dev/null +++ b/sunspec/tools/read-float-order.py @@ -0,0 +1,187 @@ +#!/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, spec SunSpec — LE PLUGIN LIT CES MODÈLES DE TRAVERS") + 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, le plugin lit ces modèles de") + print("travers, car libnymea-sunspec décode en mot faible d'abord par défaut.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 366d332bf46257af88497860045538f82d95bdb8 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Fri, 7 Aug 2026 07:44:10 +0200 Subject: [PATCH 8/8] =?UTF-8?q?sunspec:=20rendre=20le=20verdict=20de=20rea?= =?UTF-8?q?d-float-order.py=20ind=C3=A9pendant=20de=20la=20version?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le message affirmait « le plugin lit de travers », vrai avant 1.15.2+etm9 et faux depuis. L'outil sert à mesurer un APPAREIL, pas à juger une version : il dit maintenant ce que lirait un lecteur gardant le défaut de la bibliothèque, et rappelle depuis quelle version le vendor Fronius force big endian. Co-Authored-By: Claude Opus 5 (1M context) --- sunspec/tools/read-float-order.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/sunspec/tools/read-float-order.py b/sunspec/tools/read-float-order.py index 7da351a..0ef4045 100644 --- a/sunspec/tools/read-float-order.py +++ b/sunspec/tools/read-float-order.py @@ -100,7 +100,8 @@ def verdict(hz_low, hz_big): 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, spec SunSpec — LE PLUGIN LIT CES MODÈLES DE TRAVERS") + 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" @@ -178,8 +179,9 @@ def main(): 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, le plugin lit ces modèles de") - print("travers, car libnymea-sunspec décode en mot faible d'abord par défaut.") + 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