Compare commits

..

40 Commits

Author SHA1 Message Date
Patrick Schurig
69e9909a6e Merge fix/fronius-meter-sign-and-battery-presence into landing-silo
Monte le support Fronius GEN24 vers la ligne testing : signe du compteur,
batterie fantôme, et ordre des mots des float32. Les trois sont liés — le
signe n'est observable qu'une fois l'ordre des mots correct (D-8) — donc ils
montent ensemble. nymea-plugin-sunspec-etm01 passe de 1.15.2+etm6 à +etm9.

Ce que cette fusion emporte, au delà des trois correctifs : la branche
descend d'experimental-silo, donc elle amène aussi toute la ligne SunSpec qui
n'était pas encore dans landing-silo (vendoring du plugin, vendor Fronius,
seconde connexion pour le Smart Meter, chaîne de consignes storage) ainsi que
trois commits abbterra (TCP, SEGV de découverte, checkReachableRegister) et
les commits de documentation du modèle de branches. Aucun binaire abbterra
n'est publié par cette montée : seul le .deb sunspec-etm01 part vers testing.

waveshare-relay-d8 reste supprimé. f25b620 avertissait qu'une descente
pourrait le ressusciter ; ce n'est pas le cas ici, la branche fusionnée ne
touche pas ce répertoire, donc git honore la suppression.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 08:20:22 +02:00
Patrick Schurig
366d332bf4 sunspec: rendre le verdict de read-float-order.py indépendant de la version
Le message affirmait « le plugin lit de travers », vrai avant 1.15.2+etm9 et
faux depuis. L'outil sert à mesurer un APPAREIL, pas à juger une version : il
dit maintenant ce que lirait un lecteur gardant le défaut de la bibliothèque,
et rappelle depuis quelle version le vendor Fronius force big endian.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 07:44:10 +02:00
Patrick Schurig
99bc606198 debian: changelog entry 1.15.2+etm9
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 07:30:34 +02:00
Patrick Schurig
d08cf506ee fix(sunspec): lire les float32 Fronius en big endian, comme le matériel
byteOrder() renvoyait ByteOrderLittleEndian au motif qu'on « supposait le
défaut SunSpec ». La supposition confondait le défaut de la SPÉCIFICATION avec
celui de libnymea-sunspec, qui est son contraire : convertToUInt32 lit
registers.at(1) puis .at(0) en little endian. Garder ce défaut revient à lire
tous les float32 avec leurs deux mots permutés.

Mesuré le 2026-08-07 sur un Symo réel, en lecture seule : le compteur 213 sur
l'unité 200 sert Hz = 4248 0000, soit 50,0000 en big endian et 2,3777e-41 en
mots inversés. L'onduleur 113 concorde.

Le coût n'était pas un décalage mais une absurdité. Reproduit sur banc avec le
simulateur émettant l'ordre du matériel : un compteur important 2,7 kW rapporté
comme exportant 107 MW, et l'arbitre enclenchant toutes les charges sur un
« Surplus PV 107622496 W ». Le thing reste connected, sans erreur Modbus.

Conséquence sur l'ordre des correctifs : meterPowerSign() reste juste mais
n'est observable qu'une fois celui-ci appliqué — corriger le signe d'un
dénormal ne produit rien.

solarEdge force déjà big endian de son côté et le générique garde son paramètre
utilisateur : aucun des deux chemins n'est touché. test_AC_18 fige le
raisonnement plutôt que la seule valeur. Voir DIVERGENCES.md D-8.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 07:30:14 +02:00
Patrick Schurig
63abc15b44 debian: changelog entry 1.15.2+etm8
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 20:05:25 +02:00
Patrick Schurig
ea3b9bdd51 fix(sunspec): interroger le thing de connexion, pas une recherche par id
Le garde de présence batterie demandait isFroniusChild(thing->id()), qui
résout le parent par myThings().findById(). Pendant le setup initial la
connexion n'est pas encore enregistrée : la recherche répond faux, le garde
est contourné, et l'enfant storage est créé alors qu'InBatV et StorAval sont
nuls. Constaté en boucle fermée sur le simulateur, avec la trace nymead
"Invalid parent thing id ... Not adding thing to the system".

Le garde interroge maintenant directement le thing de connexion que la
fonction tient déjà. Le signe compteur passe de même par le parentThing déjà
résolu, dont la non-nullité est garantie quelques lignes plus haut.

La suite logique ne pouvait pas attraper ce défaut : elle couvre la décision
pure, pas le câblage. Consigné en D-7.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 20:05:13 +02:00
Patrick Schurig
c459ba2f9b sunspec: antidote triphasé et outil de relevé du modèle 124
L'antidote AC-16 triphasé verrouille le chemin réellement emprunté par le
matériel terrain, reproduit sur le simulateur en profil Symo (onduleur
modèle 103, compteur modèle 203) : compteur à -500/-100/-100 W, que le
plugin etm6 rapportait en +500/+100/+100.

tools/read-model124.py relève en lecture seule le bloc storage d'un
onduleur, sans aucune dépendance à installer. Il sert à lever D-6 : le côté
« batterie présente » du discriminateur reste inféré faute d'un GEN24 équipé.
Sur le simulateur il montre pourquoi ni le SoC ni les consignes ne peuvent
servir de discriminateur : le bloc annonce un SoC de 50 %, un statut Holding
et WChaMax à 10 kW alors qu'InBatV et StorAval sont nuls.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 16:06:08 +02:00
Patrick Schurig
05d68117cd debian: changelog entry 1.15.2+etm7 for the SunSpec meter sign and battery presence fixes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 15:57:36 +02:00
Patrick Schurig
89f7bf0199 fix(sunspec): signe du compteur Fronius et batterie fantôme
Deux défauts constatés sur le GEN24 des beaux-parents (Primo GEN24 3.0,
Smart Meter TS 65A-3, sans batterie).

Signe du compteur. onMeterBlockUpdated() négociait le signe de tous les
modèles compteur sans distinction de fabricant. Sur Fronius la négation est
fausse : le Smart Meter émet déjà dans la convention nymea. L'onduleur étant
monophasé, il sert de référence de direction : le plugin rapportait la phase
portant l'onduleur en soutirage et les deux autres en injection, ce qui est
impossible sans source. L'arbitre en concluait « Surplus insuffisant
(-842 W) » et laissait chauffe-eau et PAC éteints en plein export.

La décision vit dans FroniusRules::meterPowerSign() et n'est annulée que sous
une froniusConnection. Les vendors generic et solarEdge sont inchangés, ce
qu'un antidote verrouille. Les compteurs d'énergie sont corrects et ne sont
pas permutés.

Batterie fantôme. Un GEN24 hybride publie le modèle 124 sans batterie câblée,
d'où un enfant storage bloqué sur batteryCritical.
FroniusRules::hasBattery() exige une grandeur physique non nulle (InBatV ou
StorAval), le SoC étant écarté car 0 % ne se distingue pas d'une batterie
vide. Le côté « batterie présente » reste INFÉRÉ : voir D-6, à valider sur un
GEN24 équipé avant tout déploiement.

Suite logique : 50 tests verts, dont six antidotes AC-16 et AC-17.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 08:29:16 +02:00
Patrick Schurig
6bdb51cbf0 debian: changelog entry 1.15.2+etm6 for the SunSpec plugin
Records the new nymea-plugin-sunspec-etm01 binary package, the Fronius
vendor it carries, the storage setpoint corrections and the added
Build-Depends. The previous entry described an unrelated abbterra fix,
so the package would have shipped under a version that never mentioned
it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 12:10:20 +02:00
Patrick Schurig
405b764463 debian: package the SunSpec plugin as nymea-plugin-sunspec-etm01
The plugin was added to PLUGIN_DIRS by the vendoring commit but never
packaged, so dh_missing found its .so in debian/tmp with nowhere to go
and aborted the whole source package: no .deb at all came out of
experimental-silo, not even for the four unrelated plugins.

Declares the binary package and adds libnymea-sunspec-dev to
Build-Depends. Upstream does not need that dependency because it builds
libnymea-sunspec from the same source tree; this repository vendors only
the plugin and links against the installed library.

Named with the -etm01 fork suffix rather than the upstream name, which
our own repository already publishes in stable and testing as a mirror
of 1.15.2. Same file path and same pluginId, hence Conflicts, Replaces
and Provides against nymea-plugin-sunspec so the two can never be
installed side by side.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 11:52:10 +02:00
Patrick Schurig
5a6ad8a2e2 docs: fix channel/branch mapping to match DEPLOY.md
experimental-silo feeds powersync-nightly and landing-silo feeds
powersync-testing, not the other way round. DEPLOY.md in the deploy
repository is the source of truth and says so in two places: its channel
table and its infrastructure inventory, where the core forks are named
etm-nymea-nightly for experimental-silo and etm-nymea-testing for
landing-silo.

Three places carried the labels backwards, all introduced by 30b58db
this morning: the diagram, the sentence on reverting a merge that breaks
the nightly, and the promotion channel list.

The order of the chain itself was already right and is untouched, as is
everything else in the document.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 11:38:55 +02:00
Patrick Schurig
5db002f53b build: add QMAKE_RPATHDIR for the sysroot cross-build
Building against a local sysroot rather than an installed
libnymea-sunspec-dev left the resulting binaries unable to find
libnymea-sunspec1 at run time: the sysroot is not in the dynamic
linker's cache, so -L alone is not enough and every test had to be
launched through LD_LIBRARY_PATH.

Only the NYMEA_SUNSPEC_PATH branch is touched. The package build resolves
the library through pkg-config and gets no rpath, as before.

Verified both ways: with NYMEA_SUNSPEC_PATH the binary carries a RUNPATH
to the sysroot, resolves libnymea-sunspec1 there and runs its 44 tests
with LD_LIBRARY_PATH unset; without it, no sunspec rpath is generated at
all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 11:01:19 +02:00
Patrick Schurig
30b58dbf1e docs: put the promotion chain the right way round
The chain climbs experimental-silo, then landing-silo, then main: a
change is attempted in testing before it becomes a nightly candidate for
stable. The document had the two silos the other way round, which is how
the Fronius work ended up one rung below stable while it had only ever
been verified against the bench simulator.

Corrected in the diagram, the promotion criteria, the fix table and its
downward path, the packaging channel list and the agent rules. The
channel each branch feeds is unchanged: experimental-silo is testing,
landing-silo is nightly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 10:49:39 +02:00
Patrick Schurig
6ac9a1d019 Merge the Fronius work into experimental-silo
Where it should have landed in the first place. A change climbs the
chain: experimental-silo first, landing-silo once a nightly has proven
itself, main last. The previous merge put this straight into
landing-silo, one rung below stable, for work only ever verified against
the bench simulator and never on hardware.

Carries what landing-silo had wrongly received: the vendored SunSpec
plugin and the Fronius multi unit ID support, the three abbterra commits
the branch dragged in from the abbterra TCP line, and the two AGENTS.md
commits.

landing-silo goes back to main in the next step; nothing is lost, it all
lives here now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 10:48:06 +02:00
Patrick Schurig
0cdcfe433b docs: align the branch model with the actual remote
Two points the document got wrong. The stable branch is main, not
master: the remote has never had a master. And the promotion chain was
written as if it existed, while landing-silo and experimental-silo were
created only today, the first as empty branches off main.

Nothing else in the document is touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 10:29:32 +02:00
Patrick Schurig
f839f5d435 docs: document the branch and promotion model
Committed verbatim from the working tree, where it had been sitting
uncommitted. Written by Patrick, not by the agent: this commit only puts
it under version control so the promotion chain it describes can be
corrected against reality in the next one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 10:29:00 +02:00
Patrick Schurig
f0a3aafda0 Merge feature/fronius-sunspec into landing-silo
First content of the promotion chain. Brings the vendored SunSpec plugin,
the Fronius vendor and its multi unit ID support: one froniusConnection
thing now carries two SunSpecConnection objects, inverter and Smart Meter
on separate unit IDs behind the same host and port.

Verified before merging: 60 offline tests green, plus 8 green against the
bench SunSpec simulator in froniusgen24-sunspec mode.

Note: the branch was cut from the abbterra TCP line, so this merge also
brings three abbterra commits that were never in main.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 10:19:55 +02:00
Patrick Schurig
955016cc47 sunspec: fix a dangling capture in the device suite and widen it
The suite captured a stack local of initTestCase() by reference in two
lambdas that outlive it. Once the function returned they wrote into a
stack slot reused by later frames, corrupting the running test: a
QSignalSpy reported a phantom emission, reading its recorded arguments
segfaulted, and the count changed when unrelated locals were added. Both
observed failures came from that, not from the plugin. The two flags are
members now.

Covers the two remaining claims the offline suites cannot reach: a meter
slave ID of 0 opens no second connection against a real device, and
losing the meter connection leaves the parent connected and the inverter
data flowing, marking only the meter child disconnected.

That last test drives one explicit block read first. This bench replaces
the plugin timer with a null object, on purpose, so nothing would
otherwise ever mark a child connected and the cut would prove nothing.

Run against the bench simulator: 8 tests, 8 green. Each of the two
corrections was confirmed able to fail by reintroducing its pre-F3 form.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 09:51:10 +02:00
Patrick Schurig
1e02400acd sunspec: keep Qt Creator session files out of the repository
They carry absolute local paths and differ per workstation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 09:11:34 +02:00
Patrick Schurig
6eb25045e2 sunspec: pin the single connection vendors against regressions
The whole point of keeping the meter connection in a hash of its own was
that not a line of the generic and solarEdge vendors would change.
Checks it rather than claiming it: a stored solarEdge connection, whose
behaviour no suite covered until now, must still end up with exactly one
connection, as must a generic one.

Also records the connection a discovery came from in
processDiscoveryResult() rather than in the discoveryFinished handler.
The initial setup calls it from a second handler on the same signal, so
recording it there made the orphan sweep depend on the order Qt happens
to invoke the two.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 09:10:48 +02:00
Patrick Schurig
9e0cd44af0 sunspec: add a device suite for the two connection behaviour
The two most certain defects of the multi unit ID work are invisible to
the offline suites, because they only appear once a discovery has
actually run: the orphan sweep deleting the children of the other
connection, and the aggregation of the connected state.

This suite mounts the plugin behind a real ThingManager against a real
device. It is deliberately not a testcase, so "make check" never runs
it, but it is still built so it cannot rot unnoticed. Without
SUNSPEC_TEST_HOST every test skips, and no address is written into the
repository: the address of a real installation is customer data.

NOT YET RUN against hardware. It needs the address of a GEN24 and, given
the polling rate guidance, a deliberate decision to point it at a
customer inverter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 09:07:06 +02:00
Patrick Schurig
e1e91a19c3 sunspec: sweep orphan children against every connection at once
processDiscoveryResult() makes child things disappear when their model
is no longer announced. It used to confront them with the models of the
one connection that had just finished discovering, which is only half of
the picture on a thing carrying two: the inverter discovery would delete
the meter child, and the meter discovery the inverter and storage ones,
on every single reconnect.

The sweep now waits for every connection of the parent to have reported
a successful discovery, then confronts the children with the union of
their models. A grace period releases it if a connection never reports,
so a hung discovery cannot freeze the cleanup for good.

That grace period is inferred, not measured: it is set generously
because sweeping too late leaves a stale child showing as disconnected,
while sweeping too early deletes a thing of the user. Recorded as D-2 in
DIVERGENCES.md with what to measure to replace it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 09:02:35 +02:00
Patrick Schurig
a598544712 sunspec: open a second connection for the Fronius Smart Meter
A GEN24 answers for the inverter on unit ID 1 and for its Smart Meter on
unit ID 200 by default, behind the same host and port. One thing now
carries two SunSpecConnection objects, the second one driven by the
meterSlaveId parameter that until now no line of C++ ever read.

The meter connection is created during setup but left idle: it is only
brought up once the primary connection has finished discovering, so the
two never handshake at the same time towards the same inverter. An
absent meter never fails the pairing, since a GEN24 without a Smart
Meter is a legitimate installation and the parameter has a non zero
default the user did not choose.

thingRemoved() and the reconfigure path now dispose of both connections
through a single entry point. Their else if chain used to stop at the
primary one, which left the meter connection outliving its thing with an
open socket and signals pointing at a destroyed Thing.

Child things look their model up across every connection of their
parent. The two connections of a GEN24 never announce the same model ID,
so no discriminator has to be persisted.

Adds a test suite instantiating the plugin behind a real ThingManager
with two connections. It lives in its own binary because the F1 suite
checks global call counters that extra configured things would falsify.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 09:00:27 +02:00
Patrick Schurig
0954f66725 sunspec: let createConnection() serve a secondary connection
Takes the slave ID as an argument instead of reading it from the thing,
and takes whether it is the primary connection of that thing. Only the
primary one follows the network monitor, writes the connected state of
the thing and lands in m_sunSpecConnections; a secondary one lands in a
hash of its own.

Child things are updated per connection rather than per parent, resolved
from the model each child is bound to. With a single connection that is
every child of the parent, so nothing changes here yet.

No caller creates a secondary connection at this point: behaviour is
unchanged for all three vendors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 08:53:28 +02:00
Patrick Schurig
4f2076f504 sunspec: decide whether a Fronius meter connection is wanted
A GEN24 answers for the inverter on one unit ID and for the Smart Meter
on another, behind the same host and port. Isolate the decision to open
that second connection as a pure function, so it can be pinned down
without nymea, and leave the logging of the refusal to the caller.

Refuses a meter slave ID out of the 0..247 range, treats 0 as "no meter
on this connection" as documented, and refuses a meter slave ID equal to
the inverter one: a second socket on the same unit ID would rediscover
the very same models and duplicate every child thing. That last case is
reachable, since the discovery writes the scanned slave ID into the
thing parameter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 08:50:25 +02:00
Patrick Schurig
e7b751673d sunspec: record why meterSlaveId cannot be marked advanced
AC-7 asks for the meterSlaveId parameter to be advanced. That property
does not exist on nymea 1.15.2 param types: the metadata verifier
rejects unknown fields, so adding it makes the whole plugin fail to
load rather than merely hiding a parameter.

Measured by loading the plugin JSON, with and without the flag, into a
PluginMetadata linked against the installed libnymea, rather than read
from an upstream tree that may differ from the package.

Also records the three other divergences the multi unit ID work runs
into, two of which predate it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 08:50:18 +02:00
Patrick Schurig
3802807be7 sunspec: dispatch froniusConnection in setupThing()
setupThing() only knew about sunspecConnection and solarEdgeConnection, so a
froniusConnection thing fell into the final Q_ASSERT_X. In a release build the
assert is compiled out, setupThing() returns without calling info->finish(),
and the pairing hangs until the setup times out - with no message at all. In a
debug build nymead dies on the assert instead.

Take the same branch as the two vendors that already work: createConnection()
already reads the slave ID and picks the byte order for froniusConnection, so
no new logic is needed.

postSetupThing() and thingRemoved() are unchanged on purpose. The former only
special cases solarEdgeConnection, for the battery search a Fronius must not
run, and registers the refresh timer for every thing. The latter is keyed on
m_sunSpecConnections, which createConnection() now populates for Fronius as
well.

Makes the four cases of tst_sunspecplugin pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 16:20:09 +02:00
Patrick Schurig
c2eccd96e5 sunspec: add a failing test that sets up a Fronius connection
The existing suite only exercises free functions and the plugin JSON, so it
cannot see whether a thing class is dispatched at all. A thing class missing
from the dispatch in setupThing() falls into the final Q_ASSERT_X: in a debug
build the process dies, in a release build the assert is compiled out and
setupThing() returns without ever calling info->finish(), leaving the pairing
stuck with no message.

Split the tests into two suites. The logic one keeps its former scope and its
single dependency on libnymea-sunspec. The new one instantiates the plugin
behind the real ThingManager of libnymea-core and a faked HardwareManager, so
the dispatch of setupThing(), postSetupThing() and thingRemoved() is covered.
No network access takes place: the faked monitor is never reachable, so the
plugin never attempts a Modbus connection.

The four new cases fail: froniusConnection is not dispatched yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 16:18:41 +02:00
Patrick Schurig
372be1644c specs: record acceptance criteria status and remaining gaps 2026-07-31 10:33:49 +02:00
Patrick Schurig
89406495a8 sunspec: add Fronius vendor with its own control rules
Adds a fronius vendor and a froniusConnection thing class alongside the
generic sunspec and solarEdge ones, following the SolarEdge mechanism but
none of its workarounds - no proprietary battery block and no PV
production computed by difference, since the GEN24 exposes model 160.

FroniusRules holds the vendor decisions as pure functions:

  - discovery only keeps devices announcing Fronius as manufacturer;
  - the byte order is the SunSpec default, not the forced big endian that
    is specific to SolarEdge, and no endianness parameter is exposed;
  - meterSlaveId defaults to 200, 0 meaning no meter on this connection;
  - a hard 7 s floor on the poll interval. Polling faster can push the
    inverter offline towards the vendor portal, and recovery needs an
    AC+DC power cycle on site, so this is a guard in the code rather than
    a configurable default;
  - the registers the inverter accepts and then silently ignores are
    refused before anything reaches the wire;
  - the six HEMS battery modes map onto ChaGriSet, StorCtl_Mod, InWRte and
    OutWRte, negative meaning charge;
  - model 160 blocks are classified by IDStr, never by index: on a three
    or four MPPT inverter the battery blocks are not in position 3 and 4.

Storages under a Fronius connection default revertTime to 300 s.

Setpoints now reread the scale factor before writing and read the written
registers back afterwards, reporting a mismatch through a new
controlHealthy state - the only defence against writes that are accepted
and then ignored without a Modbus exception.

Byte order and the revert timer semantics on model 124 are inferences, not
measurements; see specs/fronius-sunspec.md, H-2 and H-3.
2026-07-31 10:33:12 +02:00
Patrick Schurig
c5c3f46f57 sunspec: arm the storage revert timer after every setpoint
setInOutWRteRvrtTms() existed but was never called. InOutWRte_RvrtTms is
the delay after which the inverter drops the setpoint and resumes its
native behaviour. Left at 0, a setpoint stays active indefinitely, HEMS
stopped, network lost or process crashed included - the battery stays
where the last command put it.

Add a per-thing revertTime parameter, 0..28800 s, defaulting to 0 so the
generic vendor keeps its current behaviour, and route every setpoint
through SunSpecStorageLogic::planSetpoint(). The plan orders the writes -
ChaGriSet, StorCtl_Mod, then the rates - and appends the revert timer
last, so the timer never starts counting against a setpoint that has not
been written yet.

Writes are issued sequentially rather than in parallel: the order is part
of the contract with the hardware.
2026-07-31 10:26:01 +02:00
Patrick Schurig
4ec7aa50e8 sunspec: reconcile storage states from the registers actions write
onStorageBlockUpdated() fed chargingRate from wChaGra() and dischargingRate
from wDisChaGra(). Those are the gradients, in percent of WChaMax per
second, and read only on several inverters. The matching actions write
InWRte and OutWRte, in percent of WChaMax. The user wrote one quantity and
read back another.

Feed the two rate states from inWRte() and outWRte(), and keep the
gradients under names that cannot be mistaken for setpoints:
chargeGradient and dischargeGradient, read only.

enableDischarging was never reconciled either, while enableCharging was.
It stayed frozen on the last written value and drifted silently as soon as
the inverter changed StorCtl_Mod on its own. Reconcile both flags.

The mapping moves into SunSpecStorageLogic so it can be exercised on a
register dump without a running nymea.
2026-07-31 10:22:44 +02:00
Patrick Schurig
abae49e058 sunspec: widen storage charge and discharge rate range
chargingRate and dischargingRate map onto InWRte and OutWRte of model 124,
which are int16 setpoints ranging from -100.00 to +100.00 percent of
WChaMax. Declaring them as 0..100 makes a forced charge structurally
inexpressible: nymea rejects the negative action parameter before it ever
reaches the plugin.

Also fixes the default value, which was the boolean false on an int state.
2026-07-31 10:20:05 +02:00
Patrick Schurig
d87a132f23 sunspec: add failing tests for every acceptance criterion
One test per AC of specs/fronius-sunspec.md, named test_AC_<n>_<what>.

Definition criteria (value ranges, thing classes, defaults) are checked by
re-reading integrationpluginsunspec.json. Behaviour criteria run either on
a model 124 register dump fed to the real libnymea-sunspec model, or on the
pure logic units the plugin is about to grow.

All red at this point: sunspecstoragelogic and froniusrules do not exist
yet, so the suite does not compile. That is the expected starting state.
2026-07-31 10:16:12 +02:00
Patrick Schurig
93ecfb4ca1 specs: add acceptance criteria for Fronius SunSpec support
AC-1..AC-4 are the four upstream anomalies of the generic sunspec vendor
(setpoint range, read/write mismatch on the rate states, enableDischarging
never reconciled, revert timer never written).

AC-5..AC-15 are the Fronius-specific behaviour: manufacturer filter, byte
order, connection parameters, polling floor, forbidden writes, the HEMS
intent to register table, scale factor reread, readback verification and
the model 160 block classification.

Points the design contract leaves open are recorded as explicit working
hypotheses (H-1..H-6) rather than blocking on them.
2026-07-31 10:16:06 +02:00
Patrick Schurig
bb842eea55 sunspec: vendor plugin sources from nymea-plugins-modbus 1.15.2
Vendor the sunspec plugin into this repo so the Fronius work and the
upstream storage fixes can live here. Sources only: build artefacts and
generated plugininfo headers are excluded.

libnymea-sunspec is deliberately NOT vendored - the plugin must stay
rebasable on upstream nymea, so it links against libnymea-sunspec-dev.
sunspec.pri resolves it via pkg-config, with a NYMEA_SUNSPEC_PATH
override for a local sysroot, mirroring modbus.pri.

See sunspec/VENDORED.md.
2026-07-31 10:15:59 +02:00
Patrick Schurig
cbfb5df23e abbterra: make ABB Terra AC addable over Modbus TCP (etm2..etm5)
Follow-up to the etm1 fixes, driven by live debugging against a real ABB
Terra AC 7 kW behind a Waveshare RS485->TCP gateway (192.168.1.132, slave 2):

- etm3: disable TCP auto-discovery (createMethods = "user" only). Discovery
  blind-scans the LAN and SIGSEGVs nymead as soon as any Modbus device answers
  :502 with an exception response. The crash is in the generated reachability
  handler (abbterramodbustcpconnection.cpp:2062 dereferences the check-reachable
  reply after it is freed); it could not be fixed from the discovery code alone.
  A gateway's slave id can't be discovered anyway, so manual add is required.
- etm2: still defer/guard AbbTerraTcpDiscovery::cleanupConnection() teardown
  (kept for correctness even though discovery is now off by default).
- etm4: setupTcpThing() never called connectDevice(), so manual setup hung until
  timeout. Start the connection when the network monitor reports the device.
- etm5: decode the serial-number and firmware register fields as BCD per ABB
  spec §5.1 (0x46 = week 46, not 70; 0x22 = 22 kW). The binary reading rejected
  real chargers ("does not match the ABB Terra AC Modbus register map").

Verified end to end: charger adds cleanly (TACW7-4-4624-S0034), states poll and
actions execute, no SIGSEGV. The underlying discovery/generated-code crash is
tracked separately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 12:14:44 +02:00
Patrick Schurig
368c80688a abbterra: fix TCP discovery SEGV + restore checkReachableRegister
Two coupled fixes so the ABB Terra AC can be added over Modbus TCP:

- Restore checkReachableRegister ("serialNumber") in the register JSON.
  The libnymea-modbus 1.15 code generator *requires* it — without it the
  connection classes (abbterramodbustcpconnection.h) are never generated and
  the plugin fails to build. Reverts the false lead in b23a9a2. The register
  is valid: mbpoll confirms holding reg 16384 (uint64) responds at slave 2.

- Fix a use-after-free in AbbTerraTcpDiscovery::cleanupConnection() that
  SIGSEGV'd nymead during network discovery whenever any Modbus device on the
  LAN answered on :502 with an exception (e.g. "illegal data address"). The
  method is invoked from several of the connection's own signals; it now
  bails out on the second (re-entrant) call and disconnects the connection's
  signals before disconnectDevice(), so the teardown can no longer re-enter
  while the QModbusReply "finished" handler is still dereferencing the reply.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 10:48:47 +02:00
Patrick Schurig
b23a9a2d00 abbterra: remove checkReachableRegister - not supported in libnymea-modbus 1.15 2026-07-03 09:55:59 +02:00
45 changed files with 11015 additions and 26 deletions

16
.gitignore vendored
View File

@ -5,3 +5,19 @@ moc_*
*plugininfo.h
*.so
build/
builddir/
build-*/
# Sysroot local pour libnymea-sunspec-dev (extraction du .deb hors installation
# systeme). Artefact de build, jamais versionne.
.sunspec-sysroot/
# Documents de conception du chantier Fronius : ils vivent dans hems-system et
# sont copies ici pour travailler. Leur diffusion publique n'est pas tranchee
# (cf. section 5 du contrat), donc ils ne sont pas versionnes dans ce depot.
sunspec/CONTRAT-fronius-sunspec.md
sunspec/brief-agent-plugin.md
sunspec/CLAUDE-nymea-plugin-sunspec.md
# Fichier de session Qt Creator : chemins absolus locaux, propre a chaque poste.
*.pro.user

187
AGENTS.md
View File

