etm-powersync-energy-plugin.../BRIEF_agent_plugin.md
Patrick Schurig 3b78daca7e docs(brief): ce que le lot C (app) a trouvé côté moteur — sept constats
Émis après le lot C, tout constaté sur .75 (plugin 1.15.2+etm15), rien corrigé côté moteur.
L'app s'est arrangée pour ne pas mentir en attendant.

1. Les compteurs cumulés d'Energy.GetPowerBalance sont CORROMPUS : totalReturn 9,1e+33,
   totalAcquisition 7,5e+33, totalConsumption -1,6e+33 — alors que totalProduction est sain
   et que les états du Thing compteur sont tous plausibles (1429 / 87,9 kWh). Ce n'est donc
   pas le compteur qui ment, c'est l'accumulation au-dessus.
2. Renommer une charge ne parvient jamais à l'adaptateur : updateSoftConfig() ne porte que
   priority et needs, donc le journal de la box garde l'ancien libellé jusqu'à la prochaine
   reconstruction — trompeur en mise en service.
3. relays[] est comparé PAR INDEX dans sameHardware() : un simple réordonnancement
   reconstruit la charge (contacts ouverts, verrous réarmés). Question de conception ouverte —
   le routeur dérive toutes les combinaisons, l'ordre ne change rien au routage.
4. Les paliers dérivés ne sont publiés nulle part (powerLevels vide, télémétrie limitée à
   maxStageW/stageW). L'app les dérive pour l'affichage : deux implémentations de la même
   combinatoire, et c'est l'app qui aura tort si elles divergent.
5. minCurrent n'existe dans aucun champ de ChargingInfo alors que ChargingModeEcoWithMinCurrent
   est publié : le mode se choisit, son paramètre non.
6. Pas de compteur dédié par charge. L'app affiche le bloc grisé, dérivé du schéma — il
   s'activera seul le jour où le champ existera.
7. L'avertissement LM-200 attend typeAppareil : keyé sur domain il crierait sur des
   installations correctes.

Plus la liste de ce sur quoi l'app s'appuie désormais et qu'il ne faut pas casser.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
2026-08-25 23:41:59 +02:00

