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
230 lines
11 KiB
Markdown
230 lines
11 KiB
Markdown
# 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.
|