etm-powersync-plugins-modbus/specs/fronius-sunspec.md
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

21 KiB
Raw Blame History

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.