6 Commits

Author SHA1 Message Date
Patrick Schurig
51a211e080 fix(sunspec): rendre la batterie SunSpec visible au moteur (energystorage)
sunspecStorage déclarait `battery` mais pas `energystorage`. Les trois
consommateurs qui filtrent par cette interface ne la trouvaient donc pas : le
bilan de puissance de nymea (`currentPowerStorage`, celui que le CLIENT lit sur
son tableau de bord), la réserve de stockage de la recharge VE, et celle du
waterfall. Vérifié sans authentification dans `energylogs.sqlite` de .75 : la
colonne `storage` de `powerBalance` est à 0.0 sur toutes les lignes, pendant
que la batterie affiche 50 % et « Holding ». Elle n'était pas à zéro, elle
était invisible.

Ce n'était pas une ligne de JSON. `energystorage` exige `currentPower` (W,
signé) et `capacity` (kWh), qu'aucun des quatorze états ne portait, et les
déclarer sans les câbler aurait donné une batterie éternellement inerte — pire
que son absence, puisque la réserve lirait « ni charge ni décharge » au lieu de
« je ne sais pas ».

D'où viennent les deux états. Le modèle 124 ne porte AUCUNE puissance : WChaMax
est un maximum, StorAval est en ampères-heures. La puissance instantanée vit
dans le modèle 160, réparti sur deux blocs `StCha` et `StDisCha` dont le `DCW`
est un uint16 toujours positif, l'inactif à zéro. D'où
`currentPower = DCW[StCha] - DCW[StDisCha]`, positif en charge, conformément à
libnymea/interfaces/energystorage.json (« positive is 'consumed' »). C'est
l'INVERSE de la convention constructeur du contrat §4, et
`FroniusRules::batteryPower()` est réécrite comme la négation stricte de la
nouvelle `storageCurrentPower()` pour qu'aucune des deux ne dérive seule.
`capacity` vient de WHRtg du modèle 120, et l'état est laissé tranquille plutôt
que mis à zéro quand le nameplate ne l'implémente pas.

Le signe est MESURÉ, pas déduit. Banc .211 unité 1, consigne batterie imposée
par MQTT, registres relus en lecture seule :
  +2000 W -> StCha 2000,0 W  StDisCha    0,0 W  ChaSt Charging
  -1500 W -> StCha    0,0 W  StDisCha 1500,0 W  ChaSt Discharging
Le même relevé a montré `DCW_SF` passant de -2 à -1 entre repos et charge : le
facteur d'échelle n'est jamais mis en cache d'un relevé à l'autre.

Écart assumé, D-9 dans DIVERGENCES.md : `sunspecStorage` est partagé avec le
vendor générique, et les interfaces se déclarent par thingClass. Un appareil
générique publiant le 124 sans le 160 porte désormais `energystorage` avec un
`currentPower` figé à zéro. Les trois alternatives sont pires — une thingClass
Fronius distincte détruirait le Thing existant et son historique. Le plugin
avertit donc UNE FOIS par thing quand il ne trouve pas de source de puissance,
en distinguant « pas de modèle 160 » de « modèle 160 sans bloc batterie ». Ça ne
remplace pas la mesure, mais ça retire le silence.

La cohérence des unités avec l'interface est vérifiée à la COMPILATION par
nymea-plugininfocompiler : mettre `KiloWattHour` sur `currentPower` fait échouer
le build. Onze tests `test_D9_*` figent le reste.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y3gzsuwENJfJtvuNDT3xGn
2026-08-26 14:02:37 +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
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