Constaté au banc à 14:41:58 : l'avertissement « has a model 160 but no
StCha/StDisCha block » a été émis une fois, au démarrage, sur une machine dont
les deux blocs existent et dont le currentPower a ensuite suivi la batterie
correctement en charge, en décharge et au repos.
Cause. libnymea-sunspec crée les blocs répétés d'un modèle depuis
`initFinished`, émis pendant la PREMIÈRE lecture, juste avant `blockUpdated`.
Sur ce premier signal les blocs existent mais n'ont jamais été remplis : tous
les `IDStr` sont vides, rien ne se classe, et `hasBatteryBlocks()` répond faux.
Cette réponse est exacte et hors sujet — la question n'était pas « pas de
batterie » mais « pas encore lu ».
Ce que le faux positif coûtait. Ce n'est pas du bruit : le README dit à
l'exploitation de chercher cette ligne EN PREMIER pour distinguer une batterie
cassée d'une batterie non mesurable. Une ligne qui sort à chaque démarrage sain
ne distingue plus rien, et le diagnostic que ce lot venait d'installer était
mort-né.
Les deux questions sont maintenant posées séparément : `blocksAreIdentified()`
avant `hasBatteryBlocks()`. Sans identification, on se tait et on attend le tour
suivant — `readBlockData()` passe à chaque rafraîchissement de toute façon.
Le garde en double au moment du binding disparaît aussi : les deux updaters se
gardent eux-mêmes, un seul endroit à tenir à jour plutôt que deux qui peuvent
diverger.
Deux tests figent la distinction, dont un sur le cas où l'avertissement DOIT
sortir — un GEN24 sans batterie, lu, identifié, sans bloc batterie.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y3gzsuwENJfJtvuNDT3xGn
Le relevé du 2026-08-26 a montré DCW_SF passant de -2 à -1 entre une batterie à
l'arrêt et une batterie en charge, sur le même onduleur, à quelques secondes
d'intervalle. Un DCW brut de 20000 vaut 200,0 W avec le premier facteur et
2000,0 W avec le second.
Ce que ça casserait est de la même famille que d08cf50 mais plus discret : pas
de dénormal, pas d'erreur Modbus, un appareil qui se déclare connected, et une
puissance simplement dix fois fausse. La propriété tient aujourd'hui parce que
libnymea-sunspec lit les 90 registres du modèle 160 en une transaction et relit
les facteurs du bloc fixe AVANT les blocs répétés — deux propriétés qui vivent
en amont, que rien dans le compilateur ne tient, et qu'une optimisation de la
lecture ferait sauter sans aucun signe extérieur.
Le commentaire est donc à l'endroit qui en dépend : au-dessus de
storageModuleReadings(), qui consomme dcPower(), avec un pointeur au site de
readBlockData() et sur le champ ModuleReading::dcPower.
Le README documente en outre le côté exploitation de D-9 : un onduleur qui
publie le 124 sans le 160 porte energystorage avec un currentPower figé à zéro,
avec les deux lignes de journal exactes à chercher et la commande pour les
trouver.
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
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>
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>
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>
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>
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>
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>
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>
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.
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.
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.
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.