etm-powersync-app/docs/BRIEF_plugin_depuis_maquettes.md
Patrick Schurig 95769938fc fix(ev): la tuile des favoris devient muette — elle lit la borne, ou le dit
Elle reste : la retirer aurait laissé un trou dans une liste que le client a composée
lui-même. Elle affiche maintenant ce que la box publie, et là où il n'y a rien à lire,
elle le dit.

Ce qui est lu, et sa source
  · branché / en charge   ← états pluggedIn, charging du Thing
  · puissance             ← état currentPower du Thing
  · mode                  ← GetChargingInfos + notification ChargingInfoChanged

Absent = « non publié », jamais zéro. Un mode que l'app ne connaît pas se lit « mode non
reconnu (X) » plutôt que d'être deviné, et aucun bouton n'est allumé tant que la box n'a
pas dit ce qu'elle porte.

L'écriture restitue le refus, sinon elle n'existe pas. Les pastilles de 7 à 10 px qui
avalaient les codes d'erreur de setChargingInfo passent à 44 px, montrent l'attente, et
affichent le refus tel que la box le nomme. Et l'app ne tient plus d'état de mode local :
après une écriture acceptée, elle RELIT GetChargingInfos. Elle se souvenait de ce qu'elle
avait demandé, ce qui n'est pas ce que la borne porte.

La fiction perd sa porte d'entrée. chargingMode, chargingPower (3,6 kW) et
solarSourcePercent (82 %) sont SUPPRIMÉS de EnergyData : trois valeurs par défaut que
_updateEnergyData ne touchait jamais. Ce que la box publie sur une borne vit désormais
dans EvChargerLive, champ par champ, nullable.

⚠️ Et le Sankey du tableau de bord en tirait un flux « Voiture » permanent à 3,6 kW,
voiture débranchée. Il est branché sur la mesure de la borne — « non lu » quand il n'y en
a pas. Mais la même vue invente encore PAC = 38 % de la maison, Eau chaude = 18 %,
Autres = 22 %, dont un seul marqué « estimé » : entrée 🔴 au TODO, avec la source réelle
(GetLoadTelemetry publie measuredW par charge : 1500 W et 800 W ce midi). Brancher le
Sankey dessus est une refonte de l'écran d'accueil, pas un correctif — à décider.

Deux attentes tombent, une reste (relevé Integrations.GetThings sur .75, cet après-midi)
  · currentL1/L2/L3 est DÉPLOYÉ (5,86 / 5,94 / 6,02 A) — powerL1/L2/L3 a disparu
  · phaseCount EXISTE sur la Trydan (3) — la note qui le disait absent était en retard
  · sessionEnergy existe (4,229 kWh) MAIS vaut exactement chargeEnergy : c'est ce qu'on
    attend d'une session sans interruption, donc ça ne prouve rien. LM-1009 §C1 reste
    entière tant qu'une remise à zéro de chargeEnergy n'a pas été vue avec un
    sessionEnergy qui continue.

Le brief moteur dit désormais du masquage WRITE_FAILED que ce n'est pas « ne se produit
pas » mais « ne s'est pas encore produit » : il attend une charge variable qui perd son
Thing, et le banc n'a pas eu ce cas.

154 tests (8 nouveaux sur la tuile, dont les cibles tactiles et la restitution du refus),
flutter analyze à 27 remarques, aucune nouvelle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
2026-08-28 12:49:28 +02:00

30 KiB
Raw Blame History

Brief plugin — ce que les maquettes « Configurer » demandent au moteur


2026-08-28 — THING_MISSING sort sur les bornes, et reste masqué sur les charges variables

Mesuré, NymeaEnergy.GetLoadTelemetry sur .75 le 2026-08-28 à 10:29 UTC. La charge {88160e45-…} (Terra AC Charger (TCP), dont le Thing a été supprimé de nymea) est publiée :

{ "loadId": "{88160e45-7df2-48cc-9639-f0d15ae6e382}",
  "available": false, "allocatedW": 0, "faultCode": "THING_MISSING",
  "decision": { "code": "LOAD_UNAVAILABLE", "params": { "frozenW": 0 } },
  "mechanism": { "kind": "evcharger" }, "funding": "surplus" }

