Patrick Schurig 5275ebae31 docs(releve): lot D — les trois correctifs éprouvés sur le banc
1.15.2+etm16 déployé sur .75 le 2026-08-26 09:11. Chaque critère porte sa nature :
machine, test, ou lecture.

§2 — le journal bascule de "chauffe-eau" à "Ballon 200 L" au cycle suivant l'écriture,
et le décompte de l'arbitre passe de « inchangée » à « 1 mise à jour EN PLACE ».

§3 — relais inversés (2000/1000/500), mêmes Things, mêmes puissances :
« 0 créée(s), 0 mise(s) à jour, 2 INCHANGÉE(S) ». Ni reconstruction ni mise à jour,
aucun état sûr, aucune commutation. Avant etm16 la même écriture donnait
« 1 créée(s), 1 retirée(s) » et ouvrait les contacts.

§4 — stagesW = [0,500,1000,1500,2000,2500,3000,3500] publié en télémétrie, maxStageW
étant son dernier élément. Publié LÀ et pas dans GetLoadConfig parce que la condition
« lecture seule ET rejetée à l'écriture » s'y contredit : l'app écrit en renvoyant
verbatim ce qu'elle a lu, donc elle aurait réémis le champ et le refus en bloc aurait
emporté toutes les charges.

§1 — cause datée à la seconde (d08cf50, 05:30:14 UTC) et déjà corrigée en amont ; la
séquelle, elle, était définitive par absorption flottante. Base du banc réparée :
62 726 anomalies → 0, accumulation reprise et mesurée en direct (+0,001953 kWh).
Deux corrections au brief : totalProduction n'était pas sain mais figé depuis 23 jours,
et les deux Things Fronius ont déraillé ensemble.

À remonter en amont : EnergyManagerImpl accumule sans garde-fou de plausibilité dans un
compteur persistant. N'importe quel plugin mal codé peut geler l'historique d'une box
client. Contribution nymea, pas correctif ETM.

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

11 KiB
Raw Blame History

RELEVÉ — lot D : les constats de l'agent app, dans l'ordre de ce qu'ils coûtent

Entrée : BRIEF_agent_plugin.md (sept constats, tous établis sur .75 avec traces). Livré : 1.15.2+etm16, déployé sur .75 le 2026-08-26 à 09:11. Portée : §1 à §4. §5 à §7 restent à cadrer — voir la fin.

Chaque critère porte sa nature : machine (observé sur .75), test (suite locale), ou lecture (source lue, non exercée). Un critère qui n'a pas été exercé le dit.


§1 — Compteurs cumulés corrompus : la cause était ailleurs, et déjà corrigée

Ce que la base a enregistré

Premier échantillon empoisonné : 2026-08-07 05:30 UTC. Et à cet instant ce n'est pas le cumul qui déraille — c'est la puissance instantanée :

08-07 05:15   P_acq =     -1 955 W   totalAcq = 5,01e+01     ← sain
08-07 05:30   P_acq =  -9,81e+31 W   totalAcq = 6,87e+30
08-07 05:45   P_acq =  -5,39e+35 W   totalAcq = 7,53e+33
08-07 06:00   P_acq =       -6,7 W   totalAcq = 7,53e+33     ← la puissance revient, le cumul reste

Cause établie, à la seconde près. etm-powersync-plugins-modbus d08cf50 — « fix(sunspec): lire les float32 Fronius en big endian » — est daté 2026-08-07 07:30:14 +0200, soit 05:30:14 UTC. Son propre message décrit le symptôme : « un compteur important 2,7 kW rapporté comme exportant 107 MW ». Le banc tournait le plugin aux mots permutés pendant la séance de diagnostic ; deux cycles de lecture ont suffi.

Rien à corriger dans ce moteur pour l'origine : elle l'est déjà (+etm8/+etm9).

Pourquoi la séquelle était définitive

EnergyManagerImpl::updatePowerBalance() accumule sans aucun contrôle de plausibilité, et le résultat est persisté puis rechargé à chaque démarrage (latestLogEntry()). À 7,5 × 10³³ kWh, le plus petit incrément représentable en double vaut 1,67 × 10¹⁸ kWh :

>>> 7.5345e33 + 0.001 == 7.5345e33
True

Le compteur n'était pas seulement faux, il était gelé — incapable de bouger, quoi qu'il arrive. L'app avait posé un plafond de plausibilité en attendant, avec l'espoir qu'elle « redeviendrait juste toute seule » : c'était faux. Sans purge, le plafond restait affiché indéfiniment.

