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>
21 KiB
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-sunspecn'est jamais modifiée. Tout vit danssunspec/. Voirsunspec/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.
chargingRateest alimenté depuisinWRte().dischargingRateest alimenté depuisoutWRte().- 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.
- Une valeur
revertTimeest configurable par thing (paramètre de la thingClasssunspecStorage), en secondes. - Toute séquence de consigne (
InWRte,OutWRte,StorCtlMod,ChaGriSet) se termine par une écriture deInOutWRte_RvrtTmsà cette valeur. RvrtTmsest écrit en dernier dans la séquence.- Défaut du vendor
sunspecgénérique : 0 (comportement amont inchangé). Défaut du vendorfronius: 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.
- Défauts du vendor
fronius:updateInterval = 10s,timeout = 2000ms,numberOfRetries = 3. - Garde-fou dur : toute valeur d'intervalle strictement inférieure à 7 s
configurée sur une connexion
froniusest remontée à 7 s, avec unqCWarningexplicite. Ce n'est pas une valeur par défaut modifiable : c'est un plancher dans le code. - Le plancher s'applique aussi en debug.
- Le vendor
sunspecgénérique et le vendorsolarEdgene 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 surRvrtTms = 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.
- Après chaque séquence de consigne,
StorCtl_Mod,InWRte,OutWRteetChaGriSetsont relus et comparés aux valeurs écrites. - En cas de divergence :
qCWarningexplicite nommant le registre, la valeur écrite et la valeur relue. - Un state
controlHealthy(bool) passe àfalseen cas de divergence, àtrueaprè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.
- Le classement d'un bloc en « string PV » ou « batterie » se fait sur
inputIdSting(), jamais sur l'index du bloc. - Un bloc dont
IDStrcommence parMPPTest un string PV. - Un bloc dont
IDStrcommence parStChaest le bloc batterie en charge ;StDisCha, le bloc batterie en décharge. - Un
IDStrinconnu n'est pas classé et ne contribue à aucun agrégat ; il est signalé par unqCWarning.
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.
- Puissance PV = Σ
DCWdes blocs classés « string PV ». - Puissance batterie =
DCW[StDisCha]−DCW[StCha], positif = décharge. DCW_SFest partagé par tous les modules et auto-scalé : il est relu avant conversion (INV-5).- 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
RvrtTmssur 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,
DCVetDCWHreporté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
switchdeprocessDiscoveryResult()), A6 (MinRsvPctnon exposé) et A7 (.toInt()sur des paramètres destinés à des settersfloat) : 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
froniusest configurée : c'est plus strict que l'AC, jamais moins. Le paramètreupdateIntervalpar thing a été retiré defroniusConnection: 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 parIntegrationPluginSunSpec::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
(
ModelIdMpptabsent duswitchdeprocessDiscoveryResult()) 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.