Patrick Schurig 89f7bf0199 fix(sunspec): signe du compteur Fronius et batterie fantôme
Deux défauts constatés sur le GEN24 des beaux-parents (Primo GEN24 3.0,
Smart Meter TS 65A-3, sans batterie).

Signe du compteur. onMeterBlockUpdated() négociait le signe de tous les
modèles compteur sans distinction de fabricant. Sur Fronius la négation est
fausse : le Smart Meter émet déjà dans la convention nymea. L'onduleur étant
monophasé, il sert de référence de direction : le plugin rapportait la phase
portant l'onduleur en soutirage et les deux autres en injection, ce qui est
impossible sans source. L'arbitre en concluait « Surplus insuffisant
(-842 W) » et laissait chauffe-eau et PAC éteints en plein export.

La décision vit dans FroniusRules::meterPowerSign() et n'est annulée que sous
une froniusConnection. Les vendors generic et solarEdge sont inchangés, ce
qu'un antidote verrouille. Les compteurs d'énergie sont corrects et ne sont
pas permutés.

Batterie fantôme. Un GEN24 hybride publie le modèle 124 sans batterie câblée,
d'où un enfant storage bloqué sur batteryCritical.
FroniusRules::hasBattery() exige une grandeur physique non nulle (InBatV ou
StorAval), le SoC étant écarté car 0 % ne se distingue pas d'une batterie
vide. Le côté « batterie présente » reste INFÉRÉ : voir D-6, à valider sur un
GEN24 équipé avant tout déploiement.

Suite logique : 50 tests verts, dont six antidotes AC-16 et AC-17.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 08:29:16 +02:00

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