@ -1,16 +1,189 @@
# AGENTS.md — etm-powersync-plugins-modbus
Plugins Modbus ETM (fork GPL) : compteurs, bornes, relais.
Contient abbb2x, abbterra, eastron, waveshare-relay-d8.
Tous les plugins Modbus supportés par ETM : plugins propres et forks de plugins amont.
Compteurs, bornes de recharge, relais, onduleurs.
- **Licence** : GPL-3.0 (+ LGPL pour libnymea-modbus / libnymea-sunspec) · **Miroir public** : OUI
- **Agent** : aucun en beta — plugins déjà publiés, repo de maintenance/référence.
- **Licence** : GPL-3.0 pour les plugins, LGPL-3.0 pour `libnymea-modbus` / `libnymea-sunspec`
- **Miroir public** : OUI
- **Paquets** : publiés sur `repos.etm-powersync.fr`, trois canaux
L'inventaire des plugins est l'arborescence du dépôt. Ne pas se fier à une liste écrite ici : elle
serait périmée, et un agent en conclurait que le reste n'existe pas.
Ce dépôt est autoportant : tout ce qui est nécessaire pour y travailler est ici.
## Placement du travail non amont
Une fois qu'un développement — plugin, correctif, extension vendor — n'est ni upstream officiel ni
destiné à y retourner à court terme, il vit dans le dépôt produit ETM correspondant
(`etm-powersync-plugins-modbus` ou `etm-powersync-plugins`), vendoré depuis sa source si elle existe
ailleurs (`VENDORED.md` avec commit et date), développé directement sinon. Le fork correspondant
(`nymea-plugins-modbus` ou `nymea-plugins`) ne garde que ce qui reste candidat à une contribution
amont.
## Branches et canaux
```
feature/* ──► experimental-silo ──► landing-silo ──► main
(nightly) (testing) (stable)
```
Vers la droite on promeut, vers la gauche on redescend un correctif. Une correction ne saute
jamais un cran.
Les trois branches existent sur le remote depuis le 2026-08-02. Les deux silos ont été créés
vides depuis `main` ; `experimental-silo` a ensuite reçu le travail Fronius comme premier
contenu, `landing-silo` est encore au niveau de `main`. Avant cette date le modèle ci-dessus
était une intention, pas un état du dépôt.
Aucun silo n'est une branche de travail : `landing-silo` ne reçoit que ce qui a déjà survécu à
`experimental-silo`. Le travail se fait dans `feature/*`.
### Critères de promotion
| Passage | Critère |
|---|---|
| commits dans `feature/*` | `verify` en local, l'agent boucle seul |
| `feature/*` → `experimental-silo` | PR, `verify` vert sur le runner, AC de la spec couverts par des tests |
| `experimental-silo` → `landing-silo` | build complet propre, banc nocturne vert |
| `landing-silo` → `main` | validation matérielle nommée : appareil, firmware, configuration |
Le dernier critère n'est pas automatisable et ne doit pas l'être.
### Correctifs
**Avant de créer une branche : reproduire le bug et déterminer la branche la plus proche de
`main` où il apparaît.** C'est elle qui décide du point de départ, pas l'urgence ressentie.
| Le bug se reproduit… | Départ | Ensuite |
|---|---|---|
| jusque dans `main` | `hotfix/*` depuis `main` | fusion dans `main`, puis redescente dans `landing-silo`, puis dans `experimental-silo` |
| dans `landing-silo`, pas dans `main` | `fix/*` depuis `landing-silo` | redescente dans `experimental-silo` |
| seulement dans `experimental-silo` | `fix/*` depuis `experimental-silo` | rien, il remonte par la promotion normale |
Ne jamais corriger en aval un bug présent en amont : le correctif remontera un jour, mais la
branche amont reste cassée entre-temps et la promotion suivante réintroduit l'ancien code par
dessus. Symptôme : « le correctif ne tient pas ».
Si une fusion casse le nightly, **révoquer la fusion** dans `experimental-silo` pour rendre le
canal de nouveau utilisable, puis corriger tranquillement dans la branche `feature/*` d'origine.
Ne pas corriger en avant sous pression.
Trois préfixes, pas davantage : `feature/*`, `fix/*`, `hotfix/*`.
### Versionnement des paquets
Tout paquet issu d'un dépôt produit ETM porte un suffixe `+etmN` sur la version amont qu'il modifie
ou remplace (ex. `1.15.2+etm1`), à tous les canaux de promotion (`experimental-silo`/nightly,
`landing-silo`/testing, `main`/stable). Ce suffixe garantit la préséance apt/dpkg sur le paquet
upstream identique.
Ne jamais le remplacer par un tiret (`-etmN`) : `-` a un sens réservé en version Debian (champ
`debian_revision`) et entre en conflit avec une éventuelle révision Debian amont.
## Agents
Les agents travaillent dans `feature/*` et `fix/*`. Jamais de commit direct dans `experimental-silo`,
`landing-silo` ou `main` : ces branches ne reçoivent que des fusions.
Une spec, une branche, un jeu de critères d'acceptation.
Un agent qui estime qu'un refactor s'impose sur un plugin publié le signale et s'arrête. Il ne le
fait pas.
## Invariants locaux
1. NE PAS renommer les paquets PUBLIÉS : `powersync-plugin-eastron`,
1. **NE PAS renommer les paquets PUBLIÉS** : `powersync-plugin-eastron`,
`powersync-plugin-abbb2x`, `nymea-plugin-abbterra`. Casse l'upgrade des box beta.
Valable sur toutes les branches : un renommage dans `landing-silo` finit par atteindre `main`.
2. `abbterra` est volontairement sous nom amont (transition upstream propre) — ne pas « corriger ».
3. Matrice validée beta : SDM72 / SDM120 (eastron), Waveshare D8.
4. Aucun composant propriétaire ici.
4. Aucun composant propriétaire ici. Aucun ne doit y entrer.
Carte globale et frontières : voir `../AGENTS.md`.
## Miroir public — ne doit jamais être commité
Données client : adresses de site, noms, numéros de série, relevés, adresses IP d'installations
réelles. Identifiants et jetons. Documentation constructeur sous réserve de confidentialité — elle
se cite en référence (titre, version, section) dans la table de provenance des registres, jamais
en contenu.
Les captures, journaux et dumps de registres joints à un correctif sont expurgés avant commit.
---
## Repository Guidelines
These conventions apply to every `*-plugins-modbus` repository. They are not a style preference:
plugins from all of them load into the **same nymea-core**, so they share its ABI, its Qt version,
its plugin metadata format and its interface definitions. A repository that diverges produces
plugins that do not interoperate.
**Target: nymea-core `<version>`.** Where these guidelines and the core repository's `AGENTS.md`
disagree, the core repository wins and this section is corrected.
This section is identical, byte for byte, across every `*-plugins-modbus` repository, and is the
part that can be contributed upstream. Anything specific to one repository belongs above it.
### Plugin anatomy
One directory per vendor, holding its sources, headers, plugin metadata (`*.json`), translations
and packaging fragments. Plugin IDs, translation filenames and resource prefixes follow the
directory name.
Shared code stays in the shared libraries. Never move code between an LGPL library and a GPL
plugin without addressing the licence change explicitly.
### Build
Requires `plugin.pri`, provided by `libnymea1-dev`, or passed explicitly via `PLUGIN_PRI=`.
```
qmake <project>.pro && make -j"$(nproc)" # whole repository
cd <vendor> && qmake <vendor>.pro && make # single plugin — the short loop, preferred
```
Select the Qt version before calling qmake. When building against a nymea source tree rather than
a system-wide install, `PKG_CONFIG_PATH`, `CPATH`, `LIBRARY_PATH`, `LD_LIBRARY_PATH` and a `PATH`
extended with the `nymea-plugininfocompiler` build directory are also required.
Verbose logs: `QT_LOGGING_RULES="*.debug=true"`.
### Style
Qt conventions: 4-space indent, `CamelCase` classes, `lowerCamelCase` functions and members with
the `m_` prefix, Qt containers and signals over STL, log output wrapped in
`NYMEA_LOGGING_CATEGORY`, and `foreach` rather than range-based `for` to keep C++11 compatibility.
Refresh translations whenever user-visible text changes.
### Register provenance
**Never infer a register address.** Every address comes from the manufacturer's documentation or
from a read performed against a real device. A value taken from a third-party implementation is a
hypothesis until verified on hardware, and is documented as such.
Addresses, scale factors and endianness belong in the vendor's register description, not scattered
as literals through the implementation.
### Hardware validation
Compiling is not verifying. Any claim of validation names the device, its firmware version and its
configuration. Without hardware, say so and describe the validation still required rather than
implying it was performed.
Where a Modbus server can be simulated, prefer a captured register dump replayed against the
plugin over a hand-written mock: a dump carries the quirks a mock will not.
### Device writes
A write can damage equipment or void a warranty. Write paths are reviewed more strictly than read
paths: state what is written, under which conditions, and what happens if the plugin stops
mid-operation.
### Commits and pull requests
Imperative subjects under ~72 characters.
Pull request titles: `Plugin name: Add support for xyz` or `New plugin: Plugin name`.
State whether the change was tested on hardware, and if not, name someone who can confirm it.
Update the plugin's `README.md` and the translations.
Call out packaging impacts — Qt 5 and Qt 6 packaging are maintained separately.

View File