Ce que ça règle : l'app affiche désormais, sur ce cas réel, « l'appareil n'existe plus dans nymea — réinstallez-le, ou retirez cette charge », et retire le bouton « Lever le défaut » : ClearLoadFault répondrait EnergyErrorNoError sans rien changer, et le défaut reviendrait au cycle suivant. Rendu figé par test sur la trame ci-dessus.

Ce qui reste chez vous : le masquage relevé le 2026-08-25 dans etmvariableloadadapter.cpp:148-150 — m_faulted est testé avant que THING_MISSING ne puisse s'exprimer, donc une charge variable dont le Thing a disparu sort en WRITE_FAILED après épuisement de l'échelle d'écriture. L'adaptateur evcharger n'a pas ce défaut, ce qui explique que le code sorte proprement ici et pas là. Il n'est donc pas corrigé, seulement contourné par le hasard du chemin de code — et la nuance compte : ce n'est pas « ne se produit pas », c'est « ne s'est pas encore produit ». Il attend une installation où une charge variable — relais, SG-Ready — perd son Thing. Le banc n'a pas encore eu ce cas ; une installation client l'aura.

Pourquoi ça vaut un correctif et pas un renvoi à la doc. Les deux codes envoient le client faire deux gestes différents : WRITE_FAILED fait chercher un défaut de liaison — câble, bus, alimentation ; THING_MISSING fait rouvrir nymea et réinstaller l'appareil. Publier le premier là où le second est vrai, c'est envoyer quelqu'un démonter un coffret pour un Thing effacé. L'app ne peut pas rattraper ça : elle affiche le code, elle ne le devine pas.

Deux points mineurs sur la même trame, sans urgence :

  • funding: "surplus" est publié sur une charge available: false à 0 W. Le champ ne veut rien dire pour une charge qui ne reçoit rien ; l'omettre serait plus juste que d'annoncer un financement.
  • Les deux autres bornes (Simulated wallbox, V2C Trydan) sont absentes de loads[] — aucun véhicule branché, conforme à +etm24. Rien à changer : c'est lisible, et l'app le présente comme « hors arbitrage », jamais comme un défaut.

2026-08-27 (soir) — la batterie comme charge : sept questions

Origine : la maquette « Configurer · Batterie », septième colonne de docs/mockups/configurer_mecanismes_mockup.html. Elle a été écrite en supposant la réponse « oui » à la question du billet précédent — le stockage est-il une charge arbitrable ? — puis rétrécie quatre fois. Ce qui reste à l'écran est ce qu'aucun calcul ne peut décider à la place de l'installateur ; tout le reste est ci-dessous, et c'est chez vous.

Rien de ce qui suit n'est dessiné dans l'interface. Ce sont des détails techniques.


A. Les trois questions de mécanisme

A1 — Bloquer demande un INTERDIT ACTIF, pas une allocation à zéro. La batterie agit d'elle-même : l'onduleur charge sans qu'on le lui demande. Ne rien lui allouer ne l'empêche donc de rien — il faut lui dire enableCharging = false. C'est une différence de nature avec toutes les autres charges du waterfall, où « 0 W alloué » suffit à ce qu'il ne se passe rien.

A2 — Cet interdit doit EXPIRER. Un HEMS qui tombe après avoir posé enableCharging = false laisse une batterie inerte, sans que rien ne le signale et sans que personne ne sache pourquoi. Il faut un bail à durée limitée, renouvelé tant que l'arbitre vit. L'état sûr d'un stockage, c'est « il fait ce qu'il ferait sans nous », pas « il reste sur le dernier ordre reçu ».

A3 — controlHealthy == false doit SORTIR la batterie de l'arbitrage, comme available == false sort une charge en défaut. L'état existe déjà sur la classe. Une batterie qu'on croit piloter et qui ne répond plus fausse le budget en silence — exactement ce que faisait le double commandeur des bornes avant 3g-2.


B. Ce que la recharge accélérée soulève, et qui est entièrement de votre ressort

