# 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** : ```python >>> 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.