Le miroir INTERFACE_etmvariableload.md est passé en rév. 3 pendant la session (corps
identique au canonique du dépôt plugin, vérifié). Son §10 s'adresse nommément à l'agent
app — et il RATIFIE les maquettes dessinées aujourd'hui : relays[] = {thingId, powerW}
pour l'ECS relais, maxPowerW seul pour le continu, powerLevels absents de la config et
dérivés par le routeur.
Trois recalages :
- Le picker de relais filtre sur l'interface nymea « power » et présente les things par
NOM — nommé dans la maquette, il ne l'était pas.
- Le point 4 du brief plugin est tranché par la rév. 3 : la duplication de la
combinatoire est voulue (l'app dérive pour montrer, le routeur pour exécuter). La
demande devient donc un moyen de détecter la divergence — la télémétrie publie déjà
maxStageW, l'app peut y confronter le maximum de sa propre liste.
- En-tête du pont VM Service, que le commit précédent avait laissé dans sa forme brute.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
210 lines
10 KiB
Markdown
210 lines
10 KiB
Markdown
# Brief plugin — ce que les maquettes « Configurer » demandent au moteur
|
|
|
|
**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.
|