@ -1,10 +1,10 @@
{
"className": "AbbTerra",
"protocol": "BOTH",
"checkReachableRegister": "serialNumber",
"endianness": "BigEndian",
"stringEndianness": "BigEndian",
"errorLimitUntilNotReachable": 3,
"checkReachableRegister": "serialNumber",
"queuedRequests": true,
"queuedRequestsDelay": 50,
"blocks": [

View File

@ -92,9 +92,32 @@ void AbbTerraTcpDiscovery::checkNetworkDevice(const QHostAddress &address)
void AbbTerraTcpDiscovery::cleanupConnection(AbbTerraModbusTcpConnection *connection)
{
m_connections.removeAll(connection);
connection->disconnectDevice();
connection->deleteLater();
// A single reachability failure fans out into several signals
// (reachableChanged, checkReachabilityFailed, connectionErrorOccurred), each
// wired to this method. removeAll() returns the number of entries actually
// removed, so the first call tears the connection down and every subsequent
// (possibly re-entrant) call becomes a no-op.
if (m_connections.removeAll(connection) == 0)
return;
// Stop reacting to any further signals from this connection (and its Modbus
// master) so the teardown below cannot re-enter this method.
disconnect(connection, nullptr, this, nullptr);
disconnect(connection->modbusTcpMaster(), nullptr, this, nullptr);
// Defer the actual teardown to the next event-loop iteration. cleanupConnection
// is reached *synchronously* from inside the connection's QModbusReply "finished"
// handler (the testReachability() lambda) via checkReachabilityFailed(): that
// handler still dereferences m_checkReachableReply after emitting the signal
// (abbterramodbustcpconnection.cpp:2062, "m_checkReachableReply->deleteLater()").
// Calling disconnectDevice() here synchronously frees/invalidates that in-flight
// reply mid-handler, so line 2062 dereferences freed memory -> SIGSEGV in
// QObject::deleteLater(). Queuing the teardown lets the finished handler unwind
// completely (freeing the reply itself) before we disconnect and delete.
QMetaObject::invokeMethod(connection, [connection]() {
connection->disconnectDevice();
connection->deleteLater();
}, Qt::QueuedConnection);
}
void AbbTerraTcpDiscovery::finishDiscovery()

View File

@ -34,9 +34,17 @@ inline QString connectorTypeName(quint8 connectorType)
}
}
// The numeric fields of the serial-number and firmware registers are BCD-encoded:
// the hex representation of the byte IS its decimal value (per ABB spec §5.1,
// e.g. 0x49 = week 49, 0x22 = 22 kW), not a plain binary number (0x49 = 73).
inline quint8 bcdToDec(quint8 value)
{
return static_cast<quint8>(((value >> 4) & 0x0f) * 10 + (value & 0x0f));
}
inline int ratedPowerFromCode(quint8 ratedPowerCode)
{
switch (ratedPowerCode) {
switch (bcdToDec(ratedPowerCode)) {
case 7:
return 7;
case 11:
@ -54,11 +62,11 @@ inline DeviceInfo deviceInfoFromValues(quint64 serialNumberRaw, quint32 firmware
const quint8 connectorType = static_cast<quint8>((serialNumberRaw >> 56) & 0xff);
const quint8 ratedPowerCode = static_cast<quint8>((serialNumberRaw >> 48) & 0xff);
const quint8 plantId = static_cast<quint8>((serialNumberRaw >> 40) & 0xff);
const quint8 productionWeek = static_cast<quint8>((serialNumberRaw >> 24) & 0xff);
const quint8 productionYear = static_cast<quint8>((serialNumberRaw >> 16) & 0xff);
const quint8 uniqueHigh = static_cast<quint8>((serialNumberRaw >> 8) & 0xff);
const quint8 uniqueLow = static_cast<quint8>(serialNumberRaw & 0xff);
const quint8 plantId = bcdToDec(static_cast<quint8>((serialNumberRaw >> 40) & 0xff));
const quint8 productionWeek = bcdToDec(static_cast<quint8>((serialNumberRaw >> 24) & 0xff));
const quint8 productionYear = bcdToDec(static_cast<quint8>((serialNumberRaw >> 16) & 0xff));
const quint8 uniqueHigh = bcdToDec(static_cast<quint8>((serialNumberRaw >> 8) & 0xff));
const quint8 uniqueLow = bcdToDec(static_cast<quint8>(serialNumberRaw & 0xff));
const int ratedPower = ratedPowerFromCode(ratedPowerCode);
const QString connectorName = connectorTypeName(connectorType);
@ -77,9 +85,9 @@ inline DeviceInfo deviceInfoFromValues(quint64 serialNumberRaw, quint32 firmware
.arg(uniqueLow, 2, 10, QLatin1Char('0'));
deviceInfo.productName = QStringLiteral("ABB Terra AC %1 kW %2").arg(ratedPower).arg(connectorName);
deviceInfo.firmwareVersion = QStringLiteral("%1.%2.%3")
.arg((firmwareVersionRaw >> 24) & 0xff)
.arg((firmwareVersionRaw >> 16) & 0xff)
.arg((firmwareVersionRaw >> 8) & 0xff);
.arg(bcdToDec(static_cast<quint8>((firmwareVersionRaw >> 24) & 0xff)))
.arg(bcdToDec(static_cast<quint8>((firmwareVersionRaw >> 16) & 0xff)))
.arg(bcdToDec(static_cast<quint8>((firmwareVersionRaw >> 8) & 0xff)));
if (maxCurrentRaw >= 6000 && maxCurrentRaw <= 32000) {
deviceInfo.maxChargingCurrent = maxCurrentRaw / 1000.0;
}

View File

@ -314,6 +314,23 @@ void IntegrationPluginAbbterra::setupTcpThing(ThingSetupInfo *info)
applyTimeoutSetting(thing, connection);
}
});
// Actually start the connection once the network device is online. Without
// this the connection is never established, reachableChanged() never fires,
// initialize() is never called and the setup hangs until it times out
// ("Thing setup failed"). Gate on the monitor so we only connect when the
// device's IP is known and reachable, and (re)connect if it comes back.
connect(monitor, &NetworkDeviceMonitor::reachableChanged, connection, [connection, monitor](bool reachable) {
if (reachable) {
connection->modbusTcpMaster()->setHostAddress(monitor->networkDeviceInfo().address());
if (!connection->modbusTcpMaster()->connected())
connection->connectDevice();
}
});
if (monitor->reachable()) {
connection->modbusTcpMaster()->setHostAddress(monitor->networkDeviceInfo().address());
connection->connectDevice();
}
}
void IntegrationPluginAbbterra::setupRtuThing(ThingSetupInfo *info)

View File

@ -19,10 +19,8 @@
"networkdevice"
],
"createMethods": [
"discovery",
"user"
],
"discoveryType": "weak",
"paramTypes": [
{
"id": "9b3332aa-6c26-4399-b3dc-d7fdbe3f4420",

131
debian/changelog vendored
View File

@ -1,3 +1,134 @@
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.
The plugin was added to the build by the vendoring commit but never
packaged, so dh_missing found its .so in debian/tmp with nowhere to install
it and aborted the whole source package: no binary package at all came out
of this branch, not even for the four unrelated plugins.
* sunspec: add the Fronius vendor. A GEN24 answers for the inverter on one
unit ID and for its Smart Meter on another, behind the same host and port,
so one froniusConnection thing now carries two SunSpecConnection objects.
The meter connection is brought up only once the primary has finished
discovering, and the parent connected state follows the primary alone.
* sunspec: correct the storage setpoint chain - charge and discharge rates
read back from InWRte/OutWRte instead of the read-only gradients, the rate
range widened to -100..100 so forced charging can be expressed at all,
enableDischarging reconciled from StorCtl_Mod, and the revert timer armed
with every setpoint so one left behind by a stopped HEMS expires.
* debian: add libnymea-sunspec-dev to Build-Depends. Upstream does not need
it because it builds libnymea-sunspec from the same source tree; this
repository vendors only the plugin and links against the installed library.
* The new binary package conflicts with, replaces and provides
nymea-plugin-sunspec: it ships the same plugin file and the same pluginId
as the upstream package this repository already mirrors.
-- ETM-Schurig SARL <contact@etm-schurig.eu> Sun, 02 Aug 2026 12:05:00 +0200
etm-powersync-plugins-modbus (1.15.2+etm5) trixie; urgency=medium
* abbterra: decode the serial-number and firmware register fields as BCD, as
specified by the ABB Modbus spec §5.1 (e.g. 0x49 = week 49, 0x22 = 22 kW),
instead of as plain binary. The binary reading made deviceInfoFromValues()
reject every real charger whose BCD production-week byte exceeded 0x35
(e.g. week 46 = 0x46 = 70 > 53), failing setup with "The device does not
match the ABB Terra AC Modbus register map", and mis-decoded the 11 kW/22 kW
rated-power codes. Manual TCP add of the charger now completes.
-- ETM-Schurig SARL <contact@etm-schurig.eu> Fri, 03 Jul 2026 13:15:00 +0200
etm-powersync-plugins-modbus (1.15.2+etm4) trixie; urgency=medium
* abbterra: fix TCP manual setup never establishing the connection.
setupTcpThing() wired all the connection signals but never called
connectDevice(), so reachableChanged()/initializationFinished() never fired
and the setup hung until timeout ("Thing setup failed"). Now the connection
is started when the NetworkDeviceMonitor reports the device online.
-- ETM-Schurig SARL <contact@etm-schurig.eu> Fri, 03 Jul 2026 12:55:00 +0200
etm-powersync-plugins-modbus (1.15.2+etm3) trixie; urgency=medium
* abbterra: disable TCP auto-discovery (createMethods = "user" only) for the
Terra AC Charger (TCP) thing class. Discovery blind-scans the LAN and blows
up nymead with a SIGSEGV as soon as any Modbus device answers :502 with an
exception response — the crash is in the generated reachability handler and
cannot be fixed from the discovery code alone (two attempts failed). For the
Waveshare RS485-gateway use case discovery can't determine the slave id
anyway; the charger must be added manually (address + port + slaveId). This
unblocks adding the charger and stops the crash loop. The underlying
generated-code crash is tracked separately.
-- ETM-Schurig SARL <contact@etm-schurig.eu> Fri, 03 Jul 2026 12:40:00 +0200
etm-powersync-plugins-modbus (1.15.2+etm2) trixie; urgency=medium
* abbterra: fix nymead SIGSEGV during ABB Terra AC TCP discovery. On a
reachability-check exception (e.g. any Modbus device on the LAN answering
:502 with "illegal data address"), AbbTerraTcpDiscovery::cleanupConnection()
tore the connection down synchronously from inside the connection's own
QModbusReply "finished" handler, freeing the in-flight reply that the
generated code still dereferences at abbterramodbustcpconnection.cpp:2062.
The teardown is now deferred (Qt::QueuedConnection) and made idempotent.
* abbterra: restore checkReachableRegister ("serialNumber"), required by the
libnymea-modbus 1.15 connection-class generator.
-- ETM-Schurig SARL <contact@etm-schurig.eu> Fri, 03 Jul 2026 12:00:00 +0200
etm-powersync-plugins-modbus (1.15.2+etm1) trixie; urgency=medium
* Build 1.15,2

23
debian/control vendored
View File

@ -10,6 +10,7 @@ Build-Depends: debhelper,
libnymea-dev,
nymea-dev-tools:native,
libnymea-modbus-dev,
libnymea-sunspec-dev,
qt6-base-dev,
qt6-base-dev-tools,
qt6-serialport-dev,
@ -53,3 +54,25 @@ Description: PowerSync integration plugin for V2C Trydan EV charger (Modbus TCP)
system. Implements the evcharger interface: charge enable/disable, current
setpoint, plugged-in / charging state, house and solar power telemetry.
Status: Partial — not yet validated on real hardware.
Package: nymea-plugin-sunspec-etm01
Architecture: any
Section: libs
Depends: ${shlibs:Depends},
${misc:Depends},
Conflicts: nymea-plugin-sunspec
Replaces: nymea-plugin-sunspec
Provides: nymea-plugin-sunspec
Description: SunSpec Modbus devices, ETM fork with Fronius support - vendored
nymea integration plugin for SunSpec compatible solar inverters, meters and
storage over Modbus TCP. ETM fork of the upstream plugin, adding the Fronius
vendor: a GEN24 answers for the inverter and for its Smart Meter on separate
unit IDs behind one host and port, so one connection thing carries two.
.
Ships the same plugin file and the same pluginId as the upstream
nymea-plugin-sunspec, which it therefore conflicts with and replaces. The
-etm01 suffix marks the fork; the name goes back to nymea-plugin-sunspec if
the Fronius vendor is ever merged upstream.
.
Status: Partial - verified against a SunSpec simulator only, never on a real
inverter.

View File

@ -0,0 +1 @@
usr/lib/*/nymea/plugins/libnymea_integrationpluginsunspec.so

View File

@ -7,6 +7,7 @@ PLUGIN_DIRS = \
eastron \
abbb2x \
abbterra \
sunspec \
v2c

437
specs/fronius-sunspec.md Normal file
View File

@ -0,0 +1,437 @@
# Spec — support Fronius du plugin SunSpec
Critères d'acceptation numérotés, dérivés du contrat de conception Fronius
(document interne, non versionné ici) et du brief de lot 0.
Deux natures de critères, à ne jamais mélanger dans un même commit :
| Bloc | Nature | Commits |
|---|---|---|
| **AC-1 … AC-4** | correctifs d'anomalies présentes dans nymea 1.15.2 amont | message en anglais, aucune référence ETM / Fronius / PowerSync — ils ont vocation à être proposés en amont |
| **AC-5 … AC-15** | spécifique ETM (vendor `fronius`) | commits séparés, préfixe libre |
Statut des critères : voir `## Tableau de statut` en fin de document.
---
## Invariants applicables à toute l'implémentation
Ces règles ne sont pas des critères testables un à un ; elles conditionnent
chaque AC ci-dessous.
- **INV-1 — aucune adresse de registre en dur.** Toute lecture et toute écriture
passent par la découverte dynamique puis par les offsets du modèle. Le nombre
de blocs répétitifs du modèle 160 décale l'adresse absolue du modèle 124, et
sans batterie le modèle 124 est absent : une adresse absolue n'est jamais
valide durablement. Les adresses des documentations constructeur servent à
valider un dump, jamais à adresser.
- **INV-2 — `libnymea-sunspec` n'est jamais modifiée.** Tout vit dans
`sunspec/`. Voir `sunspec/VENDORED.md`.
- **INV-3 — une consigne écrite est relue.** Plusieurs onduleurs acceptent une
écriture puis l'ignorent sans code d'exception Modbus.
- **INV-4 — la régulation se ferme sur la puissance mesurée**, jamais sur la
consigne émise.
- **INV-5 — les scale factors ne sont pas statiques.** Ils changent avec le
firmware et en fonctionnement (auto-scale). Relire le SF concerné
immédiatement avant conversion et avant écriture.
---
## Bloc 0 — correctifs upstream
### AC-1 — plage des consignes de charge/décharge (anomalie A1)
`chargingRate` et `dischargingRate` sont déclarés `minValue: 0, maxValue: 100`
dans `integrationpluginsunspec.json`. La plage réelle des points `InWRte` /
`OutWRte` du modèle 124 est **−100,00 … +100,00** (`int16`, SF −2).
**Critère.** Dans `integrationpluginsunspec.json`, les deux states
`chargingRate` et `dischargingRate` de la thingClass `sunspecStorage` ont
`minValue == -100` et `maxValue == 100`.
**Pourquoi c'est bloquant.** Avec la borne basse à 0, une consigne de charge
forcée (valeur négative, convention de signe du constructeur) est structurellement
inexprimable : nymea rejette le paramètre d'action avant qu'il n'atteigne le
plugin.
---
### AC-2 — cohérence lecture/écriture des consignes (anomalie A2)
`onStorageBlockUpdated()` alimente `chargingRate` depuis `wChaGra()` et
`dischargingRate` depuis `wDisChaGra()`. Ce sont les **gradients**
(`% WChaMax/s`), en lecture seule sur GEN24. Les actions, elles, écrivent dans
`InWRte` / `OutWRte` (`% WChaMax`). Le state relu n'est donc pas la grandeur
écrite : l'utilisateur écrit 50, relit tout autre chose.
**Critère.**
1. `chargingRate` est alimenté depuis `inWRte()`.
2. `dischargingRate` est alimenté depuis `outWRte()`.
3. Les gradients restent exposés, mais sous des noms non ambigus :
`chargeGradient` ← `wChaGra()`, `dischargeGradient` ← `wDisChaGra()`,
en lecture seule.
---
### AC-3 — réconciliation de `enableDischarging` (anomalie A3)
`onStorageBlockUpdated()` réconcilie `enableCharging` depuis
`storCtlMod().testFlag(Storctl_modCharge)` mais ne fait rien pour
`enableDischarging`. Le state reste donc figé sur sa dernière valeur écrite, et
diverge silencieusement de l'onduleur dès que celui-ci change d'état seul.
**Critère.** `enableDischarging` est alimenté depuis
`storCtlMod().testFlag(Storctl_modDiScharge)`, symétriquement à
`enableCharging`.
---
### AC-4 — écriture de `InOutWRte_RvrtTms` (anomalie A4)
Le setter `setInOutWRteRvrtTms()` existe dans `libnymea-sunspec` mais n'est
jamais appelé. `RvrtTms` est le délai au bout duquel l'onduleur abandonne la
consigne et reprend son comportement natif. À 0 — le défaut — une consigne reste
active indéfiniment, y compris si le HEMS s'arrête, perd le réseau ou plante.
**Critère.**
1. Une valeur `revertTime` est configurable par thing (paramètre de la
thingClass `sunspecStorage`), en secondes.
2. Toute séquence de consigne (`InWRte`, `OutWRte`, `StorCtlMod`, `ChaGriSet`)
se termine par une écriture de `InOutWRte_RvrtTms` à cette valeur.
3. `RvrtTms` est écrit **en dernier** dans la séquence.
4. Défaut du vendor `sunspec` générique : **0** (comportement amont inchangé).
Défaut du vendor `fronius` : **300** (voir AC-10).
**Plage.** 0 – 28800 s.
---
## Bloc 1 — vendor `fronius`, structure
### AC-5 — filtre fabricant à la découverte
**Critère.** Pour `froniusConnectionThingClassId`, `discoverThings()` ne retient
que les résultats dont au moins un modèle Common porte un nom de fabricant
contenant `Fronius`, insensible à la casse (`hasManufacturer()` fait déjà un
`contains()` insensible à la casse). Les autres résultats sont ignorés, sans
créer de `ThingDescriptor`.
Symétriquement, un GEN24 ne doit pas être proposé deux fois : le vendor
générique `sunspecConnection` continue de le proposer (comportement amont
inchangé) ; c'est l'utilisateur qui choisit la thingClass qu'il appaire.
---
### AC-6 — ordre des octets
**Critère.** Une connexion `fronius` utilise `ByteOrderLittleEndian`, c'est-à-dire
le défaut SunSpec, à la découverte comme à la connexion.
Le `ByteOrderBigEndian` forcé du vendor `solarEdge` est une anomalie propre à
SolarEdge et ne doit pas être étendue.
> **Inférence, pas fait mesuré.** L'ordre des octets du GEN24 n'est documenté
> nulle part dans la documentation constructeur consultée ; on suppose le défaut
> SunSpec. À confirmer au banc (phase 2) puis sur matériel (phase 3).
---
### AC-7 — paramètres de la thingClass `froniusConnection`
**Critère.** La thingClass `froniusConnection` existe, sous un bloc `vendors`
→ `fronius`, et porte exactement :
| Paramètre | Défaut | Contrainte |
|---|---|---|
| `address` | — | comme `sunspecConnection` |
| `hostName` | — | comme `sunspecConnection` |
| `macAddress` | — | comme `sunspecConnection` |
| `port` | 502 | — |
| `slaveId` | **1** | fixe en TCP sur GEN24 : masqué ou en lecture seule |
| `meterSlaveId` | **200** | plage 0 – 247, `advanced`, **0 = pas de compteur sur cette connexion** |
Aucun paramètre `endianness` n'est exposé (cf. AC-6 : la valeur est imposée).
---
### AC-8 — plancher de cadence d'interrogation
Le support constructeur recommande un intervalle d'interrogation supérieur à
**7 secondes** pour les applications tierces. En deçà, la surcharge de
communication peut faire passer l'onduleur hors ligne vis-à-vis du portail
constructeur ; le rétablissement exige une coupure AC+DC d'environ une minute
**sur site**.
**Critère.**
1. Défauts du vendor `fronius` : `updateInterval = 10` s, `timeout = 2000` ms,
`numberOfRetries = 3`.
2. **Garde-fou dur** : toute valeur d'intervalle strictement inférieure à 7 s
configurée sur une connexion `fronius` est remontée à 7 s, avec un
`qCWarning` explicite. Ce n'est pas une valeur par défaut modifiable : c'est
un plancher dans le code.
3. Le plancher s'applique aussi en debug.
4. Le vendor `sunspec` générique et le vendor `solarEdge` ne sont pas affectés.
---
### AC-9 — registres interdits en écriture
Sur GEN24, ces écritures sont acceptées puis **ignorées sans code d'exception
Modbus** — l'échec est invisible côté HEMS :
`WChaMax`, `WChaGra`, `WDisChaGra`, `InOutWRte_WinTms`, `InOutWRte_RmpTms`,
`VAChaMax` (dont le scale factor n'est de surcroît pas supporté).
**Critère.** Sur une connexion `fronius`, les setters correspondants
(`setWChaMax`, `setWChaGra`, `setWDisChaGra`, `setInOutWRteWinTms`,
`setInOutWRteRmpTms`, `setVAChaMax`) ne sont **jamais** appelés. Une tentative
est refusée avant émission, avec un `qCWarning` nommant le registre.
---
### AC-10 — `revertTime` par défaut à 300 s sur `fronius`
**Critère.** La thingClass `froniusConnection` impose `revertTime = 300` s aux
things `sunspecStorage` créés sous elle, là où le vendor générique laisse 0
(AC-4.4).
> **Hypothèse de travail — décision D4 non validée.** Le contrat recommande
> `RvrtTms = 300 s` à chaque consigne, avec rafraîchissement à chaque cycle
> d'optimisation, plutôt que le choix inverse (ne rien écrire) fait par evcc.
> Le raisonnement retenu : ce qui est acceptable pour un logiciel auto-hébergé
> ne l'est pas pour un produit installé sous la responsabilité d'ETM — une
> consigne persistante après un crash du HEMS immobilise la batterie du client.
> **Prérequis non levé :** la sémantique du timer est décrite dans le chapitre
> général du manuel et illustrée sur le modèle 123, pas sur le 124. Elle doit
> être vérifiée expérimentalement sur le modèle 124 avant d'être figée. Si la
> vérification échoue : replier sur `RvrtTms = 0` + watchdog applicatif côté
> HEMS.
---
## Bloc 2 — chaîne de pilotage batterie
### AC-11 — correspondance intention HEMS → registres
Convention de signe du constructeur : **négatif = charge, positif = décharge**.
**Critère.** Les six modes produisent exactement cette séquence de registres :
| Mode | `ChaGriSet` | `StorCtl_Mod` | `InWRte` | `OutWRte` | `RvrtTms` |
|---|---|---|---|---|---|
| `NORMAL` | — | 0 | — | 100 | 300 |
| `NO_DISCHARGE` | — | 2 | — | 0 | 300 |
| `NO_CHARGE` | — | 1 | 0 | — | 300 |
| `FROZEN` | — | 3 | 0 | 0 | 300 |
| `FORCE_CHARGE(p %)` | 1 | 2 | — | **−p** | 300 |
| `LIMIT(c %, d %)` | — | 3 | c | d | 300 |
`—` = registre non écrit pour ce mode.
**Ordre d'écriture imposé :** `ChaGriSet` → `StorCtl_Mod` → `InWRte` / `OutWRte`
→ `RvrtTms` en dernier.
`StorCtl_Mod` est un bitfield : bit 0 = CHARGE, bit 1 = DISCHARGE.
`ChaGriSet` : 0 = PV, 1 = GRID.
> Les exemples du manuel constructeur affichent des fenêtres de puissance dont
> les signes sont incohérents avec la convention annoncée. Les **valeurs de
> registres** de ces exemples sont correctes, les fenêtres annotées ne le sont
> pas : la table ci-dessus est construite sur les valeurs de registres.
---
### AC-12 — relecture du scale factor avant écriture
**Critère.** `InOutWRte_SF` est relu immédiatement avant chaque écriture de
`InWRte` / `OutWRte`. Une valeur de SF mise en cache lors d'un cycle
d'interrogation antérieur n'est pas réutilisée.
Motif (INV-5) : le constructeur avertit que les SF changent avec le firmware
**et en fonctionnement**. Un SF auto-scalé entre la lecture et l'écriture
produit une consigne d'un facteur 10 à côté.
---
### AC-13 — vérification par relecture et state `controlHealthy`
**Critère.**
1. Après chaque séquence de consigne, `StorCtl_Mod`, `InWRte`, `OutWRte` et
`ChaGriSet` sont relus et comparés aux valeurs écrites.
2. En cas de divergence : `qCWarning` explicite nommant le registre, la valeur
écrite et la valeur relue.
3. Un state `controlHealthy` (bool) passe à `false` en cas de divergence, à
`true` après une séquence vérifiée conforme.
C'est la seule parade aux échecs silencieux du §5 du contrat (pilotage Modbus
non autorisé, charge réseau non autorisée, restriction d'IP, assistant de
tarification qui écrase les consignes).
---
## Bloc 3 — modèle 160 (MPPT et blocs batterie)
### AC-14 — identification des blocs par `IDStr`, jamais par index
Les blocs répétitifs du modèle 160 portent les chaînes `MPPT 1`, `MPPT 2`,
`StCha 3`, `StDisCha 4`. Sur un onduleur à 3 ou 4 MPPT, les blocs batterie ne
sont **pas** en position 3 et 4.
**Critère.**
1. Le classement d'un bloc en « string PV » ou « batterie » se fait sur
`inputIdSting()`, jamais sur l'index du bloc.
2. Un bloc dont `IDStr` commence par `MPPT` est un string PV.
3. Un bloc dont `IDStr` commence par `StCha` est le bloc batterie en charge ;
`StDisCha`, le bloc batterie en décharge.
4. Un `IDStr` inconnu n'est pas classé et ne contribue à aucun agrégat ; il est
signalé par un `qCWarning`.
Le préfixe est comparé de façon insensible à la casse et après suppression des
espaces de bord. **Attention à l'ordre du test** : `StDisCha` contient
`StCha` comme sous-chaîne uniquement si on teste par `contains()` — le test doit
donc porter sur le préfixe, et `StDisCha` doit être testé avant `StCha` si une
implémentation par `startsWith` en cascade est retenue.
---
### AC-15 — agrégats de puissance issus du modèle 160
`DCW` est un `uint16`, donc toujours positif : le signe est porté par le bloc
actif, le bloc inactif étant à zéro.
**Critère.**
1. Puissance PV = Σ `DCW` des blocs classés « string PV ».
2. Puissance batterie = `DCW[StDisCha]` − `DCW[StCha]`, **positif = décharge**.
3. `DCW_SF` est partagé par tous les modules et auto-scalé : il est relu avant
conversion (INV-5).
4. **Aucun calcul de production PV par différence AC/DC ± batterie.** Le GEN24
expose la puissance PV réelle string par string ; répliquer le contournement
du vendor `solarEdge` — qui existe parce que cet onduleur n'expose pas le
modèle 160 — serait une régression.
---
## Hypothèses de travail — points non tranchés
Chacun de ces points est traité selon l'hypothèse indiquée, faute de
confirmation humaine au moment de l'implémentation.
| # | Point | Statut au contrat | Hypothèse retenue |
|---|---|---|---|
| H-1 | Architecture multi-unit-ID (D1) | ✅ tranché, **implémenté** | **Option A′** : un thing `froniusConnection` portant deux `SunSpecConnection` internes (onduleur `slaveId`, compteur `meterSlaveId`). UX correcte, aucune modification de `libnymea-sunspec`, rebasabilité préservée. La seconde connexion vit dans une table distincte de `m_sunSpecConnections`, ce qui laisse les vendors générique et `solarEdge` inchangés ligne pour ligne. |
| H-2 | Watchdog batterie (D4) | ⬜ à valider | **`RvrtTms = 300 s`** à chaque consigne (AC-10). Prérequis non levé : sémantique du timer à vérifier sur le modèle 124, pas seulement sur le 123. |
| H-3 | Ordre des octets du GEN24 | inférence explicite du brief | Défaut SunSpec, little-endian (AC-6). Non documenté par le constructeur. |
| H-4 | Deux connexions TCP simultanées vers le même onduleur | ✅ **mesuré** sur le GEN24 client | Les deux connexions tiennent simultanément. Reste distincte, et **non mesurée**, la charge de deux *découvertes* simultanées : le plugin ne monte donc la connexion compteur qu'une fois la découverte primaire terminée. La sérialisation des lectures d'interrogation reste un chantier à part. |
| H-5 | Assistant de tarification écrasant les consignes | « à confirmer », absent de la doc constructeur | Constat empirique tiers. Traité en message de diagnostic (AC-13.2), pas en logique de pilotage. |
| H-6 | Ordre des phases du chantier | contrat §8 : la phase 2 ne démarre pas avant que la phase 1 (simulateur) soit verte | **Précondition non vérifiée ici.** Le simulateur `hems-sim` n'a pas été exécuté. Les AC ci-dessous sont validés par tests unitaires hors ligne, ce qui ne remplace pas une validation contre un simulateur reproduisant la sémantique du constructeur. |
---
## Non vérifiable par les tests unitaires de ce dépôt
À porter en phase 2 (banc / simulateur) puis en phase 3 (matériel) :
- l'ordre des octets réel du GEN24 (H-3) ;
- le comportement en double connexion TCP (H-4) ;
- l'expiration effective de `RvrtTms` sur le modèle 124 (H-2) ;
- le refus d'écriture par exception Modbus quand le pilotage n'est pas autorisé
côté onduleur ;
- l'auto-scale effectif des scale factors en fonctionnement (INV-5) ;
- le fait que les registres de AC-9 soient bien ignorés silencieusement ;
- le comportement des blocs batterie du modèle 160 selon la version de firmware
(avant/après 1.31, `DCV` et `DCWH` reportés en continu ou zérotés à l'arrêt).
---
## Hors périmètre
- modèles 7xx ;
- Modbus RTU ;
- onduleurs Verto / Tauro (le mapping MPPT diffère — c'est le motif d'AC-14,
mais ils ne sont pas testés) ;
- toute modification de `libnymea-sunspec` (INV-2) ;
- tout mécanisme de compensation PV à la SolarEdge (AC-15.4) ;
- anomalies A5 (modèle 160 absent du `switch` de `processDiscoveryResult()`),
A6 (`MinRsvPct` non exposé) et A7 (`.toInt()` sur des paramètres destinés à
des setters `float`) : réelles et documentées au contrat, mais hors des quatre
correctifs demandés pour ce lot.
---
## Tableau de statut
Suite : `sunspec/tests/`, **60 tests, 60 verts** — 44 en logique pure, 6 sur
l'aiguillage du plugin, 10 sur le multi unit ID.
Une quatrième suite, `tests/integration`, monte le plugin face à un vrai
appareil. Elle est construite mais **jamais lancée par `make check`** : elle
exige `SUNSPEC_TEST_HOST`.
**Exécutée le 2026-08-02 contre le simulateur `froniusgen24-sunspec` du banc :
8 tests, 8 verts.** Unit 1 = modèles 1/113/120-123/160, unit 200 = 1/213, sans
batterie. Les deux corrections qu'aucune suite hors ligne ne pouvait couvrir y
sont vérifiées, et chacune a été confirmée capable d'échouer en réintroduisant
la forme pré-F3 : le balayage des orphelins non corrigé supprime **les deux**
enfants en deux cycles de découverte, et la propagation de `connected` non
scindée fait passer le parent déconnecté sur la seule chute du compteur.
Reste non exécuté sur **matériel réel** : le GEN24 client est une installation
distante, écartée pour ce lot (une découverte ratée y demanderait une coupure
AC+DC sur site).
Build : `qmake6 NYMEA_SUNSPEC_PATH=<sysroot> sunspec/tests/tests.pro && make check`.
Sans `NYMEA_SUNSPEC_PATH`, `sunspec.pri` résout `libnymea-sunspec` par
`pkg-config`, ce qui suppose `libnymea-sunspec-dev` installé système.
| AC | Objet | Test | Statut |
|---|---|---|---|
| AC-1 | plage −100…+100 | `test_AC_1_*` | vert |
| AC-2 | lecture `inWRte`/`outWRte` | `test_AC_2_*` | vert |
| AC-3 | `enableDischarging` relu | `test_AC_3_*` | vert |
| AC-4 | `RvrtTms` écrit en dernier | `test_AC_4_*` | vert |
| AC-5 | filtre fabricant | `test_AC_5_*` | vert |
| AC-6 | little-endian | `test_AC_6_*` | vert — mais c'est une **inférence** (H-3), pas une mesure |
| AC-7 | paramètres `froniusConnection` | `test_AC_7_*` | vert |
| AC-8 | plancher 7 s | `test_AC_8_*` | vert, avec réserve ci-dessous |
| AC-9 | registres interdits | `test_AC_9_*` | vert |
| AC-10 | `revertTime` 300 s | `test_AC_10_*` | vert |
| AC-11 | table mode → registres | `test_AC_11_*` | vert au niveau du plan ; **aucune action n'expose encore les six modes** |
| AC-12 | relecture du SF | `test_AC_12_*` | vert |
| AC-13 | relecture + `controlHealthy` | `test_AC_13_*` | vert |
| AC-14 | classement par `IDStr` | `test_AC_14_*` | vert ; **non branché** — modèle 160 toujours absent du `switch` (anomalie A5, hors périmètre) |
| AC-15 | agrégats modèle 160 | `test_AC_15_*` | idem AC-14 |
### Réserves à lever
- **AC-8** — le minuteur d'interrogation de nymea est **global au plugin**, pas
par connexion. Le plancher est donc appliqué à l'échelle du plugin dès qu'une
connexion `fronius` est configurée : c'est plus strict que l'AC, jamais moins.
Le paramètre `updateInterval` par thing a été **retiré** de
`froniusConnection` : l'architecture du plugin ne peut pas l'honorer par
connexion, et un réglage sans effet serait pire que son absence.
- **AC-11** — la fonction `FroniusRules::planBatteryMode()` produit exactement
la table du contrat, et le plan est exécutable par
`IntegrationPluginSunSpec::executeSetpointPlan()`. Il manque l'action nymea
qui l'appelle : le nommage de cette action et de ses paramètres n'est pas
tranché par le contrat.
- **AC-14 / AC-15** — les fonctions de classement et d'agrégation sont écrites
et testées, mais aucun thing du modèle 160 n'est créé tant que l'anomalie A5
(`ModelIdMppt` absent du `switch` de `processDiscoveryResult()`) n'est pas
corrigée. A5 est hors du périmètre des quatre correctifs demandés.
- **AC-15.4** — respecté par construction : aucun calcul de production PV par
différence n'a été ajouté.
### Multi-unit-ID (H-1)
**Non implémenté.** `froniusConnection` porte bien le paramètre `meterSlaveId`
(AC-7, vert), mais le plugin n'instancie encore qu'une seule `SunSpecConnection`
par thing. Le passage à deux connexions internes touche `m_sunSpecConnections`,
`createConnection()`, `setupThing()`, `processDiscoveryResult()`,
`thingRemoved()`, `postSetupThing()` et `onRefreshTimer()`.
Deux raisons de ne pas l'avoir fait dans la foulée : la décision D1 n'est pas
validée, et le brief demande explicitement de traiter ce lot **isolément**,
après que le reste soit fonctionnel sur l'onduleur seul. S'y ajoute H-4 : rien
ne dit que l'onduleur accepte deux connexions TCP simultanées, et le
constructeur recommande des requêtes séquentielles. Ce point se mesure au banc ;
le supposer serait exactement le mode de défaillance que le contrat cherche à
éviter.

View File

@ -1,7 +1,34 @@
QT *= network serialbus
top_srcdir=$$PWD
top_builddir=$$shadowed($$PWD)
# libnymea-sunspec n'est PAS vendoree : on lie contre le paquet
# libnymea-sunspec-dev. Deux resolutions possibles, meme convention que
# modbus.pri / NYMEA_MODBUS_PATH :
#
# 1) paquet installe systeme -> pkg-config nymea-sunspec (defaut)
# 2) sysroot local -> qmake6 NYMEA_SUNSPEC_PATH=/chemin/vers/sysroot
# ou <sysroot> contient usr/include/nymea-sunspec et usr/lib/<triplet>.
#
# Le paquet 1.15.2 installe les en-tetes des modeles sous
# <includedir>/home/jenkins/nymea-plugins-modbus/libnymea-sunspec/models/
# (le chemin de build Jenkins a fuite dans le .deb). Le plugin fait
# #include <models/sunspecmodelfactory.h>
# qui ne resout donc pas avec le seul -I<includedir>. On ajoute le prefixe
# manquant quand il existe, sans casser une installation correcte.
INCLUDEPATH += $$top_srcdir/libnymea-sunspec
LIBS += -L$$top_builddir/libnymea-sunspec/ -lnymea-sunspec1
isEmpty(NYMEA_SUNSPEC_PATH) {
CONFIG += link_pkgconfig
PKGCONFIG += nymea-sunspec
SUNSPEC_INCLUDEDIR = $$[QT_INSTALL_PREFIX]/include/nymea-sunspec
} else {
SUNSPEC_INCLUDEDIR = $$NYMEA_SUNSPEC_PATH/usr/include/nymea-sunspec
SUNSPEC_LIBDIR = $$NYMEA_SUNSPEC_PATH/usr/lib/$$QMAKE_HOST.arch-linux-gnu
INCLUDEPATH += $$SUNSPEC_INCLUDEDIR
LIBS += -L$$SUNSPEC_LIBDIR -lnymea-sunspec1
# Build de developpement contre un sysroot : la bibliotheque n'est pas dans
# le cache de l'editeur de liens dynamique, il faut donc l'embarquer. Ce
# rpath n'existe pas dans le build paquet, qui passe par pkg-config.
QMAKE_RPATHDIR += $$SUNSPEC_LIBDIR
}
SUNSPEC_MODELS_WORKAROUND = $$SUNSPEC_INCLUDEDIR/home/jenkins/nymea-plugins-modbus/libnymea-sunspec
exists($$SUNSPEC_MODELS_WORKAROUND/models): INCLUDEPATH += $$SUNSPEC_MODELS_WORKAROUND

245
sunspec/DIVERGENCES.md Normal file
View File

@ -0,0 +1,245 @@
# Divergences — vendor Fronius du plugin SunSpec
Écarts assumés entre ce que demandent `specs/fronius-sunspec.md` et le contrat
d'une part, et ce que le code fait d'autre part. Chaque entrée dit **pourquoi**
et **comment la lever**.
Deux étiquettes :
- **MESURÉ** — l'écart repose sur une observation faite sur du matériel ou sur
le code de la plateforme, reproductible.
- **INFÉRÉ** — l'écart repose sur un raisonnement, faute de donnée. À reprendre
dès qu'une mesure existe.
---
## D-1 — `meterSlaveId` n'est pas marqué `advanced` (MESURÉ)
**Ce que demande AC-7.** Le paramètre `meterSlaveId` de la thingClass
`froniusConnection` doit être `advanced`.
**Ce que fait le code.** Rien : le paramètre n'a pas ce drapeau.
**Pourquoi.** `advanced` n'est pas une propriété de paramType dans nymea 1.15.2.
`ParamType::typeProperties()` énumère exactement `id`, `name`, `displayName`,
`type`, `defaultValue`, `inputType`, `unit`, `minValue`, `maxValue`, `stepSize`,
`allowedValues`, `readOnly`. Le vérificateur de métadonnée refuse tout champ
inconnu :
```
Param type "meterSlaveId" has unknown fields: "advanced"
```
et `PluginMetadata::isValid()` passe à `false`. Ce n'est pas un paramètre mal
affiché : **le plugin ne se charge plus du tout**, et les erreurs suivantes en
cascade (le paramType est abandonné, donc `froniusConnection` ne satisfait plus
l'interface `networkdevice`) rendent le diagnostic trompeur.
Vérifié en chargeant le JSON, avec et sans le drapeau, dans un
`PluginMetadata` lié à la `libnymea` installée — pas seulement lu dans une
source amont qui pourrait diverger du paquet.
**Comment lever.** Rien à faire côté plugin. Il faut soit que nymea ajoute la
propriété, soit que AC-7 soit corrigé. Un test de garde
(`test_F3_meter_slave_id_is_not_marked_advanced`) empêche qu'on ajoute le
drapeau sans refaire la mesure.
---
## D-2 — délai de garde du balayage des enfants orphelins (INFÉRÉ)
**Contexte.** `processDiscoveryResult()` fait disparaître les things enfants
dont le modèle n'est plus annoncé. Avec deux connexions sous le même parent,
chacune ne voit que la moitié du tableau : balayer sur cette moitié ferait
disparaître les enfants de l'autre à chaque découverte. Le balayage attend donc
que **toutes** les connexions du parent aient réussi une découverte, puis
confronte les enfants à l'union de leurs modèles.
**L'écart.** Une connexion dont la découverte n'aboutit jamais bloquerait le
balayage pour toujours. Un délai de garde le débloque :
`OrphanSweepGraceSeconds = 600` (10 minutes).
**Pourquoi cette valeur, et pourquoi elle est INFÉRÉE.** Aucune donnée ne la
fixe. Le choix est asymétrique et volontairement paresseux : un balayage trop
tardif laisse traîner un thing périmé affiché déconnecté, un balayage trop
précoce **supprime un thing de l'utilisateur**. À conséquences inégales,
réglage prudent. 10 minutes est très au-delà de tout cycle de découverte
observé, sans être infini.
**Comment lever.** Mesurer, sur le banc puis sur matériel, le temps que met une
découverte SunSpec à échouer définitivement sur un unit ID qui ne répond pas
(timeout × retries × nombre de registres de base balayés). Le délai de garde
doit valoir quelques fois cette durée, pas dix minutes arbitraires. La valeur
est un seul `const` dans `integrationpluginsunspec.cpp` ; la changer n'a
d'effet sur rien d'autre.
---
## D-3 — la configuration du plugin écrase les défauts de cadence Fronius (MESURÉ, préexistant)
**Contexte.** `createConnection()` impose `timeout` et `numberOfRetries` du
vendor Fronius (AC-8) à la création. Mais `onPluginConfigurationChanged()`
réapplique ensuite les valeurs globales du plugin à **toutes** les connexions,
Fronius comprise.
**L'écart.** Il est antérieur à F3 et affecte déjà la connexion onduleur seule.
F3 ne le corrige pas ; il l'étend symétriquement à la connexion compteur, pour
que les deux connexions d'un même thing se comportent identiquement. Le
plancher d'intervalle d'interrogation d'AC-8, lui, reste appliqué : il vit dans
`effectiveUpdateInterval()`, pas ici.
**Comment lever.** Hors périmètre de F3 : cela demande de décider si les
réglages globaux du plugin doivent pouvoir passer outre un défaut vendor, ce que
ni le contrat ni la spec ne tranchent.
---
## D-4 — connexion détruite par `ThingSetupInfo::aborted` et laissée dans la table (MESURÉ, préexistant, amont)
**Contexte.** `setupThing()` relie `ThingSetupInfo::aborted` à
`SunSpecConnection::deleteLater()`, alors que `createConnection()` a déjà inséré
la connexion dans `m_sunSpecConnections`. Un setup avorté laisse donc un
pointeur pendant dans la table ; le setup suivant du même thing prend la branche
« reconfiguration » et appelle `deleteLater()` sur un objet déjà détruit.
**L'écart.** F3 ne corrige pas ce chemin pour la connexion primaire : il est
commun aux trois vendors, y compris `solarEdge` dont aucun test ne couvre le
comportement, et le périmètre du lot est explicitement « generic et
solarEdgeConnection ne changent pas de comportement ». La connexion **compteur**
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.

29
sunspec/README.md Normal file
View File

@ -0,0 +1,29 @@
# SunSpec
Connect nymea to SunSpec devices over Modbus TCP
## Supported Things
* SunSpec Inverter
* Model ID 101 & 111 Single phase inverter
* Model ID 102 & 112 Split phase inverter
* Model ID 103 & 113 Three phase inverter
* SunSpec Meter
* Model ID 201 & 211 Single phase meter
* Model ID 202 & 212 Split phase meter
* Model ID 203 & 213 Three phase meter
* SunSpec Storage
* Model ID 124
## Tested connections
* SolarEdge (with custom battery)
## Requirements
* The package 'nymea-plugin-sunspec' must be installed.
* The SunSpec device must support SunSpec over modbus TCP
## More
https://sunspec.org

45
sunspec/VENDORED.md Normal file
View File

@ -0,0 +1,45 @@
# Vendoring — sunspec
Copié depuis : ETM/nymea-plugins-modbus (fork de nymea/nymea-plugins-modbus)
Commit source (HEAD au moment de la copie) : 2b18e84
Dernier commit touchant les sources copiées : bf53a74
("Update license text and add SPDX identifier", 2025-11-18)
Base upstream : nymea-plugins-modbus 1.15.2
Date copie : 2026-07-31
Nom paquet cible : powersync-plugin-sunspec
Raison : le chantier Fronius (voir `specs/fronius-sunspec.md`) ajoute un vendor
`fronius` et corrige des anomalies du vendor `sunspec` générique. Ces
changements vivent dans ce dépôt ETM, pas dans le mirror nymea.
## Ce qui a été copié
Sources uniquement. Exclus de la copie : `*.o`, `moc_*`, `*.so`, `Makefile`,
`plugininfo.h`, `extern-plugininfo.h`, `moc_predefs.h`, `*.qm` — tous des
artefacts de build régénérés par `qmake6` / `nymea-plugininfocompiler`.
## Dépendance de build
`libnymea-sunspec-dev` (paquet `repos.nymea.io`, 1.15.2). `sunspec.pri` le
résout par `pkg-config nymea-sunspec`.
## Règle absolue
**`libnymea-sunspec` n'est PAS vendorée et ne doit pas l'être.** Le plugin doit
rester rebasable sur l'upstream nymea : toute modification vit dans `sunspec/`.
Si un changement de la bibliothèque paraît inévitable, s'arrêter et remonter la
question plutôt que de la modifier.
## Hygiène de commits
Deux natures de changements, à ne jamais mélanger dans un même commit :
- **correctifs upstream** (bugs présents dans nymea 1.15.2 — AC-1 à AC-4 de la
spec) : message en anglais, aucune référence à ETM / Fronius / PowerSync, pour
rester proposables en amont ;
- **spécifique ETM** (vendor `fronius`, réglages produit) : commits séparés.
## SORTIE
Si nymea intègre en amont les correctifs AC-1..AC-4 et un vendor Fronius
équivalent : rebaser sur la version amont et réduire ce dossier au delta ETM.

206
sunspec/froniusrules.cpp Normal file
View File

@ -0,0 +1,206 @@
// SPDX-License-Identifier: GPL-3.0-or-later
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
*
* Copyright (C) 2026, ETM-PowerSync
*
* This file is part of nymea.
*
* nymea is free software: you can redistribute it and/or modify it under the
* terms of the GNU General Public License as published by the Free Software
* Foundation, either version 3 of the License, or (at your option) any later
* version.
*
* nymea is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with
* nymea. If not, see <https://www.gnu.org/licenses/>.
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
#include "froniusrules.h"
using namespace SunSpecStorageLogic;
namespace FroniusRules {
bool matchesManufacturer(const QStringList &manufacturers)
{
foreach (const QString &name, manufacturers) {
if (name.contains(QStringLiteral("fronius"), Qt::CaseInsensitive))
return true;
}
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()
{
/* 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)
{
if (meterSlaveId == 0)
return MeterConnectionDisabled;
if (meterSlaveId < 0 || meterSlaveId > MaximumSlaveId)
return MeterConnectionInvalidSlaveId;
/* Not a theoretical case: the slave ID of the thing is written by the
* discovery from the slave ID it was asked to scan, so an inverter found
* with a discovery slave ID of 200 ends up with both values at 200. */
if (meterSlaveId == inverterSlaveId)
return MeterConnectionCollidesWithInverter;
return MeterConnectionCreate;
}
int clampUpdateInterval(int requestedSeconds)
{
return qMax(requestedSeconds, MinimumUpdateIntervalSeconds);
}
bool isWriteForbidden(const QString &registerName)
{
static const QStringList forbidden = {
QStringLiteral("WChaMax"),
QStringLiteral("WChaGra"),
QStringLiteral("WDisChaGra"),
QStringLiteral("InOutWRte_WinTms"),
QStringLiteral("InOutWRte_RmpTms"),
/* VAChaMax is doubly unusable: the write is ignored and its scale
* factor is not supported either. */
QStringLiteral("VAChaMax")
};
foreach (const QString &name, forbidden) {
if (registerName.compare(name, Qt::CaseInsensitive) == 0)
return true;
}
return false;
}
SetpointPlan planBatteryMode(BatteryMode mode, double chargeLimit, double dischargeLimit, quint16 revertTime)
{
/* StorCtl_Mod is a bitfield: bit 0 CHARGE, bit 1 DISCHARGE. ChaGriSet is
* 0 PV, 1 GRID. A register absent from the vector below is deliberately not
* written for that mode.
*
* planSetpoint() puts these in the mandated order and appends the revert
* timer, so the order they are listed in here does not matter. */
QVector<Write> setpoints;
switch (mode) {
case BatteryModeNormal:
setpoints.append({RegisterStorCtlMod, 0});
setpoints.append({RegisterOutWRte, 100});
break;
case BatteryModeNoDischarge:
setpoints.append({RegisterStorCtlMod, 2});
setpoints.append({RegisterOutWRte, 0});
break;
case BatteryModeNoCharge:
setpoints.append({RegisterStorCtlMod, 1});
setpoints.append({RegisterInWRte, 0});
break;
case BatteryModeFrozen:
setpoints.append({RegisterStorCtlMod, 3});
setpoints.append({RegisterInWRte, 0});
setpoints.append({RegisterOutWRte, 0});
break;
case BatteryModeForceCharge:
/* Charging from the grid has to be allowed on the inverter as well;
* ChaGriSet alone is ANDed with a setting of the component
* configuration and fails silently if that one is off. */
setpoints.append({RegisterChaGriSet, 1});
setpoints.append({RegisterStorCtlMod, 2});
/* Negative is charging. */
setpoints.append({RegisterOutWRte, -chargeLimit});
break;
case BatteryModeLimit:
setpoints.append({RegisterStorCtlMod, 3});
setpoints.append({RegisterInWRte, chargeLimit});
setpoints.append({RegisterOutWRte, dischargeLimit});
break;
}
return planSetpoint(setpoints, revertTime);
}
BlockRole classifyBlock(const QString &inputIdString)
{
const QString id = inputIdString.trimmed();
if (id.isEmpty())
return BlockRoleUnknown;
/* Prefix match, not contains: the role is decided by the string, never by
* the block index. StDisCha is checked first so that no cascade can ever
* swallow it into the charge case. */
if (id.startsWith(QStringLiteral("StDisCha"), Qt::CaseInsensitive))
return BlockRoleBatteryDischarge;
if (id.startsWith(QStringLiteral("StCha"), Qt::CaseInsensitive))
return BlockRoleBatteryCharge;
if (id.startsWith(QStringLiteral("MPPT"), Qt::CaseInsensitive))
return BlockRolePvString;
return BlockRoleUnknown;
}
double pvPower(const QList<ModuleReading> &readings)
{
double total = 0;
foreach (const ModuleReading &reading, readings) {
if (classifyBlock(reading.inputIdString) == BlockRolePvString)
total += reading.dcPower;
}
return total;
}
double batteryPower(const QList<ModuleReading> &readings)
{
double charging = 0;
double discharging = 0;
foreach (const ModuleReading &reading, readings) {
switch (classifyBlock(reading.inputIdString)) {
case BlockRoleBatteryCharge:
charging += reading.dcPower;
break;
case BlockRoleBatteryDischarge:
discharging += reading.dcPower;
break;
default:
break;
}
}
return discharging - charging;
}
}

214
sunspec/froniusrules.h Normal file
View File

