Patrick Schurig f8c0f24c40 docs(sunspec): D-10 — la géométrie des modèles est figée à la découverte
Constaté en reconstruisant le banc le 2026-08-26. Le simulateur a été redémarré
sans batterie puis de nouveau avec, sans jamais rompre la connexion TCP. Le
plugin a continué de lire l'ancienne géométrie : `ProtocolError` toutes les 7 s
sur le modèle 124 disparu, puis — une fois la batterie revenue — un
`currentPower` FIGÉ à 0 pendant que les registres portaient StCha = 2000 W et
ChaSt = Charging. Trois minutes de `thingPower` à 0.0 sur une batterie en charge.

Un `systemctl restart nymead` force la redécouverte et la valeur repart
immédiatement à 2000,0 W. Le défaut n'est donc ni dans le signe ni dans le
mapping de D-9 : ceux-là sont justes et le prouvent dès que la découverte est
refaite. Il est dans le fait qu'une liaison thing↔modèle est établie une fois et
jamais reconsidérée — et le storage y est plus exposé que les autres things
parce qu'il dépend maintenant de TROIS modèles au lieu d'un.

Rare sur matériel, mais pas théorique : une mise à jour de firmware, ou une
batterie ajoutée à un hybride déjà appairé, produisent la même transition.
Le signal existe déjà et n'est pas exploité — le ProtocolError répété à une
adresse jusque-là valide dit que la chaîne a bougé.

Documenté, pas corrigé : cela touche au cycle de découverte, hors périmètre.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y3gzsuwENJfJtvuNDT3xGn
2026-08-26 17:17:02 +02:00

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

