Patrick Schurig 51a211e080 fix(sunspec): rendre la batterie SunSpec visible au moteur (energystorage)
sunspecStorage déclarait `battery` mais pas `energystorage`. Les trois
consommateurs qui filtrent par cette interface ne la trouvaient donc pas : le
bilan de puissance de nymea (`currentPowerStorage`, celui que le CLIENT lit sur
son tableau de bord), la réserve de stockage de la recharge VE, et celle du
waterfall. Vérifié sans authentification dans `energylogs.sqlite` de .75 : la
colonne `storage` de `powerBalance` est à 0.0 sur toutes les lignes, pendant
que la batterie affiche 50 % et « Holding ». Elle n'était pas à zéro, elle
était invisible.

Ce n'était pas une ligne de JSON. `energystorage` exige `currentPower` (W,
signé) et `capacity` (kWh), qu'aucun des quatorze états ne portait, et les
déclarer sans les câbler aurait donné une batterie éternellement inerte — pire
que son absence, puisque la réserve lirait « ni charge ni décharge » au lieu de
« je ne sais pas ».

D'où viennent les deux états. Le modèle 124 ne porte AUCUNE puissance : WChaMax
est un maximum, StorAval est en ampères-heures. La puissance instantanée vit
dans le modèle 160, réparti sur deux blocs `StCha` et `StDisCha` dont le `DCW`
est un uint16 toujours positif, l'inactif à zéro. D'où
`currentPower = DCW[StCha] - DCW[StDisCha]`, positif en charge, conformément à
libnymea/interfaces/energystorage.json (« positive is 'consumed' »). C'est
l'INVERSE de la convention constructeur du contrat §4, et
`FroniusRules::batteryPower()` est réécrite comme la négation stricte de la
nouvelle `storageCurrentPower()` pour qu'aucune des deux ne dérive seule.
`capacity` vient de WHRtg du modèle 120, et l'état est laissé tranquille plutôt
que mis à zéro quand le nameplate ne l'implémente pas.

Le signe est MESURÉ, pas déduit. Banc .211 unité 1, consigne batterie imposée
par MQTT, registres relus en lecture seule :
  +2000 W -> StCha 2000,0 W  StDisCha    0,0 W  ChaSt Charging
  -1500 W -> StCha    0,0 W  StDisCha 1500,0 W  ChaSt Discharging
Le même relevé a montré `DCW_SF` passant de -2 à -1 entre repos et charge : le
facteur d'échelle n'est jamais mis en cache d'un relevé à l'autre.

Écart assumé, D-9 dans DIVERGENCES.md : `sunspecStorage` est partagé avec le
vendor générique, et les interfaces se déclarent par thingClass. Un appareil
générique publiant le 124 sans le 160 porte désormais `energystorage` avec un
`currentPower` figé à zéro. Les trois alternatives sont pires — une thingClass
Fronius distincte détruirait le Thing existant et son historique. Le plugin
avertit donc UNE FOIS par thing quand il ne trouve pas de source de puissance,
en distinguant « pas de modèle 160 » de « modèle 160 sans bloc batterie ». Ça ne
remplace pas la mesure, mais ça retire le silence.

La cohérence des unités avec l'interface est vérifiée à la COMPILATION par
nymea-plugininfocompiler : mettre `KiloWattHour` sur `currentPower` fait échouer
le build. Onze tests `test_D9_*` figent le reste.

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

18 KiB
Raw Blame History

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