174 lines
8.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Brief agent plugin — ce que le lot C (app) a trouvé côté moteur
**Émis par** : l'agent app, après le lot C · **Date** : 2026-08-25
**Banc** : `.75`, plugin `1.15.2+etm15`, nymea `1.15.2+202606191336~trixie1`
**Contexte** : `docs/RELEVE_LOTC.md` (dépôt app) porte le détail et les traces.
> Rien ici n'a été corrigé côté moteur. Tout a été **constaté** sur la machine, et l'app s'est
> arrangée pour ne pas mentir en attendant. Les sept points sont classés par ce qu'ils coûtent
> aujourd'hui, pas par difficulté.
---
## 1. ⚠️ Les compteurs cumulés d'`Energy.GetPowerBalance` sont corrompus
Le plus visible, et le plus gênant : le tableau de bord affichait **9,1 × 10³³ kWh** exportés.
```json
{ "totalAcquisition": 7.534470527909902e+33,
"totalConsumption": -1.5673735661234482e+33,
"totalReturn": 9.10184409403335e+33,
"totalProduction": 62007.5439453125 }
```
**Trois des quatre compteurs sont absurdes ; `totalProduction` est sain.** Et les états du
Thing compteur (`Fronius Three Phase Meter`) sont tous plausibles — 1 429 kWh importés,
87,9 kWh exportés. Ce n'est donc **pas** le compteur qui ment : c'est l'accumulation faite
au-dessus.
Piste : une intégration puissance × Δt où le Δt part en vrille (saut d'horloge, premier
échantillon, reprise après redémarrage). Le fait que `totalProduction` survive alors que les
trois autres tombent mérite d'être expliqué — ils ne passent probablement pas par le même
chemin.
**Ce que l'app fait en attendant** : un plafond de plausibilité (10 GWh, ~3 000 ans de
consommation) au-delà duquel la tuile affiche « — · compteur box invalide ». Elle ne corrige
rien et n'invente rien. **Elle redeviendra juste toute seule** dès que la box publiera des
valeurs saines.
---
## 2. Renommer une charge ne parvient jamais à l'adaptateur
`label` n'est pas dans `sameHardware()`, ce qui est correct. Mais la mise à jour sur place
(`updateSoftConfig()`) ne porte que `priority` et `needs`. Un changement de libellé seul donne
donc :
```
[EnergyArbitrator] 2 charge(s) active(s) — 0 créée(s), 0 mise(s) à jour, 2 inchangée(s)
```
L'adaptateur est **réutilisé tel quel**, avec son ancien libellé. `GetLoadConfig` rend bien le
nouveau — c'est ce que l'app affiche — mais **le journal de la box continue d'imprimer
l'ancien** jusqu'à la prochaine reconstruction.
Pour qui suit `journalctl` pendant une mise en service, c'est trompeur : on renomme
« chauffe-eau » en « Ballon 200 L », et le journal parle encore de « chauffe-eau ».
**Correctif pressenti** : porter `label` dans `updateSoftConfig()`. Il n'entre pas dans
`sameHardware()` pour autant — c'est une mise à jour sur place, pas une reconstruction.
---
## 3. `relays[]` est comparé PAR INDEX — un réordonnancement reconstruit la charge
Vérifié sur `.75` : `relays[]` inversé (2000/1000/500 au lieu de 500/1000/2000), **même
ensemble, mêmes Things, mêmes puissances** :
```
[RelayRouter] chauffe-eau — reprise au démarrage : palier 0 W [correspondance exacte] …
[EnergyArbitrator] relay-router construit depuis config: "chauffe-eau" ( 3 relais)
[RelayRouter] "chauffe-eau" → … (force) | "Charge désactivée — mise en état sûr … (ECS-413)"
[EnergyArbitrator] 2 charge(s) active(s) — 1 créée(s), 1 inchangée(s), 1 retirée(s)
```
Contacts ouverts, verrous réarmés à froid. **Question ouverte, et c'est une décision de
conception, pas un bug évident** : le routeur dérive ses paliers de **toutes les combinaisons**
de relais — l'ordre de la liste ne change donc rien au routage. Faut-il comparer `relays[]`
comme un **ensemble** plutôt qu'index par index ?
- **Pour** : un installateur qui remonte une ligne dans l'app ne s'attend pas à couper sa
charge. Et annuler son geste coûte une **seconde** reconstruction.
- **Contre** : si l'ordre porte un sens quelque part (priorité d'enclenchement, choix de la
combinaison à somme égale), le comparer comme un ensemble masquerait un vrai changement.
**À trancher côté moteur.** L'app annonce l'impact avant d'écrire, donc elle est correcte dans
les deux cas — mais elle annonce aujourd'hui une reconstruction pour un geste qui n'en mérite
peut-être pas une.
---
## 4. Les paliers atteignables ne sont publiés nulle part
`GetLoadConfig` rend `"powerLevels": []` sur une charge `relay-router` à trois contacteurs, et
`GetLoadTelemetry` n'expose que `maxStageW` et le `stageW` courant. **La liste des paliers
dérivés n'existe sur aucune frontière.**
L'app les **dérive pour l'affichage** (2^N combinaisons, doublons écartés, plafond 16 relais),
conformément à la maquette, et ne les enregistre jamais. C'est donc une **seconde
implémentation de la même combinatoire**, et si les deux divergent, c'est l'app qui aura tort
sans le savoir.
**Question** : faut-il que la box publie les paliers dérivés — dans `GetLoadConfig` en lecture
seule, ou dans la charge utile de mécanisme de la télémétrie ? Le champ `powerLevels` existe
déjà au schéma ; il est simplement vide sur ce mécanisme.
---
## 5. `minCurrent` n'existe dans aucun champ de `ChargingInfo`
L'énumération publie `ChargingModeEcoWithMinCurrent` et `ChargingModeEcoMinWithTargetTime`,
mais `ChargingInfo` ne porte **aucun** champ de courant :
```
chargingMode · evChargerId · assignedCarId · dailySpotMarketPercentage ·
endDateTime · repeatDays · spotMarketChargingEnabled · targetPercentage (+ r:chargingState)
```
Le mode se choisit, son paramètre non. Soit la valeur vit ailleurs (paramètre du Thing borne),
soit le champ manque.
**L'app grise le réglage et le dit** — proposer un curseur qui ne part nulle part serait pire
que de ne pas le proposer. Mais le mode reste sélectionnable, donc la borne applique **un**
courant minimum que personne ne sait régler ni lire.
---
## 6. Pas de compteur dédié par charge
`SetLoadConfig` n'accepte que `adapter · enabled · id · label · mode · priority · domain ·
maxPowerW · minOffS · minOnS · needs · powerLevels · relays · sgReady`.
L'app affiche un bloc **« Compteur dédié — prévu, pas encore supporté »**, grisé, avec sa
raison. Le grisage est **dérivé du schéma** (`JSONRPC.Introspect`), donc il s'activera seul le
jour où le champ existera, sans nouvelle version de l'app.
Le besoin est le plus fort sur `relay-router` : la box **estime** la consommation depuis les
paliers déclarés, elle ne la mesure pas. Sur `etmvariableload` la question se double — l'interface
remonte déjà la puissance réelle.
> Rappel de contrainte, pas d'inquiétude : nymea rejette toute clé inconnue **avant** le
> handler, et le refus porte sur l'appel **entier**. Inventer `meterThingId` côté app ferait
> échouer l'enregistrement de **toutes** les charges. L'app ne le fera pas.
---
## 7. L'avertissement LM-200 attend `typeAppareil`
Décidé côté modèle : une PAC ne prend ni `relay-router` ni `etmvariableload`, et le moteur doit
**avertir au journal** sans refuser. Mais l'avertissement **n'a pas de prédicat** aujourd'hui —
`domain` décrit *ce qu'on arbitre*, pas la machine, et keyé dessus il crierait sur des
installations correctes (`heating` + `relay-router` = chauffage résistif ; `hvac` +
`relay-router` = VMC).
Le discriminant est **`typeAppareil`** (`specs/spec_loadmodel.md` §3 LM-300, §8-1), valeur
*thermodynamique*. Spécifié, non implémenté — voir LM-200.
---
## Ce sur quoi l'app s'appuie désormais, et qu'il ne faut pas casser
| Comportement | Où l'app en dépend |
|---|---|
| `LoadConfigChanged` arrive **avant** l'accusé de `SetLoadConfig` | confirmation d'enregistrement (mesuré : 4 écritures sur 4) |
| `GetLoadTelemetry` rend le **dernier cycle publié**, sans recalcul | l'app n'attend pas de disparition immédiate après `enabled: false` |
| `enabled: false` fait passer la charge par `applySafeState()` | l'infobulle annonce « contacts ouverts » — vérifié PAC en état 4 → contacts ouverts |
| Refus **en bloc et sans motif** de `SetLoadConfig` | l'app valide avant d'envoyer (relays vides, powerW ≤ 0, Thing en double, état 2 absent, maxPowerW ≤ 0) |
| `JSONRPC.Introspect` décrit `SetLoadConfig` **jusqu'au champ** | tout le grisage en dérive |
| `loads[]` ⊆ `GetLoadConfig` | « non arbitrée » se lit *désactivée*, jamais *perdue* |
**Et une chose qui n'est PAS sur le fil** : la version du paquet (`1.15.2+etm15`) n'est exposée
par aucune méthode. L'app annonce la version d'**API d'expérience** (`NymeaEnergy 0.8`), qui
décrit le contrat. Si un jour la version de paquet devient utile côté client, il faudra la
publier — mais l'API est sans doute le bon discriminant de toute façon.