9 Commits

Author SHA1 Message Date
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
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
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
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
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