Merge fix/fronius-meter-sign-and-battery-presence into experimental-silo
Aligne la ligne nightly sur le support Fronius GEN24 déjà monté en testing : signe du compteur, batterie fantôme, ordre des mots des float32. nymea-plugin-sunspec-etm01 passe de 1.15.2+etm6 à +etm9. La branche descend d'experimental-silo, donc cette fusion n'apporte que les huit commits du lot — rien de landing-silo ne remonte ici. En particulier waveshare-relay-d8 reste PRÉSENT : la suppression 045d7c6 vit sur la ligne landing/main et n'appartient pas à l'historique fusionné, conformément à l'avertissement de ce commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
4d17691239
55
debian/changelog
vendored
55
debian/changelog
vendored
@ -1,3 +1,58 @@
|
|||||||
|
etm-powersync-plugins-modbus (1.15.2+etm9) trixie; urgency=medium
|
||||||
|
|
||||||
|
* sunspec: read Fronius float32 points big endian, the way the hardware
|
||||||
|
emits them. byteOrder() returned ByteOrderLittleEndian on the grounds that
|
||||||
|
"the SunSpec default is assumed"; that conflated the default of the
|
||||||
|
specification with libnymea-sunspec's own, which is the opposite. Every
|
||||||
|
float32 of the Fronius personality was therefore read with its two
|
||||||
|
registers swapped. Measured on a Symo, read only: the model 213 meter on
|
||||||
|
unit 200 serves Hz = 4248 0000, which is 50.0000 big endian and 2.3777e-41
|
||||||
|
swapped. Reproduced on the bench: a meter importing 2.7 kW reported as
|
||||||
|
exporting 107 MW, with the arbiter switching every load on, and the thing
|
||||||
|
still reporting itself connected with no Modbus error. The meter sign fix
|
||||||
|
only becomes observable once this is right. solarEdge already forces big
|
||||||
|
endian and the generic vendor keeps its user parameter; neither changes.
|
||||||
|
* sunspec: add tools/read-float-order.py, a read-only probe that decodes Hz
|
||||||
|
and PhVphA both ways so the word order can be settled by measurement
|
||||||
|
rather than assumption.
|
||||||
|
|
||||||
|
-- Patrick Schurig <patrick@etm-schurig.eu> Fri, 07 Aug 2026 07:00:00 +0200
|
||||||
|
|
||||||
|
etm-powersync-plugins-modbus (1.15.2+etm8) trixie; urgency=medium
|
||||||
|
|
||||||
|
* sunspec: ask the connection thing directly instead of resolving it by id.
|
||||||
|
The battery presence gate went through isFroniusChild(), which looks the
|
||||||
|
parent up in myThings(). During the initial setup the parent is not
|
||||||
|
registered yet, the lookup answers false, the Fronius gate is bypassed and
|
||||||
|
the phantom storage child is created after all. Caught on the bench, not
|
||||||
|
by the unit suite, which covers the decision and not the wiring. See
|
||||||
|
sunspec/DIVERGENCES.md D-7.
|
||||||
|
|
||||||
|
-- Patrick Schurig <patrick@etm-schurig.eu> Wed, 05 Aug 2026 19:20:00 +0200
|
||||||
|
|
||||||
|
etm-powersync-plugins-modbus (1.15.2+etm7) trixie; urgency=medium
|
||||||
|
|
||||||
|
* sunspec: correct the grid meter sign on Fronius. onMeterBlockUpdated()
|
||||||
|
negated the power of every meter model regardless of vendor, but the
|
||||||
|
Fronius Smart Meter already reports in the nymea convention, where a
|
||||||
|
negative value is feed-in. The negation is now undone for meters sitting
|
||||||
|
under a froniusConnection and only for those; generic and solarEdge keep
|
||||||
|
their previous behaviour. Established on a Primo GEN24 3.0, whose single
|
||||||
|
phase inverter gives the direction reference: the plugin reported the
|
||||||
|
inverter's own phase as importing while the two phases carrying no source
|
||||||
|
read as exporting. The arbiter concluded "insufficient surplus" and left
|
||||||
|
the water heater and the heat pump off while the site was exporting.
|
||||||
|
The energy counters were already correct and are left alone.
|
||||||
|
* sunspec: stop creating a phantom battery on Fronius. A hybrid GEN24
|
||||||
|
publishes model 124 whether or not a battery is wired to it, so a storage
|
||||||
|
child was created unconditionally and sat on batteryCritical forever. The
|
||||||
|
child is now created only when a physical quantity says a battery is
|
||||||
|
there. The state of charge is deliberately not used: 0% cannot be told
|
||||||
|
apart from a real but empty battery. See sunspec/DIVERGENCES.md D-6, the
|
||||||
|
battery-present side is still inferred.
|
||||||
|
|
||||||
|
-- Patrick Schurig <patrick@etm-schurig.eu> Wed, 05 Aug 2026 15:58:00 +0200
|
||||||
|
|
||||||
etm-powersync-plugins-modbus (1.15.2+etm6) trixie; urgency=medium
|
etm-powersync-plugins-modbus (1.15.2+etm6) trixie; urgency=medium
|
||||||
|
|
||||||
* sunspec: package the vendored SunSpec plugin as nymea-plugin-sunspec-etm01.
|
* sunspec: package the vendored SunSpec plugin as nymea-plugin-sunspec-etm01.
|
||||||
|
|||||||
@ -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
|
**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
|
deux connexions, dans un lot qui assume de toucher le chemin commun, avec une
|
||||||
couverture de test pour `solarEdge`.
|
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.
|
||||||
|
|||||||
@ -35,9 +35,26 @@ bool matchesManufacturer(const QStringList &manufacturers)
|
|||||||
return false;
|
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()
|
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)
|
MeterConnectionDecision decideMeterConnection(int meterSlaveId, int inverterSlaveId)
|
||||||
|
|||||||
@ -82,10 +82,82 @@ MeterConnectionDecision decideMeterConnection(int meterSlaveId, int inverterSlav
|
|||||||
|
|
||||||
bool matchesManufacturer(const QStringList &manufacturers);
|
bool matchesManufacturer(const QStringList &manufacturers);
|
||||||
|
|
||||||
/* INFERRED: the byte order of the GEN24 is not documented by the manufacturer,
|
/* Sign to apply to the raw SunSpec meter power registers (W, WphA...) to get
|
||||||
* so the SunSpec default is assumed. The forced big endian of the SolarEdge
|
* the nymea smartmeter convention, where positive is import and negative is
|
||||||
* vendor is an anomaly specific to SolarEdge and must not be extended here.
|
* export.
|
||||||
* To be confirmed on the bench, then on hardware. */
|
*
|
||||||
|
* 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();
|
SunSpecDataPoint::ByteOrder byteOrder();
|
||||||
|
|
||||||
int clampUpdateInterval(int requestedSeconds);
|
int clampUpdateInterval(int requestedSeconds);
|
||||||
|
|||||||
@ -835,9 +835,28 @@ void IntegrationPluginSunSpec::processDiscoveryResult(Thing *thing, SunSpecConne
|
|||||||
case SunSpecModelFactory::ModelIdMeterThreePhaseDeltaConnect:
|
case SunSpecModelFactory::ModelIdMeterThreePhaseDeltaConnect:
|
||||||
autocreateSunSpecModelThing(sunspecThreePhaseMeterThingClassId, QT_TR_NOOP("Three Phase Meter"), thing->id(), model);
|
autocreateSunSpecModelThing(sunspecThreePhaseMeterThingClassId, QT_TR_NOOP("Three Phase Meter"), thing->id(), model);
|
||||||
break;
|
break;
|
||||||
case SunSpecModelFactory::ModelIdStorage:
|
case SunSpecModelFactory::ModelIdStorage: {
|
||||||
|
/* A hybrid GEN24 publishes model 124 even with no battery
|
||||||
|
* wired to it. Creating the child anyway gives the customer a
|
||||||
|
* phantom battery stuck on batteryCritical. Only the Fronius
|
||||||
|
* path is gated; the generic and solarEdge vendors keep their
|
||||||
|
* previous behaviour. */
|
||||||
|
/* Asked of the connection thing we already hold, not through
|
||||||
|
* isFroniusChild(): during the initial setup the parent is not
|
||||||
|
* registered in myThings() yet, so a lookup answers false and
|
||||||
|
* the gate would be skipped exactly when it is needed. */
|
||||||
|
SunSpecStorageModel *storageModel = qobject_cast<SunSpecStorageModel *>(model);
|
||||||
|
if (storageModel && thing->thingClassId() == froniusConnectionThingClassId
|
||||||
|
&& !FroniusRules::hasBattery(storageModel->inBatV(), storageModel->storAval())) {
|
||||||
|
qCDebug(dcSunSpec()) << "Fronius model 124 found but no battery is installed"
|
||||||
|
<< "(InBatV" << storageModel->inBatV()
|
||||||
|
<< "StorAval" << storageModel->storAval()
|
||||||
|
<< "). Not creating a storage thing.";
|
||||||
|
break;
|
||||||
|
}
|
||||||
autocreateSunSpecModelThing(sunspecStorageThingClassId, QT_TR_NOOP("Storage"), thing->id(), model);
|
autocreateSunSpecModelThing(sunspecStorageThingClassId, QT_TR_NOOP("Storage"), thing->id(), model);
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
qCWarning(dcSunSpec()) << "Plugin has no implementation for detected" << model;
|
qCWarning(dcSunSpec()) << "Plugin has no implementation for detected" << model;
|
||||||
break;
|
break;
|
||||||
@ -1835,15 +1854,21 @@ void IntegrationPluginSunSpec::onMeterBlockUpdated()
|
|||||||
if (!parentThing)
|
if (!parentThing)
|
||||||
return;
|
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()) {
|
switch (model->modelId()) {
|
||||||
case SunSpecModelFactory::ModelIdMeterSinglePhase: {
|
case SunSpecModelFactory::ModelIdMeterSinglePhase: {
|
||||||
SunSpecMeterSinglePhaseModel *meter = qobject_cast<SunSpecMeterSinglePhaseModel *>(model);
|
SunSpecMeterSinglePhaseModel *meter = qobject_cast<SunSpecMeterSinglePhaseModel *>(model);
|
||||||
qCDebug(dcSunSpec()) << thing->name() << "block data updated";// << meter;
|
qCDebug(dcSunSpec()) << thing->name() << "block data updated";// << meter;
|
||||||
thing->setStateValue(sunspecSinglePhaseMeterConnectedStateTypeId, true);
|
thing->setStateValue(sunspecSinglePhaseMeterConnectedStateTypeId, true);
|
||||||
thing->setStateValue(sunspecSinglePhaseMeterCurrentPowerStateTypeId, -meter->watts());
|
thing->setStateValue(sunspecSinglePhaseMeterCurrentPowerStateTypeId, powerSign * meter->watts());
|
||||||
thing->setStateValue(sunspecSinglePhaseMeterTotalEnergyProducedStateTypeId, meter->totalWattHoursExported() / 1000.0);
|
thing->setStateValue(sunspecSinglePhaseMeterTotalEnergyProducedStateTypeId, meter->totalWattHoursExported() / 1000.0);
|
||||||
thing->setStateValue(sunspecSinglePhaseMeterTotalEnergyConsumedStateTypeId, meter->totalWattHoursImported() / 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(sunspecSinglePhaseMeterVoltagePhaseAStateTypeId, meter->phaseVoltageAn());
|
||||||
thing->setStateValue(sunspecSinglePhaseMeterFrequencyStateTypeId, meter->hz());
|
thing->setStateValue(sunspecSinglePhaseMeterFrequencyStateTypeId, meter->hz());
|
||||||
thing->setStateValue(sunspecSinglePhaseMeterVersionStateTypeId, model->commonModelInfo().versionString);
|
thing->setStateValue(sunspecSinglePhaseMeterVersionStateTypeId, model->commonModelInfo().versionString);
|
||||||
@ -1853,10 +1878,10 @@ void IntegrationPluginSunSpec::onMeterBlockUpdated()
|
|||||||
SunSpecMeterSinglePhaseFloatModel *meter = qobject_cast<SunSpecMeterSinglePhaseFloatModel *>(model);
|
SunSpecMeterSinglePhaseFloatModel *meter = qobject_cast<SunSpecMeterSinglePhaseFloatModel *>(model);
|
||||||
qCDebug(dcSunSpec()) << thing->name() << "block data updated";// << meter;
|
qCDebug(dcSunSpec()) << thing->name() << "block data updated";// << meter;
|
||||||
thing->setStateValue(sunspecSinglePhaseMeterConnectedStateTypeId, true);
|
thing->setStateValue(sunspecSinglePhaseMeterConnectedStateTypeId, true);
|
||||||
thing->setStateValue(sunspecSinglePhaseMeterCurrentPowerStateTypeId, -meter->watts());
|
thing->setStateValue(sunspecSinglePhaseMeterCurrentPowerStateTypeId, powerSign * meter->watts());
|
||||||
thing->setStateValue(sunspecSinglePhaseMeterTotalEnergyProducedStateTypeId, meter->totalWattHoursExported() / 1000.0);
|
thing->setStateValue(sunspecSinglePhaseMeterTotalEnergyProducedStateTypeId, meter->totalWattHoursExported() / 1000.0);
|
||||||
thing->setStateValue(sunspecSinglePhaseMeterTotalEnergyConsumedStateTypeId, meter->totalWattHoursImported() / 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(sunspecSinglePhaseMeterVoltagePhaseAStateTypeId, meter->phaseVoltageAn());
|
||||||
thing->setStateValue(sunspecSinglePhaseMeterFrequencyStateTypeId, meter->hz());
|
thing->setStateValue(sunspecSinglePhaseMeterFrequencyStateTypeId, meter->hz());
|
||||||
thing->setStateValue(sunspecSinglePhaseMeterVersionStateTypeId, model->commonModelInfo().versionString);
|
thing->setStateValue(sunspecSinglePhaseMeterVersionStateTypeId, model->commonModelInfo().versionString);
|
||||||
@ -1872,12 +1897,12 @@ void IntegrationPluginSunSpec::onMeterBlockUpdated()
|
|||||||
thing->setStateValue(sunspecSplitPhaseMeterEnergyConsumedPhaseBStateTypeId, meter->totalWattHoursImportedPhaseB() / 1000.0);
|
thing->setStateValue(sunspecSplitPhaseMeterEnergyConsumedPhaseBStateTypeId, meter->totalWattHoursImportedPhaseB() / 1000.0);
|
||||||
thing->setStateValue(sunspecSplitPhaseMeterEnergyProducedPhaseAStateTypeId, meter->totalWattHoursExportedPhaseA() / 1000.0);
|
thing->setStateValue(sunspecSplitPhaseMeterEnergyProducedPhaseAStateTypeId, meter->totalWattHoursExportedPhaseA() / 1000.0);
|
||||||
thing->setStateValue(sunspecSplitPhaseMeterEnergyProducedPhaseBStateTypeId, meter->totalWattHoursExportedPhaseB() / 1000.0);
|
thing->setStateValue(sunspecSplitPhaseMeterEnergyProducedPhaseBStateTypeId, meter->totalWattHoursExportedPhaseB() / 1000.0);
|
||||||
thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerStateTypeId, -meter->watts());
|
thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerStateTypeId, powerSign * meter->watts());
|
||||||
thing->setStateValue(sunspecSplitPhaseMeterTotalCurrentStateTypeId, fixValueSign(meter->amps(), -meter->watts()));
|
thing->setStateValue(sunspecSplitPhaseMeterTotalCurrentStateTypeId, fixValueSign(meter->amps(), powerSign * meter->watts()));
|
||||||
thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerPhaseAStateTypeId, -meter->wattsPhaseA());
|
thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerPhaseAStateTypeId, powerSign * meter->wattsPhaseA());
|
||||||
thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerPhaseBStateTypeId, -meter->wattsPhaseB());
|
thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerPhaseBStateTypeId, powerSign * meter->wattsPhaseB());
|
||||||
thing->setStateValue(sunspecSplitPhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), -meter->wattsPhaseA()));
|
thing->setStateValue(sunspecSplitPhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), powerSign * meter->wattsPhaseA()));
|
||||||
thing->setStateValue(sunspecSplitPhaseMeterCurrentPhaseBStateTypeId, fixValueSign(meter->ampsPhaseB(), -meter->wattsPhaseB()));
|
thing->setStateValue(sunspecSplitPhaseMeterCurrentPhaseBStateTypeId, fixValueSign(meter->ampsPhaseB(), powerSign * meter->wattsPhaseB()));
|
||||||
thing->setStateValue(sunspecSplitPhaseMeterLnACVoltageStateTypeId, meter->voltageLn());
|
thing->setStateValue(sunspecSplitPhaseMeterLnACVoltageStateTypeId, meter->voltageLn());
|
||||||
thing->setStateValue(sunspecSplitPhaseMeterVoltagePhaseAStateTypeId, meter->phaseVoltageAn());
|
thing->setStateValue(sunspecSplitPhaseMeterVoltagePhaseAStateTypeId, meter->phaseVoltageAn());
|
||||||
thing->setStateValue(sunspecSplitPhaseMeterVoltagePhaseBStateTypeId, meter->phaseVoltageBn());
|
thing->setStateValue(sunspecSplitPhaseMeterVoltagePhaseBStateTypeId, meter->phaseVoltageBn());
|
||||||
@ -1895,12 +1920,12 @@ void IntegrationPluginSunSpec::onMeterBlockUpdated()
|
|||||||
thing->setStateValue(sunspecSplitPhaseMeterEnergyConsumedPhaseBStateTypeId, meter->totalWattHoursImportedPhaseB() / 1000.0);
|
thing->setStateValue(sunspecSplitPhaseMeterEnergyConsumedPhaseBStateTypeId, meter->totalWattHoursImportedPhaseB() / 1000.0);
|
||||||
thing->setStateValue(sunspecSplitPhaseMeterEnergyProducedPhaseAStateTypeId, meter->totalWattHoursExportedPhaseA() / 1000.0);
|
thing->setStateValue(sunspecSplitPhaseMeterEnergyProducedPhaseAStateTypeId, meter->totalWattHoursExportedPhaseA() / 1000.0);
|
||||||
thing->setStateValue(sunspecSplitPhaseMeterEnergyProducedPhaseBStateTypeId, meter->totalWattHoursExportedPhaseB() / 1000.0);
|
thing->setStateValue(sunspecSplitPhaseMeterEnergyProducedPhaseBStateTypeId, meter->totalWattHoursExportedPhaseB() / 1000.0);
|
||||||
thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerStateTypeId, -meter->watts());
|
thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerStateTypeId, powerSign * meter->watts());
|
||||||
thing->setStateValue(sunspecSplitPhaseMeterTotalCurrentStateTypeId, fixValueSign(meter->amps(), -meter->watts()));
|
thing->setStateValue(sunspecSplitPhaseMeterTotalCurrentStateTypeId, fixValueSign(meter->amps(), powerSign * meter->watts()));
|
||||||
thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerPhaseAStateTypeId, -meter->wattsPhaseA());
|
thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerPhaseAStateTypeId, powerSign * meter->wattsPhaseA());
|
||||||
thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerPhaseBStateTypeId, -meter->wattsPhaseB());
|
thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerPhaseBStateTypeId, powerSign * meter->wattsPhaseB());
|
||||||
thing->setStateValue(sunspecSplitPhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), -meter->wattsPhaseA()));
|
thing->setStateValue(sunspecSplitPhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), powerSign * meter->wattsPhaseA()));
|
||||||
thing->setStateValue(sunspecSplitPhaseMeterCurrentPhaseBStateTypeId, fixValueSign(meter->ampsPhaseB(), -meter->wattsPhaseB()));
|
thing->setStateValue(sunspecSplitPhaseMeterCurrentPhaseBStateTypeId, fixValueSign(meter->ampsPhaseB(), powerSign * meter->wattsPhaseB()));
|
||||||
thing->setStateValue(sunspecSplitPhaseMeterLnACVoltageStateTypeId, meter->voltageLn());
|
thing->setStateValue(sunspecSplitPhaseMeterLnACVoltageStateTypeId, meter->voltageLn());
|
||||||
thing->setStateValue(sunspecSplitPhaseMeterVoltagePhaseAStateTypeId, meter->phaseVoltageAn());
|
thing->setStateValue(sunspecSplitPhaseMeterVoltagePhaseAStateTypeId, meter->phaseVoltageAn());
|
||||||
thing->setStateValue(sunspecSplitPhaseMeterVoltagePhaseBStateTypeId, meter->phaseVoltageBn());
|
thing->setStateValue(sunspecSplitPhaseMeterVoltagePhaseBStateTypeId, meter->phaseVoltageBn());
|
||||||
@ -1920,13 +1945,13 @@ void IntegrationPluginSunSpec::onMeterBlockUpdated()
|
|||||||
thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseAStateTypeId, meter->totalWattHoursExportedPhaseA() / 1000.0);
|
thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseAStateTypeId, meter->totalWattHoursExportedPhaseA() / 1000.0);
|
||||||
thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseBStateTypeId, meter->totalWattHoursExportedPhaseB() / 1000.0);
|
thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseBStateTypeId, meter->totalWattHoursExportedPhaseB() / 1000.0);
|
||||||
thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseCStateTypeId, meter->totalWattHoursExportedPhaseC() / 1000.0);
|
thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseCStateTypeId, meter->totalWattHoursExportedPhaseC() / 1000.0);
|
||||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerStateTypeId, -meter->watts());
|
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerStateTypeId, powerSign * meter->watts());
|
||||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseAStateTypeId, -meter->wattsPhaseA());
|
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseAStateTypeId, powerSign * meter->wattsPhaseA());
|
||||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseBStateTypeId, -meter->wattsPhaseB());
|
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseBStateTypeId, powerSign * meter->wattsPhaseB());
|
||||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseCStateTypeId, -meter->wattsPhaseC());
|
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseCStateTypeId, powerSign * meter->wattsPhaseC());
|
||||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), -meter->wattsPhaseA()));
|
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), powerSign * meter->wattsPhaseA()));
|
||||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseBStateTypeId, fixValueSign(meter->ampsPhaseB(), -meter->wattsPhaseB()));
|
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseBStateTypeId, fixValueSign(meter->ampsPhaseB(), powerSign * meter->wattsPhaseB()));
|
||||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseCStateTypeId, fixValueSign(meter->ampsPhaseC(), -meter->wattsPhaseC()));
|
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseCStateTypeId, fixValueSign(meter->ampsPhaseC(), powerSign * meter->wattsPhaseC()));
|
||||||
thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseAStateTypeId, meter->phaseVoltageAn());
|
thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseAStateTypeId, meter->phaseVoltageAn());
|
||||||
thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseBStateTypeId, meter->phaseVoltageBn());
|
thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseBStateTypeId, meter->phaseVoltageBn());
|
||||||
thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseCStateTypeId, meter->phaseVoltageCn());
|
thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseCStateTypeId, meter->phaseVoltageCn());
|
||||||
@ -1946,13 +1971,13 @@ void IntegrationPluginSunSpec::onMeterBlockUpdated()
|
|||||||
thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseAStateTypeId, meter->totalWattHoursExportedPhaseA() / 1000.0);
|
thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseAStateTypeId, meter->totalWattHoursExportedPhaseA() / 1000.0);
|
||||||
thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseBStateTypeId, meter->totalWattHoursExportedPhaseB() / 1000.0);
|
thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseBStateTypeId, meter->totalWattHoursExportedPhaseB() / 1000.0);
|
||||||
thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseCStateTypeId, meter->totalWattHoursExportedPhaseC() / 1000.0);
|
thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseCStateTypeId, meter->totalWattHoursExportedPhaseC() / 1000.0);
|
||||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerStateTypeId, -meter->watts());
|
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerStateTypeId, powerSign * meter->watts());
|
||||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseAStateTypeId, -meter->wattsPhaseA());
|
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseAStateTypeId, powerSign * meter->wattsPhaseA());
|
||||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseBStateTypeId, -meter->wattsPhaseB());
|
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseBStateTypeId, powerSign * meter->wattsPhaseB());
|
||||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseCStateTypeId, -meter->wattsPhaseC());
|
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseCStateTypeId, powerSign * meter->wattsPhaseC());
|
||||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), -meter->wattsPhaseA()));
|
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), powerSign * meter->wattsPhaseA()));
|
||||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseBStateTypeId, fixValueSign(meter->ampsPhaseB(), -meter->wattsPhaseB()));
|
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseBStateTypeId, fixValueSign(meter->ampsPhaseB(), powerSign * meter->wattsPhaseB()));
|
||||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseCStateTypeId, fixValueSign(meter->ampsPhaseC(), -meter->wattsPhaseC()));
|
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseCStateTypeId, fixValueSign(meter->ampsPhaseC(), powerSign * meter->wattsPhaseC()));
|
||||||
thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseAStateTypeId, meter->phaseVoltageAn());
|
thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseAStateTypeId, meter->phaseVoltageAn());
|
||||||
thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseBStateTypeId, meter->phaseVoltageBn());
|
thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseBStateTypeId, meter->phaseVoltageBn());
|
||||||
thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseCStateTypeId, meter->phaseVoltageCn());
|
thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseCStateTypeId, meter->phaseVoltageCn());
|
||||||
@ -1972,13 +1997,13 @@ void IntegrationPluginSunSpec::onMeterBlockUpdated()
|
|||||||
thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseAStateTypeId, meter->totalWattHoursExportedPhaseA() / 1000.0);
|
thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseAStateTypeId, meter->totalWattHoursExportedPhaseA() / 1000.0);
|
||||||
thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseBStateTypeId, meter->totalWattHoursExportedPhaseB() / 1000.0);
|
thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseBStateTypeId, meter->totalWattHoursExportedPhaseB() / 1000.0);
|
||||||
thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseCStateTypeId, meter->totalWattHoursExportedPhaseC() / 1000.0);
|
thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseCStateTypeId, meter->totalWattHoursExportedPhaseC() / 1000.0);
|
||||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerStateTypeId, -meter->watts());
|
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerStateTypeId, powerSign * meter->watts());
|
||||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseAStateTypeId, -meter->wattsPhaseA());
|
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseAStateTypeId, powerSign * meter->wattsPhaseA());
|
||||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseBStateTypeId, -meter->wattsPhaseB());
|
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseBStateTypeId, powerSign * meter->wattsPhaseB());
|
||||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseCStateTypeId, -meter->wattsPhaseC());
|
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseCStateTypeId, powerSign * meter->wattsPhaseC());
|
||||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), -meter->wattsPhaseA()));
|
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), powerSign * meter->wattsPhaseA()));
|
||||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseBStateTypeId, fixValueSign(meter->ampsPhaseB(), -meter->wattsPhaseB()));
|
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseBStateTypeId, fixValueSign(meter->ampsPhaseB(), powerSign * meter->wattsPhaseB()));
|
||||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseCStateTypeId, fixValueSign(meter->ampsPhaseC(), -meter->wattsPhaseC()));
|
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseCStateTypeId, fixValueSign(meter->ampsPhaseC(), powerSign * meter->wattsPhaseC()));
|
||||||
thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseAStateTypeId, meter->phaseVoltageAn());
|
thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseAStateTypeId, meter->phaseVoltageAn());
|
||||||
thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseBStateTypeId, meter->phaseVoltageBn());
|
thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseBStateTypeId, meter->phaseVoltageBn());
|
||||||
thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseCStateTypeId, meter->phaseVoltageCn());
|
thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseCStateTypeId, meter->phaseVoltageCn());
|
||||||
@ -1998,13 +2023,13 @@ void IntegrationPluginSunSpec::onMeterBlockUpdated()
|
|||||||
thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseAStateTypeId, meter->totalWattHoursExportedPhaseA() / 1000.0);
|
thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseAStateTypeId, meter->totalWattHoursExportedPhaseA() / 1000.0);
|
||||||
thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseBStateTypeId, meter->totalWattHoursExportedPhaseB() / 1000.0);
|
thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseBStateTypeId, meter->totalWattHoursExportedPhaseB() / 1000.0);
|
||||||
thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseCStateTypeId, meter->totalWattHoursExportedPhaseC() / 1000.0);
|
thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseCStateTypeId, meter->totalWattHoursExportedPhaseC() / 1000.0);
|
||||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerStateTypeId, -meter->watts());
|
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerStateTypeId, powerSign * meter->watts());
|
||||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseAStateTypeId, -meter->wattsPhaseA());
|
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseAStateTypeId, powerSign * meter->wattsPhaseA());
|
||||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseBStateTypeId, -meter->wattsPhaseB());
|
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseBStateTypeId, powerSign * meter->wattsPhaseB());
|
||||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseCStateTypeId, -meter->wattsPhaseC());
|
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseCStateTypeId, powerSign * meter->wattsPhaseC());
|
||||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), -meter->wattsPhaseA()));
|
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), powerSign * meter->wattsPhaseA()));
|
||||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseBStateTypeId, fixValueSign(meter->ampsPhaseB(), -meter->wattsPhaseB()));
|
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseBStateTypeId, fixValueSign(meter->ampsPhaseB(), powerSign * meter->wattsPhaseB()));
|
||||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseCStateTypeId, fixValueSign(meter->ampsPhaseC(), -meter->wattsPhaseC()));
|
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseCStateTypeId, fixValueSign(meter->ampsPhaseC(), powerSign * meter->wattsPhaseC()));
|
||||||
thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseAStateTypeId, meter->phaseVoltageAn());
|
thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseAStateTypeId, meter->phaseVoltageAn());
|
||||||
thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseBStateTypeId, meter->phaseVoltageBn());
|
thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseBStateTypeId, meter->phaseVoltageBn());
|
||||||
thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseCStateTypeId, meter->phaseVoltageCn());
|
thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseCStateTypeId, meter->phaseVoltageCn());
|
||||||
|
|||||||
@ -161,6 +161,18 @@ private slots:
|
|||||||
void test_AC_15_battery_power_is_positive_when_discharging();
|
void test_AC_15_battery_power_is_positive_when_discharging();
|
||||||
void test_AC_15_battery_power_is_negative_when_charging();
|
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
|
// F3 - multi unit ID
|
||||||
void test_F3_meter_slave_id_zero_disables_the_meter_connection();
|
void test_F3_meter_slave_id_zero_disables_the_meter_connection();
|
||||||
void test_F3_meter_slave_id_out_of_range_is_refused();
|
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()
|
void SunSpecFroniusTest::test_AC_6_fronius_uses_sunspec_default_byte_order()
|
||||||
{
|
{
|
||||||
// INFERE : non documente par le constructeur, on suppose le defaut SunSpec.
|
// MESURE le 2026-08-07 sur un Symo reel, en lecture seule : l'appareil emet
|
||||||
// Le ByteOrderBigEndian force du vendor solarEdge est une anomalie propre a
|
// en big endian, mot de poids fort d'abord, conformement a la specification
|
||||||
// SolarEdge et ne doit pas etre etendue.
|
// SunSpec.
|
||||||
QCOMPARE(FroniusRules::byteOrder(), SunSpecDataPoint::ByteOrderLittleEndian);
|
//
|
||||||
|
// 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.");
|
"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()
|
void SunSpecFroniusTest::test_F3_generic_vendors_have_no_meter_slave_id()
|
||||||
{
|
{
|
||||||
// Le multi unit ID est une particularite Fronius. Les vendors generique et
|
// Le multi unit ID est une particularite Fronius. Les vendors generique et
|
||||||
|
|||||||
189
sunspec/tools/read-float-order.py
Normal file
189
sunspec/tools/read-float-order.py
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
"""Quel ordre de mots un onduleur SunSpec émet-il pour ses float32 ?
|
||||||
|
|
||||||
|
La spécification SunSpec dit big endian, mot de poids fort en premier. Mais
|
||||||
|
libnymea-sunspec décode par défaut `ByteOrderLittleEndian`, c'est à dire
|
||||||
|
`registers.at(1)` PUIS `registers.at(0)` — mot de poids faible d'abord
|
||||||
|
(sunspecdatapoint.cpp, convertToUInt32). Le vendor Fronius du plugin garde ce
|
||||||
|
défaut. Si l'appareil émet en big endian, le plugin lit des dénormaux (~1e-41)
|
||||||
|
et aucune correction de SIGNE n'y changera quoi que ce soit.
|
||||||
|
|
||||||
|
Ce script tranche la question par la mesure, sur deux points dont la valeur
|
||||||
|
attendue est connue d'avance et non ambiguë :
|
||||||
|
- Hz : ~50 Hz
|
||||||
|
- PhVphA : ~230..240 V
|
||||||
|
Un ordre donne ces valeurs, l'autre donne des dénormaux. Il n'y a pas de doute
|
||||||
|
possible entre les deux.
|
||||||
|
|
||||||
|
LECTURE SEULE : ce script n'émet QUE des fonctions Modbus 3 (read holding
|
||||||
|
registers). Aucune écriture, aucun code fonction d'écriture n'est implémenté.
|
||||||
|
|
||||||
|
python3 read-float-order.py <ip-lan>
|
||||||
|
python3 read-float-order.py <ip-lan> --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())
|
||||||
167
sunspec/tools/read-model124.py
Normal file
167
sunspec/tools/read-model124.py
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
"""Read-only dump of the SunSpec storage block (model 124) of an inverter.
|
||||||
|
|
||||||
|
Answers one question: does this inverter have a battery physically installed?
|
||||||
|
See sunspec/DIVERGENCES.md D-6 — the "battery present" side of the presence
|
||||||
|
discriminator is still inferred and needs a reading from real hardware.
|
||||||
|
|
||||||
|
Standard library only: no pymodbus, no pysunspec2, nothing to install.
|
||||||
|
It only ever issues function code 3 (read holding registers). It never writes.
|
||||||
|
|
||||||
|
python3 read-model124.py <ip-lan>
|
||||||
|
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())
|
||||||
Loading…
x
Reference in New Issue
Block a user