# 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.