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
11 KiB
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
totalProductionn'était pas « sain », il était figé depuis le 2026-08-02 16:15, soit 23 jours. L'onduleur (41ff6f36) ne publie aucun total : sonthingCacheest à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—ChargingModeEcoWithMinCurrentexiste dans l'énumération, aucun champ deChargingInfone 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.domaindécrit ce qu'on arbitre, pas la machine.