@ -0,0 +1,214 @@
// SPDX-License-Identifier: GPL-3.0-or-later
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
*
* Copyright (C) 2026, ETM-PowerSync
*
* This file is part of nymea.
*
* nymea is free software: you can redistribute it and/or modify it under the
* terms of the GNU General Public License as published by the Free Software
* Foundation, either version 3 of the License, or (at your option) any later
* version.
*
* nymea is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with
* nymea. If not, see <https://www.gnu.org/licenses/>.
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
#ifndef FRONIUSRULES_H
#define FRONIUSRULES_H
#include <QList>
#include <QString>
#include <QStringList>
#include <sunspecdatapoint.h>
#include "sunspecstoragelogic.h"
/* Vendor specific rules for Fronius GEN24 inverters.
*
* Everything here is a pure decision: no Modbus, no thing, no timer. The
* plugin asks these functions what to do; the answers are what the unit tests
* pin down. See specs/fronius-sunspec.md.
*/
namespace FroniusRules {
/* Polling. The manufacturer's support recommends staying above seven seconds
* for third party applications. Below that, communication load can push the
* inverter offline towards the manufacturer's portal, and recovery needs an
* AC+DC power cycle on site. This floor is a guard in the code, not a
* configurable default. */
const int MinimumUpdateIntervalSeconds = 7;
const int DefaultUpdateIntervalSeconds = 10;
const int DefaultTimeoutMilliSeconds = 2000;
const int DefaultNumberOfRetries = 3;
/* Revert timer armed with every setpoint, so that a setpoint left behind by a
* stopped or crashed HEMS expires instead of holding the battery forever.
*
* WORKING HYPOTHESIS: the timer semantics are documented in the manual's
* general chapter and illustrated on model 123, not on model 124. This must be
* verified experimentally on model 124 before being frozen. If it does not
* hold, fall back to 0 plus an application level watchdog in the HEMS. */
const int DefaultRevertTimeSeconds = 300;
/* Default unit IDs. The inverter is fixed at 1 over TCP; the meter defaults to
* 200, with 201, 202... for secondary meters. 0 means "no meter on this
* connection". */
const int InverterSlaveId = 1;
const int DefaultMeterSlaveId = 200;
const int MaximumSlaveId = 247;
/* Whether a second SunSpecConnection should be opened for the Smart Meter, and
* why not when it should not. The caller logs the reason; keeping it out of
* this function is what makes the decision testable without nymea. */
enum MeterConnectionDecision {
MeterConnectionCreate,
/* 0 is the documented way of saying "no meter behind this inverter". */
MeterConnectionDisabled,
/* A second socket on the unit ID already served by the inverter would
* rediscover the very same models and duplicate every child thing. */
MeterConnectionCollidesWithInverter,
MeterConnectionInvalidSlaveId
};
MeterConnectionDecision decideMeterConnection(int meterSlaveId, int inverterSlaveId);
bool matchesManufacturer(const QStringList &manufacturers);
/* Sign to apply to the raw SunSpec meter power registers (W, WphA...) to get
* the nymea smartmeter convention, where positive is import and negative is
* export.
*
* The Fronius Smart Meter already reports in that convention: negative W is
* feed-in. The generic path negates, so on a Fronius the surplus came out with
* the wrong sign and the arbiter refused to run loads while the site was
* exporting.
*
* Established on hardware, not from the manufacturer's documentation. On a
* Primo GEN24 3.0, a *single phase* inverter feeding phase A only, the plugin
* reported phase A at +1003 W (import) while phases B and C read -33 W and
* -262 W (export). Exporting on two phases carrying no source is impossible.
* With the sign flipped the balance closes exactly: production 1190 W = phase A
* load 187 W + phase A export 1003 W, B and C import 294 W, so the house draws
* 481 W and the site exports 709 W against 708 W measured. The meter's own
* energy counters agree: over the same window its import counter stayed at
* zero while its export counter advanced.
*
* Only the instantaneous power is affected. The energy counters map correctly
* already and must not be swapped.
*
* The generic default is left untouched: other vendors keep the negation. */
double meterPowerSign(bool froniusMeter);
/* Whether a model 124 block is backed by a physically installed battery.
*
* A hybrid GEN24 publishes model 124 whether or not a battery is wired to it,
* so the presence of the model proves nothing. Creating a storage child anyway
* gives the customer a phantom battery and a permanent batteryCritical alarm:
* observed on hardware, a GEN24 with no battery reported ChaSt = Off and a
* state of charge of 0, and nymea's own energy engine then logged "There is no
* energy storage available to consider" while the child stayed visible.
*
* The discriminator is deliberately a *physical* quantity. The state of charge
* is refused because 0% cannot be told apart from a real but empty battery, and
* the setpoint registers (WChaMax and friends) are refused because they keep
* their defaults with no battery attached.
*
* INFÉRÉ: an installed battery carries a DC voltage at rest, so InBatV or
* StorAval is expected to be strictly positive whenever one is present, and
* both zero when none is. The absent side matches what the battery-less GEN24
* showed and is reproduced on the simulator. The present side could NOT be
* confirmed: no GEN24 with a battery was reachable. Confirm InBatV/StorAval on
* a GEN24 with a battery before this ships. See DIVERGENCES.md. */
bool hasBattery(double batteryVoltage, double availableCharge);
/* 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);
/* Registers the GEN24 accepts and then ignores without raising a Modbus
* exception. Writing them fails invisibly, so they are refused before being
* put on the wire. */
bool isWriteForbidden(const QString &registerName);
enum BatteryMode {
BatteryModeNormal,
BatteryModeNoDischarge,
BatteryModeNoCharge,
BatteryModeFrozen,
BatteryModeForceCharge,
BatteryModeLimit
};
/* Sign convention of the manufacturer: negative is charging, positive is
* discharging. chargeLimit and dischargeLimit are percentages of WChaMax and
* are only used by the modes that need them. */
SunSpecStorageLogic::SetpointPlan planBatteryMode(BatteryMode mode,
double chargeLimit,
double dischargeLimit,
quint16 revertTime);
/* Model 160 repeating blocks are identified by their IDStr, never by their
* index: on a three or four MPPT inverter the battery blocks are not in
* position 3 and 4. */
enum BlockRole {
BlockRolePvString,
BlockRoleBatteryCharge,
BlockRoleBatteryDischarge,
BlockRoleUnknown
};
BlockRole classifyBlock(const QString &inputIdString);
struct ModuleReading {
QString inputIdString;
double dcPower;
};
/* Sum of the DC power of the PV string blocks. */
double pvPower(const QList<ModuleReading> &readings);
/* Discharge minus charge, so positive means discharging. DCW is a uint16 and
* therefore always positive: the sign is carried by whichever block is active,
* the inactive one reading zero. */
double batteryPower(const QList<ModuleReading> &readings);
}
#endif // FRONIUSRULES_H

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,211 @@
// SPDX-License-Identifier: GPL-3.0-or-later
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
*
* Copyright (C) 2013 - 2024, nymea GmbH
* Copyright (C) 2024 - 2025, chargebyte austria GmbH
*
* This file is part of nymea-plugins-modbus.
*
* nymea-plugins-modbus is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* nymea-plugins-modbus is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with nymea-plugins-modbus. If not, see <https://www.gnu.org/licenses/>.
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
#ifndef INTEGRATIONPLUGINSUNSPEC_H
#define INTEGRATIONPLUGINSUNSPEC_H
#include <integrations/integrationplugin.h>
#include <network/networkdevicemonitor.h>
#include <plugintimer.h>
#include <sunspecconnection.h>
#include <models/sunspecmodelfactory.h>
#include "sunspecthing.h"
#include "sunspecstoragelogic.h"
#include "froniusrules.h"
#include "extern-plugininfo.h"
#include <QUuid>
class IntegrationPluginSunSpec: public IntegrationPlugin
{
Q_OBJECT
Q_PLUGIN_METADATA(IID "io.nymea.IntegrationPlugin" FILE "integrationpluginsunspec.json")
Q_INTERFACES(IntegrationPlugin)
public:
explicit IntegrationPluginSunSpec();
void init() override;
void discoverThings(ThingDiscoveryInfo *info) override;
void setupThing(ThingSetupInfo *info) override;
void postSetupThing(Thing *thing) override;
void thingRemoved(Thing *thing) override;
void executeAction(ThingActionInfo *info) override;
#ifdef SUNSPEC_PLUGIN_TEST_ACCESS
/* Test only, compiled in by sunspec/tests/ and by nothing else.
*
* Whether a thing ended up with one connection or two is not observable
* from the outside: both connections sit behind the same network monitor
* and the same host and port. Without this, the multi unit ID behaviour
* could only be checked against a real device. */
SunSpecConnection *testPrimaryConnection(const ThingId &parentId) const { return m_sunSpecConnections.value(parentId); }
SunSpecConnection *testMeterConnection(const ThingId &parentId) const { return m_meterConnections.value(parentId); }
#endif
private:
// SunSpec Connection params map
QHash<ThingClassId, ParamTypeId> m_connectionDiscoverySlaveIdParamTypeIds;
QHash<ThingClassId, ParamTypeId> m_connectionMacAddressParamTypeIds;
QHash<ThingClassId, ParamTypeId> m_connectionAddressParamTypeIds;
QHash<ThingClassId, ParamTypeId> m_connectionHostNameParamTypeIds;
QHash<ThingClassId, ParamTypeId> m_connectionPortParamTypeIds;
QHash<ThingClassId, ParamTypeId> m_connectionSlaveIdParamTypeIds;
// SunSpec thing params map
QHash<ThingClassId, ParamTypeId> m_modelIdParamTypeIds;
QHash<ThingClassId, ParamTypeId> m_modbusAddressParamTypeIds;
QHash<ThingClassId, ParamTypeId> m_manufacturerParamTypeIds;
QHash<ThingClassId, ParamTypeId> m_deviceModelParamTypeIds;
QHash<ThingClassId, ParamTypeId> m_serialNumberParamTypeIds;
PluginTimer *m_refreshTimer = nullptr;
QHash<Thing *, NetworkDeviceMonitor *> m_monitors;
/* The primary connection of a thing: the one that carries its identity, its
* connected state and its timeout and retry settings. Every vendor has
* exactly one. */
QHash<ThingId, SunSpecConnection *> m_sunSpecConnections;
/* Second, optional connection towards the Smart Meter of a Fronius GEN24,
* on the same host and port as the primary one but on another unit ID.
* Indexed on the same parent ThingId.
*
* Kept apart from m_sunSpecConnections rather than turning that one into a
* hash of lists: this way not a single call site of the generic and
* solarEdge vendors changes, including the reverse lookup
* m_sunSpecConnections.key() the SolarEdge battery search relies on. */
QHash<ThingId, SunSpecConnection *> m_meterConnections;
QHash<Thing *, SunSpecThing *> m_sunSpecThings;
QHash<Thing *, SunSpecModel *> m_sunSpecInverters;
QHash<Thing *, SunSpecModel *> m_sunSpecMeters;
QHash<Thing *, SunSpecModel *> m_sunSpecStorages;
/* Connections having completed at least one successful discovery. The
* orphan sweep needs to know whether it is looking at the whole picture or
* at half of it. Entries are removed when the connection is destroyed. */
QSet<SunSpecConnection *> m_discoveredConnections;
Thing *getThingForSunSpecModel(uint modelId, uint modbusAddress, const ThingId &parentId);
bool sunspecThingAlreadyAdded(uint modelId, uint modbusAddress, const ThingId &parentId);
void processDiscoveryResult(Thing *thing, SunSpecConnection *connection);
/* Whether the children of a parent may be confronted with the models found
* so far. False while a connection of that parent has not reported yet, so
* that one connection does not sweep away the children of the other.
*
* INFERRED: no measurement backs this grace period. See D-2 in
* DIVERGENCES.md for how to replace it with one. */
bool orphanSweepAllowed(Thing *thing);
QHash<ThingId, QDateTime> m_orphanSweepDeadlines;
// SunSpec things
SunSpecConnection *createConnection(Thing *thing, uint slaveId, bool isPrimary);
/* Every connection of a parent thing, primary first. One entry for the
* generic and solarEdge vendors, one or two for Fronius. */
QList<SunSpecConnection *> connectionsFor(const ThingId &parentId) const;
QList<SunSpecConnection *> allConnections() const;
/* The connection a child thing reads its data from, or null while it is not
* bound to a model yet. Resolved from the model the child is bound to
* rather than from a persisted unit ID: a child stores only its model ID
* and start register, and the two connections of a Fronius thing never
* announce the same model. */
SunSpecConnection *connectionForChild(Thing *child) const;
/* The model a child thing is described by, looked up across every
* connection of its parent. */
SunSpecModel *findModelFor(const ThingId &parentId, uint modelId, int modbusStartRegister) const;
/* Children of a parent that read from one given connection. A child not
* bound to any model yet follows the primary connection, which is what
* makes the parent reachable at all. */
QList<Thing *> childThingsOn(Thing *parent, SunSpecConnection *connection) const;
// Custom types
void setupSolarEdgeBattery(ThingSetupInfo *info);
// Discover SolarEdge batteries
void searchSolarEdgeBatteries(SunSpecConnection *connection);
void searchSolarEdgeBattery(SunSpecConnection *connection, const ThingId &parentThingId, quint16 startRegister);
// SolarEdge
double calculateSolarEdgePvProduction(Thing *thing, double acPower, double dcPower);
void autocreateSunSpecModelThing(const ThingClassId &thingClassId, const QString &thingName, const ThingId &parentId, SunSpecModel *model);
QString getInverterStateString(quint16 state);
QString getInverterErrorString(quint32 flag);
double fixValueSign(double targetValue, double powerValue);
bool hasManufacturer(const QStringList &manufacturers, const QString &manufacturer);
void markThingStatesDisconnected(Thing *thing);
// Fronius
bool isFroniusChild(const ThingId &parentId);
int effectiveUpdateInterval();
/* Creates the Smart Meter connection of a Fronius thing, or returns null
* and says why. The object is created here but left idle: it is connected
* only once the primary connection is done discovering, so that the two
* never handshake at the same time towards the same inverter. */
SunSpecConnection *createMeterConnection(Thing *thing);
void startMeterConnection(Thing *thing);
/* Destroys every connection of a thing. A single entry point, so that the
* meter connection cannot be forgotten on the paths that used to have only
* one connection to dispose of. */
void destroyConnections(const ThingId &thingId);
// Storage setpoints
void executeSetpointPlan(ThingActionInfo *info, SunSpecStorageModel *storage, const SunSpecStorageLogic::SetpointPlan &plan);
void executeSetpointWrite(ThingActionInfo *info, SunSpecStorageModel *storage, const SunSpecStorageLogic::SetpointPlan &plan, int index);
QModbusReply *writeSetpointRegister(SunSpecStorageModel *storage, const SunSpecStorageLogic::Write &write);
double readSetpointRegister(SunSpecStorageModel *storage, SunSpecStorageLogic::Register reg);
void refreshStorageBlock(ThingActionInfo *info, SunSpecStorageModel *storage, const std::function<void()> &continuation);
void verifySetpointPlan(ThingActionInfo *info, SunSpecStorageModel *storage, const SunSpecStorageLogic::SetpointPlan &plan);
private slots:
void onRefreshTimer();
void onPluginConfigurationChanged(const ParamTypeId &paramTypeId, const QVariant &value);
void onInverterBlockUpdated();
void onMeterBlockUpdated();
void onStorageBlockUpdated();
void onSolarEdgeBatteryBlockUpdated();
void evaluateEnergyProducedValue(Thing *inverterThing, float energyProduced);
};
#endif // INTEGRATIONPLUGINSUNSPEC_H

File diff suppressed because it is too large Load Diff

14
sunspec/meta.json Normal file
View File

@ -0,0 +1,14 @@
{
"title": "SunSpec",
"tagline": "Connect to SunSpec devices.",
"icon": "sunspec.png",
"stability": "consumer",
"offline": true,
"technologies": [
"network",
"modbus"
],
"categories": [
"energy"
]
}

View File

@ -0,0 +1,253 @@
// SPDX-License-Identifier: GPL-3.0-or-later
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
*
* Copyright (C) 2013 - 2024, nymea GmbH
* Copyright (C) 2024 - 2025, chargebyte austria GmbH
*
* This file is part of nymea-plugins-modbus.
*
* nymea-plugins-modbus is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* nymea-plugins-modbus is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with nymea-plugins-modbus. If not, see <https://www.gnu.org/licenses/>.
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
#include "solaredgebattery.h"
#include "extern-plugininfo.h"
#include <sunspecdatapoint.h>
#include <sunspecconnection.h>
SolarEdgeBattery::SolarEdgeBattery(Thing *thing, SunSpecConnection *connection, int modbusStartRegister, QObject *parent) :
SunSpecThing(thing, nullptr, parent),
m_connection(connection),
m_modbusStartRegister(modbusStartRegister)
{
m_timer.setSingleShot(true);
m_timer.setInterval(10000);
connect(&m_timer, &QTimer::timeout, this, [this](){
if (!m_initFinishedSuccess) {
emit initFinished(false);
}
});
}
SunSpecConnection *SolarEdgeBattery::connection() const
{
return m_connection;
}
quint16 SolarEdgeBattery::modbusStartRegister() const
{
return m_modbusStartRegister;
}
SolarEdgeBattery::BatteryData SolarEdgeBattery::batteryData() const
{
return m_batteryData;
}
void SolarEdgeBattery::init()
{
qCDebug(dcSunSpec()) << "Initializing battery on" << m_modbusStartRegister;
m_initFinishedSuccess = false;
readBlockData();
m_timer.start();
}
void SolarEdgeBattery::readBlockData()
{
// Read the data in 2 block requests
qCDebug(dcSunSpec()) << "SolarEdgeBattery: Read block 1 from modbus address" << m_modbusStartRegister << "length" << 107<< ", Slave ID" << m_connection->slaveId();
// Total possible block size is 0xE19A - 0xE100 = 0x9A = 153 registers
// First block request 0x00 - 0x4C
QModbusDataUnit request = QModbusDataUnit(QModbusDataUnit::RegisterType::HoldingRegisters, m_modbusStartRegister, 0x4C);
if (QModbusReply *reply = m_connection->modbusTcpClient()->sendReadRequest(request, m_connection->slaveId())) {
if (!reply->isFinished()) {
connect(reply, &QModbusReply::finished, reply, &QModbusReply::deleteLater);
connect(reply, &QModbusReply::finished, this, [=]() {
if (reply->error() != QModbusDevice::NoError) {
qCWarning(dcSunSpec()) << "SolarEdgeBattery: Read response error:" << reply->error();
if (!m_initFinishedSuccess) {
m_timer.stop();
emit initFinished(false);
}
return;
}
// Example data:
// "(0x3438, 0x565f, 0x4c47, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x4c47, 0x4320, 0x5245, 0x5355, 0x2031, 0x3000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x00ff, 0x0000, 0xffff, 0xff7f, 0xffff, 0xff7f, 0xffff, 0xff7f, 0xffff, 0xff7f, 0xffff, 0xff7f)"
// 255 "48V_LG" "LGC RESU 10" "" ""
// "(0x3438, 0x565f, 0x4c47, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x4c47, 0x4320, 0x5245, 0x5355, 0x2031, 0x3000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x3438, 0x5620, 0x4443, 0x4443, 0x2032, 0x2e32, 0x2e39, 0x3120, 0x424d, 0x5320, 0x302e, 0x302e, 0x3000, 0x0000, 0x0000, 0x0000, 0x3745, 0x3034, 0x3432, 0x4543, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0070, 0x0000, 0x2000, 0x4619, 0x4000, 0x459c, 0x4000, 0x459c, 0x4000, 0x44ce, 0x4000, 0x459c)"
// 112 "48V_LG" "LGC RESU 10" "48V DCDC 2.2.91 BMS 0.0.0" "7E0442EC"
const QModbusDataUnit unit = reply->result();
QVector<quint16> values = unit.values();
qCDebug(dcSunSpec()) << "SolarEdgeBattery: Received first block data" << m_modbusStartRegister << values.length();
qCDebug(dcSunSpec()) << "SolarEdgeBattery:" << SunSpecDataPoint::registersToString(values);
m_batteryData.manufacturerName = SunSpecDataPoint::convertToString(values.mid(ManufacturerName, 16));
m_batteryData.model = SunSpecDataPoint::convertToString(values.mid(Model, 16));
m_batteryData.firmwareVersion = SunSpecDataPoint::convertToString(values.mid(FirmwareVersion, 16));
m_batteryData.serialNumber = SunSpecDataPoint::convertToString(values.mid(SerialNumber, 16));
m_batteryData.batteryDeviceId = values[BatteryDeviceId];
qCDebug(dcSunSpec()) << "SolarEdgeBattery:" << m_batteryData.batteryDeviceId << m_batteryData.manufacturerName << m_batteryData.model << m_batteryData.firmwareVersion << m_batteryData.serialNumber;
// Check if there is a battery connected, if so, one of the string must contain vaild data...
if (m_batteryData.manufacturerName.isEmpty() && m_batteryData.model.isEmpty() && m_batteryData.serialNumber.isEmpty() && m_batteryData.firmwareVersion.isEmpty()) {
qCWarning(dcSunSpec()) << "SolarEdgeBattery: No valid information detected about the battery. Probably no battery connected at register" << m_modbusStartRegister;
if (!m_initFinishedSuccess) {
m_timer.stop();
emit initFinished(false);
}
return;
}
// For some reason, there might be even data in there but no battery connected, let's check if there are invalid registers
// Check if there is a battery connected, if so, one of the string must contain vaild data...
const QVector<quint16> invalidRegisters = { 0xffff, 0xff7f };
if (values.mid(RatedEnergy, 2) == invalidRegisters && values.mid(MaxChargeContinuesPower, 2) == invalidRegisters &&
values.mid(MaxDischargeContinuesPower, 2) == invalidRegisters && values.mid(MaxChargePeakPower, 2) == invalidRegisters &&
values.mid(MaxDischargePeakPower, 2) == invalidRegisters) {
qCWarning(dcSunSpec()) << "SolarEdgeBattery: No valid information detected about the battery. Probably no battery connected at register" << m_modbusStartRegister;
if (!m_initFinishedSuccess) {
m_timer.stop();
emit initFinished(false);
}
return;
}
m_batteryData.ratedEnergy = SunSpecDataPoint::convertToFloat32(values.mid(RatedEnergy, 2));
m_batteryData.maxChargeContinuesPower = SunSpecDataPoint::convertToFloat32(values.mid(MaxChargeContinuesPower, 2));
m_batteryData.maxDischargeContinuesPower = SunSpecDataPoint::convertToFloat32(values.mid(MaxDischargeContinuesPower, 2));
m_batteryData.maxChargePeakPower = SunSpecDataPoint::convertToFloat32(values.mid(MaxChargePeakPower, 2));
m_batteryData.maxDischargePeakPower = SunSpecDataPoint::convertToFloat32(values.mid(MaxDischargePeakPower, 2));
// First block looks good, continue with second block
// 8192 17945 536888857 536888857 1.08652e-19
// 0x2000 0x4619
// Read from 0x6c to 0x86
int offset = 0x6c;
QModbusDataUnit request = QModbusDataUnit(QModbusDataUnit::RegisterType::HoldingRegisters, m_modbusStartRegister + offset, 28);
if (QModbusReply *reply = m_connection->modbusTcpClient()->sendReadRequest(request, m_connection->slaveId())) {
if (!reply->isFinished()) {
connect(reply, &QModbusReply::finished, reply, &QModbusReply::deleteLater);
connect(reply, &QModbusReply::finished, this, [=]() {
if (reply->error() != QModbusDevice::NoError) {
qCWarning(dcSunSpec()) << "SolarEdgeBattery: Read response error:" << reply->error();
if (!m_initFinishedSuccess) {
m_timer.stop();
emit initFinished(false);
}
return;
}
const QModbusDataUnit unit = reply->result();
QVector<quint16> values = unit.values();
qCDebug(dcSunSpec()) << "SolarEdgeBattery: Received second block data" << m_modbusStartRegister + offset << values.length();
qCDebug(dcSunSpec()) << "SolarEdgeBattery:" << SunSpecDataPoint::registersToString(values);
QVector<quint16> valueRegisters;
valueRegisters = values.mid(BatteryAverageTemperature - offset, 2);
m_batteryData.averageTemperature = SunSpecDataPoint::convertToFloat32(valueRegisters);
qCDebug(dcSunSpec()) << "SolarEdgeBattery: Average temperature:" << SunSpecDataPoint::registersToString(valueRegisters) << m_batteryData.averageTemperature;
m_batteryData.maxTemperature = SunSpecDataPoint::convertToFloat32(values.mid(BatteryMaxTemperature - offset, 2));
m_batteryData.instantaneousVoltage = SunSpecDataPoint::convertToFloat32(values.mid(InstantaneousVoltage - offset, 2));
m_batteryData.instantaneousCurrent = SunSpecDataPoint::convertToFloat32(values.mid(InstantaneousCurrent - offset, 2));
m_batteryData.instantaneousPower = SunSpecDataPoint::convertToFloat32(values.mid(InstantaneousPower - offset, 2));
m_batteryData.maxEnergy = SunSpecDataPoint::convertToFloat32(values.mid(MaxEnergy - offset, 2));
valueRegisters = values.mid(AvailableEnergy - offset, 2);
m_batteryData.availableEnergy = SunSpecDataPoint::convertToFloat32(valueRegisters);
qCDebug(dcSunSpec()) << "SolarEdgeBattery: Available energy:" << (AvailableEnergy - offset) << SunSpecDataPoint::registersToString(valueRegisters) << m_batteryData.availableEnergy;
m_batteryData.stateOfHealth = SunSpecDataPoint::convertToFloat32(values.mid(StateOfHealth - offset, 2));
m_batteryData.stateOfEnergy = SunSpecDataPoint::convertToFloat32(values.mid(StateOfEnergy - offset, 2));
m_batteryData.batteryStatus = static_cast<BatteryStatus>(SunSpecDataPoint::convertToUInt32(values.mid(Status - offset, 2)));
if (!m_initFinishedSuccess) {
m_timer.stop();
m_initFinishedSuccess = true;
emit initFinished(true);
}
emit blockDataUpdated();
});
connect(reply, &QModbusReply::errorOccurred, this, [] (QModbusDevice::Error error) {
qCWarning(dcSunSpec()) << "SolarEdgeBattery: Modbus reply error:" << error;
});
} else {
qCWarning(dcSunSpec()) << "SolarEdgeBattery: Read error: " << m_connection->modbusTcpClient()->errorString();
reply->deleteLater(); // broadcast replies return immediately
if (!m_initFinishedSuccess) {
m_timer.stop();
emit initFinished(false);
}
return;
}
} else {
qCWarning(dcSunSpec()) << "SolarEdgeBattery: Read error: " << m_connection->modbusTcpClient()->errorString();
return;
}
});
connect(reply, &QModbusReply::errorOccurred, this, [] (QModbusDevice::Error error) {
qCWarning(dcSunSpec()) << "SolarEdgeBattery: Modbus reply error:" << error;
});
} else {
qCWarning(dcSunSpec()) << "SolarEdgeBattery: Read error: " << m_connection->modbusTcpClient()->errorString();
reply->deleteLater(); // broadcast replies return immediately
if (!m_initFinishedSuccess) {
m_timer.stop();
emit initFinished(false);
}
return;
}
} else {
qCWarning(dcSunSpec()) << "SolarEdgeBattery: Read error: " << m_connection->modbusTcpClient()->errorString();
return;
}
}
QDebug operator<<(QDebug debug, const SolarEdgeBattery::BatteryData &batteryData)
{
debug << "SolarEdgeBatteryData(" << batteryData.manufacturerName << "-" << batteryData.model << ")" << "\n";
debug << " - Battery Device ID" << batteryData.batteryDeviceId << "\n";
debug << " - Firmware version" << batteryData.firmwareVersion << "\n";
debug << " - Serial number" << batteryData.serialNumber << "\n";
debug << " - Rated Energy" << batteryData.ratedEnergy << "W * H" << "\n";
debug << " - Max charging continues power" << batteryData.maxChargeContinuesPower << "W" << "\n";
debug << " - Max discharging continues power" << batteryData.maxDischargeContinuesPower << "W" << "\n";
debug << " - Max charging peak power" << batteryData.maxChargePeakPower << "W" << "\n";
debug << " - Max discharging peak power" << batteryData.maxDischargePeakPower << "W" << "\n";
debug << " - Average temperature" << batteryData.averageTemperature << "°C" << "\n";
debug << " - Max temperature" << batteryData.maxTemperature << "°C" << "\n";
debug << " - Instantuouse Voltage" << batteryData.instantaneousVoltage << "V" << "\n";
debug << " - Instantuouse Current" << batteryData.instantaneousCurrent << "A" << "\n";
debug << " - Instantuouse Power" << batteryData.instantaneousPower << "W" << "\n";
debug << " - Max energy" << batteryData.maxEnergy << "W * H" << "\n";
debug << " - Available energy" << batteryData.availableEnergy << "W * H" << "\n";
debug << " - State of health" << batteryData.stateOfHealth << "%" << "\n";
debug << " - State of energy" << batteryData.stateOfEnergy << "%" << "\n";
debug << " - Battery status" << batteryData.batteryStatus << "\n";
return debug;
}

131
sunspec/solaredgebattery.h Normal file
View File

@ -0,0 +1,131 @@
// SPDX-License-Identifier: GPL-3.0-or-later
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
*
* Copyright (C) 2013 - 2024, nymea GmbH
* Copyright (C) 2024 - 2025, chargebyte austria GmbH
*
* This file is part of nymea-plugins-modbus.
*
* nymea-plugins-modbus is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* nymea-plugins-modbus is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with nymea-plugins-modbus. If not, see <https://www.gnu.org/licenses/>.
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
#ifndef SOLAREDGEBATTERY_H
#define SOLAREDGEBATTERY_H
#include <QObject>
#include <QDebug>
#include <QTimer>
#include "sunspecthing.h"
/* Note: this is actually not a sunspec thing, since SolarEdge provide battery information using a non SunSpec modbus register section.
* For keeping the code as simple as possible we make it a SunSpec thing */
class SolarEdgeBattery : public SunSpecThing
{
Q_OBJECT
public:
enum BatteryRegister {
ManufacturerName = 0x00,
Model = 0x10,
FirmwareVersion = 0x20,
SerialNumber = 0x30,
BatteryDeviceId = 0x40,
RatedEnergy = 0x42, // W * H
MaxChargeContinuesPower = 0x44, // W
MaxDischargeContinuesPower = 0x46, // W
MaxChargePeakPower = 0x48, // W
MaxDischargePeakPower = 0x4A, // W
// 32 byte reserved
BatteryAverageTemperature = 0x6c, // °C
BatteryMaxTemperature = 0x6E,
InstantaneousVoltage = 0x70, // V
InstantaneousCurrent = 0x72, // A
InstantaneousPower = 0x74, // W
LifetimeExportEnergyCounter = 0x76, // W * H
LifetimeImportEnergyCounter = 0x7A, // W * H
MaxEnergy = 0x7E, // W * H
AvailableEnergy = 0x80, // W * H
StateOfHealth = 0x82,
StateOfEnergy = 0x84,
Status = 0x86
};
Q_ENUM(BatteryRegister)
enum BatteryStatus {
Off = 0,
Standby = 1,
Init = 2,
Charge = 3,
Discharge = 4,
Fault = 5,
Holding = 6,
Idle = 7
};
Q_ENUM(BatteryStatus)
struct BatteryData {
QString manufacturerName;
QString model;
QString firmwareVersion;
QString serialNumber;
quint16 batteryDeviceId;
float ratedEnergy;
float maxChargeContinuesPower;
float maxDischargeContinuesPower;
float maxChargePeakPower;
float maxDischargePeakPower;
float averageTemperature;
float maxTemperature;
float instantaneousVoltage;
float instantaneousCurrent;
float instantaneousPower;
quint64 lifetimeExportEnergyCounter;
quint64 lifetimeImportEnergyCounter;
float maxEnergy;
float availableEnergy;
float stateOfHealth;
float stateOfEnergy;
BatteryStatus batteryStatus;
};
explicit SolarEdgeBattery(Thing *thing, SunSpecConnection *connection, int modbusStartRegister, QObject *parent = nullptr);
SunSpecConnection *connection() const override;
quint16 modbusStartRegister() const override;
BatteryData batteryData() const;
void init();
void readBlockData() override;
signals:
void initFinished(bool success);
void blockDataUpdated();
private:
QTimer m_timer;
SunSpecConnection *m_connection = nullptr;
int m_modbusStartRegister;
bool m_initFinishedSuccess = false;
BatteryData m_batteryData;
};
QDebug operator<<(QDebug debug, const SolarEdgeBattery::BatteryData &batteryData);
#endif // SOLAREDGEBATTERY_H

BIN
sunspec/sunspec.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

18
sunspec/sunspec.pro Normal file
View File

@ -0,0 +1,18 @@
include(../plugins.pri)
include(../sunspec.pri)
SOURCES += \
froniusrules.cpp \
integrationpluginsunspec.cpp \
solaredgebattery.cpp \
sunspecdiscovery.cpp \
sunspecstoragelogic.cpp \
sunspecthing.cpp
HEADERS += \
froniusrules.h \
integrationpluginsunspec.h \
solaredgebattery.h \
sunspecdiscovery.h \
sunspecstoragelogic.h \
sunspecthing.h

View File