# Divergences — vendor Fronius du plugin SunSpec
Écarts assumés entre ce que demandent `specs/fronius-sunspec.md` et le contrat
d'une part, et ce que le code fait d'autre part. Chaque entrée dit **pourquoi**
et **comment la lever**.
Deux étiquettes :
- **MESURÉ** — l'écart repose sur une observation faite sur du matériel ou sur
le code de la plateforme, reproductible.
- **INFÉRÉ** — l'écart repose sur un raisonnement, faute de donnée. À reprendre
dès qu'une mesure existe.
---
## D-1 — `meterSlaveId` n'est pas marqué `advanced` (MESURÉ)
**Ce que demande AC-7.** Le paramètre `meterSlaveId` de la thingClass
`froniusConnection` doit être `advanced`.
**Ce que fait le code.** Rien : le paramètre n'a pas ce drapeau.
**Pourquoi.** `advanced` n'est pas une propriété de paramType dans nymea 1.15.2.
`ParamType::typeProperties()` énumère exactement `id`, `name`, `displayName`,
`type`, `defaultValue`, `inputType`, `unit`, `minValue`, `maxValue`, `stepSize`,
`allowedValues`, `readOnly`. Le vérificateur de métadonnée refuse tout champ
inconnu :
```
Param type "meterSlaveId" has unknown fields: "advanced"
```
et `PluginMetadata::isValid()` passe à `false`. Ce n'est pas un paramètre mal
affiché : **le plugin ne se charge plus du tout**, et les erreurs suivantes en
cascade (le paramType est abandonné, donc `froniusConnection` ne satisfait plus
l'interface `networkdevice`) rendent le diagnostic trompeur.
Vérifié en chargeant le JSON, avec et sans le drapeau, dans un
`PluginMetadata` lié à la `libnymea` installée — pas seulement lu dans une
source amont qui pourrait diverger du paquet.
**Comment lever.** Rien à faire côté plugin. Il faut soit que nymea ajoute la
propriété, soit que AC-7 soit corrigé. Un test de garde
(`test_F3_meter_slave_id_is_not_marked_advanced`) empêche qu'on ajoute le
drapeau sans refaire la mesure.
---
## D-2 — délai de garde du balayage des enfants orphelins (INFÉRÉ)
**Contexte.** `processDiscoveryResult()` fait disparaître les things enfants
dont le modèle n'est plus annoncé. Avec deux connexions sous le même parent,
chacune ne voit que la moitié du tableau : balayer sur cette moitié ferait
disparaître les enfants de l'autre à chaque découverte. Le balayage attend donc
que **toutes** les connexions du parent aient réussi une découverte, puis
confronte les enfants à l'union de leurs modèles.
**L'écart.** Une connexion dont la découverte n'aboutit jamais bloquerait le
balayage pour toujours. Un délai de garde le débloque :
`OrphanSweepGraceSeconds = 600` (10 minutes).
**Pourquoi cette valeur, et pourquoi elle est INFÉRÉE.** Aucune donnée ne la
fixe. Le choix est asymétrique et volontairement paresseux : un balayage trop
tardif laisse traîner un thing périmé affiché déconnecté, un balayage trop
précoce **supprime un thing de l'utilisateur**. À conséquences inégales,
réglage prudent. 10 minutes est très au-delà de tout cycle de découverte
observé, sans être infini.
**Comment lever.** Mesurer, sur le banc puis sur matériel, le temps que met une
découverte SunSpec à échouer définitivement sur un unit ID qui ne répond pas
(timeout × retries × nombre de registres de base balayés). Le délai de garde
doit valoir quelques fois cette durée, pas dix minutes arbitraires. La valeur
est un seul `const` dans `integrationpluginsunspec.cpp` ; la changer n'a
d'effet sur rien d'autre.
---
## D-3 — la configuration du plugin écrase les défauts de cadence Fronius (MESURÉ, préexistant)
**Contexte.** `createConnection()` impose `timeout` et `numberOfRetries` du
vendor Fronius (AC-8) à la création. Mais `onPluginConfigurationChanged()`
réapplique ensuite les valeurs globales du plugin à **toutes** les connexions,
Fronius comprise.
**L'écart.** Il est antérieur à F3 et affecte déjà la connexion onduleur seule.
F3 ne le corrige pas ; il l'étend symétriquement à la connexion compteur, pour
que les deux connexions d'un même thing se comportent identiquement. Le
plancher d'intervalle d'interrogation d'AC-8, lui, reste appliqué : il vit dans
`effectiveUpdateInterval()`, pas ici.
**Comment lever.** Hors périmètre de F3 : cela demande de décider si les
réglages globaux du plugin doivent pouvoir passer outre un défaut vendor, ce que
ni le contrat ni la spec ne tranchent.
---
## D-4 — connexion détruite par `ThingSetupInfo::aborted` et laissée dans la table (MESURÉ, préexistant, amont)
**Contexte.** `setupThing()` relie `ThingSetupInfo::aborted` à
`SunSpecConnection::deleteLater()`, alors que `createConnection()` a déjà inséré
la connexion dans `m_sunSpecConnections`. Un setup avorté laisse donc un
pointeur pendant dans la table ; le setup suivant du même thing prend la branche
« reconfiguration » et appelle `deleteLater()` sur un objet déjà détruit.
**L'écart.** F3 ne corrige pas ce chemin pour la connexion primaire : il est
commun aux trois vendors, y compris `solarEdge` dont aucun test ne couvre le
comportement, et le périmètre du lot est explicitement « generic et
solarEdgeConnection ne changent pas de comportement ». La connexion **compteur**
ajoutée par F3, elle, se retire proprement de sa table à la destruction.
**Comment lever.** Relier `QObject::destroyed` au retrait de la table pour les
deux connexions, dans un lot qui assume de toucher le chemin commun, avec une
couverture de test pour `solarEdge`.
---
## D-5 — le signe du compteur est corrigé côté Fronius seulement (MESURÉ)
**Contexte.** `onMeterBlockUpdated()` négociait le signe de façon **générique** :
tous les modèles compteur (201, 202, 203, 204, 211…214) faisaient
`-meter->watts()`. Il n'existait aucun mapping spécifique à un fabricant.
**L'écart.** Sur Fronius cette négation est fausse : le Smart Meter émet déjà
dans la convention nymea (négatif = injection). Le correctif n'annule la
négation que lorsque le compteur est enfant d'une `froniusConnection` ; les
vendors generic et solarEdge gardent la négation historique, verrouillée par
`test_AC_16_generic_meter_keeps_the_historical_negation`.
**Preuve.** Relevé du 02/08/2026 sur un Primo GEN24 3.0, onduleur **monophasé**
(modèle 101) et Smart Meter TS 65A-3 (modèle **203**, pas 213). L'onduleur
n'injecte que sur la phase A ; le plugin rapportait phase A = +1003 W
(soutirage) et phases B et C à −33 W et −262 W, soit une injection sur deux
phases ne portant aucune source. En inversant, le bilan se referme : production
1190 W = charge phase A 187 W + export 1003 W ; import B+C 294 W ; consommation
481 W ; export réseau 709 W contre 708 W mesuré. Corroboration indépendante :
sur la même fenêtre, le compteur d'énergie *import* de l'appareil reste à 0
pendant que l'*export* s'incrémente. Conséquence observée :
`[Arbitre] "chauffe-eau" → "Surplus insuffisant (-842 W)"` alors que le site
exportait ~842 W.
**Non touché.** Les compteurs d'énergie (`totalEnergyConsumed` /
`totalEnergyProduced`) sont **corrects** et ne sont pas permutés : ce sont eux
qui servent de témoin à charge ci-dessus.
**Comment lever.** Déterminer si la négation générique est fausse pour tous les
fabricants ou seulement pour Fronius demanderait un compteur d'un autre vendor
sur banc. Tant que ce n'est pas tranché, le générique ne bouge pas.
---
## D-6 — présence batterie : le côté « batterie présente » n'est pas validé (INFÉRÉ)
**Contexte.** Un GEN24 hybride publie le modèle 124 même sans batterie câblée.
Le plugin créait donc un enfant storage systématiquement, d'où batterie fantôme
et `batteryCritical` permanent.
**L'écart.** `FroniusRules::hasBattery()` exige désormais `InBatV > 0` ou
`StorAval > 0` pour créer l'enfant, et seulement sous une `froniusConnection`.
Le SoC est écarté (0 % ne se distingue pas d'une batterie vide) ainsi que les
registres de consigne (`WChaMax`… gardent leurs défauts sans batterie).
**Ce qui est établi.** Le côté « absente » est confirmé sur matériel : le GEN24
des beaux-parents, sans batterie, exposait le modèle 124 avec `ChaSt = Off` et
un SoC de 0, et nymea journalisait « There is no energy storage available to
consider » pendant que l'enfant restait visible.
**Ce qui ne l'est pas.** `InBatV` et `StorAval` n'étant pas exposés en states
nymea, leurs valeurs sur ce GEN24 sans batterie **n'ont pas pu être relevées**,
et aucun GEN24 **avec** batterie n'était joignable. L'hypothèse « une batterie
installée porte une tension au repos » est donc **INFÉRÉE des deux côtés**.
**Comment lever.** Relever en lecture seule le bloc 124 d'un GEN24 équipé d'une
batterie et vérifier `InBatV`/`StorAval` non nuls. **À faire avant tout
déploiement** : un discriminateur trop strict ferait disparaître une vraie
batterie chez un client.
---
## D-7 — la présence batterie ne peut pas être vérifiée par la suite logique (MESURÉ)
**Contexte.** La première version du garde de D-6 demandait
`isFroniusChild(thing->id())`, qui résout le parent par `myThings().findById()`.
**L'écart.** Pendant le setup initial, la connexion parente n'est **pas encore
enregistrée** dans `myThings()` : la recherche répond faux, le garde Fronius est
contourné, et l'enfant storage est créé malgré `InBatV = StorAval = 0`. Trace
nymead correspondante :
`ThingManager: Invalid parent thing id … Not adding thing to the system.`
Le garde interroge désormais directement le `thing` de connexion que la fonction
tient déjà (`thing->thingClassId() == froniusConnectionThingClassId`), sans
aucune recherche. Même traitement pour le signe compteur, qui utilise le
`parentThing` déjà résolu et non nul.
**Ce que cela apprend.** La suite `tests/logic` ne peut pas attraper ce défaut :
elle couvre la décision pure (`FroniusRules`), pas le câblage du plugin, c'est à
dire *quel prédicat est interrogé et à quel moment du cycle de vie*. Seul un
essai en boucle fermée contre un simulateur le révèle. Toute modification du
garde de présence doit donc être rejouée sur banc, pas seulement en unitaire.
**Comment lever.** Étendre `tests/plugin`, qui instancie réellement le plugin, à
un scénario de setup initial avec un modèle 124 à zéro. Hors périmètre ici.
---
## D-8 — ordre des mots des float32 : le défaut de la bibliothèque n'est pas celui de la spec (MESURÉ)
**Contexte.** `FroniusRules::byteOrder()` renvoyait `ByteOrderLittleEndian`, avec
pour justification « non documenté par le constructeur, on suppose le défaut
SunSpec ».
**L'écart.** La supposition confondait deux « défauts ». Un float32 SunSpec
occupe deux registres, **mot de poids fort d'abord**. Or
`SunSpecDataPoint::convertToUInt32` lit `registers.at(0)` puis `.at(1)` en
`ByteOrderBigEndian`, et l'**inverse** en `ByteOrderLittleEndian` — qui est le
défaut de libnymea-sunspec. Garder ce défaut revient à lire tous les float32 de
la personnalité Fronius avec leurs deux mots permutés.
**Mesure.** Relevé du 2026-08-07 sur un Fronius Symo réel, en lecture seule
(`sunspec/tools/read-float-order.py`) :
| point | registres bruts | big endian | mots inversés |
|---|---|---|---|
| compteur 213 u200 `Hz` | `4248 0000` | **50,0000** | 2,3777e−41 |
| compteur 213 u200 `PhVphA` | `436a 8000` | **234,5000** | −2,4184e−41 |
| onduleur 113 u1 `Hz` | `4247 fc48` | **49,9964** | −4,1592e+36 |
**Ce que cela coûtait.** Pas un décalage : une absurdité. Reproduit sur banc avec
le simulateur émettant l'ordre du matériel — un compteur **important 2,7 kW** a
été rapporté comme **exportant 107 MW**, `Current load on root meter:
-1.07621e+08W`, et l'arbitre a enclenché toutes les charges sur un « Surplus PV
107622496 W ». Un tel appareil se présente `connected = true`, sans aucune
erreur Modbus.
**Ordre des correctifs.** `meterPowerSign()` (D-5) reste juste, mais n'est
**observable** qu'une fois l'ordre des mots corrigé : corriger le signe d'un
dénormal ne produit rien.
**Non touché.** Le vendor solarEdge force déjà big endian sur son propre chemin ;
le vendor générique conserve son paramètre `endianness` réglable par
l'utilisateur. Aucun des deux n'est modifié.
**Verrou.** `test_AC_6` affirme désormais `ByteOrderBigEndian`, et
`test_AC_18_swapped_words_yield_denormals` fige le raisonnement : les registres
réels lus à l'envers donnent un dénormal, jamais une mesure plausible.
---
## D-9 — `sunspecStorage` déclare `energystorage` mais tous ses porteurs ne savent pas mesurer (MESURÉ)
**Ce que demande le brief.** `BRIEF_sunspec_energystorage.md` : le Thing déclarait
`battery` + `connectable`, donc les trois consommateurs qui filtrent par
`filterByInterface("energystorage")` ne le trouvaient pas — le bilan de puissance
que le client lit, la réserve de recharge VE, la réserve du waterfall. Sur `.75`,
`currentPowerStorage` valait 0 pendant que la batterie affichait 50 % et
« Holding » : elle n'était pas à zéro, elle était invisible.
**Ce que fait le code.** `sunspecStorage` déclare désormais
`["battery", "energystorage", "connectable"]` et porte `currentPower` (W) et
`capacity` (kWh), câblés sur des registres.
### D'où vient `currentPower`
**Le modèle 124 ne porte aucune puissance.** Il décrit ce que la batterie *est*
— SoC, statut, consignes — pas ce qu'elle *fait* en watts. `WChaMax` est une
puissance **maximale**, `StorAval` est en ampères-heures. Vérifié registre par
registre sur le banc.
La puissance instantanée vit dans le **modèle 160**, réparti sur deux blocs
distincts que Fronius nomme `StCha` (charge) et `StDisCha` (décharge). `DCW` est
un `uint16` : chaque bloc est toujours positif, et le bloc inactif est à zéro.
D'où
```
currentPower = DCW[StCha] − DCW[StDisCha]
```
**Le signe est mesuré, pas déduit.** Relevé du 2026-08-26, banc `.211` unité 1,
modèle 160 à 40265, consigne batterie imposée par MQTT (`banc/cmd/manual`) et
registres relus en lecture seule :
| consigne banc | `banc/etat/batterie` | `StCha` | `StDisCha` | `ChaSt` (124) | `StCha − StDisCha` |
|---|---|---|---|---|---|
| +2000 W (charge) | +2000,0 W | **2000,0 W** | 0,0 W | Charging | **+2000,0 W** |
| −1500 W (décharge) | −1500,0 W | 0,0 W | **1500,0 W** | Discharging | **−1500,0 W** |
| 0 W | 0,0 W | 0,0 W | 0,0 W | Holding | 0,0 W |
`libnymea/interfaces/energystorage.json` : « positive is 'consumed' ». Une
batterie qui charge consomme, donc charge = positif. C'est **l'inverse** de la
convention constructeur du contrat §4 et de `FroniusRules::batteryPower()`. Les
deux fonctions coexistent, et `batteryPower()` est écrite comme la négation de
`storageCurrentPower()` pour qu'aucune des deux ne puisse dériver seule.
**Ce que le relevé a montré en plus.** `DCW_SF` est passé de **−2 à −1** entre la
lecture au repos et la lecture en charge, sans hystérésis (I5 du contrat). Un
plugin qui mettrait ce facteur en cache à la découverte lirait des puissances
fausses d'un facteur 10. libnymea-sunspec ne le met pas en cache : les 90
registres du modèle 160 sont lus en une seule transaction Modbus, et
`processBlockData()` relit les facteurs d'échelle du bloc fixe **avant** de
traiter les blocs répétés. La propriété est donc tenue, mais elle tient à cet
ordre — la casser en amont casserait cette lecture sans aucun signe extérieur.
### D'où vient `capacity`
`WHRtg` du modèle 120 (plaque signalétique), Wh → kWh.
`SunSpecStorageLogic::nameplateCapacityKwh()` rend une valeur **négative** quand
le nameplate n'en porte pas, et le plugin laisse alors l'état tranquille plutôt
que de publier un zéro qui se lirait comme une mesure.
**Non vérifiable au banc.** Le simulateur sert les modèles 120 à 123 en
**coquilles vides** (choix assumé du lot 1) : les 26 registres du 120 sont à
zéro, `WHRtg` compris. `capacity` reste donc à 0 sur `.75`, et le chemin de
lecture n'est éprouvé que par les tests unitaires. **Comment lever** : peupler
`WHRtg` dans la vue `sunspec-fronius` du banc, ou relever le modèle 120 d'un
GEN24 réel équipé d'une batterie. Aucun des trois consommateurs cités par le
brief ne lit `capacity` — la conséquence d'un 0 est bornée à l'affichage.
### L'écart assumé
**`sunspecStorage` est partagé entre les vendors `sunspec` générique et Fronius**,
et les interfaces se déclarent par thingClass, pas par thing. Un appareil SunSpec
générique qui publie le modèle 124 **sans** modèle 160 — c'est le cas du
simulateur générique `.210` — porte désormais l'interface `energystorage` avec un
`currentPower` qui ne bougera jamais de zéro. C'est exactement le défaut que le
brief décrit : un zéro se lit « ni charge ni décharge » là où l'absence se lisait
« je ne sais pas ».
**Pourquoi l'accepter quand même.** Les trois alternatives sont pires :
- créer une thingClass Fronius distincte (à la `solarEdgeBattery`) contredit le
contrat §A′ ligne 44, et **détruit le Thing existant** de `.75` avec son
historique, puisque le balayage des orphelins ferait disparaître l'ancien ;
- ne pas déclarer l'interface laisse les trois consommateurs aveugles, ce qui est
le défaut d'origine ;
- refuser de créer l'enfant storage quand la puissance n'est pas mesurable ferait
disparaître des appareils génériques qui fonctionnent aujourd'hui.
**Ce qui rend l'écart audible.** Le plugin **avertit une fois par thing** quand il
ne trouve pas de source de puissance, en distinguant les deux cas :
pas de modèle 160 du tout sur la connexion, ou modèle 160 sans bloc
`StCha`/`StDisCha` (un GEN24 sans batterie publie `N=2`). L'avertissement ne
remplace pas la mesure, mais il retire le silence — qui était le vrai coût du
défaut d'origine.
**Comment lever.** Que nymea permette de déclarer une interface par thing et non
par thingClass, ou que le vendor générique reçoive sa propre thingClass storage
au moment où un appareil générique avec batterie entre réellement au parc.
**Verrous.** `test_D9_*` dans `tests/logic` : la déclaration d'interface, les deux
états et leurs unités, le signe dans les deux sens, l'opposition stricte avec
`batteryPower()`, l'exclusion des strings PV, et la distinction entre « batterie
à l'arrêt » et « pas de bloc batterie ». La cohérence des unités avec l'interface
est en outre vérifiée **à la compilation** par `nymea-plugininfocompiler` :
essayer `KiloWattHour` sur `currentPower` fait échouer le build avec
« claims to implement interface "energystorage" but state "currentPower" has not
matching unit ».
---
## D-10 — la géométrie des modèles est figée à la découverte (MESURÉ)
**Contexte.** Le plugin lie chaque thing à un `SunSpecModel` au moment de la
découverte : identifiant de modèle, registre de départ, longueur. Les modèles
compagnons du storage (160, 120) sont liés de la même façon, par
`bindStorageCompanionModels()`.
**L'écart.** Rien ne redécouvre la chaîne tant que la **connexion** ne tombe pas.
Si l'appareil change la FORME de sa chaîne sans rompre le TCP, le plugin
continue de lire l'ancienne géométrie.
**Mesure du 2026-08-26.** Le simulateur `.211` a été redémarré sans batterie, puis
de nouveau avec. Trois états successifs, connexion Modbus jamais rompue :
| moment | chaîne | ce que fait le plugin |
|---|---|---|
| avant | 160 `L=88` (4 blocs) · 124 présent | `currentPower` suit la batterie |
| sans batterie | 160 `L=48` (2 blocs) · **124 absent** | `Read data block response error: ProtocolError` toutes les 7 s sur `"storage"` — il lit toujours 40355 |
| batterie revenue | 160 `L=88` · 124 présent | les erreurs cessent, mais **`currentPower` reste figé à sa dernière valeur** |
`thingPower` du storage : `0.0` à 16:09, 16:10, 16:11 pendant que les registres
portaient `StCha = 2000 W` et `ChaSt = Charging`. Un `systemctl restart nymead`
force la redécouverte, et la valeur repart immédiatement — `2000.0` à 16:13.
**Ce que cela apprend.** Le défaut n'est ni dans le signe ni dans le mapping de
D-9 : ceux-là sont justes, et le prouvent dès que la découverte est refaite.
Il est dans le fait qu'une liaison thing↔modèle est établie une fois et jamais
reconsidérée. Le storage y est plus exposé que les autres things parce qu'il
dépend de **trois** modèles au lieu d'un.
**Portée réelle.** Au banc, c'est fréquent : tout redémarrage du simulateur avec
d'autres options change la chaîne. Sur du matériel, c'est rare mais pas
théorique — une mise à jour de firmware, ou une batterie ajoutée à un hybride
déjà appairé, produisent exactement cette transition.
**Contournement aujourd'hui.** Redémarrer `nymead`, ou retirer et rajouter la
connexion. La perte de la connexion TCP suffit aussi, puisqu'elle déclenche une
redécouverte.
**Comment lever.** Redécouvrir périodiquement, ou sur erreur Modbus répétée à une
adresse jusque-là valide — le `ProtocolError` toutes les 7 s du tableau ci-dessus
est précisément le signal que la chaîne a bougé, et il est actuellement journalisé
sans conséquence. Hors périmètre de D-9, qui ne touche pas au cycle de découverte.