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>
12 KiB
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.