etm-powersync-app/docs/BRIEF_plugin_depuis_maquettes.md
Patrick Schurig a1d0659db7 docs(brief): la question au moteur — le stockage est-il une charge arbitrable ?
Formulée depuis une demande simple de Patrick : que le surplus aille d'abord à l'eau chaude,
et à la batterie seulement ensuite. Elle n'est pas exprimable aujourd'hui, et le brief dit
pourquoi avec ce qui a été vérifié plutôt qu'avec ce qui est supposé.

Quatre constats : batteryLevelConsideration donne la priorité à la BATTERIE (le budget est
annulé sous le seuil, pas au-dessus) ; la batterie n'est dans aucun GetLoadConfig ;
enableCharging et ses quatre voisins ont zéro occurrence dans energyplugin/ ; et pourtant la
classe SunSpec Storage de .75 les déclare comme actions exécutables. Plus un indice :
domain: "battery" est déjà accepté par SetLoadConfig sans que rien n'en fasse quoi que ce
soit.

Quatre sous-questions, dont la première est la leçon de 3g-2 retournée vers eux : l'onduleur
Fronius a sa propre logique de charge, donc avant d'ajouter un commandeur il faut savoir qui
commande déjà. C'est littéralement le défaut d'adjustEvChargers(), et il serait absurde de le
réintroduire sur la batterie le jour où ils viennent de le supprimer sur les bornes.

Et ce que nous nous interdisons en attendant, dit explicitement : une règle Rules.* qui
couperait enableCharging fonctionnerait, et nous l'écartons — ce serait un second commandeur
sans arbitre. Mieux vaut dire « pas réglable aujourd'hui » qu'offrir un réglage dont personne
ne garantit l'effet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
2026-08-27 19:58:54 +02:00