@ -0,0 +1,235 @@
// SPDX-License-Identifier: GPL-3.0-or-later
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
*
* Copyright (C) 2013 - 2024, nymea GmbH
* Copyright (C) 2024 - 2025, chargebyte austria GmbH
*
* This file is part of nymea-plugins-modbus.
*
* nymea-plugins-modbus is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* nymea-plugins-modbus is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with nymea-plugins-modbus. If not, see <https://www.gnu.org/licenses/>.
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
#include "sunspecdiscovery.h"
#include <sunspecmodel.h>
#include <models/sunspecmodelfactory.h>
#include <models/sunspeccommonmodel.h>
SunSpecDiscovery::SunSpecDiscovery(NetworkDeviceDiscovery *networkDeviceDiscovery, quint16 slaveId, SunSpecDataPoint::ByteOrder byteOrder, QObject *parent)
: QObject{parent},
m_networkDeviceDiscovery{networkDeviceDiscovery},
m_slaveId{slaveId},
m_byteOrder{byteOrder}
{
m_scanPorts.append(502);
m_scanPorts.append(1502);
}
QList<SunSpecDiscovery::Result> SunSpecDiscovery::results() const
{
return m_results;
}
void SunSpecDiscovery::addCustomDiscoveryPort(quint16 port)
{
if (m_scanPorts.contains(port))
return;
m_scanPorts.append(port);
}
void SunSpecDiscovery::startDiscovery()
{
qCInfo(dcSunSpec()) << "Discovery: Start searching for SunSpec devices in the network...";
NetworkDeviceDiscoveryReply *discoveryReply = m_networkDeviceDiscovery->discover();
m_startDateTime = QDateTime::currentDateTime();
// Imedialty check any new device gets discovered
connect(discoveryReply, &NetworkDeviceDiscoveryReply::hostAddressDiscovered, this, &SunSpecDiscovery::checkNetworkDevice);
// Check what might be left on finished
connect(discoveryReply, &NetworkDeviceDiscoveryReply::finished, discoveryReply, &NetworkDeviceDiscoveryReply::deleteLater);
connect(discoveryReply, &NetworkDeviceDiscoveryReply::finished, this, [this, discoveryReply](){
qCDebug(dcSunSpec()) << "Discovery: Network discovery finished. Give some time for pending discovery checks to finish...";
m_networkDeviceInfos = discoveryReply->networkDeviceInfos();
// Give the last connections added right before the network discovery finished a chance to check the device...
QTimer::singleShot(3000, this, [this](){
finishDiscovery();
});
});
}
void SunSpecDiscovery::testNextConnection(const QHostAddress &address)
{
if (!m_pendingConnectionAttempts.contains(address))
return;
SunSpecConnection *connection = m_pendingConnectionAttempts[address].dequeue();
if (m_pendingConnectionAttempts.value(address).isEmpty())
m_pendingConnectionAttempts.remove(address);
qCDebug(dcSunSpec()) << "Discovery: Start searching on" << QString("%1:%2").arg(address.toString()).arg(connection->port()) << "slave ID:" << connection->slaveId();
// Try to connect, maybe it works, maybe not...
if (!connection->connectDevice()) {
qCDebug(dcSunSpec()) << "Discovery: Failed to connect to" << QString("%1:%2").arg(address.toString()).arg(connection->port()) << "slave ID:" << connection->slaveId() << "Continue...";;
cleanupConnection(connection);
return;
}
/* Some connection will block the connection process and the connection will pending in the
* connecting state for over a minute before failing.
* This behavior blocks further connection tests for during the disdovery process and the discovery failes to detect the device.
* This timer will make sure the connection can be established within 5 seconds, otherwise we continue with the next connection.
*/
QTimer *connectionTimer = new QTimer(connection);
connectionTimer->setInterval(5000);
connectionTimer->setSingleShot(true);
m_connectionTimers.insert(connection, connectionTimer);
connect(connectionTimer, &QTimer::timeout, connection, [this, connection, connectionTimer](){
qCDebug(dcSunSpec()) << "Discovery: Could not establish a connection within" << connectionTimer->interval() << "ms. Continue...";
m_connectionTimers.remove(connection);
connection->disconnectDevice();
// Note: since the connection is the parent of the timer, it will be deleted with the connection
cleanupConnection(connection);
});
// Once connected, the timer will be stopped.
connectionTimer->start();
}
void SunSpecDiscovery::checkNetworkDevice(const QHostAddress &address)
{
// Create a connection queue for this network device
QQueue<SunSpecConnection *> connectionQueue;
// Check all ports for this host
foreach (quint16 port, m_scanPorts) {
SunSpecConnection *connection = new SunSpecConnection(address, port, m_slaveId, m_byteOrder, this);
connection->setNumberOfRetries(1);
connection->setTimeout(500);
m_connections.append(connection);
connectionQueue.enqueue(connection);
connect(connection, &SunSpecConnection::connectedChanged, this, [this, connection, connectionQueue, address](bool connected){
if (!connected) {
// Disconnected ... done with this connection
cleanupConnection(connection);
return;
}
// Successfully connected, we can stop the connection timer which takes care about blocking connection attempts
if (m_connectionTimers.contains(connection)) {
QTimer *connectionTimer = m_connectionTimers.take(connection);
connectionTimer->stop();
connectionTimer->deleteLater();
}
// Modbus TCP connected, try to discovery sunspec models...
connect(connection, &SunSpecConnection::discoveryFinished, this, [=](bool success){
if (!success) {
qCDebug(dcSunSpec()) << "Discovery: SunSpec discovery failed on"
<< QString("%1:%2").arg(address.toString()).arg(connection->port())
<< "slave ID:" << connection->slaveId() << "Continue...";
cleanupConnection(connection);
return;
}
// Success, we found some sunspec models here, let's read some infomation from the models
Result result;
result.address = address;
result.port = connection->port();
result.slaveId = connection->slaveId();
qCDebug(dcSunSpec()) << "Discovery: --> Found SunSpec devices on" << result.networkDeviceInfo << "port" << result.port << "slave ID:" << result.slaveId;
foreach (SunSpecModel *model, connection->models()) {
if (model->modelId() == SunSpecModelFactory::ModelIdCommon) {
SunSpecCommonModel *commonModel = qobject_cast<SunSpecCommonModel *>(model);
QString manufacturer = commonModel->manufacturer();
if (!manufacturer.isEmpty() && !result.modelManufacturers.contains(manufacturer)) {
result.modelManufacturers.append(manufacturer);
}
}
}
m_results.append(result);
// Done with this connection
cleanupConnection(connection);
});
// Run SunSpec discovery on connection...
if (!connection->startDiscovery()) {
qCDebug(dcSunSpec()) << "Discovery: Unable to discover SunSpec data on connection"
<< QString("%1:%2").arg(address.toString()).arg(connection->port())
<< "slave ID:" << connection->slaveId() << "Continue...";
cleanupConnection(connection);
}
});
// If we get any error...skip this host...
connect(connection->modbusTcpClient(), &QModbusTcpClient::errorOccurred, this, [=](QModbusDevice::Error error){
if (error != QModbusDevice::NoError) {
qCDebug(dcSunSpec()) << "Discovery: Connection error on"
<< QString("%1:%2").arg(address.toString()).arg(connection->port())
<< "slave ID:" << connection->slaveId() << "Continue...";
cleanupConnection(connection);
}
});
}
m_pendingConnectionAttempts[address] = connectionQueue;
testNextConnection(address);
}
void SunSpecDiscovery::cleanupConnection(SunSpecConnection *connection)
{
m_connections.removeAll(connection);
connection->disconnectDevice();
connection->deleteLater();
if (m_connectionTimers.contains(connection)) {
QTimer *connectionTimer = m_connectionTimers.take(connection);
connectionTimer->stop();
}
testNextConnection(connection->hostAddress());
}
void SunSpecDiscovery::finishDiscovery()
{
qint64 durationMilliSeconds = QDateTime::currentMSecsSinceEpoch() - m_startDateTime.toMSecsSinceEpoch();
// Fill in all network device infos we have
for (int i = 0; i < m_results.length(); i++)
m_results[i].networkDeviceInfo = m_networkDeviceInfos.get(m_results.at(i).address);
// Cleanup any leftovers...we don't care any more
foreach (SunSpecConnection *connection, m_connections)
cleanupConnection(connection);
qCInfo(dcSunSpec()) << "Discovery: Finished the discovery process. Found" << m_results.length()
<< "SunSpec devices in" << QTime::fromMSecsSinceStartOfDay(durationMilliSeconds).toString("mm:ss.zzz");
emit discoveryFinished();
}

View File

@ -0,0 +1,77 @@
// SPDX-License-Identifier: GPL-3.0-or-later
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
*
* Copyright (C) 2013 - 2024, nymea GmbH
* Copyright (C) 2024 - 2025, chargebyte austria GmbH
*
* This file is part of nymea-plugins-modbus.
*
* nymea-plugins-modbus is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* nymea-plugins-modbus is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with nymea-plugins-modbus. If not, see <https://www.gnu.org/licenses/>.
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
#ifndef SUNSPECDISCOVERY_H
#define SUNSPECDISCOVERY_H
#include <QQueue>
#include <QObject>
#include <QDateTime>
#include <sunspecconnection.h>
#include <network/networkdevicediscovery.h>
class SunSpecDiscovery : public QObject
{
Q_OBJECT
public:
explicit SunSpecDiscovery(NetworkDeviceDiscovery *networkDeviceDiscovery, quint16 slaveId, SunSpecDataPoint::ByteOrder byteOrder = SunSpecDataPoint::ByteOrderLittleEndian, QObject *parent = nullptr);
typedef struct Result {
QHostAddress address;
NetworkDeviceInfo networkDeviceInfo;
quint16 port;
quint16 slaveId;
QStringList modelManufacturers;
} Result;
QList<Result> results() const;
void addCustomDiscoveryPort(quint16 port);
void startDiscovery();
signals:
void discoveryFinished();
private:
NetworkDeviceDiscovery *m_networkDeviceDiscovery = nullptr;
QList<quint16> m_scanPorts;
quint16 m_slaveId = 1;
SunSpecDataPoint::ByteOrder m_byteOrder;
QDateTime m_startDateTime;
QHash<QHostAddress, QQueue<SunSpecConnection *>> m_pendingConnectionAttempts;
QHash<SunSpecConnection *, QTimer *> m_connectionTimers;
QList<SunSpecConnection *> m_connections;
NetworkDeviceInfos m_networkDeviceInfos;
QList<Result> m_results;
void testNextConnection(const QHostAddress &address);
void checkNetworkDevice(const QHostAddress &address);
void cleanupConnection(SunSpecConnection *connection);
void finishDiscovery();
};
#endif // SUNSPECDISCOVERY_H

View File

@ -0,0 +1,126 @@
// SPDX-License-Identifier: GPL-3.0-or-later
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
*
* Copyright (C) 2013 - 2024, nymea GmbH
* Copyright (C) 2024 - 2025, chargebyte austria GmbH
*
* This file is part of nymea.
*
* nymea is free software: you can redistribute it and/or modify it under the
* terms of the GNU General Public License as published by the Free Software
* Foundation, either version 3 of the License, or (at your option) any later
* version.
*
* nymea is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with
* nymea. If not, see <https://www.gnu.org/licenses/>.
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
#include "sunspecstoragelogic.h"
#include <QtMath>
namespace SunSpecStorageLogic {
StorageStates readStates(SunSpecStorageModel *storage)
{
StorageStates states;
if (!storage)
return states;
/* InWRte / OutWRte are what the chargingRate / dischargingRate actions
* write. Reading the gradients here instead would mean the user writes one
* quantity and reads back another. */
states.chargingRate = storage->inWRte();
states.dischargingRate = storage->outWRte();
/* The gradients stay available, under names that cannot be mistaken for the
* setpoints above. */
states.chargeGradient = storage->wChaGra();
states.dischargeGradient = storage->wDisChaGra();
const SunSpecStorageModel::Storctl_modFlags controlMode = storage->storCtlMod();
states.enableCharging = controlMode.testFlag(SunSpecStorageModel::Storctl_modCharge);
states.enableDischarging = controlMode.testFlag(SunSpecStorageModel::Storctl_modDiScharge);
states.gridCharging = (storage->chaGriSet() == SunSpecStorageModel::ChagrisetGrid);
states.batteryLevel = qRound(storage->chaState());
states.batteryCritical = storage->chaState() < 5;
return states;
}
bool isScaledRate(Register reg)
{
return reg == RegisterInWRte || reg == RegisterOutWRte;
}
QString registerName(Register reg)
{
switch (reg) {
case RegisterChaGriSet: return QStringLiteral("ChaGriSet");
case RegisterStorCtlMod: return QStringLiteral("StorCtl_Mod");
case RegisterInWRte: return QStringLiteral("InWRte");
case RegisterOutWRte: return QStringLiteral("OutWRte");
case RegisterRvrtTms: return QStringLiteral("InOutWRte_RvrtTms");
}
return QString();
}
/* Rank within the write order: ChaGriSet, then StorCtl_Mod, then the rates,
* then the revert timer. Arming the timer first would start it counting against
* a setpoint that has not been written yet. */
static int writeOrderRank(Register reg)
{
switch (reg) {
case RegisterChaGriSet: return 0;
case RegisterStorCtlMod: return 1;
case RegisterInWRte:
case RegisterOutWRte: return 2;
case RegisterRvrtTms: return 3;
}
return 3;
}
SetpointPlan planSetpoint(const QVector<Write> &setpoints, quint16 revertTime)
{
SetpointPlan plan;
QVector<Write> writes;
foreach (const Write &write, setpoints) {
/* The revert time is appended below, once, in last position. A caller
* passing it explicitly must not end up with two of them. */
if (write.reg == RegisterRvrtTms)
continue;
if (isScaledRate(write.reg))
plan.rereadScaleFactor = true;
writes.append(write);
}
std::stable_sort(writes.begin(), writes.end(), [](const Write &left, const Write &right) {
return writeOrderRank(left.reg) < writeOrderRank(right.reg);
});
/* Every register that carries the intent is read back afterwards. The
* revert timer is excluded: it is a countdown, so its read-back value is
* not expected to match what was written. */
foreach (const Write &write, writes) {
if (!plan.verifyReadback.contains(write.reg))
plan.verifyReadback.append(write.reg);
}
plan.writes = writes;
plan.writes.append({RegisterRvrtTms, static_cast<double>(revertTime)});
return plan;
}
}

View File

@ -0,0 +1,116 @@
// SPDX-License-Identifier: GPL-3.0-or-later
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
*
* Copyright (C) 2013 - 2024, nymea GmbH
* Copyright (C) 2024 - 2025, chargebyte austria GmbH
*
* This file is part of nymea.
*
* nymea is free software: you can redistribute it and/or modify it under the
* terms of the GNU General Public License as published by the Free Software
* Foundation, either version 3 of the License, or (at your option) any later
* version.
*
* nymea is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with
* nymea. If not, see <https://www.gnu.org/licenses/>.
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
#ifndef SUNSPECSTORAGELOGIC_H
#define SUNSPECSTORAGELOGIC_H
#include <QVector>
#include <models/sunspecstoragemodel.h>
/* Decision logic of the storage (model 124) handling, kept free of the
* integration framework so it can be exercised without a running nymea.
*
* Two things live here:
*
* - readStates() maps a storage block update onto the state values the plugin
* publishes. Having it in one place is what makes it possible to check that
* a state is fed from the very register the matching action writes to;
* - planSetpoint() turns a set of setpoints into an ordered sequence of
* register writes. Order matters on real hardware, and the revert timer
* must close every sequence.
*/
namespace SunSpecStorageLogic {
/* The registers of model 124 this plugin ever writes. */
enum Register {
RegisterChaGriSet,
RegisterStorCtlMod,
RegisterInWRte,
RegisterOutWRte,
RegisterRvrtTms
};
struct Write {
Register reg;
double value;
};
struct SetpointPlan {
/* Scale factors are not necessarily static: they may change with firmware
* and at runtime. Whenever a scaled quantity is written, its scale factor
* should be read again immediately before the write rather than reused
* from an earlier poll. */
bool rereadScaleFactor = false;
/* Ordered: ChaGriSet, StorCtl_Mod, InWRte/OutWRte, RvrtTms last. */
QVector<Write> writes;
/* Registers to read back and compare after the sequence. Several inverters
* accept a write and then ignore it without raising a Modbus exception, so
* an unverified setpoint is an unapplied setpoint. */
QVector<Register> verifyReadback;
};
struct StorageStates {
/* Percent of WChaMax, from InWRte / OutWRte - the very registers the
* chargingRate / dischargingRate actions write to. */
double chargingRate = 0;
double dischargingRate = 0;
/* Percent of WChaMax per second, from WChaGra / WDisChaGra. A different
* quantity from the two above, and read only on several inverters. */
double chargeGradient = 0;
double dischargeGradient = 0;
/* Both flags are reconciled from StorCtl_Mod. Reconciling only one lets the
* other drift silently as soon as the inverter changes it on its own. */
bool enableCharging = false;
bool enableDischarging = false;
bool gridCharging = false;
int batteryLevel = 0;
bool batteryCritical = false;
};
/* Value range of the InWRte / OutWRte setpoints of model 124. */
const double MinimumRatePercent = -100.0;
const double MaximumRatePercent = 100.0;
/* Range of InOutWRte_RvrtTms. */
const int MaximumRevertTimeSeconds = 28800;
StorageStates readStates(SunSpecStorageModel *storage);
/* True for the registers carried by InOutWRte_SF, i.e. those whose write should
* be preceded by a fresh read of that scale factor. */
bool isScaledRate(Register reg);
QString registerName(Register reg);
SetpointPlan planSetpoint(const QVector<Write> &setpoints, quint16 revertTime);
}
#endif // SUNSPECSTORAGELOGIC_H

70
sunspec/sunspecthing.cpp Normal file
View File

@ -0,0 +1,70 @@
// SPDX-License-Identifier: GPL-3.0-or-later
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
*
* Copyright (C) 2013 - 2024, nymea GmbH
* Copyright (C) 2024 - 2025, chargebyte austria GmbH
*
* This file is part of nymea-plugins-modbus.
*
* nymea-plugins-modbus is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* nymea-plugins-modbus is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with nymea-plugins-modbus. If not, see <https://www.gnu.org/licenses/>.
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
#include "sunspecthing.h"
SunSpecThing::SunSpecThing(Thing *thing, SunSpecModel *model, QObject *parent) :
QObject(parent),
m_thing(thing),
m_model(model)
{
}
SunSpecConnection *SunSpecThing::connection() const
{
if (!m_model)
return nullptr;
return m_model->connection();
}
SunSpecModel *SunSpecThing::model() const
{
return m_model;
}
Thing *SunSpecThing::thing() const
{
return m_thing;
}
quint16 SunSpecThing::modbusStartRegister() const
{
if (!m_model)
return 0xFFFF;
return m_model->modbusStartRegister();
}
void SunSpecThing::executeAction(ThingActionInfo *info)
{
Q_ASSERT_X(false, "executeAction", QString("Unhandled action: %1").arg(info->action().actionTypeId().toString()).toUtf8());
info->finish(Thing::ThingErrorActionTypeNotFound);
}
void SunSpecThing::onBlockDataUpdated()
{
// Reimplement if required
}

63
sunspec/sunspecthing.h Normal file
View File

@ -0,0 +1,63 @@
// SPDX-License-Identifier: GPL-3.0-or-later
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
*
* Copyright (C) 2013 - 2024, nymea GmbH
* Copyright (C) 2024 - 2025, chargebyte austria GmbH
*
* This file is part of nymea-plugins-modbus.
*
* nymea-plugins-modbus is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* nymea-plugins-modbus is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with nymea-plugins-modbus. If not, see <https://www.gnu.org/licenses/>.
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
#ifndef SUNSPECTHING_H
#define SUNSPECTHING_H
#include <QObject>
#include "extern-plugininfo.h"
#include <integrations/integrationplugin.h>
#include <sunspecmodel.h>
#include <sunspecconnection.h>
class SunSpecThing : public QObject
{
Q_OBJECT
public:
explicit SunSpecThing(Thing *thing, SunSpecModel *model, QObject *parent = nullptr);
virtual ~SunSpecThing() = default;
virtual SunSpecConnection *connection() const;
SunSpecModel *model() const;
Thing *thing() const;
virtual quint16 modbusStartRegister() const;
virtual void readBlockData() = 0;
virtual void executeAction(ThingActionInfo *info);
protected:
Thing *m_thing = nullptr;
SunSpecModel *m_model = nullptr;
private slots:
virtual void onBlockDataUpdated();
};
#endif // SUNSPECTHING_H

19
sunspec/test/sunspec_server.sh Executable file
View File

@ -0,0 +1,19 @@
#!/bin/bash
if ! command -v suns &> /dev/null
then
echo "suns could not be found"
echo "... installing"
sudo apt update
sudo apt install libmodbus-dev flex bison git
if [ ! -d "sunspec"]; then
git clone https://github.com/Boernsman/sunspec.git
fi
cd ./sunspec/src
make
sudo make install
fi
echo "Startin sunspec test server"
sudo suns -s -vvvv -m models/test/composite_superdevice.model

View File

@ -0,0 +1,75 @@
include(../../../sunspec.pri)
QT += testlib network serialbus
QT -= gui
TEMPLATE = app
TARGET = tst_sunspecdevice
# Suite d'integration F3 : le plugin, monte derriere un vrai ThingManager, face
# a un vrai appareil.
#
# VOLONTAIREMENT PAS "testcase" : "make check" ne la lance jamais. Elle est
# quand meme construite, pour qu'elle ne pourrisse pas en silence.
#
# Elle exige un appareil joignable, passe par variable d'environnement :
#
# SUNSPEC_TEST_HOST=<adresse> [SUNSPEC_TEST_PORT=502]
# [SUNSPEC_TEST_METER_SLAVE_ID=200] [SUNSPEC_TEST_SLAVE_ID=1]
# ./tst_sunspecdevice
#
# Sans SUNSPEC_TEST_HOST, chaque test se met en QSKIP. Aucune adresse par
# defaut n'est ecrite ici : l'adresse d'une installation reelle est une donnee
# client et n'a rien a faire dans un depot a miroir public (voir AGENTS.md).
#
# ATTENTION avant de la lancer sur un onduleur client : le constructeur
# recommande de rester au-dessus de 7 s d'intervalle d'interrogation, faute de
# quoi l'onduleur peut passer hors ligne vis-a-vis de son portail, avec
# retablissement par coupure AC+DC sur site. Cette suite ne fait qu'un cycle de
# decouverte et n'interroge pas en boucle, mais elle ouvre bien deux connexions.
CONFIG += console c++17
CONFIG -= app_bundle
CONFIG += link_pkgconfig
PKGCONFIG += nymea nymea-core
PLUGIN_DIR = $$PWD/../..
INCLUDEPATH += $$PLUGIN_DIR
DEFINES += SUNSPEC_PLUGIN_TEST_ACCESS
JSONFILE = $$PLUGIN_DIR/integrationpluginsunspec.json
OTHER_FILES += $$JSONFILE
plugininfo.input = JSONFILE
plugininfo.output = plugininfo.h
plugininfo.commands = nymea-plugininfocompiler ${QMAKE_FILE_NAME} --output ${QMAKE_FILE_OUT}
plugininfo.dependency_type = TYPE_C
plugininfo.CONFIG += no_link combine
QMAKE_EXTRA_COMPILERS += plugininfo
extern_plugininfo.input = JSONFILE
extern_plugininfo.output = extern-plugininfo.h
extern_plugininfo.commands = nymea-plugininfocompiler ${QMAKE_FILE_NAME} --extern ${QMAKE_FILE_OUT}
extern_plugininfo.dependency_type = TYPE_C
extern_plugininfo.CONFIG += no_link combine
QMAKE_EXTRA_COMPILERS += extern_plugininfo
DEFINES += PLUGIN_JSON_PATH=\\\"$$JSONFILE\\\"
SOURCES += \
tst_sunspecdevice.cpp \
$$PLUGIN_DIR/integrationpluginsunspec.cpp \
$$PLUGIN_DIR/froniusrules.cpp \
$$PLUGIN_DIR/solaredgebattery.cpp \
$$PLUGIN_DIR/sunspecdiscovery.cpp \
$$PLUGIN_DIR/sunspecstoragelogic.cpp \
$$PLUGIN_DIR/sunspecthing.cpp
HEADERS += \
$$PLUGIN_DIR/integrationpluginsunspec.h \
$$PLUGIN_DIR/froniusrules.h \
$$PLUGIN_DIR/solaredgebattery.h \
$$PLUGIN_DIR/sunspecdiscovery.h \
$$PLUGIN_DIR/sunspecstoragelogic.h \
$$PLUGIN_DIR/sunspecthing.h

View File

