Merge fix/fronius-meter-sign-and-battery-presence into experimental-silo
Aligne la ligne nightly sur le support Fronius GEN24 déjà monté en testing : signe du compteur, batterie fantôme, ordre des mots des float32. nymea-plugin-sunspec-etm01 passe de 1.15.2+etm6 à +etm9. La branche descend d'experimental-silo, donc cette fusion n'apporte que les huit commits du lot — rien de landing-silo ne remonte ici. En particulier waveshare-relay-d8 reste PRÉSENT : la suppression f25b620 vit sur la ligne landing/main et n'appartient pas à l'historique fusionné, conformément à l'avertissement de ce commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
f98542fe0c
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
|
||||
|
||||
* 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
|
||||
deux connexions, dans un lot qui assume de toucher le chemin commun, avec une
|
||||
couverture de test pour `solarEdge`.
|
||||
|
||||
---
|
||||
|
||||
## D-5 — le signe du compteur est corrigé côté Fronius seulement (MESURÉ)
|
||||
|
||||
**Contexte.** `onMeterBlockUpdated()` négociait le signe de façon **générique** :
|
||||
tous les modèles compteur (201, 202, 203, 204, 211…214) faisaient
|
||||
`-meter->watts()`. Il n'existait aucun mapping spécifique à un fabricant.
|
||||
|
||||
**L'écart.** Sur Fronius cette négation est fausse : le Smart Meter émet déjà
|
||||
dans la convention nymea (négatif = injection). Le correctif n'annule la
|
||||
négation que lorsque le compteur est enfant d'une `froniusConnection` ; les
|
||||
vendors generic et solarEdge gardent la négation historique, verrouillée par
|
||||
`test_AC_16_generic_meter_keeps_the_historical_negation`.
|
||||
|
||||
**Preuve.** Relevé du 02/08/2026 sur un Primo GEN24 3.0, onduleur **monophasé**
|
||||
(modèle 101) et Smart Meter TS 65A-3 (modèle **203**, pas 213). L'onduleur
|
||||
n'injecte que sur la phase A ; le plugin rapportait phase A = +1003 W
|
||||
(soutirage) et phases B et C à −33 W et −262 W, soit une injection sur deux
|
||||
phases ne portant aucune source. En inversant, le bilan se referme : production
|
||||
1190 W = charge phase A 187 W + export 1003 W ; import B+C 294 W ; consommation
|
||||
481 W ; export réseau 709 W contre 708 W mesuré. Corroboration indépendante :
|
||||
sur la même fenêtre, le compteur d'énergie *import* de l'appareil reste à 0
|
||||
pendant que l'*export* s'incrémente. Conséquence observée :
|
||||
`[Arbitre] "chauffe-eau" → "Surplus insuffisant (-842 W)"` alors que le site
|
||||
exportait ~842 W.
|
||||
|
||||
**Non touché.** Les compteurs d'énergie (`totalEnergyConsumed` /
|
||||
`totalEnergyProduced`) sont **corrects** et ne sont pas permutés : ce sont eux
|
||||
qui servent de témoin à charge ci-dessus.
|
||||
|
||||
**Comment lever.** Déterminer si la négation générique est fausse pour tous les
|
||||
fabricants ou seulement pour Fronius demanderait un compteur d'un autre vendor
|
||||
sur banc. Tant que ce n'est pas tranché, le générique ne bouge pas.
|
||||
|
||||
---
|
||||
|
||||
## D-6 — présence batterie : le côté « batterie présente » n'est pas validé (INFÉRÉ)
|
||||
|
||||
**Contexte.** Un GEN24 hybride publie le modèle 124 même sans batterie câblée.
|
||||
Le plugin créait donc un enfant storage systématiquement, d'où batterie fantôme
|
||||
et `batteryCritical` permanent.
|
||||
|
||||
**L'écart.** `FroniusRules::hasBattery()` exige désormais `InBatV > 0` ou
|
||||
`StorAval > 0` pour créer l'enfant, et seulement sous une `froniusConnection`.
|
||||
Le SoC est écarté (0 % ne se distingue pas d'une batterie vide) ainsi que les
|
||||
registres de consigne (`WChaMax`… gardent leurs défauts sans batterie).
|
||||
|
||||
**Ce qui est établi.** Le côté « absente » est confirmé sur matériel : le GEN24
|
||||
des beaux-parents, sans batterie, exposait le modèle 124 avec `ChaSt = Off` et
|
||||
un SoC de 0, et nymea journalisait « There is no energy storage available to
|
||||
consider » pendant que l'enfant restait visible.
|
||||
|
||||
**Ce qui ne l'est pas.** `InBatV` et `StorAval` n'étant pas exposés en states
|
||||
nymea, leurs valeurs sur ce GEN24 sans batterie **n'ont pas pu être relevées**,
|
||||
et aucun GEN24 **avec** batterie n'était joignable. L'hypothèse « une batterie
|
||||
installée porte une tension au repos » est donc **INFÉRÉE des deux côtés**.
|
||||
|
||||
**Comment lever.** Relever en lecture seule le bloc 124 d'un GEN24 équipé d'une
|
||||
batterie et vérifier `InBatV`/`StorAval` non nuls. **À faire avant tout
|
||||
déploiement** : un discriminateur trop strict ferait disparaître une vraie
|
||||
batterie chez un client.
|
||||
|
||||
---
|
||||
|
||||
## D-7 — la présence batterie ne peut pas être vérifiée par la suite logique (MESURÉ)
|
||||
|
||||
**Contexte.** La première version du garde de D-6 demandait
|
||||
`isFroniusChild(thing->id())`, qui résout le parent par `myThings().findById()`.
|
||||
|
||||
**L'écart.** Pendant le setup initial, la connexion parente n'est **pas encore
|
||||
enregistrée** dans `myThings()` : la recherche répond faux, le garde Fronius est
|
||||
contourné, et l'enfant storage est créé malgré `InBatV = StorAval = 0`. Trace
|
||||
nymead correspondante :
|
||||
`ThingManager: Invalid parent thing id … Not adding thing to the system.`
|
||||
|
||||
Le garde interroge désormais directement le `thing` de connexion que la fonction
|
||||
tient déjà (`thing->thingClassId() == froniusConnectionThingClassId`), sans
|
||||
aucune recherche. Même traitement pour le signe compteur, qui utilise le
|
||||
`parentThing` déjà résolu et non nul.
|
||||
|
||||
**Ce que cela apprend.** La suite `tests/logic` ne peut pas attraper ce défaut :
|
||||
elle couvre la décision pure (`FroniusRules`), pas le câblage du plugin, c'est à
|
||||
dire *quel prédicat est interrogé et à quel moment du cycle de vie*. Seul un
|
||||
essai en boucle fermée contre un simulateur le révèle. Toute modification du
|
||||
garde de présence doit donc être rejouée sur banc, pas seulement en unitaire.
|
||||
|
||||
**Comment lever.** Étendre `tests/plugin`, qui instancie réellement le plugin, à
|
||||
un scénario de setup initial avec un modèle 124 à zéro. Hors périmètre ici.
|
||||
|
||||
---
|
||||
|
||||
## D-8 — ordre des mots des float32 : le défaut de la bibliothèque n'est pas celui de la spec (MESURÉ)
|
||||
|
||||
**Contexte.** `FroniusRules::byteOrder()` renvoyait `ByteOrderLittleEndian`, avec
|
||||
pour justification « non documenté par le constructeur, on suppose le défaut
|
||||
SunSpec ».
|
||||
|
||||
**L'écart.** La supposition confondait deux « défauts ». Un float32 SunSpec
|
||||
occupe deux registres, **mot de poids fort d'abord**. Or
|
||||
`SunSpecDataPoint::convertToUInt32` lit `registers.at(0)` puis `.at(1)` en
|
||||
`ByteOrderBigEndian`, et l'**inverse** en `ByteOrderLittleEndian` — qui est le
|
||||
défaut de libnymea-sunspec. Garder ce défaut revient à lire tous les float32 de
|
||||
la personnalité Fronius avec leurs deux mots permutés.
|
||||
|
||||
**Mesure.** Relevé du 2026-08-07 sur un Fronius Symo réel, en lecture seule
|
||||
(`sunspec/tools/read-float-order.py`) :
|
||||
|
||||
| point | registres bruts | big endian | mots inversés |
|
||||
|---|---|---|---|
|
||||
| compteur 213 u200 `Hz` | `4248 0000` | **50,0000** | 2,3777e−41 |
|
||||
| compteur 213 u200 `PhVphA` | `436a 8000` | **234,5000** | −2,4184e−41 |
|
||||
| onduleur 113 u1 `Hz` | `4247 fc48` | **49,9964** | −4,1592e+36 |
|
||||
|
||||
**Ce que cela coûtait.** Pas un décalage : une absurdité. Reproduit sur banc avec
|
||||
le simulateur émettant l'ordre du matériel — un compteur **important 2,7 kW** a
|
||||
été rapporté comme **exportant 107 MW**, `Current load on root meter:
|
||||
-1.07621e+08W`, et l'arbitre a enclenché toutes les charges sur un « Surplus PV
|
||||
107622496 W ». Un tel appareil se présente `connected = true`, sans aucune
|
||||
erreur Modbus.
|
||||
|
||||
**Ordre des correctifs.** `meterPowerSign()` (D-5) reste juste, mais n'est
|
||||
**observable** qu'une fois l'ordre des mots corrigé : corriger le signe d'un
|
||||
dénormal ne produit rien.
|
||||
|
||||
**Non touché.** Le vendor solarEdge force déjà big endian sur son propre chemin ;
|
||||
le vendor générique conserve son paramètre `endianness` réglable par
|
||||
l'utilisateur. Aucun des deux n'est modifié.
|
||||
|
||||
**Verrou.** `test_AC_6` affirme désormais `ByteOrderBigEndian`, et
|
||||
`test_AC_18_swapped_words_yield_denormals` fige le raisonnement : les registres
|
||||
réels lus à l'envers donnent un dénormal, jamais une mesure plausible.
|
||||
|
||||
@ -35,9 +35,26 @@ bool matchesManufacturer(const QStringList &manufacturers)
|
||||
return false;
|
||||
}
|
||||
|
||||
double meterPowerSign(bool froniusMeter)
|
||||
{
|
||||
/* The Fronius meter is already in the nymea convention, so the generic
|
||||
* negation has to be undone for it, and only for it. */
|
||||
return froniusMeter ? 1.0 : -1.0;
|
||||
}
|
||||
|
||||
bool hasBattery(double batteryVoltage, double availableCharge)
|
||||
{
|
||||
/* Strictly positive only: zero is the no-battery reading, and a negative
|
||||
* value is the library's way of surfacing a not-implemented point rather
|
||||
* than a real measurement. */
|
||||
return batteryVoltage > 0.0 || availableCharge > 0.0;
|
||||
}
|
||||
|
||||
SunSpecDataPoint::ByteOrder byteOrder()
|
||||
{
|
||||
return SunSpecDataPoint::ByteOrderLittleEndian;
|
||||
/* Big endian is what the hardware emits, and what SunSpec specifies. The
|
||||
* library's default is the opposite; see the header for the measurement. */
|
||||
return SunSpecDataPoint::ByteOrderBigEndian;
|
||||
}
|
||||
|
||||
MeterConnectionDecision decideMeterConnection(int meterSlaveId, int inverterSlaveId)
|
||||
|
||||
@ -82,10 +82,82 @@ MeterConnectionDecision decideMeterConnection(int meterSlaveId, int inverterSlav
|
||||
|
||||
bool matchesManufacturer(const QStringList &manufacturers);
|
||||
|
||||
/* INFERRED: the byte order of the GEN24 is not documented by the manufacturer,
|
||||
* so the SunSpec default is assumed. The forced big endian of the SolarEdge
|
||||
* vendor is an anomaly specific to SolarEdge and must not be extended here.
|
||||
* To be confirmed on the bench, then on hardware. */
|
||||
/* Sign to apply to the raw SunSpec meter power registers (W, WphA...) to get
|
||||
* the nymea smartmeter convention, where positive is import and negative is
|
||||
* export.
|
||||
*
|
||||
* The Fronius Smart Meter already reports in that convention: negative W is
|
||||
* feed-in. The generic path negates, so on a Fronius the surplus came out with
|
||||
* the wrong sign and the arbiter refused to run loads while the site was
|
||||
* exporting.
|
||||
*
|
||||
* Established on hardware, not from the manufacturer's documentation. On a
|
||||
* Primo GEN24 3.0, a *single phase* inverter feeding phase A only, the plugin
|
||||
* reported phase A at +1003 W (import) while phases B and C read -33 W and
|
||||
* -262 W (export). Exporting on two phases carrying no source is impossible.
|
||||
* With the sign flipped the balance closes exactly: production 1190 W = phase A
|
||||
* load 187 W + phase A export 1003 W, B and C import 294 W, so the house draws
|
||||
* 481 W and the site exports 709 W against 708 W measured. The meter's own
|
||||
* energy counters agree: over the same window its import counter stayed at
|
||||
* zero while its export counter advanced.
|
||||
*
|
||||
* Only the instantaneous power is affected. The energy counters map correctly
|
||||
* already and must not be swapped.
|
||||
*
|
||||
* The generic default is left untouched: other vendors keep the negation. */
|
||||
double meterPowerSign(bool froniusMeter);
|
||||
|
||||
/* Whether a model 124 block is backed by a physically installed battery.
|
||||
*
|
||||
* A hybrid GEN24 publishes model 124 whether or not a battery is wired to it,
|
||||
* so the presence of the model proves nothing. Creating a storage child anyway
|
||||
* gives the customer a phantom battery and a permanent batteryCritical alarm:
|
||||
* observed on hardware, a GEN24 with no battery reported ChaSt = Off and a
|
||||
* state of charge of 0, and nymea's own energy engine then logged "There is no
|
||||
* energy storage available to consider" while the child stayed visible.
|
||||
*
|
||||
* The discriminator is deliberately a *physical* quantity. The state of charge
|
||||
* is refused because 0% cannot be told apart from a real but empty battery, and
|
||||
* the setpoint registers (WChaMax and friends) are refused because they keep
|
||||
* their defaults with no battery attached.
|
||||
*
|
||||
* INFÉRÉ: an installed battery carries a DC voltage at rest, so InBatV or
|
||||
* StorAval is expected to be strictly positive whenever one is present, and
|
||||
* both zero when none is. The absent side matches what the battery-less GEN24
|
||||
* showed and is reproduced on the simulator. The present side could NOT be
|
||||
* confirmed: no GEN24 with a battery was reachable. Confirm InBatV/StorAval on
|
||||
* a GEN24 with a battery before this ships. See DIVERGENCES.md. */
|
||||
bool hasBattery(double batteryVoltage, double availableCharge);
|
||||
|
||||
/* Word order of the float32 points, measured on hardware.
|
||||
*
|
||||
* This used to read "INFERRED: not documented by the manufacturer, so the
|
||||
* SunSpec default is assumed. To be confirmed on the bench, then on hardware."
|
||||
* It has now been confirmed, and the assumption was wrong: what was assumed to
|
||||
* be "the SunSpec default" was in fact libnymea-sunspec's own default, which is
|
||||
* the opposite of the specification.
|
||||
*
|
||||
* A float32 spans two registers. SunSpec puts the high word first.
|
||||
* SunSpecDataPoint::convertToUInt32 reads registers.at(0) then .at(1) for
|
||||
* ByteOrderBigEndian, and the reverse for ByteOrderLittleEndian — and little
|
||||
* endian is the library's default. Keeping that default on a Fronius means
|
||||
* reading every float32 with its two words swapped.
|
||||
*
|
||||
* Measured 2026-08-07 on a Fronius Symo, read only:
|
||||
* meter 213, unit 200 : Hz = 4248 0000 -> 50.0000 big endian
|
||||
* PhVphA = 436a 8000 -> 234.5000
|
||||
* inverter 113, unit 1: Hz = 4247 fc48 -> 49.9964
|
||||
* Swapped, those same registers give 2.4e-41 and -4.2e+36: denormals, the
|
||||
* signature of a wrong interpretation rather than a measurement.
|
||||
*
|
||||
* What it costs to get this wrong is not a small offset. Reproduced on the
|
||||
* bench with the simulator emitting the hardware's order: a meter importing
|
||||
* 2.7 kW was reported as exporting 107 MW, and the arbiter switched every load
|
||||
* on. Correcting the SIGN of a denormal changes nothing, so meterPowerSign()
|
||||
* only becomes observable once this is right.
|
||||
*
|
||||
* The SolarEdge vendor already forces big endian on its own path; the generic
|
||||
* vendor keeps its user-settable parameter. Neither is affected. */
|
||||
SunSpecDataPoint::ByteOrder byteOrder();
|
||||
|
||||
int clampUpdateInterval(int requestedSeconds);
|
||||
|
||||
@ -835,9 +835,28 @@ void IntegrationPluginSunSpec::processDiscoveryResult(Thing *thing, SunSpecConne
|
||||
case SunSpecModelFactory::ModelIdMeterThreePhaseDeltaConnect:
|
||||
autocreateSunSpecModelThing(sunspecThreePhaseMeterThingClassId, QT_TR_NOOP("Three Phase Meter"), thing->id(), model);
|
||||
break;
|
||||
case SunSpecModelFactory::ModelIdStorage:
|
||||
case SunSpecModelFactory::ModelIdStorage: {
|
||||
/* A hybrid GEN24 publishes model 124 even with no battery
|
||||
* wired to it. Creating the child anyway gives the customer a
|
||||
* phantom battery stuck on batteryCritical. Only the Fronius
|
||||
* path is gated; the generic and solarEdge vendors keep their
|
||||
* previous behaviour. */
|
||||
/* Asked of the connection thing we already hold, not through
|
||||
* isFroniusChild(): during the initial setup the parent is not
|
||||
* registered in myThings() yet, so a lookup answers false and
|
||||
* the gate would be skipped exactly when it is needed. */
|
||||
SunSpecStorageModel *storageModel = qobject_cast<SunSpecStorageModel *>(model);
|
||||
if (storageModel && thing->thingClassId() == froniusConnectionThingClassId
|
||||
&& !FroniusRules::hasBattery(storageModel->inBatV(), storageModel->storAval())) {
|
||||
qCDebug(dcSunSpec()) << "Fronius model 124 found but no battery is installed"
|
||||
<< "(InBatV" << storageModel->inBatV()
|
||||
<< "StorAval" << storageModel->storAval()
|
||||
<< "). Not creating a storage thing.";
|
||||
break;
|
||||
}
|
||||
autocreateSunSpecModelThing(sunspecStorageThingClassId, QT_TR_NOOP("Storage"), thing->id(), model);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
qCWarning(dcSunSpec()) << "Plugin has no implementation for detected" << model;
|
||||
break;
|
||||
@ -1835,15 +1854,21 @@ void IntegrationPluginSunSpec::onMeterBlockUpdated()
|
||||
if (!parentThing)
|
||||
return;
|
||||
|
||||
/* Sign applied to every power reading of this block. The generic vendors
|
||||
* keep the historical negation; a meter sitting under a Fronius connection
|
||||
* already reports in the nymea convention. See FroniusRules. */
|
||||
const double powerSign = FroniusRules::meterPowerSign(
|
||||
parentThing->thingClassId() == froniusConnectionThingClassId);
|
||||
|
||||
switch (model->modelId()) {
|
||||
case SunSpecModelFactory::ModelIdMeterSinglePhase: {
|
||||
SunSpecMeterSinglePhaseModel *meter = qobject_cast<SunSpecMeterSinglePhaseModel *>(model);
|
||||
qCDebug(dcSunSpec()) << thing->name() << "block data updated";// << meter;
|
||||
thing->setStateValue(sunspecSinglePhaseMeterConnectedStateTypeId, true);
|
||||
thing->setStateValue(sunspecSinglePhaseMeterCurrentPowerStateTypeId, -meter->watts());
|
||||
thing->setStateValue(sunspecSinglePhaseMeterCurrentPowerStateTypeId, powerSign * meter->watts());
|
||||
thing->setStateValue(sunspecSinglePhaseMeterTotalEnergyProducedStateTypeId, meter->totalWattHoursExported() / 1000.0);
|
||||
thing->setStateValue(sunspecSinglePhaseMeterTotalEnergyConsumedStateTypeId, meter->totalWattHoursImported() / 1000.0);
|
||||
thing->setStateValue(sunspecSinglePhaseMeterCurrentPhaseAStateTypeId, -meter->ampsPhaseA());
|
||||
thing->setStateValue(sunspecSinglePhaseMeterCurrentPhaseAStateTypeId, powerSign * meter->ampsPhaseA());
|
||||
thing->setStateValue(sunspecSinglePhaseMeterVoltagePhaseAStateTypeId, meter->phaseVoltageAn());
|
||||
thing->setStateValue(sunspecSinglePhaseMeterFrequencyStateTypeId, meter->hz());
|
||||
thing->setStateValue(sunspecSinglePhaseMeterVersionStateTypeId, model->commonModelInfo().versionString);
|
||||
@ -1853,10 +1878,10 @@ void IntegrationPluginSunSpec::onMeterBlockUpdated()
|
||||
SunSpecMeterSinglePhaseFloatModel *meter = qobject_cast<SunSpecMeterSinglePhaseFloatModel *>(model);
|
||||
qCDebug(dcSunSpec()) << thing->name() << "block data updated";// << meter;
|
||||
thing->setStateValue(sunspecSinglePhaseMeterConnectedStateTypeId, true);
|
||||
thing->setStateValue(sunspecSinglePhaseMeterCurrentPowerStateTypeId, -meter->watts());
|
||||
thing->setStateValue(sunspecSinglePhaseMeterCurrentPowerStateTypeId, powerSign * meter->watts());
|
||||
thing->setStateValue(sunspecSinglePhaseMeterTotalEnergyProducedStateTypeId, meter->totalWattHoursExported() / 1000.0);
|
||||
thing->setStateValue(sunspecSinglePhaseMeterTotalEnergyConsumedStateTypeId, meter->totalWattHoursImported() / 1000.0);
|
||||
thing->setStateValue(sunspecSinglePhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), -meter->watts()));
|
||||
thing->setStateValue(sunspecSinglePhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), powerSign * meter->watts()));
|
||||
thing->setStateValue(sunspecSinglePhaseMeterVoltagePhaseAStateTypeId, meter->phaseVoltageAn());
|
||||
thing->setStateValue(sunspecSinglePhaseMeterFrequencyStateTypeId, meter->hz());
|
||||
thing->setStateValue(sunspecSinglePhaseMeterVersionStateTypeId, model->commonModelInfo().versionString);
|
||||
@ -1872,12 +1897,12 @@ void IntegrationPluginSunSpec::onMeterBlockUpdated()
|
||||
thing->setStateValue(sunspecSplitPhaseMeterEnergyConsumedPhaseBStateTypeId, meter->totalWattHoursImportedPhaseB() / 1000.0);
|
||||
thing->setStateValue(sunspecSplitPhaseMeterEnergyProducedPhaseAStateTypeId, meter->totalWattHoursExportedPhaseA() / 1000.0);
|
||||
thing->setStateValue(sunspecSplitPhaseMeterEnergyProducedPhaseBStateTypeId, meter->totalWattHoursExportedPhaseB() / 1000.0);
|
||||
thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerStateTypeId, -meter->watts());
|
||||
thing->setStateValue(sunspecSplitPhaseMeterTotalCurrentStateTypeId, fixValueSign(meter->amps(), -meter->watts()));
|
||||
thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerPhaseAStateTypeId, -meter->wattsPhaseA());
|
||||
thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerPhaseBStateTypeId, -meter->wattsPhaseB());
|
||||
thing->setStateValue(sunspecSplitPhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), -meter->wattsPhaseA()));
|
||||
thing->setStateValue(sunspecSplitPhaseMeterCurrentPhaseBStateTypeId, fixValueSign(meter->ampsPhaseB(), -meter->wattsPhaseB()));
|
||||
thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerStateTypeId, powerSign * meter->watts());
|
||||
thing->setStateValue(sunspecSplitPhaseMeterTotalCurrentStateTypeId, fixValueSign(meter->amps(), powerSign * meter->watts()));
|
||||
thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerPhaseAStateTypeId, powerSign * meter->wattsPhaseA());
|
||||
thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerPhaseBStateTypeId, powerSign * meter->wattsPhaseB());
|
||||
thing->setStateValue(sunspecSplitPhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), powerSign * meter->wattsPhaseA()));
|
||||
thing->setStateValue(sunspecSplitPhaseMeterCurrentPhaseBStateTypeId, fixValueSign(meter->ampsPhaseB(), powerSign * meter->wattsPhaseB()));
|
||||
thing->setStateValue(sunspecSplitPhaseMeterLnACVoltageStateTypeId, meter->voltageLn());
|
||||
thing->setStateValue(sunspecSplitPhaseMeterVoltagePhaseAStateTypeId, meter->phaseVoltageAn());
|
||||
thing->setStateValue(sunspecSplitPhaseMeterVoltagePhaseBStateTypeId, meter->phaseVoltageBn());
|
||||
@ -1895,12 +1920,12 @@ void IntegrationPluginSunSpec::onMeterBlockUpdated()
|
||||
thing->setStateValue(sunspecSplitPhaseMeterEnergyConsumedPhaseBStateTypeId, meter->totalWattHoursImportedPhaseB() / 1000.0);
|
||||
thing->setStateValue(sunspecSplitPhaseMeterEnergyProducedPhaseAStateTypeId, meter->totalWattHoursExportedPhaseA() / 1000.0);
|
||||
thing->setStateValue(sunspecSplitPhaseMeterEnergyProducedPhaseBStateTypeId, meter->totalWattHoursExportedPhaseB() / 1000.0);
|
||||
thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerStateTypeId, -meter->watts());
|
||||
thing->setStateValue(sunspecSplitPhaseMeterTotalCurrentStateTypeId, fixValueSign(meter->amps(), -meter->watts()));
|
||||
thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerPhaseAStateTypeId, -meter->wattsPhaseA());
|
||||
thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerPhaseBStateTypeId, -meter->wattsPhaseB());
|
||||
thing->setStateValue(sunspecSplitPhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), -meter->wattsPhaseA()));
|
||||
thing->setStateValue(sunspecSplitPhaseMeterCurrentPhaseBStateTypeId, fixValueSign(meter->ampsPhaseB(), -meter->wattsPhaseB()));
|
||||
thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerStateTypeId, powerSign * meter->watts());
|
||||
thing->setStateValue(sunspecSplitPhaseMeterTotalCurrentStateTypeId, fixValueSign(meter->amps(), powerSign * meter->watts()));
|
||||
thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerPhaseAStateTypeId, powerSign * meter->wattsPhaseA());
|
||||
thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerPhaseBStateTypeId, powerSign * meter->wattsPhaseB());
|
||||
thing->setStateValue(sunspecSplitPhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), powerSign * meter->wattsPhaseA()));
|
||||
thing->setStateValue(sunspecSplitPhaseMeterCurrentPhaseBStateTypeId, fixValueSign(meter->ampsPhaseB(), powerSign * meter->wattsPhaseB()));
|
||||
thing->setStateValue(sunspecSplitPhaseMeterLnACVoltageStateTypeId, meter->voltageLn());
|
||||
thing->setStateValue(sunspecSplitPhaseMeterVoltagePhaseAStateTypeId, meter->phaseVoltageAn());
|
||||
thing->setStateValue(sunspecSplitPhaseMeterVoltagePhaseBStateTypeId, meter->phaseVoltageBn());
|
||||
@ -1920,13 +1945,13 @@ void IntegrationPluginSunSpec::onMeterBlockUpdated()
|
||||
thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseAStateTypeId, meter->totalWattHoursExportedPhaseA() / 1000.0);
|
||||
thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseBStateTypeId, meter->totalWattHoursExportedPhaseB() / 1000.0);
|
||||
thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseCStateTypeId, meter->totalWattHoursExportedPhaseC() / 1000.0);
|
||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerStateTypeId, -meter->watts());
|
||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseAStateTypeId, -meter->wattsPhaseA());
|
||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseBStateTypeId, -meter->wattsPhaseB());
|
||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseCStateTypeId, -meter->wattsPhaseC());
|
||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), -meter->wattsPhaseA()));
|
||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseBStateTypeId, fixValueSign(meter->ampsPhaseB(), -meter->wattsPhaseB()));
|
||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseCStateTypeId, fixValueSign(meter->ampsPhaseC(), -meter->wattsPhaseC()));
|
||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerStateTypeId, powerSign * meter->watts());
|
||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseAStateTypeId, powerSign * meter->wattsPhaseA());
|
||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseBStateTypeId, powerSign * meter->wattsPhaseB());
|
||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseCStateTypeId, powerSign * meter->wattsPhaseC());
|
||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), powerSign * meter->wattsPhaseA()));
|
||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseBStateTypeId, fixValueSign(meter->ampsPhaseB(), powerSign * meter->wattsPhaseB()));
|
||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseCStateTypeId, fixValueSign(meter->ampsPhaseC(), powerSign * meter->wattsPhaseC()));
|
||||
thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseAStateTypeId, meter->phaseVoltageAn());
|
||||
thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseBStateTypeId, meter->phaseVoltageBn());
|
||||
thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseCStateTypeId, meter->phaseVoltageCn());
|
||||
@ -1946,13 +1971,13 @@ void IntegrationPluginSunSpec::onMeterBlockUpdated()
|
||||
thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseAStateTypeId, meter->totalWattHoursExportedPhaseA() / 1000.0);
|
||||
thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseBStateTypeId, meter->totalWattHoursExportedPhaseB() / 1000.0);
|
||||
thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseCStateTypeId, meter->totalWattHoursExportedPhaseC() / 1000.0);
|
||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerStateTypeId, -meter->watts());
|
||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseAStateTypeId, -meter->wattsPhaseA());
|
||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseBStateTypeId, -meter->wattsPhaseB());
|
||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseCStateTypeId, -meter->wattsPhaseC());
|
||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), -meter->wattsPhaseA()));
|
||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseBStateTypeId, fixValueSign(meter->ampsPhaseB(), -meter->wattsPhaseB()));
|
||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseCStateTypeId, fixValueSign(meter->ampsPhaseC(), -meter->wattsPhaseC()));
|
||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerStateTypeId, powerSign * meter->watts());
|
||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseAStateTypeId, powerSign * meter->wattsPhaseA());
|
||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseBStateTypeId, powerSign * meter->wattsPhaseB());
|
||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseCStateTypeId, powerSign * meter->wattsPhaseC());
|
||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), powerSign * meter->wattsPhaseA()));
|
||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseBStateTypeId, fixValueSign(meter->ampsPhaseB(), powerSign * meter->wattsPhaseB()));
|
||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseCStateTypeId, fixValueSign(meter->ampsPhaseC(), powerSign * meter->wattsPhaseC()));
|
||||
thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseAStateTypeId, meter->phaseVoltageAn());
|
||||
thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseBStateTypeId, meter->phaseVoltageBn());
|
||||
thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseCStateTypeId, meter->phaseVoltageCn());
|
||||
@ -1972,13 +1997,13 @@ void IntegrationPluginSunSpec::onMeterBlockUpdated()
|
||||
thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseAStateTypeId, meter->totalWattHoursExportedPhaseA() / 1000.0);
|
||||
thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseBStateTypeId, meter->totalWattHoursExportedPhaseB() / 1000.0);
|
||||
thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseCStateTypeId, meter->totalWattHoursExportedPhaseC() / 1000.0);
|
||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerStateTypeId, -meter->watts());
|
||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseAStateTypeId, -meter->wattsPhaseA());
|
||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseBStateTypeId, -meter->wattsPhaseB());
|
||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseCStateTypeId, -meter->wattsPhaseC());
|
||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), -meter->wattsPhaseA()));
|
||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseBStateTypeId, fixValueSign(meter->ampsPhaseB(), -meter->wattsPhaseB()));
|
||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseCStateTypeId, fixValueSign(meter->ampsPhaseC(), -meter->wattsPhaseC()));
|
||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerStateTypeId, powerSign * meter->watts());
|
||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseAStateTypeId, powerSign * meter->wattsPhaseA());
|
||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseBStateTypeId, powerSign * meter->wattsPhaseB());
|
||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseCStateTypeId, powerSign * meter->wattsPhaseC());
|
||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), powerSign * meter->wattsPhaseA()));
|
||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseBStateTypeId, fixValueSign(meter->ampsPhaseB(), powerSign * meter->wattsPhaseB()));
|
||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseCStateTypeId, fixValueSign(meter->ampsPhaseC(), powerSign * meter->wattsPhaseC()));
|
||||
thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseAStateTypeId, meter->phaseVoltageAn());
|
||||
thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseBStateTypeId, meter->phaseVoltageBn());
|
||||
thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseCStateTypeId, meter->phaseVoltageCn());
|
||||
@ -1998,13 +2023,13 @@ void IntegrationPluginSunSpec::onMeterBlockUpdated()
|
||||
thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseAStateTypeId, meter->totalWattHoursExportedPhaseA() / 1000.0);
|
||||
thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseBStateTypeId, meter->totalWattHoursExportedPhaseB() / 1000.0);
|
||||
thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseCStateTypeId, meter->totalWattHoursExportedPhaseC() / 1000.0);
|
||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerStateTypeId, -meter->watts());
|
||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseAStateTypeId, -meter->wattsPhaseA());
|
||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseBStateTypeId, -meter->wattsPhaseB());
|
||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseCStateTypeId, -meter->wattsPhaseC());
|
||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), -meter->wattsPhaseA()));
|
||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseBStateTypeId, fixValueSign(meter->ampsPhaseB(), -meter->wattsPhaseB()));
|
||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseCStateTypeId, fixValueSign(meter->ampsPhaseC(), -meter->wattsPhaseC()));
|
||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerStateTypeId, powerSign * meter->watts());
|
||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseAStateTypeId, powerSign * meter->wattsPhaseA());
|
||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseBStateTypeId, powerSign * meter->wattsPhaseB());
|
||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseCStateTypeId, powerSign * meter->wattsPhaseC());
|
||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), powerSign * meter->wattsPhaseA()));
|
||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseBStateTypeId, fixValueSign(meter->ampsPhaseB(), powerSign * meter->wattsPhaseB()));
|
||||
thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseCStateTypeId, fixValueSign(meter->ampsPhaseC(), powerSign * meter->wattsPhaseC()));
|
||||
thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseAStateTypeId, meter->phaseVoltageAn());
|
||||
thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseBStateTypeId, meter->phaseVoltageBn());
|
||||
thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseCStateTypeId, meter->phaseVoltageCn());
|
||||
|
||||
@ -161,6 +161,18 @@ private slots:
|
||||
void test_AC_15_battery_power_is_positive_when_discharging();
|
||||
void test_AC_15_battery_power_is_negative_when_charging();
|
||||
|
||||
void test_AC_16_fronius_meter_keeps_the_device_sign();
|
||||
void test_AC_16_generic_meter_keeps_the_historical_negation();
|
||||
void test_AC_16_field_case_export_is_reported_negative();
|
||||
|
||||
void test_AC_16_three_phase_meter_export_on_every_phase();
|
||||
|
||||
void test_AC_18_swapped_words_yield_denormals();
|
||||
|
||||
void test_AC_17_no_battery_means_no_storage_child();
|
||||
void test_AC_17_an_installed_battery_is_still_detected();
|
||||
void test_AC_17_presence_does_not_depend_on_state_of_charge();
|
||||
|
||||
// F3 - multi unit ID
|
||||
void test_F3_meter_slave_id_zero_disables_the_meter_connection();
|
||||
void test_F3_meter_slave_id_out_of_range_is_refused();
|
||||
@ -411,10 +423,44 @@ void SunSpecFroniusTest::test_AC_5_manufacturer_filter_rejects_other_manufacture
|
||||
|
||||
void SunSpecFroniusTest::test_AC_6_fronius_uses_sunspec_default_byte_order()
|
||||
{
|
||||
// INFERE : non documente par le constructeur, on suppose le defaut SunSpec.
|
||||
// Le ByteOrderBigEndian force du vendor solarEdge est une anomalie propre a
|
||||
// SolarEdge et ne doit pas etre etendue.
|
||||
QCOMPARE(FroniusRules::byteOrder(), SunSpecDataPoint::ByteOrderLittleEndian);
|
||||
// MESURE le 2026-08-07 sur un Symo reel, en lecture seule : l'appareil emet
|
||||
// en big endian, mot de poids fort d'abord, conformement a la specification
|
||||
// SunSpec.
|
||||
//
|
||||
// compteur 213 unite 200 : Hz = 4248 0000 -> 50.0000
|
||||
// onduleur 113 unite 1 : Hz = 4247 fc48 -> 49.9964
|
||||
//
|
||||
// Ce test affirmait l'inverse, avec pour justification « on suppose le
|
||||
// defaut SunSpec ». La supposition confondait le defaut de la SPEC avec
|
||||
// celui de libnymea-sunspec, qui est son contraire. Le nom du test est
|
||||
// conserve pour ne pas casser les references, mais « sunspec_default »
|
||||
// designe desormais le defaut de la specification, pas celui de la lib.
|
||||
QCOMPARE(FroniusRules::byteOrder(), SunSpecDataPoint::ByteOrderBigEndian);
|
||||
}
|
||||
|
||||
void SunSpecFroniusTest::test_AC_18_swapped_words_yield_denormals()
|
||||
{
|
||||
// Antidote : verrouille le RAISON du choix, pas seulement le choix.
|
||||
// Les registres ci-dessous sont ceux releves sur le Symo. Lus dans le bon
|
||||
// ordre ils donnent une frequence reseau ; lus mots inverses, un denormal.
|
||||
// Si quelqu'un rebascule byteOrder() en little endian, c'est cette lecture
|
||||
// absurde que le plugin produira sur du materiel reel.
|
||||
const quint16 hi = 0x4248, lo = 0x0000; // Hz du compteur 213
|
||||
|
||||
auto asFloat = [](quint16 first, quint16 second) {
|
||||
const quint32 raw = (quint32(first) << 16) | second;
|
||||
float value;
|
||||
memcpy(&value, &raw, sizeof(raw));
|
||||
return value;
|
||||
};
|
||||
|
||||
// Big endian : le registre de poids fort d'abord.
|
||||
QVERIFY(qAbs(asFloat(hi, lo) - 50.0f) < 0.01f);
|
||||
|
||||
// Mots inverses : un denormal, jamais une mesure plausible.
|
||||
const float swapped = asFloat(lo, hi);
|
||||
QVERIFY(swapped > 0.0f);
|
||||
QVERIFY(swapped < 1.0e-40f);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -862,6 +908,91 @@ void SunSpecFroniusTest::test_F3_meter_slave_id_is_not_marked_advanced()
|
||||
"Voir sunspec/DIVERGENCES.md.");
|
||||
}
|
||||
|
||||
// AC-16 - signe du compteur reseau
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void SunSpecFroniusTest::test_AC_16_fronius_meter_keeps_the_device_sign()
|
||||
{
|
||||
// Le Smart Meter Fronius emet deja dans la convention nymea : negatif =
|
||||
// injection. La negation generique doit donc etre annulee pour lui.
|
||||
QCOMPARE(FroniusRules::meterPowerSign(true), 1.0);
|
||||
}
|
||||
|
||||
void SunSpecFroniusTest::test_AC_16_generic_meter_keeps_the_historical_negation()
|
||||
{
|
||||
// Antidote de non regression : le chemin generique et solarEdge ne bougent
|
||||
// pas. Ce correctif est cible Fronius, et rien d'autre.
|
||||
QCOMPARE(FroniusRules::meterPowerSign(false), -1.0);
|
||||
}
|
||||
|
||||
void SunSpecFroniusTest::test_AC_16_field_case_export_is_reported_negative()
|
||||
{
|
||||
// Cas terrain du 02/08/2026, Primo GEN24 3.0 monophase (modele 101) avec un
|
||||
// Smart Meter TS 65A-3 (modele 203). L'onduleur n'injecte que sur la phase
|
||||
// A. Le plugin rapportait phase A = +1003 W (soutirage) et les phases B et
|
||||
// C negatives, soit une injection sur deux phases sans aucune source : le
|
||||
// signe etait inverse. L'arbitre voyait "Surplus insuffisant (-842 W)" et
|
||||
// laissait chauffe-eau et PAC eteints alors que le site exportait.
|
||||
const double sign = FroniusRules::meterPowerSign(true);
|
||||
|
||||
const double wattsTotal = -708.0; // registre brut du compteur
|
||||
const double wattsPhaseA = -1003.1;
|
||||
const double wattsPhaseB = 32.8;
|
||||
const double wattsPhaseC = 261.6;
|
||||
|
||||
// Export vu comme export, et non plus comme soutirage.
|
||||
QVERIFY(sign * wattsTotal < 0.0);
|
||||
// La phase portant l'onduleur exporte ; les deux autres soutirent.
|
||||
QVERIFY(sign * wattsPhaseA < 0.0);
|
||||
QVERIFY(sign * wattsPhaseB > 0.0);
|
||||
QVERIFY(sign * wattsPhaseC > 0.0);
|
||||
}
|
||||
|
||||
void SunSpecFroniusTest::test_AC_16_three_phase_meter_export_on_every_phase()
|
||||
{
|
||||
// Reproduit sur le simulateur, profil Symo triphase (onduleur modele 103,
|
||||
// compteur modele 203). Le compteur emet -500 / -100 / -100 W, soit un
|
||||
// export sur les trois phases. Le plugin etm6 rapportait +500 / +100 /
|
||||
// +100, c'est a dire du soutirage sur les trois. Le chemin triphase est
|
||||
// celui du materiel terrain : il merite son propre verrou.
|
||||
const double sign = FroniusRules::meterPowerSign(true);
|
||||
|
||||
QCOMPARE(sign * -500.0, -500.0);
|
||||
QCOMPARE(sign * -100.0, -100.0);
|
||||
|
||||
// Et le total suit la meme regle, quel que soit son signe.
|
||||
QCOMPARE(sign * -700.0, -700.0);
|
||||
QCOMPARE(sign * 3262.0, 3262.0);
|
||||
}
|
||||
|
||||
// AC-17 - presence physique de la batterie
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void SunSpecFroniusTest::test_AC_17_no_battery_means_no_storage_child()
|
||||
{
|
||||
// Cas fantome : le GEN24 hybride publie le modele 124 sans batterie cablee.
|
||||
// Constate sur le materiel des beaux-parents, ou l'enfant batterie etait
|
||||
// cree et restait bloque sur batteryCritical.
|
||||
QVERIFY(!FroniusRules::hasBattery(0.0, 0.0));
|
||||
}
|
||||
|
||||
void SunSpecFroniusTest::test_AC_17_an_installed_battery_is_still_detected()
|
||||
{
|
||||
// Antidote symetrique : le correctif ne doit pas faire disparaitre une
|
||||
// vraie batterie. Une batterie installee porte une tension au repos.
|
||||
QVERIFY(FroniusRules::hasBattery(230.4, 0.0)); // tension seule
|
||||
QVERIFY(FroniusRules::hasBattery(0.0, 12.5)); // capacite dispo seule
|
||||
QVERIFY(FroniusRules::hasBattery(230.4, 12.5)); // les deux
|
||||
}
|
||||
|
||||
void SunSpecFroniusTest::test_AC_17_presence_does_not_depend_on_state_of_charge()
|
||||
{
|
||||
// Le SoC est volontairement hors du discriminateur : 0 % ne se distingue
|
||||
// pas d'une vraie batterie vide. Une batterie a plat mais cablee garde sa
|
||||
// tension, donc reste detectee.
|
||||
QVERIFY(FroniusRules::hasBattery(230.4, 0.0));
|
||||
}
|
||||
|
||||
void SunSpecFroniusTest::test_F3_generic_vendors_have_no_meter_slave_id()
|
||||
{
|
||||
// Le multi unit ID est une particularite Fronius. Les vendors generique et
|
||||
|
||||
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 192.168.3.230
|
||||
python3 read-float-order.py 192.168.3.230 --inverter-unit 1 --meter-unit 200
|
||||
|
||||
Bibliothèque standard uniquement : rien à installer.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
|
||||
SUNS = (0x5375, 0x6E53)
|
||||
|
||||
# Offsets comptés depuis le registre ID du modèle (les données commencent à +2).
|
||||
# Chaque point float32 occupe DEUX registres, d'où le pas de 2.
|
||||
INV_F = {"W": 22, "Hz": 24, "PhVphA": 16, "PhVphB": 18, "PhVphC": 20, "DCW": 38}
|
||||
MET_F = {"W": 28, "WphA": 30, "WphB": 32, "WphC": 34,
|
||||
"Hz": 26, "PhV": 10, "PhVphA": 12, "PhVphB": 14, "PhVphC": 16}
|
||||
|
||||
INVERTER_FLOAT = (111, 112, 113)
|
||||
METER_FLOAT = (211, 212, 213, 214)
|
||||
INVERTER_INT = (101, 102, 103)
|
||||
METER_INT = (201, 202, 203, 204)
|
||||
|
||||
|
||||
def read_holding(sock, unit, addr, count):
|
||||
"""Fonction 3, et rien d'autre."""
|
||||
sock.sendall(struct.pack(">HHHBBHH", 1, 0, 6, unit, 3, addr, count))
|
||||
hdr = b""
|
||||
while len(hdr) < 8:
|
||||
chunk = sock.recv(8 - len(hdr))
|
||||
if not chunk:
|
||||
raise IOError("connexion fermée (en-tête)")
|
||||
hdr += chunk
|
||||
_, _, length, _, fc = struct.unpack(">HHHBB", hdr)
|
||||
body = b""
|
||||
while len(body) < length - 2:
|
||||
chunk = sock.recv(length - 2 - len(body))
|
||||
if not chunk:
|
||||
raise IOError("connexion fermée (corps)")
|
||||
body += chunk
|
||||
if fc & 0x80:
|
||||
raise IOError("exception modbus %d en lisant %d registres à %d"
|
||||
% (body[0], count, addr))
|
||||
return list(struct.unpack(">%dH" % (body[0] // 2), body[1:1 + body[0]]))
|
||||
|
||||
|
||||
def decode(r0, r1, low_word_first):
|
||||
hi, lo = (r1, r0) if low_word_first else (r0, r1)
|
||||
return struct.unpack(">f", struct.pack(">HH", hi, lo))[0]
|
||||
|
||||
|
||||
def find_base(sock, unit):
|
||||
for base in (40000, 50000, 0):
|
||||
try:
|
||||
if tuple(read_holding(sock, unit, base, 2)) == SUNS:
|
||||
return base
|
||||
except IOError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def walk(sock, unit, base):
|
||||
"""(model_id, adresse du registre ID, longueur) pour toute la chaîne."""
|
||||
out, addr = [], base + 2
|
||||
while addr < base + 5000:
|
||||
mid, mlen = read_holding(sock, unit, addr, 2)
|
||||
if mid == 0xFFFF:
|
||||
return out
|
||||
out.append((mid, addr, mlen))
|
||||
addr += 2 + mlen
|
||||
return out
|
||||
|
||||
|
||||
def verdict(hz_low, hz_big):
|
||||
"""Quel ordre donne une fréquence réseau plausible ?"""
|
||||
def plausible(v):
|
||||
return 45.0 <= v <= 65.0
|
||||
if plausible(hz_low) and not plausible(hz_big):
|
||||
return "MOT DE POIDS FAIBLE D'ABORD (ce que le plugin lit déjà)"
|
||||
if plausible(hz_big) and not plausible(hz_low):
|
||||
return ("BIG ENDIAN, conforme à la spec SunSpec — un plugin qui garde le "
|
||||
"défaut de libnymea-sunspec lira des dénormaux")
|
||||
if hz_low == 0.0 and hz_big == 0.0:
|
||||
return "INDÉCIDABLE : registres à zéro, aucun ordre ne se distingue"
|
||||
return "INDÉCIDABLE : aucune des deux lectures ne donne 45..65 Hz"
|
||||
|
||||
|
||||
def probe(host, port, unit, wanted_float, wanted_int, table, points, role):
|
||||
print("=" * 74)
|
||||
print("%s — unité %d" % (role, unit))
|
||||
print("=" * 74)
|
||||
sock = socket.create_connection((host, port), timeout=10)
|
||||
try:
|
||||
base = find_base(sock, unit)
|
||||
if base is None:
|
||||
print(" aucun marqueur « SunS » : rien à cette unité.\n")
|
||||
return
|
||||
chain = walk(sock, unit, base)
|
||||
print(" base %d · chaîne : %s" % (
|
||||
base, ", ".join("%d@%d(L=%d)" % (m, a, l) for m, a, l in chain)))
|
||||
|
||||
found = [c for c in chain if c[0] in wanted_float]
|
||||
if not found:
|
||||
ints = [c for c in chain if c[0] in wanted_int]
|
||||
if ints:
|
||||
print(" modèle %d : ENTIER+SF, pas de float32 — la question de "
|
||||
"l'ordre des mots ne se pose pas ici.\n" % ints[0][0])
|
||||
else:
|
||||
print(" aucun modèle attendu dans cette chaîne.\n")
|
||||
return
|
||||
|
||||
mid, id_addr, mlen = found[0]
|
||||
print(" modèle float %d, registre ID %d, longueur %d\n" % (mid, id_addr, mlen))
|
||||
print(" %-8s %-9s %-14s %14s %16s %s" % (
|
||||
"point", "registre", "brut (hex)", "mot faible 1er", "big endian", "lecture"))
|
||||
print(" " + "-" * 86)
|
||||
hz = None
|
||||
for name in points:
|
||||
off = table[name]
|
||||
addr = id_addr + off
|
||||
r0, r1 = read_holding(sock, unit, addr, 2)
|
||||
low, big = decode(r0, r1, True), decode(r0, r1, False)
|
||||
if name == "Hz":
|
||||
hz = (low, big)
|
||||
note = "nul — n'apprend rien" if (r0 == 0 and r1 == 0) else ""
|
||||
print(" %-8s %-9d %-14s %14.4f %16.4e %s" % (
|
||||
name, addr, "%04X %04X" % (r0, r1), low, big, note))
|
||||
print()
|
||||
if hz:
|
||||
print(" >>> %s" % verdict(*hz))
|
||||
print()
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Ordre des mots des float32 SunSpec — LECTURE SEULE.")
|
||||
ap.add_argument("host")
|
||||
ap.add_argument("--port", type=int, default=502)
|
||||
ap.add_argument("--inverter-unit", type=int, default=1)
|
||||
ap.add_argument("--meter-unit", type=int, default=200)
|
||||
args = ap.parse_args()
|
||||
|
||||
# Le COMPTEUR d'abord : c'est lui qui tranche. Il mesure le réseau en
|
||||
# permanence, donc ses registres portent des valeurs même onduleur en veille.
|
||||
# Un onduleur au repos sert un bloc à zéro, et zéro se décode en 0.0 dans les
|
||||
# DEUX ordres : ça n'apprend rien.
|
||||
probe(args.host, args.port, args.meter_unit, METER_FLOAT, METER_INT,
|
||||
MET_F, ("Hz", "PhVphA", "PhVphB", "PhVphC", "W", "WphA"),
|
||||
"COMPTEUR — mesure décisive")
|
||||
probe(args.host, args.port, args.inverter_unit, INVERTER_FLOAT, INVERTER_INT,
|
||||
INV_F, ("Hz", "PhVphA", "PhVphB", "PhVphC", "W"),
|
||||
"ONDULEUR — confirmation, nul si en veille")
|
||||
|
||||
print("Lecture : la colonne qui donne ~50 Hz et ~230..240 V est l'ordre que")
|
||||
print("l'appareil émet. L'autre affiche des dénormaux (~1e-41) : c'est la")
|
||||
print("signature d'une mauvaise interprétation, pas une vraie mesure.")
|
||||
print()
|
||||
print("Si « big endian » est la bonne colonne, tout lecteur qui garde le défaut")
|
||||
print("de libnymea-sunspec (mot faible d'abord) lira ces modèles de travers.")
|
||||
print("Le vendor Fronius du plugin force big endian depuis 1.15.2+etm9.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
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 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())
|
||||
Loading…
x
Reference in New Issue
Block a user