423 lines
21 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 plugin — ce que les maquettes « Configurer » demandent au moteur
---
## 2026-08-27 — QUESTION : le stockage est-il une charge arbitrable ?
**Origine** : une demande de Patrick, formulée simplement — *« si je veux que l'énergie aille
d'abord dans l'eau chaude, et seulement si l'eau est chaude je commence à charger la
batterie, je fais comment ? »*
**Réponse honnête que nous lui avons faite : aujourd'hui, il ne peut pas.** Et le réglage
qu'il a sous les yeux fait l'inverse de ce qu'il demande. Voilà ce que nous avons vérifié
avant de vous écrire.
### 1. `batteryLevelConsideration` donne la priorité à la BATTERIE, pas aux charges
Sous le seuil, le budget de surplus est **annulé** (`rulebasedscheduler.cpp:125`) : l'ECS ne
reçoit rien tant que la batterie n'a pas dépassé 40 %. C'est exactement la priorité inverse
de celle qu'il demande, et c'est le seul levier « batterie » du contrat.
Le mettre à 0 ne donne pas non plus « ECS d'abord » : ça retire le blocage, après quoi
l'ECS et la batterie se servent **en même temps** et c'est l'onduleur qui tranche
physiquement. « Les deux se disputent » n'est pas « l'un puis l'autre ».
### 2. La batterie n'est dans aucun waterfall, et le moteur ne la commande nulle part
- Aucune entrée `GetLoadConfig` pour le stockage — quatre entrées sur `.75` : `chauffe-eau`,
`pac-terrain`, et les deux bornes depuis `+etm23`.
- `enableCharging`, `enableDischarging`, `chargingRate`, `dischargingRate`, `gridCharging` :
**zéro occurrence** dans `energyplugin/` (grep sur `.cpp` et `.h`).
### 3. Le levier matériel existe pourtant, et il est déclaré
La classe **SunSpec Storage** de `.75` (le Fronius du banc) publie ces cinq **actions
exécutables** :
```
enableCharging · enableDischarging · chargingRate · dischargingRate · gridCharging
```
La box sait donc dire « ne charge pas maintenant ». Personne ne le lui demande.
### 4. Et `battery` est déjà dans l'énumération fermée des domaines assignables
`SetLoadConfig` accepte `domain: "battery"` — au même titre que `ecs`, `ev`, `heating`,
`hvac`. Rien n'en fait quoi que ce soit. Cela ressemble à une question qui a été effleurée
puis laissée ; d'où notre question.
---
### La question, et ses quatre sous-questions
> **Le stockage doit-il devenir une charge du waterfall, avec un `priority` ordinaire —
> comme les bornes viennent de le devenir en 3g-2 ? Ou reste-t-il définitivement hors du
> budget ?**
Si la réponse est **oui**, la demande de Patrick s'exprime sans aucun écran nouveau : il
glisse « eau chaude » au-dessus de « batterie », et c'est fini. Quatre points à trancher
avec vous :
**a) Le double commandeur — et c'est la leçon de 3g-2, pas une objection de forme.**
L'onduleur Fronius a sa propre logique de charge. Si l'arbitre commande `enableCharging =
false`, est-ce que l'onduleur obéit, ou re-décide derrière lui ? C'est **littéralement** le
défaut que `adjustEvChargers()` produisait sur les bornes : la décision publiée n'atteignait
pas le matériel, et l'écran avait l'air faux. Avant d'ajouter un commandeur, il faut savoir
qui commande déjà.
**b) Que devient `batteryLevelConsideration` ?** Un rang de batterie et un seuil qui annule
tout le budget expriment deux stratégies concurrentes. Si les deux survivent, il faut dire
laquelle gagne — sinon nous aurons deux réglages dont l'un défait l'autre en silence, ce que
l'écran ne saura pas présenter.
**c) La sûreté.** `batteryCritical` existe sur le Thing. Un rang de priorité doit-il pouvoir
faire descendre le stockage sous un plancher de sécurité, ou y a-t-il un seuil que
l'arbitrage ne franchit jamais ? Nous ne voulons pas offrir un glissement qui vide une
batterie.
**d) Le sens du signe.** Pour l'ECS, « allouer 2 000 W » veut dire *consommer*. Pour le
stockage, cela veut dire *stocker* — même unité, sens opposé au bilan. Si le stockage entre
dans `loads[]`, comment son `allocatedW` se réconcilie-t-il avec `budget.allocatedW` ?
C'est la question qui nous a déjà mordus avec `EV_GRID_START`.
### Ce que nous ne ferons PAS en attendant
Une règle `Rules.*` du type *« si l'ECS n'est pas chaud → `enableCharging = false` »*
**fonctionnerait**, et nous l'écartons volontairement : ce serait un second commandeur sur
la batterie, sans arbitre entre lui et l'onduleur. C'est le défaut que vous venez de
supprimer sur les bornes, réintroduit ailleurs par la porte de l'app.
Nous préférons dire à l'utilisateur « ce n'est pas réglable aujourd'hui » plutôt que lui
donner un réglage dont personne ne garantit l'effet.
---
---
## 2026-08-27 (soir) — retour sur le brief 3g-2
Les quatre points sont traités. Deux vérifications que vous nous demandiez, et trois
constats en retour.
### A. Vérifié sur `.75` en `+etm24`, comme demandé
- **Aller-retour neutre avec les entrées `evcharger`** : `loadconfig_roundtrip.dart` sur
les quatre entrées → verdict `IDENTIQUE`. Aucun adaptateur reconstruit.
- **Le glissement les traite sans cas particulier.** Il n'y avait rien à ajouter : le
réaplatissage renumérote `1..N` sur toute la liste et ne touche que `priority`. Aucune
branche ne regarde l'adaptateur, y compris à l'inversion borne / charge pilotée.
- **Votre correctif de description est en place** : `SetChargingInfo` énumère désormais
chaque défaut et conclut *« Read the object with GetChargingInfos, change what you need,
and send it back complete. »* Le point A du retour précédent est clos.
### B. `EV_GRID_START` aurait cassé notre réconciliation en silence
C'est le paragraphe le plus utile de votre brief, et il est facile à lire de travers. Nous
avions posé hier l'identité *somme des `allocatedW` financés au surplus ==
`budget.allocatedW`*, en filtrant sur `funding`. Avec `funding: "grid"`, une ligne
`EV_GRID_START` aurait compté **zéro** au lieu de `budgetW` — un trou de la taille de la
part surplus, dans un contrôle dont l'échec ne dit jamais pourquoi.
Traité : la part réconciliable d'une charge n'est plus `allocatedW` filtré mais une part
explicite, qui vaut `budgetW` sur ce motif et `allocatedW` partout ailleurs. Sans
`budgetW`, elle rend zéro plutôt qu'une part devinée.
Le motif n'ayant jamais été vu sur machine, cette branche n'est **éprouvée que par test**.
Signalez-nous la première occurrence réelle.
### C. Il n'existe aucun SOC de véhicule dans le contrat — et l'écran en promettait un
Notre carte de recharge affichait un état de charge codé en dur. En cherchant à le
brancher, le relevé est net : sur les **58 classes** de `.75`, **aucune** classe portant
l'interface `evcharger` ne déclare d'état de charge, **aucune** ne porte l'interface `car`,
et les deux bornes du banc publient `pluggedIn`, `charging`, `maxChargingCurrent`,
`phaseCount`, `sessionEnergy` — jamais un pourcentage.
Retiré côté app plutôt que remplacé : le seul `batteryLevel` de l'installation est celui de
la batterie de la maison, et l'afficher en face d'une cible de recharge remplacerait un
chiffre faux par un chiffre faux et crédible.
**La question qui vous revient** : `targetPercentage` est une cible sans mesure d'avancement.
Tant que rien ne publie le SOC du véhicule, une échéance de départ ne peut être ni suivie ni
vérifiée à l'écran — l'utilisateur pose une cible et n'apprend jamais si elle a été tenue.
Est-ce que quelque chose est prévu de ce côté, ou faut-il considérer la cible comme
définitivement aveugle ?
### D. Rang par défaut — pris comme non tranché
L'écran nomme désormais les égalités de rang au lieu de les présenter comme un classement,
et la note disparaît d'elle-même au premier vrai réglage. Nous ne fixons rien de plus tant
que le point n'est pas tranché chez vous.
### E. Méthode
La forge est toujours à terre (`192.168.1.113` refuse la connexion, front public en 503).
Nos **34 commits** attendent en local, avec un bundle de secours en
`~/bundles/etm-powersync-app_feature-beta-add-config_2026-08-27.bundle` (vérifié, historique
complète). Votre brief 3g-2 n'ayant pas pu voyager non plus, nous l'avons rapatrié depuis
`etm-powersync-energy-plugin-etm/docs/`.
---
## 2026-08-27 — retour sur le brief 3g-1 (`docs/BRIEF_depuis_plugin.md`)
Les quatre points sont traités côté app. Deux constats en retour, dont un actionnable chez
vous.
### A. `SetChargingInfo` — la box PROMET l'écriture partielle, par écrit
`nymeaenergyjsonhandler.cpp:114` publie cette description, qui part dans
`JSONRPC.Introspect` et donc chez tout client :
> *« Set the charging info for an EV charger. Only given properties will be set, others
> will be untouched. »*
C'est l'inverse du comportement réel — un champ omis retombe sur son défaut C++. La phrase
ne se contente pas de ne pas prévenir : **elle autorise explicitement l'erreur**. Un client
qui la lit écrit un champ à la fois, en toute bonne foi, et repose 80 % de cible à chaque
changement de mode.
Reproduit ici le 2026-08-27 sur `.75` (`+etm22`), borne `88160e45` :
`targetPercentage` valait `0` ; un `SetChargingInfo` portant le seul `chargingMode` l'a
laissé à **`80`**, réponse `EnergyErrorNoError`. Banc restauré.
**Demande :** corriger la description (une ligne), ou rendre l'écriture réellement
partielle. La première suffit — l'app fait déjà lire-patcher-réécrire. Tant que la phrase
est là, le prochain client la croira.
### B. `BATTERY_RESERVE` — la garde `withheldW > 0` fait bien son travail
En cherchant à observer le motif sur `.75`, la bascule du seuil à 0,9 a produit exactement
le cas que votre garde couvre : `surplusW` à **−3 551 W**, donc rien à confisquer, donc
**aucun `BATTERY_RESERVE` publié** — les charges sont sorties en `SURPLUS_INSUFFICIENT` et
`BELOW_MIN_POWER`, ce qui est la vérité. Le motif n'a donc pas pu être vu en vrai d'ici :
il faut du surplus RÉEL sous le seuil, et le banc importait au moment de l'essai. Seuil
remis à 0,4.
Le rendu français côté app est aligné sur `socPercent` / `reservePercent` / `withheldW` et
mène par les watts retenus, comme votre `decisionreason.cpp`.
### C. Ce que l'app dépend désormais de 3g-2
L'écran affiche l'état réel d'une borne (`mechanism.chargingEnabled` / `currentA`) **avant**
l'allocation, et étiquette celle-ci « décision de l'arbitre ». C'est un contournement, pas
une conception : il existe uniquement parce que les deux peuvent se contredire. Le jour où
`adjustEvChargers()` ne commande plus le surplus, `allocationIsCommand` rend `true` partout
et ces branches disparaissent. Prévenez-nous au déploiement.
### D. Méthode
`origin` est basculé en SSH (`gitea-lan:pakutz79/etm-powersync-app.git`). La forge était
injoignable au moment du lot — `192.168.1.113` sans route, front public en 503 — donc les
commits d'hier et celui-ci attendent en local. Ils partent dès qu'elle répond.
---
**Origine :** session du 2026-08-25, côté app. Écrit après le passage sur appareil et la
conception des six écrans « Configurer » (`docs/mockups/`).
**Cible :** `etm-powersync-energy-plugin-etm`, plugin déployé au moment du relevé :
**1.15.2+etm15** sur `.75`.
**Méthode :** chaque point est un constat vérifié contre la machine ou la source, pas une
préférence d'UI. Les maquettes sont jointes — elles montrent où chaque manque se voit.
---
## 1. `SetChargingInfo` ne permet pas l'écran borne — seul blocage dur
**Ce que l'écran veut** (`configurer_mecanismes_mockup.html`, écran ③) : trois modes
`[PV] [Min+PV] [Boost]`, une option d'échéance de départ avec charge cible et heure, et le
basculement automatique vers les variantes `…WithTargetTime`.
**Ce que la box expose**, introspecté sur `.75` :
```
ChargingInfo = chargingMode · evChargerId
· o:assignedCarId · o:dailySpotMarketPercentage · o:endDateTime (Uint)
· o:repeatDays · o:spotMarketChargingEnabled · o:targetPercentage
· r:chargingState
```
L'échéance est **partiellement là** : `targetPercentage` et `endDateTime` existent, et les
modes `ChargingModeEcoWithTargetTime` / `ChargingModeEcoMinWithTargetTime` sont dans
l'énumération. Ce qui manque est ailleurs :
**⛔ `minCurrent` n'existe nulle part.** `ChargingModeEcoWithMinCurrent` est un mode valide,
mais aucun champ de `ChargingInfo` ne porte le courant à maintenir. Deux issues possibles,
et elles ne demandent pas le même travail :
- le courant est déjà fixé ailleurs (paramètre du Thing borne, réglage global) → **le dire**,
et l'app pointera l'utilisateur au bon endroit ;
- il manque → **ajouter le champ**, et dire s'il est par borne ou global.
Tant que ce n'est pas tranché, l'app **ne proposera pas** de réglage de courant minimum : un
champ qui ne part nulle part est pire que pas de champ. Le mode Min+PV reste donc à moitié
livrable — sélectionnable, mais non réglable.
> Corollaire déjà traité côté app : `CLAUDE.md` documentait `mode` / `minCurrent` /
> `targetSoc` / `endTime` sur un namespace `EnergyPlugin.*` inexistant. Corrigé le
> 2026-08-25 contre `Introspect`.
---
## 2. Le motif de refus de `SetLoadConfig` ne franchit pas la frontière
**Constat, reproduit deux fois.** Une charge invalide fait répondre :
```json
{"params":{"energyError":"EnergyErrorInvalidParameter"},"status":"success"}
```
…et rien d'autre. Le motif reste au journal de la box :
```
W | NymeaEnergy: [NymeaEnergy] SetLoadConfig rejetée:
"Config \"SONDE-TEST-A-SUPPRIMER\" invalide : etmvariableload fixed : powerLevels requis (non vide)"
```
**Pourquoi ça compte maintenant.** Les six écrans « Configurer » écrivent tous par
`SetLoadConfig`, et le refus est **en bloc** : une seule charge invalide fait échouer
l'enregistrement de toutes. L'installateur apprendra qu'on a refusé, jamais pourquoi ni sur
quelle charge — devant un client, avec un tableau électrique ouvert.
**Ce que l'app peut faire seule, et sa limite.** Rejouer `validateSet()` côté client couvre
l'unicité des ids et des Things. Elle ne couvre pas les règles de mécanisme
(`powerLevels` commençant par 0, `powerW > 0`, `relays[]` interdit en modulable, `mode`
imposé…) sans dupliquer `LoadConfig::validate()` — deuxième implémentation, deuxième
divergence.
**Demande.** Le même remède que la télémétrie : un **code** et des **paramètres**, pas une
phrase. Par exemple, à côté de `energyError` :
```json
{"energyError":"EnergyErrorInvalidParameter",
"rejection":{"loadId":"chauffe-eau","code":"POWERLEVELS_REQUIRED","params":{"mode":"fixed"}}}
```
Le libellé français est fabriqué par l'app, comme pour `decision.code`.
---
## 3. `WRITE_FAILED` masque `THING_MISSING`
**Constat**, provoqué délibérément sur `.75` (`tools/rpc/fault_probe.dart`) : une charge
`etmvariableload` dont le ThingId n'existe pas publie **`WRITE_FAILED`**, pas
`THING_MISSING`. L'écriture est tentée, échoue faute de Thing, l'échelle s'épuise, et
`m_faulted` prend le pas — l'ordre des `if` dans `etmvariableloadadapter.cpp:148-150` :
```cpp
if (m_faulted) v.faultCode = "WRITE_FAILED";
else if (!thingManager->findConfiguredThing(...)) v.faultCode = "THING_MISSING";
```
**Pourquoi ça compte.** Les deux codes appellent **deux gestes différents** chez le client.
« Échec d'écriture » envoie chercher un défaut de liaison — bus Modbus, alimentation,
contacteur. « Appareil absent » envoie dans la configuration nymea. L'app rend fidèlement ce
que la box dit, et la box envoie au mauvais endroit.
**Demande.** Faire primer la cause structurelle : un Thing absent est un `THING_MISSING`,
même si des écritures ont échoué entre-temps. Ou publier les deux.
---
## 4. Les paliers atteignables ne sont pas exposés
**Constat.** `mechanism` (kind `relay`) publie `stageW` et `maxStageW` — **la liste des
paliers atteignables n'existe dans aucun champ** :
```
mechanism = kind · o:stageW · o:maxStageW · o:setpointW · o:percent · o:maxPowerW
· o:state · o:estimatedPowerW · o:chargingEnabled · o:currentA
· o:phaseCount · o:pluggedIn
```
**Ce que la maquette affiche** (écran ①, routeur de relais) :
> 3 relais → paliers atteignables : **0 · 500 · 1 000 · 1 500 · 2 000 · 2 500 · 3 000 · 3 500 W**
C'est l'aide la plus utile du formulaire : elle dit à l'installateur ce que son câblage
permet, avant qu'il n'enregistre.
**Le risque.** Faute d'exposition, l'app **dérivera** cette liste — donc réimplémentera la
combinatoire de `relayrouter.cpp` (toutes les combinaisons 2^N, doublons écartés, plafond
16 relais). Deux implémentations d'une même règle divergeront : à la première correction
côté moteur, l'app annoncera des paliers que la box n'atteint plus.
**⚠️ Tranché par la rév. 3 du contrat, arrivée pendant la rédaction de ce brief.**
`INTERFACE_etmvariableload.md` §10 dit désormais : l'assistant calcule et **affiche** les
paliers atteignables côté app, et ces `powerLevels` ne sont **pas** envoyés — ils sont
« dérivés côté plugin ». La duplication est donc **voulue et contractuelle** : l'app dérive
pour montrer, le routeur dérive pour exécuter.
**Ce qui reste, et qui devient la vraie demande :** un moyen de vérifier que les deux
dérivations n'ont pas divergé. Il existe déjà à moitié — la télémétrie publie
`mechanism.maxStageW`. L'app peut donc comparer **le maximum de sa propre liste** à celui que
la box annonce, et se taire si ça correspond, alerter sinon. Ça ne couvre pas les paliers
intermédiaires, mais ça attrape le cas qui compte : une règle de combinatoire changée d'un
côté seulement.
**Demande minimale** : confirmer que `maxStageW` reste bien le maximum atteignable et non un
plafond de sécurité — sinon la comparaison ne vaut rien. **Demande confortable** : publier la
liste complète (`mechanism.stagesW: [...]`), et l'app cessera de dériver.
---
## 5. `enabled: false` — ce que deviennent les contacts
**Ce que l'app sait déjà**, et qui est bon : la charge quitte l'arbitrage, ne consomme aucun
budget, et disparaît de `loads[]` **au cycle suivant** — pas dans la seconde.
**Ce qu'il reste à établir côté app :** l'état matériel. Le relevé du lot B-bis (§4) répond
sur `sg-ready` — contacts K1/K2 mesurés ouverts après désactivation, l'état sûr passe bien
par `applySafeState()` avant retrait de l'adaptateur. **Reste à confirmer pour
`relay-router` et `etmvariableload`**, sur matériel.
**Pourquoi ça compte.** La maquette écrit aujourd'hui « à la désactivation, le retrait passe
par l'état sûr : tous les relais ouverts ». Si c'est faux pour un mécanisme, l'écran laisse
croire à une coupure qui n'a pas eu lieu — sur un chauffe-eau, c'est une promesse de
sécurité.
**Demande.** Confirmer, par mécanisme, ce que produit `enabled: false` sur le matériel. Une
ligne de journal par adaptateur suffit ; c'est déjà le cas pour SG-Ready.
---
## 6. `estimatedPowerW` SG-Ready reste fictif sur `.75`, et il alimente le recrédit
**Constat, déjà relevé côté plugin** (`RELEVE_LOTBbis.md` §7) : les valeurs {état 3 : 1500 W,
état 4 : 3000 W} et `minStateHoldS: 300` sont reconduites du bloc codé en dur supprimé, où
elles décrivaient une PAC **inventée**. Aucune PAC n'est sous tension sur K1/K2.
**Ce que ça change côté app.** L'écran SG-Ready (écran ②) présente `estimatedPowerW` comme
une **donnée d'installation**, saisie par l'installateur d'après la plaque signalétique. Sur
le banc, c'est une valeur de test — et elle sert de base au **recrédit de budget** et au
choix d'état, donc elle décale l'allocation de tout le waterfall en aval.
**Demande.** Rien à changer dans le code : c'est un point de **données de banc**. Mais tant
qu'il n'est pas clos, aucune capture de `.75` ne doit servir d'exemple d'installation, et le
§4 de `TEST_TERRAIN.md` reste non transposable.
---
## Ce que l'app livre sans rien attendre
Pour situer ce qui est bloqué et ce qui ne l'est pas :
| Écran | État |
|---|---|
| Liste « Charges pilotables » à deux niveaux, télémétrie sur carte | livrable |
| Configurer · routeur de relais | livrable — sauf l'aperçu des paliers (point 4) |
| Configurer · modulable | livrable |
| Configurer · SG-Ready | livrable |
| Configurer · borne de recharge | **bloqué sur le point 1** pour le mode Min+PV |
| Zones HVAC + sélecteur d'appareils | livrable côté RPC — **aucun matériel HVAC sur `.75`** |
| Raccordement réseau / protection de surcharge | livrable, mais voir ci-dessous |
**Et une question de périmètre, pas un défaut.** La protection de surcharge ne déleste
aujourd'hui **que les bornes de recharge** (`m_evChargers` dans
`SmartChargingManager::verifyOverloadProtection`). Le chauffe-eau, la PAC et les charges
modulables ne sont pas réduits par elle. L'écran « Raccordement » le dit explicitement plutôt
que de promettre une protection générale. **Étendre le délestage au waterfall est une
décision produit** — si elle est prise, l'app aura besoin d'un ordre de délestage, qui n'est
pas l'ordre de service : le premier retire de la puissance en surcharge, le second en
répartit en surplus. Deux listes qui peuvent légitimement différer.