Étendue : 12 542 lignes sur 20 665 (60,7 %) portaient les totaux empoisonnés. Seules 6 lignes avaient une puissance instantanée aberrante — l'historique de puissance était donc sain, et seules les colonnes total* étaient à réparer.

Deux corrections au brief

  • totalProduction n'était pas « sain », il était figé depuis le 2026-08-02 16:15, soit 23 jours. L'onduleur (41ff6f36) ne publie aucun total : son thingCache est à 0.0 / 0.0. La production a survécu à la corruption parce qu'elle vient d'un autre Thing, qui ne contribue rien du tout. Le brief y lisait l'indice de chemins différents ; l'explication est plus simple, et le défaut d'intégration SunSpec reste ouvert.
  • Les deux Things Fronius ont déraillé ensemble (onduleur à 3,6e36 W, compteur à 5,4e35 W) — même connexion SunSpec, une seule salve.

Réparation — machine

tools/repair_energylogs.py, appliqué sur .75 le 2026-08-26 :

12 542 ligne(s) powerBalance : cumuls reportés au palier sain
     6 / 5 / 6 ligne(s)      : consumption / production / acquisition → NULL
12 542 ligne(s) thingPower   : cumuls reportés, par Thing
    11 ligne(s) thingPower   : currentPower invraisemblable → NULL
── contrôle après réparation ──   BASE SAINE   (62 726 anomalies → 0)

thingCache non touché : il porte les compteurs réels du Thing et c'est lui qui réarme les caches de delta au démarrage.

Accumulation reprise, vérifiée en direct — c'est le point qui comptait :

totalAcquisition   palier = 50,1286  →  50,134190  →  50,136143   Δ = +0,001953 kWh
totalConsumption   palier = 6332,28  → 6332,285650 → 6332,287603   Δ = +0,001953 kWh

Des deltas de 2 Wh sont de nouveau encaissés. L'absorption flottante a disparu avec la magnitude.

Ce que le script n'invente pas : l'énergie des 19 jours empoisonnés est perdue. Les relevés bruts du compteur pour cette période ne sont nulle part dans la base. Un palier plat l'assume ; une rampe l'aurait maquillée.

À remonter en amont

EnergyManagerImpl n'a aucun garde-fou de plausibilité : il accumule n'importe quel double venu d'un état de Thing dans un compteur persistant et irréversible. C'est de l'amont nymea non forké (1.15.2+202606191336~trixie1 sur le banc), donc une contribution, pas un correctif ETM. Tant qu'il est là, n'importe quel plugin mal codé peut geler définitivement l'historique d'une box client. À porter dans la doc d'exploitation.


§2 — Renommer une charge atteint enfin l'adaptateur

Correctif : label entre dans updateSoftConfig(label, priority, needs) — interface, quatre adaptateurs, et le prédicat soft de l'arbitre. Il n'entre pas dans sameHardware() : c'est une mise à jour en place, pas une reconstruction.

La vraie prise : SgReadyAdapter::updateSoftConfig() ignorait tout (Q_UNUSED), hérité d'avant le lot B-bis qui a rendu la PAC configurable. Sans le corriger, renommer la PAC — le cas exact du banc, celui qu'on allait tester — n'aurait rien changé, et on aurait conclu que le correctif ne marchait pas.

Machine — journal de .75, écriture de 09:12:45 :

09:12:00  [RelayRouter] "chauffe-eau"  → consigne 1500 W … INCHANGÉ
09:13:00  [RelayRouter] "Ballon 200 L" → consigne 1000 W → palier 1000 W
09:12:45  [EnergyArbitrator] 0 créée(s), 1 mise(s) à jour EN PLACE, 1 inchangée(s)

Le décompte le dit aussi : avant +etm16, un renommage seul comptait « inchangée ».


§3 — relays[] comparé par la table dérivée, pas par index

La question posée, et sa réponse

L'ordre de déclaration porte un sens, mais un seul : relayrouter.cpp construit les paliers de toutes les combinaisons et, à somme égale, retient la première rencontrée. Donc :

  • puissances distinctes (500/1000/2000, le cas du banc) → chaque somme a une combinaison unique, l'ordre est inerte ;
  • deux relais de même puissance → l'ordre décide quel contact physique sert ce palier. Autre contacteur qui s'use, autre résistance qui chauffe.

Comparer comme un ensemble aurait donc masqué un vrai changement ; comparer par index annonçait une reconstruction dans tous les autres cas.

