diff --git a/BRIEF_agent_plugin.md b/BRIEF_agent_plugin.md new file mode 100644 index 0000000..ed77df4 --- /dev/null +++ b/BRIEF_agent_plugin.md @@ -0,0 +1,173 @@ +# 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.