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