Aller-retour neutre confirmé sur les quatre entrées, glissement sans cas particulier confirmé, correctif de description constaté en place. Deux choses à leur rendre : EV_GRID_START aurait cassé en silence la réconciliation posée hier (une ligne à funding grid dont la part surplus n'est pas nulle), et il n'existe aucun SOC de véhicule dans le contrat — donc targetPercentage est une cible sans mesure d'avancement, qu'aucun écran ne peut suivre. La question leur revient. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
331 lines
16 KiB
Markdown
331 lines
16 KiB
Markdown
# Brief plugin — ce que les maquettes « Configurer » demandent au moteur
|
||
|
||
---
|
||
|
||
## 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.
|