Décision : comparer la table dérivée (levels, mapping). Deux listes qui produisent les mêmes paliers avec les mêmes contacts sont matériellement identiques — par construction, pas par pari. RelayRouter::deriveStages() est l'implémentation unique, partagée par la construction du routeur, la comparaison et la publication (§4).

Le test a attrapé un défaut de la correction elle-même

Première version de RelayStageTable::operator== : comparaison des listes de ThingIds élément par élément. Elle échouait sur le cas même que §3 corrige — le palier 1 500 W sort {K1,K2} en ordre direct et {K2,K1} en ordre inverse. Même geste électrique, deux listes. Les contacts d'un palier se comparent donc comme un ensemble, les paliers eux-mêmes position par position.

Machine — écriture de 09:14:55, relais inversés (2000/1000/500), mêmes Things, mêmes puissances :

09:14:55  [EnergyArbitrator] 0 créée(s), 0 mise(s) à jour, 2 INCHANGÉE(S), 0 retirée(s)

Ni reconstruction, ni même mise à jour : l'adaptateur est réutilisé tel quel. Aucune ligne « construit depuis config », aucun état sûr, aucun ECS-413, aucune commutation entre 09:14 et 09:16 — le RelayRouter poursuit son cycle normal. Avant +etm16, la même écriture donnait 1 créée(s), 1 retirée(s) et ouvrait les contacts.

Test — tests/auto/loadmodel, 8/8, 2 ms, sans serveur nymea :

testStageTableIsDerivedFromSubsetSums              testReorderingDistinctRelaysKeepsTheAdapter
testRelayOrderIsInertWhenPowersAreDistinct         testSwappingTiedRelaysRebuilds
testRelayOrderDecidesWhichContactServesATiedStage  testSoftFieldsNeverRebuild

§4 — Paliers atteignables publiés, en lecture seule

Machine — GetLoadTelemetry sur .75, cycle 2026-08-26T08:12:00Z :

chauffe-eau  kind=relay  stageW=1500  maxStageW=3500
             stagesW = [0, 500, 1000, 1500, 2000, 2500, 3000, 3500]

Les huit paliers du banc, et maxStageW est bien le dernier élément — le contrôle croisé que le client peut faire sur sa propre dérivation.

Publié en télémétrie et PAS dans GetLoadConfig, à dessein. La condition posée était « lecture seule et rejetée à l'écriture ». Dans GetLoadConfig les deux se contredisent : l'app écrit en renvoyant verbatim ce qu'elle a lu (aller-retour neutre, vérifié), donc elle aurait réémis le champ publié, et un refus en bloc de SetLoadConfig aurait emporté toutes les charges. La télémétrie n'a aucun chemin d'écriture : « lecture seule » y est vrai par construction, sans rien casser.


Ce sur quoi l'app s'appuie — contrôlé, rien de cassé

Comportement État après +etm16
LoadConfigChanged avant l'accusé de SetLoadConfig intact (non modifié)
GetLoadTelemetry rend le dernier cycle publié, sans recalcul intact — et observé : timestamp absent juste après le redémarrage, jusqu'au premier cycle
enabled: false passe par applySafeState() intact (non modifié)
JSONRPC.Introspect décrit SetLoadConfig jusqu'au champ intact ; o:stagesW ajouté au schéma de télémétrie, pas à celui de SetLoadConfig
loads[] ⊆ GetLoadConfig intact
Aller-retour neutre Get → Set intact — et c'est ce qui a décidé où publier §4

Modification d'API

EnergyArbitrator::sameHardware() passe public. Ce prédicat décide si une écriture coûte un geste matériel — contacts ouverts, verrous réarmés à froid — ou une simple mise à jour en place. C'est à ce titre un élément de contrat, que le client annonce à l'installateur avant d'enregistrer, et il est éprouvé comme tel. Statique et sans état : l'exposer n'ouvre rien.


§5 à §7 — à cadrer, pas faits

  • §5 minCurrent — ChargingModeEcoWithMinCurrent existe dans l'énumération, aucun champ de ChargingInfo ne porte le courant. Soit il vit ailleurs (paramètre du Thing borne), soit il manque. Tant que ce n'est pas tranché, l'app ne propose pas le réglage : un champ qui ne part nulle part est pire que pas de champ. Recoupe le lot E §3 (plancher).
  • §6 compteur par charge — deux champs de même forme (o:meterThingId, o:sensorThingId) : c'est l'objet du lot E §2.
  • §7 prédicat typeAppareil — l'avertissement LM-200 attend son discriminant. domain décrit ce qu'on arbitre, pas la machine.