@ -0,0 +1,584 @@
// SPDX-License-Identifier: GPL-3.0-or-later
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
*
* Copyright (C) 2026, ETM-PowerSync
*
* This file is part of nymea.
*
* nymea is free software: you can redistribute it and/or modify it under the
* terms of the GNU General Public License as published by the Free Software
* Foundation, either version 3 of the License, or (at your option) any later
* version.
*
* nymea is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with
* nymea. If not, see <https://www.gnu.org/licenses/>.
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
/* F3 face a un vrai appareil.
*
* Les deux defauts les plus certains du multi unit ID sont invisibles pour les
* suites hors ligne, parce qu'ils n'apparaissent qu'une fois la decouverte
* reellement passee :
*
* - le balayage des orphelins, qui sur une seule des deux connexions ferait
* disparaitre les enfants de l'autre a chaque decouverte ;
* - l'agregation de l'etat connected entre les deux connexions.
*
* Cette suite n'est pas lancee par "make check" : elle exige un appareil. Voir
* integration.pro pour les variables d'environnement. Sans SUNSPEC_TEST_HOST,
* tout se met en QSKIP.
*
* Aucune adresse par defaut n'est ecrite ici. L'adresse d'une installation
* reelle est une donnee client (AGENTS.md, "Miroir public").
*/
#include <QtTest>
#include <QCoreApplication>
#include <QTemporaryDir>
#include <nymeasettings.h>
#include <hardwaremanager.h>
#include <plugintimer.h>
#include <logging/logengine.h>
#include <logging/logger.h>
#include <network/networkdevicediscovery.h>
#include <network/networkdevicemonitor.h>
#include <integrations/thingmanagerimplementation.h>
#include <integrations/thingsetupinfo.h>
#include <sunspecconnection.h>
#include "integrationpluginsunspec.h"
/* --------------------------------------------------------------------------
* Doublures
* -------------------------------------------------------------------------- */
/* Contrairement aux suites hors ligne, le moniteur porte ici une vraie adresse
* et se declare joignable : c'est ce qui autorise le plugin a se connecter. */
class DeviceMonitor : public NetworkDeviceMonitor
{
public:
DeviceMonitor(const QHostAddress &address, QObject *parent = nullptr)
: NetworkDeviceMonitor(parent), m_address(address)
{
m_info = NetworkDeviceInfo(address);
m_info.setMonitorMode(NetworkDeviceInfo::MonitorModeIp);
}
MacAddress macAddress() const override { return MacAddress(); }
QString hostName() const override { return QString(); }
QHostAddress address() const override { return m_address; }
NetworkDeviceInfo::MonitorMode monitorMode() const override { return NetworkDeviceInfo::MonitorModeIp; }
NetworkDeviceInfo networkDeviceInfo() const override { return m_info; }
bool reachable() const override { return true; }
QDateTime lastSeen() const override { return QDateTime::currentDateTime(); }
uint pingRetries() const override { return 0; }
void setPingRetries(uint) override {}
private:
QHostAddress m_address;
NetworkDeviceInfo m_info;
};
class DeviceDiscovery : public NetworkDeviceDiscovery
{
public:
DeviceDiscovery(const QHostAddress &address, QObject *parent = nullptr)
: NetworkDeviceDiscovery(parent), m_address(address) {}
NetworkDeviceDiscoveryReply *discover() override { return nullptr; }
bool running() const override { return false; }
NetworkDeviceMonitor *registerMonitor(Thing *) override { return new DeviceMonitor(m_address, this); }
void unregisterMonitor(NetworkDeviceMonitor *monitor) override { delete monitor; }
PingReply *ping(const QHostAddress &, uint) override { return nullptr; }
PingReply *ping(const QString &, uint) override { return nullptr; }
MacAddressDatabaseReply *lookupMacAddress(const QString &) override { return nullptr; }
MacAddressDatabaseReply *lookupMacAddress(const MacAddress &) override { return nullptr; }
bool sendArpRequest(const QHostAddress &) override { return false; }
NetworkDeviceInfos cache() const override { return NetworkDeviceInfos(); }
bool available() const override { return true; }
bool enabled() const override { return true; }
protected:
void setEnabled(bool) override {}
private:
QHostAddress m_address;
};
class NullPluginTimer : public PluginTimer
{
public:
explicit NullPluginTimer(int seconds, QObject *parent = nullptr)
: PluginTimer(parent), m_interval(seconds) {}
int interval() const override { return m_interval; }
int currentTick() const override { return 0; }
bool running() const override { return m_running; }
void reset() override {}
void start() override { m_running = true; }
void stop() override { m_running = false; }
void pause() override {}
void resume() override {}
private:
int m_interval = 0;
bool m_running = false;
};
/* Le minuteur n'est jamais declenche par cette suite. Interroger en boucle un
* onduleur client est precisement ce que le plancher de cadence cherche a
* eviter ; ici on ne veut que la decouverte. */
class NullPluginTimerManager : public PluginTimerManager
{
public:
explicit NullPluginTimerManager(QObject *parent = nullptr) : PluginTimerManager(parent) {}
PluginTimer *registerTimer(int seconds) override { return new NullPluginTimer(seconds, this); }
void unregisterTimer(PluginTimer *timer) override { delete timer; }
bool available() const override { return true; }
bool enabled() const override { return true; }
protected:
void setEnabled(bool) override {}
};
/* Les things enfants sont crees par le plugin, pas ecrits d'avance dans les
* reglages : on ne peut donc pas leur retirer leurs states journalises comme le
* font les suites hors ligne. Il faut un LogEngine, meme creux, sans quoi le
* ThingManager en dereference un absent des la creation du premier enfant. */
class NullLogEngine : public LogEngine
{
public:
explicit NullLogEngine(QObject *parent = nullptr) : LogEngine(parent) {}
Logger *registerLogSource(const QString &name, const QStringList &tags,
Types::LoggingType loggingType, const QString &) override
{
Logger *logger = createLogger(name, tags, loggingType);
m_loggers.insert(name, logger);
return logger;
}
void unregisterLogSource(const QString &name) override { m_loggers.remove(name); }
LogFetchJob *fetchLogEntries(const QStringList &, const QStringList &, const QDateTime &,
const QDateTime &, const QVariantMap &, Types::SampleRate,
Qt::SortOrder, int, int) override
{
LogFetchJob *job = new LogFetchJob(this);
finishFetchJob(job, LogEntries());
return job;
}
bool jobsRunning() const override { return false; }
void clear(const QString &) override {}
void enable() override {}
void disable() override {}
private:
void logEvent(Logger *, const QStringList &, const QVariantMap &) override {}
QHash<QString, Logger *> m_loggers;
};
class DeviceHardwareManager : public HardwareManager
{
public:
DeviceHardwareManager(const QHostAddress &address, QObject *parent = nullptr)
: HardwareManager(parent)
, m_pluginTimerManager(new NullPluginTimerManager(this))
, m_networkDeviceDiscovery(new DeviceDiscovery(address, this))
{}
Radio433 *radio433() override { return nullptr; }
PluginTimerManager *pluginTimerManager() override { return m_pluginTimerManager; }
NetworkAccessManager *networkManager() override { return nullptr; }
UpnpDiscovery *upnpDiscovery() override { return nullptr; }
PlatformZeroConfController *zeroConfController() override { return nullptr; }
BluetoothLowEnergyManager *bluetoothLowEnergyManager() override { return nullptr; }
MqttProvider *mqttProvider() override { return nullptr; }
I2CManager *i2cManager() override { return nullptr; }
ZigbeeHardwareResource *zigbeeResource() override { return nullptr; }
ZWaveHardwareResource *zwaveResource() override { return nullptr; }
ModbusRtuHardwareResource *modbusRtuResource() override { return nullptr; }
NetworkDeviceDiscovery *networkDeviceDiscovery() override { return m_networkDeviceDiscovery; }
private:
NullPluginTimerManager *m_pluginTimerManager = nullptr;
DeviceDiscovery *m_networkDeviceDiscovery = nullptr;
};
class TestableSunSpecPlugin : public IntegrationPluginSunSpec
{
public:
using IntegrationPlugin::setMetaData;
};
/* -------------------------------------------------------------------------- */
class SunSpecDeviceTest : public QObject
{
Q_OBJECT
private slots:
void initTestCase();
void cleanupTestCase();
void test_F3_both_connections_reach_the_device();
void test_F3_meter_connects_only_after_the_primary_discovery();
void test_F3_inverter_and_meter_children_share_one_parent();
void test_F3_no_child_disappears_across_repeated_discoveries();
void test_F3_meter_slave_id_zero_opens_no_second_connection();
void test_F3_losing_the_meter_leaves_the_inverter_alone();
private:
QTemporaryDir m_pluginSearchDir;
QHostAddress m_address;
int m_port = 502;
int m_slaveId = 1;
int m_meterSlaveId = 200;
DeviceHardwareManager *m_hardwareManager = nullptr;
ThingManagerImplementation *m_thingManager = nullptr;
NullLogEngine *m_logEngine = nullptr;
TestableSunSpecPlugin *m_plugin = nullptr;
bool m_configured = false;
/* Ces deux temoins sont ecrits par des lambdas connectees dans
* initTestCase() et relues par un test ulterieur : ce sont donc des membres,
* jamais des locales capturees par reference. Une locale de initTestCase()
* capturee par reference serait relachee au retour de la fonction, et les
* lambdas ecriraient ensuite dans une case de pile reutilisee par les frames
* suivantes - corruption silencieuse du test lui-meme. */
bool m_primaryDiscovered = false;
/* Vrai des que la connexion compteur signale connected, pour verifier que
* cela n'arrive jamais avant la fin de la decouverte primaire. */
bool m_meterConnectedBeforePrimaryDiscovery = false;
bool skipUnlessConfigured();
QList<Thing *> childrenOfKind(const ThingClassId &thingClassId) const;
void storeConnection(const ThingId &thingId, const QString &name,
const PluginId &pluginId, int meterSlaveId);
SunSpecModel *modelOn(SunSpecConnection *connection, quint16 modelId) const;
};
static const ThingId DeviceThingId = ThingId("5d3f8a26-41bc-4e79-a05f-9b7c2e6d1483");
static const ThingId DeviceNoMeterThingId = ThingId("6a4e1b07-52d8-4c93-8e7b-1f39c04a5d62");
void SunSpecDeviceTest::storeConnection(const ThingId &thingId, const QString &name,
const PluginId &pluginId, int meterSlaveId)
{
NymeaSettings settings(NymeaSettings::SettingsRoleThings);
settings.beginGroup("ThingConfig");
settings.beginGroup(thingId.toString());
settings.setValue("thingName", name);
settings.setValue("pluginid", pluginId.toString());
settings.setValue("thingClassId", froniusConnectionThingClassId.toString());
settings.beginGroup("Params");
settings.setValue(froniusConnectionThingAddressParamTypeId.toString(), m_address.toString());
settings.setValue(froniusConnectionThingPortParamTypeId.toString(), m_port);
settings.setValue(froniusConnectionThingSlaveIdParamTypeId.toString(), m_slaveId);
settings.setValue(froniusConnectionThingMeterSlaveIdParamTypeId.toString(), meterSlaveId);
settings.endGroup();
settings.endGroup();
settings.endGroup();
settings.sync();
}
bool SunSpecDeviceTest::skipUnlessConfigured()
{
if (m_configured)
return false;
/* QSKIP ne peut pas etre appele depuis une fonction auxiliaire, donc le
* message est laisse a l'appelant. */
return true;
}
QList<Thing *> SunSpecDeviceTest::childrenOfKind(const ThingClassId &thingClassId) const
{
QList<Thing *> found;
foreach (Thing *thing, m_thingManager->configuredThings()) {
if (thing->thingClassId() == thingClassId && thing->parentId() == DeviceThingId)
found.append(thing);
}
return found;
}
void SunSpecDeviceTest::initTestCase()
{
const QByteArray host = qgetenv("SUNSPEC_TEST_HOST");
if (host.isEmpty()) {
qInfo() << "SUNSPEC_TEST_HOST is not set: every test will be skipped.";
return;
}
m_address = QHostAddress(QString::fromUtf8(host));
QVERIFY2(!m_address.isNull(), "SUNSPEC_TEST_HOST is not a valid address");
if (!qgetenv("SUNSPEC_TEST_PORT").isEmpty())
m_port = qgetenv("SUNSPEC_TEST_PORT").toInt();
if (!qgetenv("SUNSPEC_TEST_SLAVE_ID").isEmpty())
m_slaveId = qgetenv("SUNSPEC_TEST_SLAVE_ID").toInt();
if (!qgetenv("SUNSPEC_TEST_METER_SLAVE_ID").isEmpty())
m_meterSlaveId = qgetenv("SUNSPEC_TEST_METER_SLAVE_ID").toInt();
QCoreApplication::setOrganizationName("nymea-test-sunspec-device");
NymeaSettings thingSettings(NymeaSettings::SettingsRoleThings);
thingSettings.clear();
NymeaSettings pluginSettings(NymeaSettings::SettingsRolePlugins);
pluginSettings.clear();
QVERIFY(m_pluginSearchDir.isValid());
qputenv("NYMEA_PLUGINS_PATH", m_pluginSearchDir.path().toUtf8());
QFile jsonFile(PLUGIN_JSON_PATH);
QVERIFY2(jsonFile.open(QIODevice::ReadOnly), qPrintable(QString("Cannot open %1").arg(PLUGIN_JSON_PATH)));
QJsonParseError error;
const QJsonDocument document = QJsonDocument::fromJson(jsonFile.readAll(), &error);
QVERIFY2(error.error == QJsonParseError::NoError, qPrintable(error.errorString()));
const PluginMetadata metadata(document.object());
QVERIFY2(metadata.isValid(), "metadonnee du plugin invalide");
storeConnection(DeviceThingId, "Fronius", metadata.pluginId(), m_meterSlaveId);
/* Le cas degenere documente au contrat : 0 = pas de compteur derriere cet
* onduleur. Meme appareil, meme unit ID onduleur, mais aucune seconde
* connexion ne doit etre ouverte. */
storeConnection(DeviceNoMeterThingId, "Fronius sans compteur", metadata.pluginId(), 0);
m_hardwareManager = new DeviceHardwareManager(m_address, this);
m_logEngine = new NullLogEngine(this);
m_thingManager = new ThingManagerImplementation(m_hardwareManager, m_logEngine, QLocale(), this);
m_plugin = new TestableSunSpecPlugin();
m_plugin->setMetaData(metadata);
m_thingManager->registerStaticPlugin(m_plugin);
Thing *thing = nullptr;
QTRY_VERIFY_WITH_TIMEOUT((thing = m_thingManager->findConfiguredThing(DeviceThingId)) != nullptr, 5000);
QTRY_COMPARE_WITH_TIMEOUT(thing->setupStatus(), Thing::ThingSetupStatusComplete, 30000);
SunSpecConnection *primary = m_plugin->testPrimaryConnection(DeviceThingId);
SunSpecConnection *meter = m_plugin->testMeterConnection(DeviceThingId);
QVERIFY(primary);
QVERIFY2(meter, "aucune connexion compteur creee : verifier SUNSPEC_TEST_METER_SLAVE_ID");
/* Arme avant toute attente, pour que l'ordre des deux evenements soit
* observe et pas reconstitue apres coup. */
connect(primary, &SunSpecConnection::discoveryFinished, this, [this](bool success) {
if (success)
m_primaryDiscovered = true;
});
connect(meter, &SunSpecConnection::connectedChanged, this, [this](bool connected) {
if (connected && !m_primaryDiscovered)
m_meterConnectedBeforePrimaryDiscovery = true;
});
m_configured = true;
}
void SunSpecDeviceTest::cleanupTestCase()
{
delete m_thingManager;
m_thingManager = nullptr;
}
void SunSpecDeviceTest::test_F3_both_connections_reach_the_device()
{
if (skipUnlessConfigured())
QSKIP("SUNSPEC_TEST_HOST is not set");
SunSpecConnection *primary = m_plugin->testPrimaryConnection(DeviceThingId);
SunSpecConnection *meter = m_plugin->testMeterConnection(DeviceThingId);
QVERIFY(primary && meter);
QTRY_VERIFY_WITH_TIMEOUT(primary->connected(), 30000);
QTRY_VERIFY_WITH_TIMEOUT(meter->connected(), 60000);
QCOMPARE(static_cast<int>(primary->slaveId()), m_slaveId);
QCOMPARE(static_cast<int>(meter->slaveId()), m_meterSlaveId);
// Les deux ont trouve un registre de base SunSpec, chacune sur son unit ID.
QTRY_VERIFY_WITH_TIMEOUT(!primary->models().isEmpty(), 60000);
QTRY_VERIFY_WITH_TIMEOUT(!meter->models().isEmpty(), 60000);
}
/* Q4. Deux decouvertes SunSpec simultanees seraient la charge la plus lourde
* que ce plugin fasse jamais subir a un onduleur. */
void SunSpecDeviceTest::test_F3_meter_connects_only_after_the_primary_discovery()
{
if (skipUnlessConfigured())
QSKIP("SUNSPEC_TEST_HOST is not set");
QVERIFY2(!m_meterConnectedBeforePrimaryDiscovery,
"la connexion compteur est montee avant la fin de la decouverte primaire");
}
/* Un seul appareil a appairer, tous les enfants sous le meme parent. */
void SunSpecDeviceTest::test_F3_inverter_and_meter_children_share_one_parent()
{
if (skipUnlessConfigured())
QSKIP("SUNSPEC_TEST_HOST is not set");
QTRY_VERIFY_WITH_TIMEOUT(!childrenOfKind(sunspecThreePhaseInverterThingClassId).isEmpty()
|| !childrenOfKind(sunspecSplitPhaseInverterThingClassId).isEmpty()
|| !childrenOfKind(sunspecSinglePhaseInverterThingClassId).isEmpty(), 60000);
QTRY_VERIFY_WITH_TIMEOUT(!childrenOfKind(sunspecThreePhaseMeterThingClassId).isEmpty()
|| !childrenOfKind(sunspecSplitPhaseMeterThingClassId).isEmpty()
|| !childrenOfKind(sunspecSinglePhaseMeterThingClassId).isEmpty(), 60000);
}
/* Le defaut le plus certain du multi unit ID : chaque connexion ne voit que la
* moitie du tableau, donc un balayage des orphelins fait sur cette moitie ferait
* disparaitre les enfants de l'autre. Une redecouverte suffit a le declencher. */
void SunSpecDeviceTest::test_F3_no_child_disappears_across_repeated_discoveries()
{
if (skipUnlessConfigured())
QSKIP("SUNSPEC_TEST_HOST is not set");
const int childrenBefore = m_thingManager->configuredThings().filterByParentId(DeviceThingId).count();
QVERIFY2(childrenBefore > 0, "aucun enfant a surveiller");
QSignalSpy disappeared(m_plugin, &IntegrationPlugin::autoThingDisappeared);
SunSpecConnection *primary = m_plugin->testPrimaryConnection(DeviceThingId);
SunSpecConnection *meter = m_plugin->testMeterConnection(DeviceThingId);
for (int round = 0; round < 2; round++) {
QSignalSpy primaryDone(primary, &SunSpecConnection::discoveryFinished);
primary->startDiscovery();
QVERIFY2(primaryDone.wait(60000), "la decouverte primaire n'a pas rendu la main");
QSignalSpy meterDone(meter, &SunSpecConnection::discoveryFinished);
meter->startDiscovery();
QVERIFY2(meterDone.wait(60000), "la decouverte compteur n'a pas rendu la main");
}
QCOMPARE(disappeared.count(), 0);
QCOMPARE(m_thingManager->configuredThings().filterByParentId(DeviceThingId).count(), childrenBefore);
}
SunSpecModel *SunSpecDeviceTest::modelOn(SunSpecConnection *connection, quint16 modelId) const
{
foreach (SunSpecModel *model, connection->models()) {
if (model->modelId() == modelId)
return model;
}
return nullptr;
}
/* Le cas degenere : meterSlaveId a 0 veut dire "pas de compteur derriere cet
* onduleur". Meme appareil, meme unit ID onduleur, mais une seule connexion.
*
* On n'affirme rien sur les enfants de ce second thing : le premier a deja cree
* un enfant onduleur portant le numero de serie du simulateur, et la
* deduplication par numero de serie de autocreateSunSpecModelThing() est
* globale, pas par parent. C'est la connexion qui est mesuree ici. */
void SunSpecDeviceTest::test_F3_meter_slave_id_zero_opens_no_second_connection()
{
if (skipUnlessConfigured())
QSKIP("SUNSPEC_TEST_HOST is not set");
Thing *thing = nullptr;
QTRY_VERIFY_WITH_TIMEOUT((thing = m_thingManager->findConfiguredThing(DeviceNoMeterThingId)) != nullptr, 5000);
QTRY_COMPARE_WITH_TIMEOUT(thing->setupStatus(), Thing::ThingSetupStatusComplete, 30000);
QVERIFY2(m_plugin->testPrimaryConnection(DeviceNoMeterThingId),
"la connexion onduleur n'a pas ete creee");
QVERIFY2(!m_plugin->testMeterConnection(DeviceNoMeterThingId),
"meterSlaveId a 0 ne doit ouvrir aucune connexion compteur");
}
/* Le second defaut invisible hors ligne : l'etat connected du parent suit la
* connexion primaire seule. Un Smart Meter qui tombe ne doit ni marquer
* l'onduleur injoignable, ni effacer les donnees qu'il continue de livrer.
*
* Place en dernier, parce qu'il coupe une connexion pour de bon. */
void SunSpecDeviceTest::test_F3_losing_the_meter_leaves_the_inverter_alone()
{
if (skipUnlessConfigured())
QSKIP("SUNSPEC_TEST_HOST is not set");
Thing *parent = m_thingManager->findConfiguredThing(DeviceThingId);
QVERIFY(parent);
QVERIFY2(parent->stateValue("connected").toBool(), "le parent n'etait pas connecte au depart");
QList<Thing *> inverters = childrenOfKind(sunspecThreePhaseInverterThingClassId);
QList<Thing *> meters = childrenOfKind(sunspecThreePhaseMeterThingClassId);
QVERIFY2(!inverters.isEmpty(), "pas d'enfant onduleur a surveiller");
QVERIFY2(!meters.isEmpty(), "pas d'enfant compteur a couper");
Thing *inverterThing = inverters.first();
Thing *meterThing = meters.first();
SunSpecConnection *primary = m_plugin->testPrimaryConnection(DeviceThingId);
SunSpecConnection *meter = m_plugin->testMeterConnection(DeviceThingId);
QVERIFY(primary && meter);
SunSpecModel *inverterModel = modelOn(primary, 113);
SunSpecModel *meterModel = modelOn(meter, 213);
QVERIFY2(inverterModel, "modele onduleur 113 introuvable sur la connexion primaire");
QVERIFY2(meterModel, "modele compteur 213 introuvable sur la connexion compteur");
/* Un tour d'interrogation explicite. Ce banc remplace le minuteur du plugin
* par un objet creux - on ne veut pas interroger un appareil en boucle - or
* c'est la mise a jour de bloc qui met un enfant a connected. Sans ce tour,
* les enfants n'auraient jamais ete connectes et la coupure ci-dessous ne
* prouverait rien. */
QSignalSpy inverterFirst(inverterModel, &SunSpecModel::blockUpdated);
QSignalSpy meterFirst(meterModel, &SunSpecModel::blockUpdated);
inverterModel->readBlockData();
meterModel->readBlockData();
QVERIFY2(inverterFirst.wait(30000), "pas de donnee onduleur avant la coupure");
if (meterFirst.isEmpty())
QVERIFY2(meterFirst.wait(30000), "pas de donnee compteur avant la coupure");
QVERIFY2(inverterThing->stateValue("connected").toBool(),
"l'enfant onduleur n'etait pas connecte avant la coupure");
QVERIFY2(meterThing->stateValue("connected").toBool(),
"l'enfant compteur n'etait pas connecte avant la coupure");
// Coupure franche de la seule connexion compteur.
meter->disconnectDevice();
QTRY_VERIFY_WITH_TIMEOUT(!meter->connected(), 15000);
// Le parent reste connecte : c'est la primaire qui fait foi, jamais un ET logique.
QVERIFY2(parent->stateValue("connected").toBool(),
"le parent est passe deconnecte alors que seule la connexion compteur est tombee");
QVERIFY(primary->connected());
// L'enfant onduleur n'est pas affecte...
QVERIFY2(inverterThing->stateValue("connected").toBool(),
"l'enfant onduleur a ete marque deconnecte par la chute du compteur");
QVERIFY2(inverterThing->stateValue("currentPower").isValid(),
"les donnees onduleur ont ete effacees");
// ...et ses donnees continuent d'arriver.
QSignalSpy inverterUpdated(inverterModel, &SunSpecModel::blockUpdated);
inverterModel->readBlockData();
QVERIFY2(inverterUpdated.wait(30000),
"plus aucune donnee onduleur apres la chute du compteur");
// Seul l'enfant compteur est marque deconnecte.
QVERIFY2(!meterThing->stateValue("connected").toBool(),
"l'enfant compteur aurait du etre marque deconnecte");
}
QTEST_MAIN(SunSpecDeviceTest)
#include "tst_sunspecdevice.moc"

View File

@ -0,0 +1,29 @@
include(../../../sunspec.pri)
QT += testlib
QT -= gui
CONFIG += console testcase
CONFIG -= app_bundle
TEMPLATE = app
TARGET = tst_sunspec
# Suite de logique pure : ce que le plugin *decide*. Elle ne charge ni nymea ni
# le plugin lui-meme, et ne demande donc que libnymea-sunspec. La suite qui
# instancie reellement le plugin vit dans ../plugin/ et porte ses propres
# dependances. Ce qui est emis sur le bus releve du banc (voir "Non verifiable
# par les tests unitaires" dans specs/fronius-sunspec.md).
INCLUDEPATH += ../..
# Chemin du JSON de definition du plugin, pour les criteres qui portent sur les
# thingClasses et leurs plages de valeurs (AC-1, AC-7, AC-8, AC-10).
DEFINES += PLUGIN_JSON_PATH=\\\"$$PWD/../../integrationpluginsunspec.json\\\"
SOURCES += \
tst_sunspec.cpp \
../../sunspecstoragelogic.cpp \
../../froniusrules.cpp
HEADERS += \
../../sunspecstoragelogic.h \
../../froniusrules.h

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,64 @@
include(../../../sunspec.pri)
QT += testlib network serialbus
QT -= gui
CONFIG += console testcase c++17
CONFIG -= app_bundle
TEMPLATE = app
TARGET = tst_sunspecmultiunit
# Suite F3 : un thing froniusConnection porte deux SunSpecConnection, une par
# unit ID, sur le meme host:port.
#
# Binaire separe de ../plugin plutot que tests ajoutes a celui-la : les things
# deja configures sont ecrits dans les reglages avant le demarrage du
# ThingManager, donc en ajouter ferait mentir les compteurs d'appels que la
# suite F1 verifie. Chaque binaire a son propre nom d'organisation, donc son
# propre fichier de reglages, et n'interfere pas avec l'autre.
CONFIG += link_pkgconfig
PKGCONFIG += nymea nymea-core
PLUGIN_DIR = $$PWD/../..
INCLUDEPATH += $$PLUGIN_DIR
# Accesseurs reserves aux tests : voir integrationpluginsunspec.h. Sans eux, le
# nombre de connexions d'un thing n'est pas observable, les deux vivant derriere
# le meme moniteur reseau et le meme host:port.
DEFINES += SUNSPEC_PLUGIN_TEST_ACCESS
JSONFILE = $$PLUGIN_DIR/integrationpluginsunspec.json
OTHER_FILES += $$JSONFILE
plugininfo.input = JSONFILE
plugininfo.output = plugininfo.h
plugininfo.commands = nymea-plugininfocompiler ${QMAKE_FILE_NAME} --output ${QMAKE_FILE_OUT}
plugininfo.dependency_type = TYPE_C
plugininfo.CONFIG += no_link combine
QMAKE_EXTRA_COMPILERS += plugininfo
extern_plugininfo.input = JSONFILE
extern_plugininfo.output = extern-plugininfo.h
extern_plugininfo.commands = nymea-plugininfocompiler ${QMAKE_FILE_NAME} --extern ${QMAKE_FILE_OUT}
extern_plugininfo.dependency_type = TYPE_C
extern_plugininfo.CONFIG += no_link combine
QMAKE_EXTRA_COMPILERS += extern_plugininfo
DEFINES += PLUGIN_JSON_PATH=\\\"$$JSONFILE\\\"
SOURCES += \
tst_sunspecmultiunit.cpp \
$$PLUGIN_DIR/integrationpluginsunspec.cpp \
$$PLUGIN_DIR/froniusrules.cpp \
$$PLUGIN_DIR/solaredgebattery.cpp \
$$PLUGIN_DIR/sunspecdiscovery.cpp \
$$PLUGIN_DIR/sunspecstoragelogic.cpp \
$$PLUGIN_DIR/sunspecthing.cpp
HEADERS += \
$$PLUGIN_DIR/integrationpluginsunspec.h \
$$PLUGIN_DIR/froniusrules.h \
$$PLUGIN_DIR/solaredgebattery.h \
$$PLUGIN_DIR/sunspecdiscovery.h \
$$PLUGIN_DIR/sunspecstoragelogic.h \
$$PLUGIN_DIR/sunspecthing.h

View File

@ -0,0 +1,450 @@
// SPDX-License-Identifier: GPL-3.0-or-later
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
*
* Copyright (C) 2026, ETM-PowerSync
*
* This file is part of nymea.
*
* nymea is free software: you can redistribute it and/or modify it under the
* terms of the GNU General Public License as published by the Free Software
* Foundation, either version 3 of the License, or (at your option) any later
* version.
*
* nymea is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with
* nymea. If not, see <https://www.gnu.org/licenses/>.
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
/* F3 - un thing froniusConnection, deux unit IDs.
*
* Le GEN24 repond pour l'onduleur sur l'unit ID 1 et pour le Smart Meter sur
* l'unit ID 200 par defaut, derriere le meme host:port. Le plugin ouvre donc
* deux SunSpecConnection pour un seul thing.
*
* Ce que cette suite couvre : la creation, le nombre, les unit IDs, et surtout
* la destruction - une connexion compteur qui survit a son thing garde une
* socket ouverte et des signaux pointant vers un Thing detruit.
*
* Ce qu'elle ne couvre pas, faute de pair Modbus : la decouverte, la creation
* des things enfants, le balayage des orphelins et l'agregation de l'etat
* connected. Voir ../integration.
*
* Binaire distinct de ../plugin : la suite F1 y verifie des compteurs d'appels
* globaux que des things configures supplementaires feraient mentir.
*/
#include <QtTest>
#include <QCoreApplication>
#include <QTemporaryDir>
#include <nymeasettings.h>
#include <hardwaremanager.h>
#include <plugintimer.h>
#include <network/networkdevicediscovery.h>
#include <network/networkdevicemonitor.h>
#include <integrations/thingmanagerimplementation.h>
#include <integrations/thingsetupinfo.h>
#include <sunspecconnection.h>
#include "integrationpluginsunspec.h"
/* --------------------------------------------------------------------------
* Doublures du materiel. Le moniteur ne connait aucune adresse et n'est jamais
* joignable : aucune connexion Modbus n'est donc tentee.
* -------------------------------------------------------------------------- */
class FakeNetworkDeviceMonitor : public NetworkDeviceMonitor
{
public:
explicit FakeNetworkDeviceMonitor(QObject *parent = nullptr) : NetworkDeviceMonitor(parent) {}
MacAddress macAddress() const override { return MacAddress(); }
QString hostName() const override { return QString(); }
QHostAddress address() const override { return QHostAddress(); }
NetworkDeviceInfo::MonitorMode monitorMode() const override { return NetworkDeviceInfo::MonitorModeIp; }
NetworkDeviceInfo networkDeviceInfo() const override { return NetworkDeviceInfo(); }
bool reachable() const override { return false; }
QDateTime lastSeen() const override { return QDateTime(); }
uint pingRetries() const override { return 0; }
void setPingRetries(uint) override {}
};
class FakeNetworkDeviceDiscovery : public NetworkDeviceDiscovery
{
public:
explicit FakeNetworkDeviceDiscovery(QObject *parent = nullptr) : NetworkDeviceDiscovery(parent) {}
int registerMonitorCount = 0;
int unregisterMonitorCount = 0;
NetworkDeviceDiscoveryReply *discover() override { return nullptr; }
bool running() const override { return false; }
NetworkDeviceMonitor *registerMonitor(Thing *) override
{
registerMonitorCount++;
return new FakeNetworkDeviceMonitor(this);
}
void unregisterMonitor(NetworkDeviceMonitor *monitor) override
{
unregisterMonitorCount++;
delete monitor;
}
PingReply *ping(const QHostAddress &, uint) override { return nullptr; }
PingReply *ping(const QString &, uint) override { return nullptr; }
MacAddressDatabaseReply *lookupMacAddress(const QString &) override { return nullptr; }
MacAddressDatabaseReply *lookupMacAddress(const MacAddress &) override { return nullptr; }
bool sendArpRequest(const QHostAddress &) override { return false; }
NetworkDeviceInfos cache() const override { return NetworkDeviceInfos(); }
bool available() const override { return true; }
bool enabled() const override { return true; }
protected:
void setEnabled(bool) override {}
};
class FakePluginTimer : public PluginTimer
{
public:
explicit FakePluginTimer(int seconds, QObject *parent = nullptr)
: PluginTimer(parent), m_interval(seconds) {}
int interval() const override { return m_interval; }
int currentTick() const override { return 0; }
bool running() const override { return m_running; }
void reset() override {}
void start() override { m_running = true; }
void stop() override { m_running = false; }
void pause() override {}
void resume() override {}
private:
int m_interval = 0;
bool m_running = false;
};
class FakePluginTimerManager : public PluginTimerManager
{
public:
explicit FakePluginTimerManager(QObject *parent = nullptr) : PluginTimerManager(parent) {}
PluginTimer *registerTimer(int seconds) override { return new FakePluginTimer(seconds, this); }
void unregisterTimer(PluginTimer *timer) override { delete timer; }
bool available() const override { return true; }
bool enabled() const override { return true; }
protected:
void setEnabled(bool) override {}
};
class FakeHardwareManager : public HardwareManager
{
public:
explicit FakeHardwareManager(QObject *parent = nullptr)
: HardwareManager(parent)
, m_pluginTimerManager(new FakePluginTimerManager(this))
, m_networkDeviceDiscovery(new FakeNetworkDeviceDiscovery(this))
{}
Radio433 *radio433() override { return nullptr; }
PluginTimerManager *pluginTimerManager() override { return m_pluginTimerManager; }
NetworkAccessManager *networkManager() override { return nullptr; }
UpnpDiscovery *upnpDiscovery() override { return nullptr; }
PlatformZeroConfController *zeroConfController() override { return nullptr; }
BluetoothLowEnergyManager *bluetoothLowEnergyManager() override { return nullptr; }
MqttProvider *mqttProvider() override { return nullptr; }
I2CManager *i2cManager() override { return nullptr; }
ZigbeeHardwareResource *zigbeeResource() override { return nullptr; }
ZWaveHardwareResource *zwaveResource() override { return nullptr; }
ModbusRtuHardwareResource *modbusRtuResource() override { return nullptr; }
NetworkDeviceDiscovery *networkDeviceDiscovery() override { return m_networkDeviceDiscovery; }
FakeNetworkDeviceDiscovery *monitors() const { return m_networkDeviceDiscovery; }
private:
FakePluginTimerManager *m_pluginTimerManager = nullptr;
FakeNetworkDeviceDiscovery *m_networkDeviceDiscovery = nullptr;
};
class TestableSunSpecPlugin : public IntegrationPluginSunSpec
{
public:
using IntegrationPlugin::setMetaData;
};
/* -------------------------------------------------------------------------- */
class SunSpecMultiUnitTest : public QObject
{
Q_OBJECT
private slots:
void initTestCase();
void cleanupTestCase();
void test_F3_fronius_connection_creates_two_connections();
void test_F3_meter_connection_uses_the_meter_slave_id();
void test_F3_meter_connection_stays_idle_until_the_primary_discovered();
void test_F3_meter_slave_id_zero_creates_only_one_connection();
void test_F3_meter_slave_id_colliding_with_the_inverter_is_refused();
void test_F3_generic_connection_creates_no_meter_connection();
void test_F3_solaredge_connection_creates_no_meter_connection();
void test_F3_removal_destroys_both_connections();
private:
QTemporaryDir m_pluginSearchDir;
FakeHardwareManager *m_hardwareManager = nullptr;
ThingManagerImplementation *m_thingManager = nullptr;
TestableSunSpecPlugin *m_plugin = nullptr;
void storeConnection(const ThingId &thingId, const ThingClassId &thingClassId,
const QString &name, const PluginId &pluginId, int meterSlaveId);
Thing *awaitThing(const ThingId &thingId);
};
/* Things deja configures, ecrits dans les reglages avant le demarrage du
* ThingManager. C'est le chemin emprunte a chaque demarrage de nymead pour une
* connexion deja appairee, et le seul qui aille jusqu'au bout du setup sans
* appareil joignable. */
static const ThingId FroniusWithMeterId = ThingId("1f0a5c7e-3b42-4d18-9e6a-70c2d4b85f31");
static const ThingId FroniusWithoutMeterId = ThingId("2c7d9e14-8a56-4f03-b21d-63e8a95c4072");
static const ThingId FroniusCollidingId = ThingId("3b8e2f60-95c1-42a7-8d34-51fa7e60b9c8");
static const ThingId GenericConnectionId = ThingId("4a1c6d83-27e9-4b50-a6f2-8c05d31e7ba4");
static const ThingId SolarEdgeConnectionId = ThingId("5e2b7f49-6d38-41ca-b95e-0a4c8d71e263");
static const int MeterSlaveId = 200;
static const int InverterSlaveId = 1;
void SunSpecMultiUnitTest::storeConnection(const ThingId &thingId, const ThingClassId &thingClassId,
const QString &name, const PluginId &pluginId, int meterSlaveId)
{
NymeaSettings settings(NymeaSettings::SettingsRoleThings);
settings.beginGroup("ThingConfig");
settings.beginGroup(thingId.toString());
settings.setValue("thingName", name);
settings.setValue("pluginid", pluginId.toString());
settings.setValue("thingClassId", thingClassId.toString());
/* Le ThingManager est monte sans LogEngine - son en-tete ne fait pas partie
* de l'API publique de nymea-core - donc le thing ne doit journaliser aucun
* state. */
settings.setValue("loggedStateTypeIds", QStringList());
settings.setValue("loggedEventTypeIds", QStringList());
settings.setValue("loggedActionTypeIds", QStringList());
settings.beginGroup("Params");
if (thingClassId == froniusConnectionThingClassId) {
settings.setValue(froniusConnectionThingAddressParamTypeId.toString(), "192.0.2.1");
settings.setValue(froniusConnectionThingPortParamTypeId.toString(), 502);
settings.setValue(froniusConnectionThingSlaveIdParamTypeId.toString(), InverterSlaveId);
settings.setValue(froniusConnectionThingMeterSlaveIdParamTypeId.toString(), meterSlaveId);
} else if (thingClassId == solarEdgeConnectionThingClassId) {
settings.setValue(solarEdgeConnectionThingAddressParamTypeId.toString(), "192.0.2.1");
settings.setValue(solarEdgeConnectionThingPortParamTypeId.toString(), 502);
settings.setValue(solarEdgeConnectionThingSlaveIdParamTypeId.toString(), InverterSlaveId);
} else {
settings.setValue(sunspecConnectionThingAddressParamTypeId.toString(), "192.0.2.1");
settings.setValue(sunspecConnectionThingPortParamTypeId.toString(), 502);
settings.setValue(sunspecConnectionThingSlaveIdParamTypeId.toString(), InverterSlaveId);
}
settings.endGroup();
settings.endGroup();
settings.endGroup();
settings.sync();
}
Thing *SunSpecMultiUnitTest::awaitThing(const ThingId &thingId)
{
Thing *thing = nullptr;
[&]() {
QTRY_VERIFY_WITH_TIMEOUT((thing = m_thingManager->findConfiguredThing(thingId)) != nullptr, 2000);
QTRY_COMPARE_WITH_TIMEOUT(thing->setupStatus(), Thing::ThingSetupStatusComplete, 2000);
}();
return thing;
}
void SunSpecMultiUnitTest::initTestCase()
{
/* Nom d'organisation propre a ce binaire : les reglages ne croisent donc ni
* ceux de l'utilisateur ni ceux de la suite ../plugin. */
QCoreApplication::setOrganizationName("nymea-test-sunspec-multiunit");
NymeaSettings thingSettings(NymeaSettings::SettingsRoleThings);
thingSettings.clear();
NymeaSettings pluginSettings(NymeaSettings::SettingsRolePlugins);
pluginSettings.clear();
QVERIFY(m_pluginSearchDir.isValid());
qputenv("NYMEA_PLUGINS_PATH", m_pluginSearchDir.path().toUtf8());
QFile jsonFile(PLUGIN_JSON_PATH);
QVERIFY2(jsonFile.open(QIODevice::ReadOnly), qPrintable(QString("Cannot open %1").arg(PLUGIN_JSON_PATH)));
QJsonParseError error;
const QJsonDocument document = QJsonDocument::fromJson(jsonFile.readAll(), &error);
QVERIFY2(error.error == QJsonParseError::NoError, qPrintable(error.errorString()));
const PluginMetadata metadata(document.object());
QVERIFY2(metadata.isValid(), "metadonnee du plugin invalide");
storeConnection(FroniusWithMeterId, froniusConnectionThingClassId, "Fronius avec compteur",
metadata.pluginId(), MeterSlaveId);
storeConnection(FroniusWithoutMeterId, froniusConnectionThingClassId, "Fronius sans compteur",
metadata.pluginId(), 0);
storeConnection(FroniusCollidingId, froniusConnectionThingClassId, "Fronius unit ID en conflit",
metadata.pluginId(), InverterSlaveId);
storeConnection(GenericConnectionId, sunspecConnectionThingClassId, "SunSpec generique",
metadata.pluginId(), 0);
storeConnection(SolarEdgeConnectionId, solarEdgeConnectionThingClassId, "SolarEdge",
metadata.pluginId(), 0);
m_hardwareManager = new FakeHardwareManager(this);
m_thingManager = new ThingManagerImplementation(m_hardwareManager, nullptr, QLocale(), this);
m_plugin = new TestableSunSpecPlugin();
m_plugin->setMetaData(metadata);
m_thingManager->registerStaticPlugin(m_plugin);
QVERIFY2(!m_thingManager->findThingClass(froniusConnectionThingClassId).id().isNull(),
"la thingClass froniusConnection n'est pas connue du ThingManager");
}
void SunSpecMultiUnitTest::cleanupTestCase()
{
delete m_thingManager;
m_thingManager = nullptr;
}
/* Le coeur de F3 : un seul thing, deux connexions. */
void SunSpecMultiUnitTest::test_F3_fronius_connection_creates_two_connections()
{
Thing *thing = awaitThing(FroniusWithMeterId);
QVERIFY(thing);
QVERIFY2(m_plugin->testPrimaryConnection(FroniusWithMeterId),
"la connexion onduleur n'a pas ete creee");
QVERIFY2(m_plugin->testMeterConnection(FroniusWithMeterId),
"la connexion compteur n'a pas ete creee alors que meterSlaveId vaut 200");
QVERIFY(m_plugin->testPrimaryConnection(FroniusWithMeterId)
!= m_plugin->testMeterConnection(FroniusWithMeterId));
}
/* Deux connexions vers le meme host:port ne se distinguent que par l'unit ID.
* S'il n'est pas repris du parametre, les deux interrogent l'onduleur. */
void SunSpecMultiUnitTest::test_F3_meter_connection_uses_the_meter_slave_id()
{
SunSpecConnection *primary = m_plugin->testPrimaryConnection(FroniusWithMeterId);
SunSpecConnection *meter = m_plugin->testMeterConnection(FroniusWithMeterId);
QVERIFY(primary && meter);
QCOMPARE(static_cast<int>(primary->slaveId()), InverterSlaveId);
QCOMPARE(static_cast<int>(meter->slaveId()), MeterSlaveId);
// Meme appareil, donc meme hote et meme port.
QCOMPARE(meter->hostAddress(), primary->hostAddress());
QCOMPARE(meter->port(), primary->port());
}
/* Q4 : la connexion compteur n'est montee qu'une fois la decouverte primaire
* terminee. Deux decouvertes SunSpec simultanees seraient la charge la plus
* lourde que ce plugin fasse jamais subir a un onduleur, et le cout de la
* prudence n'est que de quelques secondes d'appairage.
*
* Ici le moniteur n'est jamais joignable, donc la primaire ne decouvre jamais :
* la connexion compteur doit exister sans etre connectee. */
void SunSpecMultiUnitTest::test_F3_meter_connection_stays_idle_until_the_primary_discovered()
{
SunSpecConnection *meter = m_plugin->testMeterConnection(FroniusWithMeterId);
QVERIFY(meter);
QVERIFY2(!meter->connected(),
"la connexion compteur ne doit pas etre montee avant la fin de la decouverte primaire");
}
/* 0 est la facon documentee de dire "pas de compteur derriere cet onduleur". */
void SunSpecMultiUnitTest::test_F3_meter_slave_id_zero_creates_only_one_connection()
{
Thing *thing = awaitThing(FroniusWithoutMeterId);
QVERIFY(thing);
QVERIFY(m_plugin->testPrimaryConnection(FroniusWithoutMeterId));
QVERIFY2(!m_plugin->testMeterConnection(FroniusWithoutMeterId),
"meterSlaveId a 0 ne doit ouvrir aucune connexion compteur");
}
/* Une seconde socket sur l'unit ID deja servi par l'onduleur redecouvrirait les
* memes modeles et dupliquerait chaque thing enfant. */
void SunSpecMultiUnitTest::test_F3_meter_slave_id_colliding_with_the_inverter_is_refused()
{
Thing *thing = awaitThing(FroniusCollidingId);
QVERIFY(thing);
QVERIFY(m_plugin->testPrimaryConnection(FroniusCollidingId));
QVERIFY2(!m_plugin->testMeterConnection(FroniusCollidingId),
"un meterSlaveId egal au slaveId de l'onduleur doit etre refuse");
}
/* Non regression : le multi unit ID est une particularite Fronius. */
void SunSpecMultiUnitTest::test_F3_generic_connection_creates_no_meter_connection()
{
Thing *thing = awaitThing(GenericConnectionId);
QVERIFY(thing);
QVERIFY(m_plugin->testPrimaryConnection(GenericConnectionId));
QVERIFY2(!m_plugin->testMeterConnection(GenericConnectionId),
"le vendor generique garde exactement une connexion par thing");
}
/* Non regression du vendor solarEdge, celui dont aucune suite ne couvrait le
* comportement. C'est la raison pour laquelle la connexion compteur vit dans
* une table separee plutot que dans un QHash de listes : ainsi pas une seule
* ligne de solarEdge n'a change, y compris la recherche inverse
* m_sunSpecConnections.key() dont depend la decouverte des batteries. */
void SunSpecMultiUnitTest::test_F3_solaredge_connection_creates_no_meter_connection()
{
Thing *thing = awaitThing(SolarEdgeConnectionId);
QVERIFY(thing);
QVERIFY(m_plugin->testPrimaryConnection(SolarEdgeConnectionId));
QVERIFY2(!m_plugin->testMeterConnection(SolarEdgeConnectionId),
"le vendor solarEdge garde exactement une connexion par thing");
}
/* thingRemoved() est une chaine de else if dont la premiere branche portait sur
* la seule connexion primaire. Sans reprise explicite, la connexion compteur
* survit a son thing : socket ouverte et signaux pointant vers un Thing detruit.
*
* QSignalSpy sur destroyed() plutot qu'une inspection de la table : c'est la
* duree de vie de l'objet qui est en cause, pas la propriete de la table. */
void SunSpecMultiUnitTest::test_F3_removal_destroys_both_connections()
{
SunSpecConnection *primary = m_plugin->testPrimaryConnection(FroniusWithMeterId);
SunSpecConnection *meter = m_plugin->testMeterConnection(FroniusWithMeterId);
QVERIFY(primary && meter);
QSignalSpy primaryDestroyed(primary, &QObject::destroyed);
QSignalSpy meterDestroyed(meter, &QObject::destroyed);
QCOMPARE(m_thingManager->removeConfiguredThing(FroniusWithMeterId), Thing::ThingErrorNoError);
// Les deux sont detruites par deleteLater(), donc au prochain tour de boucle.
QTRY_COMPARE_WITH_TIMEOUT(primaryDestroyed.count(), 1, 2000);
QTRY_COMPARE_WITH_TIMEOUT(meterDestroyed.count(), 1, 2000);
QVERIFY(!m_plugin->testPrimaryConnection(FroniusWithMeterId));
QVERIFY2(!m_plugin->testMeterConnection(FroniusWithMeterId),
"la connexion compteur est restee dans la table apres la suppression du thing");
}
QTEST_MAIN(SunSpecMultiUnitTest)
#include "tst_sunspecmultiunit.moc"

