byteOrder() renvoyait ByteOrderLittleEndian au motif qu'on « supposait le défaut SunSpec ». La supposition confondait le défaut de la SPÉCIFICATION avec celui de libnymea-sunspec, qui est son contraire : convertToUInt32 lit registers.at(1) puis .at(0) en little endian. Garder ce défaut revient à lire tous les float32 avec leurs deux mots permutés. Mesuré le 2026-08-07 sur un Symo réel, en lecture seule : le compteur 213 sur l'unité 200 sert Hz = 4248 0000, soit 50,0000 en big endian et 2,3777e-41 en mots inversés. L'onduleur 113 concorde. Le coût n'était pas un décalage mais une absurdité. Reproduit sur banc avec le simulateur émettant l'ordre du matériel : un compteur important 2,7 kW rapporté comme exportant 107 MW, et l'arbitre enclenchant toutes les charges sur un « Surplus PV 107622496 W ». Le thing reste connected, sans erreur Modbus. Conséquence sur l'ordre des correctifs : meterPowerSign() reste juste mais n'est observable qu'une fois celui-ci appliqué — corriger le signe d'un dénormal ne produit rien. solarEdge force déjà big endian de son côté et le générique garde son paramètre utilisateur : aucun des deux chemins n'est touché. test_AC_18 fige le raisonnement plutôt que la seule valeur. Voir DIVERGENCES.md D-8. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
246 lines
12 KiB
Markdown
246 lines
12 KiB
Markdown
# 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.
|