Le « boost » a changé de nature en cours de maquette, et c'est ce changement qui rend ces questions nécessaires. Ce n'est pas « vider la batterie dans la voiture » : c'est charger la voiture plus vite que l'abonnement ne le permet. Un client en 6 kVA veut charger à 7,5 ; le réseau fournit 6, la batterie comble l'écart, le disjoncteur ne saute pas.

C'est le délestage de LM-1006-1 vu par l'autre bout. Le délestage applique un plafond de soutirage en réduisant les charges pour tenir dessous ; ici on tient sous le même plafond en complétant par la batterie. Même objet, même plafond, même arbitre — à écrire comme un cas du mécanisme existant, pas comme un mécanisme parallèle.

B1 — La boucle est trop lente pour le transitoire, et il faut l'assumer. L'arbitre tourne à la minute ; un disjoncteur saute en secondes. Raser le plafond est donc exclu : il faut une marge assumée, et elle doit se dimensionner contre le pire démarrage de charge non pilotée entre deux cycles — un four ou une plaque, soit 2 à 3 kW qui apparaissent sans prévenir.

Conséquence honnête, à écrire quelque part plutôt qu'à découvrir sur le terrain : avec un abonnement 6 kVA, le gain réel de la recharge accélérée est de un à deux kilowatts, pas de 1,5 garanti. L'écran affiche « 6,0 + 1,5 » comme une illustration ; le moteur devra dire ce qu'il peut vraiment tenir.

B2 — Le filet de sécurité n'est pas la batterie, c'est la BORNE. Elle est pilotable et rapide. Si la consommation grimpe, on descend le courant de charge ; la batterie ne fait que le complément stable. C'est plus sûr que de compter sur l'onduleur pour rattraper, et le HEMS sait déjà le faire — c'est le même geste que l'arbitrage EV ordinaire.

B3 — À MESURER sur le banc, et ça conditionne tout le reste : avec quelle latence le Fronius applique une consigne de décharge ? Si c'est plusieurs secondes, la batterie ne tient qu'un régime établi et ne rattrape aucun transitoire — auquel cas la marge de B1 doit couvrir la totalité du pire démarrage, et B2 n'est plus une précaution mais la seule protection réelle.

B4 — Corollaire de la même famille : chargingRate a le même emploi à l'envers. Une charge de batterie depuis le réseau à pleine puissance sur un abonnement 6 kVA fait sauter le disjoncteur au premier four allumé. Le plafond de soutirage vaut donc dans les deux sens, et c'est un argument de plus pour que ce soit un seul mécanisme.


C. Une question d'interface, et elle vient d'un défaut que nous avons failli livrer

enableDischarging est binaire, et l'onduleur ne sait pas qui tire — il voit une consommation nette. Notre maquette portait le libellé « la maison continue d'être servie ; seule la voiture ne l'est pas ». C'est faux : coupé, le réfrigérateur passe au réseau comme la voiture. Le libellé est corrigé en « geler la batterie », qui décrit ce qui se passe.

La question qui reste : « servir la maison mais pas la voiture » demanderait de plafonner dischargingRate sur la consommation hors borne, recalculée à chaque cycle — de la modulation continue, pas un interrupteur. Est-ce que ça a un sens de l'écrire, ou est-ce que le mécanisme ne le permet pas proprement ? Nous ne dessinerons pas le réglage tant que la réponse n'est pas connue : c'est exactement la classe de défaut que nous traquons depuis trois jours — un écran qui promet ce que la box ne sait pas faire.


Ce que l'écran garde, pour que vous sachiez ce qui vous revient

Après quatre passes de retrait, Configurer · Batterie ne porte plus que : le nom, le rang (en lecture seule — il se règle par glisser-déposer dans la liste, comme toutes les autres charges), l'état réel, l'interrupteur de décharge ci-dessus, un plancher de secours (combien d'autonomie garder pour une coupure — une préférence de risque, pas une optimisation), et la charge depuis le réseau en manuel.

Sont sortis de l'écran, et vous reviennent : le tableau de permissions par charge (arbitrage → Héos ou décision par le prix) et la charge différée sur prévision (sans prévision, elle ne se règle pas — elle se calcule).



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 :

{"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 :

{"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 :

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.