View File

@ -0,0 +1,62 @@
include(../../../sunspec.pri)
QT += testlib network serialbus
QT -= gui
CONFIG += console testcase c++17
CONFIG -= app_bundle
TEMPLATE = app
TARGET = tst_sunspecplugin
# Suite qui instancie reellement IntegrationPluginSunSpec, derriere un
# ThingManager reel (celui de libnymea-core) et un HardwareManager double. Elle
# couvre ce que la suite de logique pure ne peut pas voir : l'aiguillage de
# setupThing() / postSetupThing() / thingRemoved() sur la thingClass.
#
# Dependances supplementaires par rapport a ../logic : libnymea1-dev et
# libnymea-core (pkg-config nymea et nymea-core).
CONFIG += link_pkgconfig
PKGCONFIG += nymea nymea-core
PLUGIN_DIR = $$PWD/../..
INCLUDEPATH += $$PLUGIN_DIR
# Le plugin s'appuie sur les identifiants generes depuis son JSON. On les
# fabrique ici exactement comme le fait plugin.pri pour le plugin lui-meme :
# plugininfo.h (definitions, inclus par integrationpluginsunspec.cpp) et
# extern-plugininfo.h (declarations, inclus par les en-tetes et par le test).
JSONFILE = $$PLUGIN_DIR/integrationpluginsunspec.json
OTHER_FILES += $$JSONFILE
plugininfo.input = JSONFILE
plugininfo.output = plugininfo.h
plugininfo.commands = nymea-plugininfocompiler ${QMAKE_FILE_NAME} --output ${QMAKE_FILE_OUT}
plugininfo.dependency_type = TYPE_C
plugininfo.CONFIG += no_link combine
QMAKE_EXTRA_COMPILERS += plugininfo
extern_plugininfo.input = JSONFILE
extern_plugininfo.output = extern-plugininfo.h
extern_plugininfo.commands = nymea-plugininfocompiler ${QMAKE_FILE_NAME} --extern ${QMAKE_FILE_OUT}
extern_plugininfo.dependency_type = TYPE_C
extern_plugininfo.CONFIG += no_link combine
QMAKE_EXTRA_COMPILERS += extern_plugininfo
DEFINES += PLUGIN_JSON_PATH=\\\"$$JSONFILE\\\"
SOURCES += \
tst_sunspecplugin.cpp \
$$PLUGIN_DIR/integrationpluginsunspec.cpp \
$$PLUGIN_DIR/froniusrules.cpp \
$$PLUGIN_DIR/solaredgebattery.cpp \
$$PLUGIN_DIR/sunspecdiscovery.cpp \
$$PLUGIN_DIR/sunspecstoragelogic.cpp \
$$PLUGIN_DIR/sunspecthing.cpp
HEADERS += \
$$PLUGIN_DIR/integrationpluginsunspec.h \
$$PLUGIN_DIR/froniusrules.h \
$$PLUGIN_DIR/solaredgebattery.h \
$$PLUGIN_DIR/sunspecdiscovery.h \
$$PLUGIN_DIR/sunspecstoragelogic.h \
$$PLUGIN_DIR/sunspecthing.h

View File

@ -0,0 +1,388 @@
// SPDX-License-Identifier: GPL-3.0-or-later
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
*
* Copyright (C) 2026, ETM-PowerSync
*
* This file is part of nymea.
*
* nymea is free software: you can redistribute it and/or modify it under the
* terms of the GNU General Public License as published by the Free Software
* Foundation, either version 3 of the License, or (at your option) any later
* version.
*
* nymea is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with
* nymea. If not, see <https://www.gnu.org/licenses/>.
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
/* Suite qui instancie reellement le plugin.
*
* La suite de logique pure (../logic) ne peut voir que ce que le plugin
* *decide* : elle appelle des fonctions libres et lit le JSON. Elle ne peut pas
* voir l'aiguillage, c'est a dire le fait qu'une thingClass soit reconnue par
* setupThing(), postSetupThing() et thingRemoved(). Une thingClass oubliee dans
* l'un de ces trois aiguillages tombe dans un Q_ASSERT_X final : en debug le
* processus meurt, en release l'assert est compile hors du binaire et
* setupThing() rend la main sans jamais appeler info->finish(). L'appairage
* reste alors bloque indefiniment, sans le moindre message.
*
* C'est exactement ce qui est arrive a froniusConnection, et aucun test ne
* pouvait le voir.
*
* Le plugin est monte derriere le vrai ThingManager de libnymea-core - c'est
* lui qui construit les Thing et les ThingSetupInfo, et lui qui appelle
* postSetupThing() - et derriere un HardwareManager double. Aucun acces reseau
* n'a lieu : le moniteur double n'est jamais joignable, donc le plugin ne tente
* jamais de connexion Modbus.
*/
#include <QtTest>
#include <QCoreApplication>
#include <QTemporaryDir>
#include <nymeasettings.h>
#include <hardwaremanager.h>
#include <plugintimer.h>
#include <network/networkdevicediscovery.h>
#include <network/networkdevicemonitor.h>
#include <integrations/thingmanagerimplementation.h>
#include <integrations/thingsetupinfo.h>
#include "integrationpluginsunspec.h"
/* --------------------------------------------------------------------------
* Doublures du materiel
*
* Le moniteur ne connait aucune adresse et n'est jamais joignable : c'est l'etat
* dans lequel se trouve une connexion dont l'appareil n'a pas encore ete vu sur
* le reseau. Le plugin doit y repondre, pas s'y bloquer.
* -------------------------------------------------------------------------- */
class FakeNetworkDeviceMonitor : public NetworkDeviceMonitor
{
public:
explicit FakeNetworkDeviceMonitor(QObject *parent = nullptr) : NetworkDeviceMonitor(parent) {}
MacAddress macAddress() const override { return MacAddress(); }
QString hostName() const override { return QString(); }
QHostAddress address() const override { return QHostAddress(); }
NetworkDeviceInfo::MonitorMode monitorMode() const override { return NetworkDeviceInfo::MonitorModeIp; }
NetworkDeviceInfo networkDeviceInfo() const override { return NetworkDeviceInfo(); }
bool reachable() const override { return false; }
QDateTime lastSeen() const override { return QDateTime(); }
uint pingRetries() const override { return 0; }
void setPingRetries(uint) override {}
};
class FakeNetworkDeviceDiscovery : public NetworkDeviceDiscovery
{
public:
explicit FakeNetworkDeviceDiscovery(QObject *parent = nullptr) : NetworkDeviceDiscovery(parent) {}
/* registerMonitor() n'est appele que par la branche "connexion" de
* setupThing(). Compter les appels dit donc si le plugin a bien pris cette
* branche, et pas seulement s'il a fini par repondre quelque chose. */
int registerMonitorCount = 0;
int unregisterMonitorCount = 0;
NetworkDeviceDiscoveryReply *discover() override { return nullptr; }
bool running() const override { return false; }
NetworkDeviceMonitor *registerMonitor(Thing *) override
{
registerMonitorCount++;
return new FakeNetworkDeviceMonitor(this);
}
void unregisterMonitor(NetworkDeviceMonitor *monitor) override
{
unregisterMonitorCount++;
delete monitor;
}
PingReply *ping(const QHostAddress &, uint) override { return nullptr; }
PingReply *ping(const QString &, uint) override { return nullptr; }
MacAddressDatabaseReply *lookupMacAddress(const QString &) override { return nullptr; }
MacAddressDatabaseReply *lookupMacAddress(const MacAddress &) override { return nullptr; }
bool sendArpRequest(const QHostAddress &) override { return false; }
NetworkDeviceInfos cache() const override { return NetworkDeviceInfos(); }
bool available() const override { return true; }
bool enabled() const override { return true; }
protected:
void setEnabled(bool) override {}
};
class FakePluginTimer : public PluginTimer
{
public:
explicit FakePluginTimer(int seconds, QObject *parent = nullptr)
: PluginTimer(parent), m_interval(seconds) {}
int interval() const override { return m_interval; }
int currentTick() const override { return 0; }
bool running() const override { return m_running; }
void reset() override {}
void start() override { m_running = true; }
void stop() override { m_running = false; }
void pause() override {}
void resume() override {}
private:
int m_interval = 0;
bool m_running = false;
};
class FakePluginTimerManager : public PluginTimerManager
{
public:
explicit FakePluginTimerManager(QObject *parent = nullptr) : PluginTimerManager(parent) {}
/* Le minuteur d'interrogation est arme par postSetupThing(). Le compteur dit
* donc si postSetupThing() a bien traite la connexion. */
int registerTimerCount = 0;
PluginTimer *registerTimer(int seconds) override
{
registerTimerCount++;
return new FakePluginTimer(seconds, this);
}
void unregisterTimer(PluginTimer *timer) override { delete timer; }
bool available() const override { return true; }
bool enabled() const override { return true; }
protected:
void setEnabled(bool) override {}
};
class FakeHardwareManager : public HardwareManager
{
public:
explicit FakeHardwareManager(QObject *parent = nullptr)
: HardwareManager(parent)
, m_pluginTimerManager(new FakePluginTimerManager(this))
, m_networkDeviceDiscovery(new FakeNetworkDeviceDiscovery(this))
{}
Radio433 *radio433() override { return nullptr; }
PluginTimerManager *pluginTimerManager() override { return m_pluginTimerManager; }
NetworkAccessManager *networkManager() override { return nullptr; }
UpnpDiscovery *upnpDiscovery() override { return nullptr; }
PlatformZeroConfController *zeroConfController() override { return nullptr; }
BluetoothLowEnergyManager *bluetoothLowEnergyManager() override { return nullptr; }
MqttProvider *mqttProvider() override { return nullptr; }
I2CManager *i2cManager() override { return nullptr; }
ZigbeeHardwareResource *zigbeeResource() override { return nullptr; }
ZWaveHardwareResource *zwaveResource() override { return nullptr; }
ModbusRtuHardwareResource *modbusRtuResource() override { return nullptr; }
NetworkDeviceDiscovery *networkDeviceDiscovery() override { return m_networkDeviceDiscovery; }
FakePluginTimerManager *timers() const { return m_pluginTimerManager; }
FakeNetworkDeviceDiscovery *monitors() const { return m_networkDeviceDiscovery; }
private:
FakePluginTimerManager *m_pluginTimerManager = nullptr;
FakeNetworkDeviceDiscovery *m_networkDeviceDiscovery = nullptr;
};
/* setMetaData() est protege : le ThingManager le renseigne normalement depuis le
* QPluginLoader. Ici le plugin est instancie en dur, donc le test lui passe la
* meme metadonnee, lue dans le meme JSON. */
class TestableSunSpecPlugin : public IntegrationPluginSunSpec
{
public:
using IntegrationPlugin::setMetaData;
};
/* -------------------------------------------------------------------------- */
class SunSpecPluginTest : public QObject
{
Q_OBJECT
private slots:
void initTestCase();
void cleanupTestCase();
void test_F1_stored_fronius_connection_completes_its_setup();
void test_F1_stored_fronius_connection_is_removed_cleanly();
void test_F1_added_fronius_connection_is_answered();
void test_F1_added_fronius_connection_answers_like_the_generic_vendor();
private:
QTemporaryDir m_pluginSearchDir;
FakeHardwareManager *m_hardwareManager = nullptr;
ThingManagerImplementation *m_thingManager = nullptr;
ThingSetupInfo *addConnection(const ThingClassId &thingClassId, const QString &name);
};
/* Thing deja configure, ecrit dans les reglages avant le demarrage du
* ThingManager. C'est le chemin emprunte a chaque demarrage de nymead pour une
* connexion deja appairee, et le seul qui aille jusqu'au bout du setup sans
* appareil joignable. */
static const ThingId StoredFroniusThingId = ThingId("4e9b1c2d-6f31-4a58-91a7-2b8c0d5e7f14");
void SunSpecPluginTest::initTestCase()
{
/* "nymea-test" deporte les reglages dans /tmp, hors de la configuration
* reelle de l'utilisateur. */
QCoreApplication::setOrganizationName("nymea-test");
NymeaSettings thingSettings(NymeaSettings::SettingsRoleThings);
thingSettings.clear();
NymeaSettings pluginSettings(NymeaSettings::SettingsRolePlugins);
pluginSettings.clear();
/* Repertoire de recherche vide : le seul plugin en jeu est celui que le test
* enregistre lui-meme, pas ceux installes sur la machine. */
QVERIFY(m_pluginSearchDir.isValid());
qputenv("NYMEA_PLUGINS_PATH", m_pluginSearchDir.path().toUtf8());
QFile jsonFile(PLUGIN_JSON_PATH);
QVERIFY2(jsonFile.open(QIODevice::ReadOnly), qPrintable(QString("Cannot open %1").arg(PLUGIN_JSON_PATH)));
QJsonParseError error;
const QJsonDocument document = QJsonDocument::fromJson(jsonFile.readAll(), &error);
QVERIFY2(error.error == QJsonParseError::NoError, qPrintable(error.errorString()));
const PluginMetadata metadata(document.object());
QVERIFY2(metadata.isValid(), "metadonnee du plugin invalide");
NymeaSettings settings(NymeaSettings::SettingsRoleThings);
settings.beginGroup("ThingConfig");
settings.beginGroup(StoredFroniusThingId.toString());
settings.setValue("thingName", "Fronius");
settings.setValue("pluginid", metadata.pluginId().toString());
settings.setValue("thingClassId", froniusConnectionThingClassId.toString());
/* Le ThingManager est monte sans LogEngine - son en-tete ne fait pas partie
* de l'API publique de nymea-core - donc le thing ne doit journaliser aucun
* state, sans quoi le ThingManager enregistrerait un logger sur un moteur
* absent. Ces trois cles ecrasent les valeurs suggerees par la thingClass. */
settings.setValue("loggedStateTypeIds", QStringList());
settings.setValue("loggedEventTypeIds", QStringList());
settings.setValue("loggedActionTypeIds", QStringList());
settings.beginGroup("Params");
settings.setValue(froniusConnectionThingAddressParamTypeId.toString(), "192.0.2.1");
settings.setValue(froniusConnectionThingPortParamTypeId.toString(), 502);
settings.setValue(froniusConnectionThingSlaveIdParamTypeId.toString(), 1);
settings.endGroup();
settings.endGroup();
settings.endGroup();
settings.sync();
m_hardwareManager = new FakeHardwareManager(this);
m_thingManager = new ThingManagerImplementation(m_hardwareManager, nullptr, QLocale(), this);
TestableSunSpecPlugin *plugin = new TestableSunSpecPlugin();
plugin->setMetaData(metadata);
m_thingManager->registerStaticPlugin(plugin);
QVERIFY2(!m_thingManager->findThingClass(froniusConnectionThingClassId).id().isNull(),
"la thingClass froniusConnection n'est pas connue du ThingManager");
}
void SunSpecPluginTest::cleanupTestCase()
{
delete m_thingManager;
m_thingManager = nullptr;
}
ThingSetupInfo *SunSpecPluginTest::addConnection(const ThingClassId &thingClassId, const QString &name)
{
const ThingClass thingClass = m_thingManager->findThingClass(thingClassId);
ParamList params;
params.append(Param(thingClass.paramTypes().findByName("address").id(), QString("192.0.2.1")));
return m_thingManager->addConfiguredThing(thingClassId, params, name);
}
/* Le chemin du demarrage de nymead : la connexion est deja dans les reglages,
* le setup n'est pas un setup initial et doit aboutir sans appareil joignable.
* C'est le seul cas ou l'on peut verifier que le succes est bien annonce.
*
* Avant correction, setupThing() ne reconnait pas froniusConnection, ne finit
* jamais, et le thing reste en ThingSetupStatusInProgress pour toujours. */
void SunSpecPluginTest::test_F1_stored_fronius_connection_completes_its_setup()
{
Thing *thing = nullptr;
QTRY_VERIFY_WITH_TIMEOUT((thing = m_thingManager->findConfiguredThing(StoredFroniusThingId)) != nullptr, 2000);
QCOMPARE(thing->thingClassId(), froniusConnectionThingClassId);
QTRY_COMPARE_WITH_TIMEOUT(thing->setupStatus(), Thing::ThingSetupStatusComplete, 2000);
QCOMPARE(thing->setupError(), Thing::ThingErrorNoError);
/* La branche connexion de setupThing() a bien ete prise... */
QCOMPARE(m_hardwareManager->monitors()->registerMonitorCount, 1);
/* ...et postSetupThing() a arme le minuteur d'interrogation. */
QCOMPARE(m_hardwareManager->timers()->registerTimerCount, 1);
}
/* thingRemoved() est indexe sur m_sunSpecConnections, pas sur la thingClass : il
* n'a donc rien de specifique a faire pour Fronius. Verifie plutot que suppose. */
void SunSpecPluginTest::test_F1_stored_fronius_connection_is_removed_cleanly()
{
QVERIFY(m_thingManager->findConfiguredThing(StoredFroniusThingId));
const int unregisteredBefore = m_hardwareManager->monitors()->unregisterMonitorCount;
QCOMPARE(m_thingManager->removeConfiguredThing(StoredFroniusThingId), Thing::ThingErrorNoError);
QVERIFY(!m_thingManager->findConfiguredThing(StoredFroniusThingId));
QCOMPARE(m_hardwareManager->monitors()->unregisterMonitorCount, unregisteredBefore + 1);
}
/* Le chemin de l'appairage. Sans adresse connue, un setup initial se termine en
* ThingErrorHardwareFailure : c'est un refus explicite, pas un blocage. Avant
* correction, aucune reponse n'arrive jamais. */
void SunSpecPluginTest::test_F1_added_fronius_connection_is_answered()
{
const int monitorsBefore = m_hardwareManager->monitors()->registerMonitorCount;
ThingSetupInfo *info = addConnection(froniusConnectionThingClassId, "Fronius appaire");
QVERIFY(info);
if (!info->isFinished()) {
QSignalSpy finished(info, &ThingSetupInfo::finished);
QVERIFY2(finished.wait(2000), "setupThing() n'a jamais appele info->finish()");
}
QCOMPARE(m_hardwareManager->monitors()->registerMonitorCount, monitorsBefore + 1);
QCOMPARE(info->status(), Thing::ThingErrorHardwareFailure);
QVERIFY(!info->displayMessage().isEmpty());
}
/* Le vendor generique passe par exactement le meme chemin. Si les deux reponses
* divergent, c'est que froniusConnection a ete branche autrement, pas comme les
* vendors deja fonctionnels. */
void SunSpecPluginTest::test_F1_added_fronius_connection_answers_like_the_generic_vendor()
{
ThingSetupInfo *generic = addConnection(sunspecConnectionThingClassId, "SunSpec appaire");
QVERIFY(generic);
if (!generic->isFinished()) {
QSignalSpy finished(generic, &ThingSetupInfo::finished);
QVERIFY2(finished.wait(2000), "setupThing() n'a jamais appele info->finish() pour sunspecConnection");
}
ThingSetupInfo *fronius = addConnection(froniusConnectionThingClassId, "Fronius appaire 2");
QVERIFY(fronius);
if (!fronius->isFinished()) {
QSignalSpy finished(fronius, &ThingSetupInfo::finished);
QVERIFY2(finished.wait(2000), "setupThing() n'a jamais appele info->finish() pour froniusConnection");
}
QCOMPARE(fronius->status(), generic->status());
QCOMPARE(fronius->displayMessage(), generic->displayMessage());
}
QTEST_MAIN(SunSpecPluginTest)
#include "tst_sunspecplugin.moc"

20
sunspec/tests/tests.pro Normal file
View File

@ -0,0 +1,20 @@
TEMPLATE = subdirs
# logic : logique pure du plugin, sans nymea. Ne demande que libnymea-sunspec.
# plugin : instancie reellement IntegrationPluginSunSpec derriere un
# ThingManager et un HardwareManager doubles. Demande en plus
# libnymea1-dev et libnymea-core (pkg-config nymea, nymea-core).
# multiunit : meme montage que plugin, mais pour un thing froniusConnection
# portant deux SunSpecConnection (F3). Binaire distinct pour ne pas
# fausser les compteurs d'appels globaux verifies par plugin.
# integration : le plugin face a un vrai appareil. Construite mais JAMAIS lancee
# par "make check" : elle exige un onduleur joignable, passe par
# SUNSPEC_TEST_HOST. Voir integration/integration.pro.
SUBDIRS += \
logic \
plugin \
multiunit \
integration

View File

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

View File

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

View File

