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

438 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.