@ -0,0 +1,976 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE TS>
<TS version="2.1">
<context>
<name>IntegrationPluginSunSpec</name>
<message>
<location filename="../integrationpluginsunspec.cpp" line="147"/>
<source>The discovery is not available.</source>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../integrationpluginsunspec.cpp" line="354"/>
<source>The SunSpec connection is not connected.</source>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../integrationpluginsunspec.cpp" line="471"/>
<source>Single Phase Inverter</source>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../integrationpluginsunspec.cpp" line="475"/>
<source>Split Phase Inverter</source>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../integrationpluginsunspec.cpp" line="479"/>
<source>Three Phase Inverter</source>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../integrationpluginsunspec.cpp" line="483"/>
<source>Single Phase Meter</source>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../integrationpluginsunspec.cpp" line="487"/>
<source>Split Phase Meter</source>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../integrationpluginsunspec.cpp" line="493"/>
<source>Three Phase Meter</source>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../integrationpluginsunspec.cpp" line="496"/>
<source>Storage</source>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../integrationpluginsunspec.cpp" line="556"/>
<source>The SunSpec discovery finished with errors. Please make sure this is a SunSpec device.</source>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../integrationpluginsunspec.cpp" line="890"/>
<source>Ground fault</source>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../integrationpluginsunspec.cpp" line="894"/>
<source>DC over voltage</source>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../integrationpluginsunspec.cpp" line="898"/>
<source>AC disconnect</source>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../integrationpluginsunspec.cpp" line="902"/>
<source>DC disconnect</source>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../integrationpluginsunspec.cpp" line="906"/>
<source>Grid disconnect</source>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../integrationpluginsunspec.cpp" line="910"/>
<source>Cabinet open</source>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../integrationpluginsunspec.cpp" line="914"/>
<source>Manual shutdown</source>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../integrationpluginsunspec.cpp" line="918"/>
<source>Over temperature</source>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../integrationpluginsunspec.cpp" line="922"/>
<source>Over frequency</source>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../integrationpluginsunspec.cpp" line="926"/>
<source>Under frequency</source>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../integrationpluginsunspec.cpp" line="930"/>
<source>AC over voltage</source>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../integrationpluginsunspec.cpp" line="934"/>
<source>AC under voltage</source>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../integrationpluginsunspec.cpp" line="938"/>
<source>Blown string fuse</source>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../integrationpluginsunspec.cpp" line="942"/>
<source>Under temperature</source>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../integrationpluginsunspec.cpp" line="946"/>
<source>Memory loss</source>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../integrationpluginsunspec.cpp" line="950"/>
<source>Hardware test failure</source>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../integrationpluginsunspec.cpp" line="954"/>
<source>No error</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>SunSpec</name>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="219"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="222"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="225"/>
<source>AC energy</source>
<extracomment>The name of the StateType ({d493880d-eb58-4530-8010-8ea4f6d63387}) of ThingClass sunspecThreePhaseInverter
----------
The name of the StateType ({fe3f8a65-121a-4ae1-b22a-ae325dc3e7e6}) of ThingClass sunspecSplitPhaseInverter
----------
The name of the StateType ({4c0407b3-5cd5-438d-bfa8-9a8d6695b458}) of ThingClass sunspecSinglePhaseInverter</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="228"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="231"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="234"/>
<source>AC power</source>
<extracomment>The name of the StateType ({14036f44-25fd-4e93-8e8c-c677b06a2c34}) of ThingClass sunspecThreePhaseInverter
----------
The name of the StateType ({9235eb4b-906c-4557-8e18-bca268a367cc}) of ThingClass sunspecSplitPhaseInverter
----------
The name of the StateType ({f49591d6-d759-4be3-bafc-b6a7a72cf023}) of ThingClass sunspecSinglePhaseInverter</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="237"/>
<source>Available energy</source>
<extracomment>The name of the StateType ({7ac7ccd1-b5d1-488b-a99b-98ef000b7d03}) of ThingClass solarEdgeBattery</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="240"/>
<source>Average temperature</source>
<extracomment>The name of the StateType ({f46298e5-d06c-4ebe-869a-d9feff1a4689}) of ThingClass solarEdgeBattery</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="243"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="246"/>
<source>Battery critical</source>
<extracomment>The name of the StateType ({897616db-e815-4cda-b890-13b37a94ad17}) of ThingClass solarEdgeBattery
----------
The name of the StateType ({3171a6e0-43a7-4de8-8e20-f748e44af7ac}) of ThingClass sunspecStorage</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="249"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="252"/>
<source>Battery level</source>
<extracomment>The name of the StateType ({a4b0c8c9-44f0-43e2-ab25-63fdc9772fb8}) of ThingClass solarEdgeBattery
----------
The name of the StateType ({0bf53f80-97f8-488b-b514-58f9fe08c183}) of ThingClass sunspecStorage</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="255"/>
<source>Battery status</source>
<extracomment>The name of the StateType ({9c24b27a-3ea1-4db1-8868-6c999aad0a18}) of ThingClass solarEdgeBattery</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="258"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="261"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="264"/>
<source>Cabinet temperature</source>
<extracomment>The name of the StateType ({44b0320f-89a7-4248-bad4-288ef898a5cc}) of ThingClass sunspecThreePhaseInverter
----------
The name of the StateType ({6d314d50-b990-4a58-a37f-4a3da42c4407}) of ThingClass sunspecSplitPhaseInverter
----------
The name of the StateType ({51461bde-1a6b-4aa1-94cc-59829ea0a7c8}) of ThingClass sunspecSinglePhaseInverter</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="267"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="270"/>
<source>Charging</source>
<extracomment>The name of the ParamType (ThingClass: sunspecStorage, ActionType: enableCharging, ID: {1f530f79-c0d2-466b-90e1-79149e34d92f})
----------
The name of the StateType ({1f530f79-c0d2-466b-90e1-79149e34d92f}) of ThingClass sunspecStorage</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="273"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="276"/>
<source>Charging rate</source>
<extracomment>The name of the ParamType (ThingClass: sunspecStorage, ActionType: chargingRate, ID: {7f469bbc-64a5-4045-8d5f-9a1a85039851})
----------
The name of the StateType ({7f469bbc-64a5-4045-8d5f-9a1a85039851}) of ThingClass sunspecStorage</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="279"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="282"/>
<source>Charging state</source>
<extracomment>The name of the StateType ({815b0826-c392-4249-a271-19e92ebe46e0}) of ThingClass solarEdgeBattery
----------
The name of the StateType ({f6178427-8e04-45c7-abec-27d04dd5ff04}) of ThingClass sunspecStorage</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="285"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="288"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="291"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="294"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="297"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="300"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="303"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="306"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="309"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="312"/>
<source>Connected</source>
<extracomment>The name of the StateType ({8addf8aa-f879-4afa-935d-4d2ffa38d05e}) of ThingClass solarEdgeBattery
----------
The name of the StateType ({f51d4e5a-24ea-41f9-a453-d2bdd68d804c}) of ThingClass solarEdgeConnection
----------
The name of the StateType ({25a1fb10-a6b9-4037-b7cf-ad481a65beb4}) of ThingClass sunspecStorage
----------
The name of the StateType ({36f861c7-afc1-4725-b41f-67113200d78f}) of ThingClass sunspecThreePhaseMeter
----------
The name of the StateType ({34e34ec9-dab0-438c-9493-a3068bc401de}) of ThingClass sunspecSplitPhaseMeter
----------
The name of the StateType ({d960e7b1-d4aa-4cab-8f54-6bcfdbb8be36}) of ThingClass sunspecSinglePhaseMeter
----------
The name of the StateType ({4401468c-0385-40a9-b436-daf7ed6a50d5}) of ThingClass sunspecThreePhaseInverter
----------
The name of the StateType ({27b49640-f58b-466e-a225-a4663cf3ed96}) of ThingClass sunspecSplitPhaseInverter
----------
The name of the StateType ({48bf31c4-fda7-41e5-a3ef-3011bf96e104}) of ThingClass sunspecSinglePhaseInverter
----------
The name of the StateType ({3e767ad0-b4b3-4398-94c1-00579ea09ca8}) of ThingClass sunspecConnection</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="315"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="318"/>
<source>Current power phase A</source>
<extracomment>The name of the StateType ({0e3e8870-3d59-4ee8-b7aa-33bda68b6ae3}) of ThingClass sunspecThreePhaseMeter
----------
The name of the StateType ({57c0f5ba-a104-424b-b8d4-8bab90acf009}) of ThingClass sunspecSplitPhaseMeter</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="321"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="324"/>
<source>Current power phase B</source>
<extracomment>The name of the StateType ({453d046c-3a66-4590-882c-1283882918fc}) of ThingClass sunspecThreePhaseMeter
----------
The name of the StateType ({456f7cdf-9756-49b8-9084-f212199d4014}) of ThingClass sunspecSplitPhaseMeter</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="327"/>
<source>Current power phase C</source>
<extracomment>The name of the StateType ({ba4e94de-9136-48a7-aad8-3d5cd0e7d968}) of ThingClass sunspecThreePhaseMeter</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="330"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="333"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="336"/>
<source>DC current</source>
<extracomment>The name of the StateType ({81a989aa-4104-438e-bfc5-4f44ce5ceeaf}) of ThingClass sunspecThreePhaseInverter
----------
The name of the StateType ({68e7239f-d7b2-4ab2-9cf3-e769fb5ddfc0}) of ThingClass sunspecSplitPhaseInverter
----------
The name of the StateType ({79bd1748-b540-467d-b6fa-b0cc56d9775a}) of ThingClass sunspecSinglePhaseInverter</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="339"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="342"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="345"/>
<source>DC power</source>
<extracomment>The name of the StateType ({1397d935-1206-4ceb-883d-1fcf41ba6214}) of ThingClass sunspecThreePhaseInverter
----------
The name of the StateType ({560b5ec8-1b2c-47d3-b55e-40f4d0aebfe0}) of ThingClass sunspecSplitPhaseInverter
----------
The name of the StateType ({852a8c28-b14d-492e-b2b3-f22219f4ed36}) of ThingClass sunspecSinglePhaseInverter</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="348"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="351"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="354"/>
<source>DC voltage</source>
<extracomment>The name of the StateType ({8c1c71c4-28fd-472a-83a6-3e96ab03d677}) of ThingClass sunspecThreePhaseInverter
----------
The name of the StateType ({d36bcaba-1e77-431e-89dc-af8ac6172d7d}) of ThingClass sunspecSplitPhaseInverter
----------
The name of the StateType ({157376b8-19ae-415f-b693-cb0a9ba88863}) of ThingClass sunspecSinglePhaseInverter</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="357"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="360"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="363"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="366"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="369"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="372"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="375"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="378"/>
<source>Device model</source>
<extracomment>The name of the ParamType (ThingClass: solarEdgeBattery, Type: thing, ID: {e90a3833-1eb6-43ed-a72a-0cf7cb8015ca})
----------
The name of the ParamType (ThingClass: sunspecStorage, Type: thing, ID: {2abf786d-18be-46cb-b4a3-7e0934a4c3fd})
----------
The name of the ParamType (ThingClass: sunspecThreePhaseMeter, Type: thing, ID: {00d308dc-e9a3-40e4-acad-3cad2ca3590f})
----------
The name of the ParamType (ThingClass: sunspecSplitPhaseMeter, Type: thing, ID: {6bc978cb-a779-4f5f-87a4-3cbbe2a55ca8})
----------
The name of the ParamType (ThingClass: sunspecSinglePhaseMeter, Type: thing, ID: {e31333c8-746d-4955-af0a-ae18d7100de7})
----------
The name of the ParamType (ThingClass: sunspecThreePhaseInverter, Type: thing, ID: {938b6555-e0af-4222-b85b-90bc1bc78c8f})
----------
The name of the ParamType (ThingClass: sunspecSplitPhaseInverter, Type: thing, ID: {d40b1b9d-994d-422c-8a41-b98cf03c1678})
----------
The name of the ParamType (ThingClass: sunspecSinglePhaseInverter, Type: thing, ID: {12f1b02c-033d-423f-a217-955ddb995dd5})</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="381"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="384"/>
<source>Discharging</source>
<extracomment>The name of the ParamType (ThingClass: sunspecStorage, ActionType: enableDischarging, ID: {bc99a159-815a-40ab-a6e8-b46f315305f7})
----------
The name of the StateType ({bc99a159-815a-40ab-a6e8-b46f315305f7}) of ThingClass sunspecStorage</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="387"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="390"/>
<source>Discharging rate</source>
<extracomment>The name of the ParamType (ThingClass: sunspecStorage, ActionType: dischargingRate, ID: {6068f030-acce-44a2-b95f-bd00dd5ca760})
----------
The name of the StateType ({6068f030-acce-44a2-b95f-bd00dd5ca760}) of ThingClass sunspecStorage</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="393"/>
<source>Enable charging</source>
<extracomment>The name of the ActionType ({1f530f79-c0d2-466b-90e1-79149e34d92f}) of ThingClass sunspecStorage</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="396"/>
<source>Enable discharging</source>
<extracomment>The name of the ActionType ({bc99a159-815a-40ab-a6e8-b46f315305f7}) of ThingClass sunspecStorage</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="399"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="402"/>
<source>Energy consumed phase A</source>
<extracomment>The name of the StateType ({cc0fd197-22ed-4461-8bba-47460396798a}) of ThingClass sunspecThreePhaseMeter
----------
The name of the StateType ({481b9895-1c3e-4e74-9418-ee51dae80a63}) of ThingClass sunspecSplitPhaseMeter</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="405"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="408"/>
<source>Energy consumed phase B</source>
<extracomment>The name of the StateType ({e928fde3-b35b-4884-9810-1cd083725e1c}) of ThingClass sunspecThreePhaseMeter
----------
The name of the StateType ({10245c6c-4b0d-44b4-be10-72026b842c86}) of ThingClass sunspecSplitPhaseMeter</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="411"/>
<source>Energy consumed phase C</source>
<extracomment>The name of the StateType ({2a12a991-d6c8-4665-bcc8-ea2685f7f0d4}) of ThingClass sunspecThreePhaseMeter</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="414"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="417"/>
<source>Energy produced phase A</source>
<extracomment>The name of the StateType ({fe7caa7e-cf37-4550-833e-7f9d35001fdd}) of ThingClass sunspecThreePhaseMeter
----------
The name of the StateType ({2dc76576-561c-4bce-8799-fadc55e84aee}) of ThingClass sunspecSplitPhaseMeter</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="420"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="423"/>
<source>Energy produced phase B</source>
<extracomment>The name of the StateType ({edbe64a5-121e-4c96-9dc8-bbc2a33dfdb7}) of ThingClass sunspecThreePhaseMeter
----------
The name of the StateType ({b1489efd-ece0-419e-ab1c-d3d25ab6b958}) of ThingClass sunspecSplitPhaseMeter</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="426"/>
<source>Energy produced phase C</source>
<extracomment>The name of the StateType ({a26790c0-7378-471c-80ba-67021352415d}) of ThingClass sunspecThreePhaseMeter</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="429"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="432"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="435"/>
<source>Error</source>
<extracomment>The name of the StateType ({4479af96-c499-4f15-abd6-4afdb18a9e09}) of ThingClass sunspecThreePhaseInverter
----------
The name of the StateType ({5cbfccc9-6afb-404c-a85e-e0323659a25f}) of ThingClass sunspecSplitPhaseInverter
----------
The name of the StateType ({49240259-d82a-4fe6-b3f5-1cd6a67c87a7}) of ThingClass sunspecSinglePhaseInverter</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="438"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="441"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="444"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="447"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="450"/>
<source>Frequency</source>
<extracomment>The name of the StateType ({66734fc7-b3e4-48d4-8cb7-f0a9f7cef88a}) of ThingClass sunspecThreePhaseMeter
----------
The name of the StateType ({db977c04-a3e1-436f-a0cd-9ce5b7bc6b89}) of ThingClass sunspecSplitPhaseMeter
----------
The name of the StateType ({3a2ce51d-7fa0-4188-bbd6-00d25de90e15}) of ThingClass sunspecSinglePhaseMeter
----------
The name of the StateType ({faa45cae-ed28-4150-9036-fceddf9d6776}) of ThingClass sunspecThreePhaseInverter
----------
The name of the StateType ({611df2ce-2b9c-49f3-9fa7-5706776e812c}) of ThingClass sunspecSinglePhaseInverter</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="453"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="456"/>
<source>Grid charging</source>
<extracomment>The name of the ParamType (ThingClass: sunspecStorage, ActionType: gridCharging, ID: {221a2ef6-0a92-4ff0-87fe-7bd920dbec0b})
----------
The name of the StateType ({221a2ef6-0a92-4ff0-87fe-7bd920dbec0b}) of ThingClass sunspecStorage</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="459"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="462"/>
<source>IP address</source>
<extracomment>The name of the ParamType (ThingClass: solarEdgeConnection, Type: thing, ID: {94f6ba3c-6b8b-47a9-a5cb-73a091ae4cf7})
----------
The name of the ParamType (ThingClass: sunspecConnection, Type: thing, ID: {6be6abc4-e2b2-4687-9343-0e5164ed0ab2})</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="465"/>
<source>Instantaneous current</source>
<extracomment>The name of the StateType ({89f58155-ca0c-48a8-aa7f-6e4d1b1bad36}) of ThingClass solarEdgeBattery</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="468"/>
<source>Instantaneous power</source>
<extracomment>The name of the StateType ({caccf9fd-0f83-4dd5-ae48-a497f13d2a2e}) of ThingClass solarEdgeBattery</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="471"/>
<source>Instantaneous voltage</source>
<extracomment>The name of the StateType ({9d17defb-fc6e-4c10-af53-14b796222a14}) of ThingClass solarEdgeBattery</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="474"/>
<source>Line frequency</source>
<extracomment>The name of the StateType ({874f5e4a-a009-4c28-b211-2af90a24b2ac}) of ThingClass sunspecSplitPhaseInverter</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="477"/>
<source>Line to Neutral AC Voltage</source>
<extracomment>The name of the StateType ({0ac79508-07c3-4d01-97a3-6edf121bdf32}) of ThingClass sunspecSplitPhaseMeter</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="480"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="483"/>
<source>MAC address</source>
<extracomment>The name of the ParamType (ThingClass: solarEdgeConnection, Type: thing, ID: {bb395c12-54d6-4139-b0a6-e31b28bc4d2e})
----------
The name of the ParamType (ThingClass: sunspecConnection, Type: thing, ID: {f65d6c36-1672-44cb-b52a-62d71837ae67})</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="486"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="489"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="492"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="495"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="498"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="501"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="504"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="507"/>
<source>Manufacturer</source>
<extracomment>The name of the ParamType (ThingClass: solarEdgeBattery, Type: thing, ID: {8a4b19a9-ee14-42aa-9146-fa84e15970ee})
----------
The name of the ParamType (ThingClass: sunspecStorage, Type: thing, ID: {7ee31b97-caec-4cdb-80c9-71e232c5fb22})
----------
The name of the ParamType (ThingClass: sunspecThreePhaseMeter, Type: thing, ID: {462552de-af84-4893-9132-81c1adce67ad})
----------
The name of the ParamType (ThingClass: sunspecSplitPhaseMeter, Type: thing, ID: {ecbdee01-8be7-42bf-9e1a-a0f700669b09})
----------
The name of the ParamType (ThingClass: sunspecSinglePhaseMeter, Type: thing, ID: {811de380-848a-448c-8446-daa0e0fd8e50})
----------
The name of the ParamType (ThingClass: sunspecThreePhaseInverter, Type: thing, ID: {640b71ba-2ccd-40d6-bf45-8271a9046f81})
----------
The name of the ParamType (ThingClass: sunspecSplitPhaseInverter, Type: thing, ID: {c2c35b68-1c0f-47cc-b09f-49d9cf67c0ff})
----------
The name of the ParamType (ThingClass: sunspecSinglePhaseInverter, Type: thing, ID: {5fbb0b62-e0f9-4aee-aba7-fd00ae8b0a5d})</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="510"/>
<source>Maximum energy</source>
<extracomment>The name of the StateType ({d8cc799d-9709-4342-8766-52ff5f12aecc}) of ThingClass solarEdgeBattery</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="513"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="516"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="519"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="522"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="525"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="528"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="531"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="534"/>
<source>Modbus address</source>
<extracomment>The name of the ParamType (ThingClass: solarEdgeBattery, Type: thing, ID: {7413cce6-48c1-4924-89bc-915beed67b57})
----------
The name of the ParamType (ThingClass: sunspecStorage, Type: thing, ID: {3f107844-00c5-4f39-86e5-485b3d1f5c1a})
----------
The name of the ParamType (ThingClass: sunspecThreePhaseMeter, Type: thing, ID: {6d5dbd35-1bf6-46db-bee9-90c679421b89})
----------
The name of the ParamType (ThingClass: sunspecSplitPhaseMeter, Type: thing, ID: {a56f198d-ed86-429f-b839-8e11a32da8c1})
----------
The name of the ParamType (ThingClass: sunspecSinglePhaseMeter, Type: thing, ID: {30b90ec0-429b-4e6c-88e9-155aa4bcad47})
----------
The name of the ParamType (ThingClass: sunspecThreePhaseInverter, Type: thing, ID: {e5465ede-9d3d-4558-b614-40dda743ddae})
----------
The name of the ParamType (ThingClass: sunspecSplitPhaseInverter, Type: thing, ID: {37582a96-f2f2-4845-abef-973c7dd0ad57})
----------
The name of the ParamType (ThingClass: sunspecSinglePhaseInverter, Type: thing, ID: {26ae9050-7090-453a-85a3-307bfebe6fed})</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="537"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="540"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="543"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="546"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="549"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="552"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="555"/>
<source>Model</source>
<extracomment>The name of the ParamType (ThingClass: sunspecStorage, Type: thing, ID: {219beb96-b9fe-4dd2-a386-ecfbbab8786d})
----------
The name of the ParamType (ThingClass: sunspecThreePhaseMeter, Type: thing, ID: {a1960821-155c-4176-86fa-974429039182})
----------
The name of the ParamType (ThingClass: sunspecSplitPhaseMeter, Type: thing, ID: {89aeec6d-abeb-48b5-9594-214ad5db2d03})
----------
The name of the ParamType (ThingClass: sunspecSinglePhaseMeter, Type: thing, ID: {7d6fcafb-c62e-4a21-aae2-f4041c487149})
----------
The name of the ParamType (ThingClass: sunspecThreePhaseInverter, Type: thing, ID: {8d5b2b58-ce46-406d-844e-f53136afcf09})
----------
The name of the ParamType (ThingClass: sunspecSplitPhaseInverter, Type: thing, ID: {c42fb50e-210f-4b53-88eb-fa216e15f88f})
----------
The name of the ParamType (ThingClass: sunspecSinglePhaseInverter, Type: thing, ID: {41715d00-a947-4f43-a475-cea05790e01d})</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="558"/>
<source>Number of retries</source>
<extracomment>The name of the ParamType (ThingClass: sunSpec, Type: plugin, ID: {9a4bfe01-315f-4ee7-98a9-f16b08ba12ad})</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="561"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="564"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="567"/>
<source>Operating state</source>
<extracomment>The name of the StateType ({cebdce98-42d1-4a28-8834-8960efc0e83f}) of ThingClass sunspecThreePhaseInverter
----------
The name of the StateType ({6c1e2929-bc9a-4ce9-a405-6df2633a5131}) of ThingClass sunspecSplitPhaseInverter
----------
The name of the StateType ({47543a7f-425f-406b-a458-b79c36b65f6c}) of ThingClass sunspecSinglePhaseInverter</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="570"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="573"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="576"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="579"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="582"/>
<source>Phase A current</source>
<extracomment>The name of the StateType ({46ae5c40-f389-4f7e-a59f-8947d7dc9f3f}) of ThingClass sunspecThreePhaseMeter
----------
The name of the StateType ({e8c0f4bf-a704-46f2-80a0-cf490bd7871b}) of ThingClass sunspecSplitPhaseMeter
----------
The name of the StateType ({4a058e36-0b45-4388-9a26-0615f7aafa0d}) of ThingClass sunspecSinglePhaseMeter
----------
The name of the StateType ({3140ccd3-40cf-46c8-8bb2-8c3ea4582f84}) of ThingClass sunspecThreePhaseInverter
----------
The name of the StateType ({be7b86b4-aeeb-49ba-9b6b-9792dceed6b5}) of ThingClass sunspecSplitPhaseInverter</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="585"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="588"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="591"/>
<source>Phase A voltage</source>
<extracomment>The name of the StateType ({4ff342de-f0b7-475a-99e4-3a926326d1b7}) of ThingClass sunspecThreePhaseMeter
----------
The name of the StateType ({1bd7e53e-abf8-4d62-b87c-2c84c283567b}) of ThingClass sunspecSplitPhaseMeter
----------
The name of the StateType ({5e7a65a5-b740-4395-87fa-ffbe8fe1c823}) of ThingClass sunspecSinglePhaseMeter</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="594"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="597"/>
<source>Phase AN voltage</source>
<extracomment>The name of the StateType ({f08521aa-9c38-4c31-95e1-acb616f6e9c6}) of ThingClass sunspecThreePhaseInverter
----------
The name of the StateType ({17c24cfc-cb41-4873-82b4-19a20d6be146}) of ThingClass sunspecSplitPhaseInverter</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="600"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="603"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="606"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="609"/>
<source>Phase B current</source>
<extracomment>The name of the StateType ({efc75436-ac51-4fee-81f1-65a726416980}) of ThingClass sunspecThreePhaseMeter
----------
The name of the StateType ({4281f6fc-d5a0-4a22-ac61-6bec88efbc80}) of ThingClass sunspecSplitPhaseMeter
----------
The name of the StateType ({7ea1a53a-6fd9-4914-8283-b57aa1aaaebf}) of ThingClass sunspecThreePhaseInverter
----------
The name of the StateType ({fc5df18d-cf2f-4944-97b7-e57dabef8778}) of ThingClass sunspecSplitPhaseInverter</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="612"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="615"/>
<source>Phase B voltage</source>
<extracomment>The name of the StateType ({cc8434f1-02fe-41ad-bc8a-94e57293f8fc}) of ThingClass sunspecThreePhaseMeter
----------
The name of the StateType ({377b5279-ddb6-451d-8377-a9389c749393}) of ThingClass sunspecSplitPhaseMeter</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="618"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="621"/>
<source>Phase BN voltage</source>
<extracomment>The name of the StateType ({739b8805-d522-4406-bede-d1e4200a3aa9}) of ThingClass sunspecThreePhaseInverter
----------
The name of the StateType ({f8645ee2-a1e6-4d09-8c20-f6fd02a9e896}) of ThingClass sunspecSplitPhaseInverter</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="624"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="627"/>
<source>Phase C current</source>
<extracomment>The name of the StateType ({618cbcc5-2efe-49d2-9c5c-53fb3eaa3a9d}) of ThingClass sunspecThreePhaseMeter
----------
The name of the StateType ({aa4b4cf5-43d0-4be5-9505-403918b5371d}) of ThingClass sunspecThreePhaseInverter</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="630"/>
<source>Phase C voltage</source>
<extracomment>The name of the StateType ({fbd673cf-8891-4aef-b112-2829f5fcfe06}) of ThingClass sunspecThreePhaseMeter</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="633"/>
<source>Phase CN voltage</source>
<extracomment>The name of the StateType ({949b797d-5566-4667-8982-e430d23548e2}) of ThingClass sunspecThreePhaseInverter</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="636"/>
<source>Phase voltage</source>
<extracomment>The name of the StateType ({4ca086e9-82b9-461c-b168-1d61b542b884}) of ThingClass sunspecSinglePhaseInverter</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="639"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="642"/>
<source>Port</source>
<extracomment>The name of the ParamType (ThingClass: solarEdgeConnection, Type: thing, ID: {1bcede48-d167-4ced-8f1b-bea6302dd43f})
----------
The name of the ParamType (ThingClass: sunspecConnection, Type: thing, ID: {1fa4fc9c-f6be-47c7-928a-bcefc1142eec})</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="645"/>
<source>Rated energy</source>
<extracomment>The name of the StateType ({f6c6f548-8a12-4a62-a641-08bb003c55fa}) of ThingClass solarEdgeBattery</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="648"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="651"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="654"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="657"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="660"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="663"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="666"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="669"/>
<source>Serial number</source>
<extracomment>The name of the ParamType (ThingClass: solarEdgeBattery, Type: thing, ID: {efd99fe4-3a48-4208-829d-14e062015ee7})
----------
The name of the ParamType (ThingClass: sunspecStorage, Type: thing, ID: {5cf43b80-3e65-4f69-8bf5-91810da0ead4})
----------
The name of the ParamType (ThingClass: sunspecThreePhaseMeter, Type: thing, ID: {97bbcc98-f4ec-4060-bac2-31b40720862c})
----------
The name of the ParamType (ThingClass: sunspecSplitPhaseMeter, Type: thing, ID: {fb7cc04b-91cb-4f3d-a444-201cca57934a})
----------
The name of the ParamType (ThingClass: sunspecSinglePhaseMeter, Type: thing, ID: {581fa623-0005-448f-a5b1-8db1043795a3})
----------
The name of the ParamType (ThingClass: sunspecThreePhaseInverter, Type: thing, ID: {ee169df5-475c-4dfb-870a-0785899b7dc1})
----------
The name of the ParamType (ThingClass: sunspecSplitPhaseInverter, Type: thing, ID: {8afdd256-0393-4610-9477-7c4541c92ffe})
----------
The name of the ParamType (ThingClass: sunspecSinglePhaseInverter, Type: thing, ID: {59b85218-f144-4ce8-95f7-19a1dc2e2a8d})</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="672"/>
<source>Set charging rate</source>
<extracomment>The name of the ActionType ({7f469bbc-64a5-4045-8d5f-9a1a85039851}) of ThingClass sunspecStorage</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="675"/>
<source>Set discharging rate</source>
<extracomment>The name of the ActionType ({6068f030-acce-44a2-b95f-bd00dd5ca760}) of ThingClass sunspecStorage</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="678"/>
<source>Set grid charging</source>
<extracomment>The name of the ActionType ({221a2ef6-0a92-4ff0-87fe-7bd920dbec0b}) of ThingClass sunspecStorage</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="681"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="684"/>
<source>Slave ID</source>
<extracomment>The name of the ParamType (ThingClass: solarEdgeConnection, Type: thing, ID: {b9cc58cf-ad15-447a-a94d-17b9204313bc})
----------
The name of the ParamType (ThingClass: sunspecConnection, Type: thing, ID: {953064e0-4675-4538-a9a2-fa22ce2f347c})</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="687"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="690"/>
<source>SolarEdge</source>
<extracomment>The name of the ThingClass ({7a92bf65-b443-4491-a012-2bec35eb5bf0})
----------
The name of the vendor ({addd7dd8-13e1-405b-ba61-b2d82eaf0426})</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="693"/>
<source>SolarEdge battery</source>
<extracomment>The name of the ThingClass ({7900471a-da3f-4f03-98be-3e3d9c841546})</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="696"/>
<source>State of health (SOH)</source>
<extracomment>The name of the StateType ({24b501db-f301-422d-ad58-f16ca025f6ee}) of ThingClass solarEdgeBattery</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="699"/>
<source>Status</source>
<extracomment>The name of the StateType ({da2b19c5-0f48-49d1-93f0-abdc0051407d}) of ThingClass sunspecStorage</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="702"/>
<source>SunSpec</source>
<extracomment>The name of the vendor ({c143a7b4-a16c-4fff-86a3-9ffab3d6841d})</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="705"/>
<source>SunSpec Generic</source>
<extracomment>The name of the ThingClass ({f51853f3-8815-4cf3-b337-45cda1f3e6d5})</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="708"/>
<source>SunSpec Storage</source>
<extracomment>The name of the ThingClass ({9a643ba8-346c-4127-a2f8-956a7133d75e})</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="711"/>
<source>SunSpec single phase inverter</source>
<extracomment>The name of the ThingClass ({c5d5204e-3375-4b92-8128-fab77a671fed})</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="714"/>
<source>SunSpec single phase meter</source>
<extracomment>The name of the ThingClass ({7ffa43b8-b56f-4435-8509-980e9d81dfa8})</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="717"/>
<source>SunSpec split phase inverter</source>
<extracomment>The name of the ThingClass ({61b38f93-d331-42bf-b1ef-d3fb16ad1230})</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="720"/>
<source>SunSpec split phase meter</source>
<extracomment>The name of the ThingClass ({b8a18e45-5ff5-4f43-915f-04ee216c809d})</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="723"/>
<source>SunSpec three phase inverter</source>
<extracomment>The name of the ThingClass ({2e4122ea-96a5-415c-b5e2-7d6012265a83})</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="726"/>
<source>SunSpec three phase meter</source>
<extracomment>The name of the ThingClass ({68f822f9-ff30-4275-b229-39a3674fead7})</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="729"/>
<source>Timout</source>
<extracomment>The name of the ParamType (ThingClass: sunSpec, Type: plugin, ID: {1a8895a0-c746-48af-9307-3a4636f24cc2})</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="732"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="735"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="738"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="741"/>
<source>Total AC current</source>
<extracomment>The name of the StateType ({e85024af-5376-4ff1-813e-5a56990c11cc}) of ThingClass sunspecSplitPhaseMeter
----------
The name of the StateType ({26560dd8-6de4-445e-ba55-391d7241c370}) of ThingClass sunspecThreePhaseInverter
----------
The name of the StateType ({9dbd8da7-dc22-4c3a-b941-47520fde705f}) of ThingClass sunspecSplitPhaseInverter
----------
The name of the StateType ({f02d1c99-9b43-45a6-8a06-2ed4d6e5d497}) of ThingClass sunspecSinglePhaseInverter</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="744"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="747"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="750"/>
<source>Total real energy exported</source>
<extracomment>The name of the StateType ({01a69b2c-cc96-4925-9b1c-a2f204ef0837}) of ThingClass sunspecThreePhaseMeter
----------
The name of the StateType ({8a63bd73-0546-4636-8da2-23238cc06fb2}) of ThingClass sunspecSplitPhaseMeter
----------
The name of the StateType ({ba275bdf-f418-4ef0-afbe-ac425c6f6783}) of ThingClass sunspecSinglePhaseMeter</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="753"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="756"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="759"/>
<source>Total real energy imported</source>
<extracomment>The name of the StateType ({0a98b229-7625-4811-8bf8-ed22be2db3fb}) of ThingClass sunspecThreePhaseMeter
----------
The name of the StateType ({51ffb2ae-3920-40df-8290-bbf5b6e1a68f}) of ThingClass sunspecSplitPhaseMeter
----------
The name of the StateType ({c51dc6cb-5c05-4078-b11a-26afb2f85541}) of ThingClass sunspecSinglePhaseMeter</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="762"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="765"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="768"/>
<source>Total real power</source>
<extracomment>The name of the StateType ({47780b0a-68b7-46c3-bb98-79ec9a57fd69}) of ThingClass sunspecThreePhaseMeter
----------
The name of the StateType ({ef4bc0f8-f516-49b7-aba8-d5f987485aca}) of ThingClass sunspecSplitPhaseMeter
----------
The name of the StateType ({93cf8c6a-2620-42ed-9070-e0726d7b1dbc}) of ThingClass sunspecSinglePhaseMeter</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="771"/>
<source>Update interval</source>
<extracomment>The name of the ParamType (ThingClass: sunSpec, Type: plugin, ID: {52da5222-9a94-47a2-9adc-004541d2f5ed})</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="774"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="777"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="780"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="783"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="786"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="789"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="792"/>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="795"/>
<source>Version</source>
<extracomment>The name of the StateType ({db2e0304-4f0e-461f-8bab-c63fcb5deb1b}) of ThingClass solarEdgeBattery
----------
The name of the StateType ({fca0c151-d8fa-4d6e-a5d7-b3bc965abc09}) of ThingClass sunspecStorage
----------
The name of the StateType ({3d908dd2-4a93-4b62-8cbb-2fa9ef4d28fa}) of ThingClass sunspecThreePhaseMeter
----------
The name of the StateType ({85c5087c-77b4-4ac8-bdff-020f6242bd57}) of ThingClass sunspecSplitPhaseMeter
----------
The name of the StateType ({94adff6f-f672-4e4e-a72c-cd6cd7e8721a}) of ThingClass sunspecSinglePhaseMeter
----------
The name of the StateType ({3b543b60-d8f6-4bc0-a79f-cb9cb20e4bb8}) of ThingClass sunspecThreePhaseInverter
----------
The name of the StateType ({2986cac4-f4ac-4299-ac9e-48339e2a54ef}) of ThingClass sunspecSplitPhaseInverter
----------
The name of the StateType ({f4439de3-a461-42d6-b4c9-aa35f696a420}) of ThingClass sunspecSinglePhaseInverter</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="798"/>
<source>sunspec</source>
<extracomment>The name of the plugin SunSpec ({cb4bdec6-cf2c-4a0f-9709-42d951ca2d8b})</extracomment>
<translation type="unfinished"></translation>
</message>
</context>
</TS>