etm-powersync-app/docs/BRIEF_plugin_depuis_maquettes.md
Patrick Schurig f2bd07d538 docs(ev): le « Min » de Min+PV ne sert aucun plancher — mesuré, et le plancher est dérivable
Sémantique voulue (Patrick, 2026-09-07) : Min+PV charge toujours au minimum de
la borne, acheté au réseau s'il le faut, et ajoute le surplus par-dessus. 400 W
d'injection sur un plancher de 4 140 W donnent 4 540 W.

Mesuré sur .75, voiture branchée et borne joignable : la borne ne reçoit qu'un
niveau `comfort`, aucun niveau `eco`, décision `SURPLUS_INSUFFICIENT`,
`chargingEnabled: false`. ChargingModeEcoWithMinCurrent se comporte exactement
comme ChargingModeEco.

Le mécanisme existe déjà — `ECO_FLOOR_GRID`, ce que le chauffe-eau fait dans la
même trame : plancher servi, acheté au réseau, compté dans draw.committedW,
surplus distribué au-dessus par comfort. Il n'est pas câblé sur la borne.

Et le blocage noté depuis longtemps (« minCurrent n'existe nulle part ») n'en
est pas un : la valeur est sur la box, dans la classe du Thing —
maxChargingCurrent.minValue = 6 A, phaseCount = 3, soit les 4 140 W attendus.
Aucun champ de contrat à ajouter, et rien à saisir côté app : une limite
matérielle se lit sur le matériel.

Pourquoi ça n'a pas été vu : sur ce banc le surplus n'atteint jamais le
plancher — 0 quart d'heure sur 97 le 2026-09-06, meilleur 2 214 W — donc les
deux modes rendent le même rien et ne se distinguent par aucune observation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-07 10:24:12 +02:00

96 KiB
Raw Blame History

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


2026-09-07 — ChargingModeEcoWithMinCurrent ne sert aucun plancher : le « Min » ne fait rien

Ce que le mode doit faire, spécifié par Patrick aujourd'hui : Min+PV charge toujours au minimum de la borne, acheté au réseau s'il le faut, et ajoute le surplus par-dessus. Avec 400 W d'injection, une borne dont le minimum vaut 4 140 W doit charger à 4 540 W.

Ce qu'il fait sur .75 : rien. Mesuré ce matin, voiture branchée et borne joignable —

// GetChargingInfos
{ "evChargerId": "4d1adafd-…", "chargingMode": "ChargingModeEcoWithMinCurrent",
  "chargingState": "ChargingStateIdle" }

// GetLoadTelemetry, la borne
{ "loadId": "{4d1adafd-…}", "allocatedW": 0, "available": true,
  "mechanism": { "kind": "evcharger", "chargingEnabled": false, "currentA": 6,
                 "phaseCount": 3, "pluggedIn": true },
  "decision": { "code": "SURPLUS_INSUFFICIENT", "level": "comfort",
                "params": { "budgetW": -3 } },
  "levels": [ { "level": "comfort", "targetW": 0, "decision": {…} } ] }

Un seul niveau comfort, aucun niveau eco. La borne est arbitrée sur le surplus seul : ChargingModeEcoWithMinCurrent se comporte donc exactement comme ChargingModeEco. Le mode existe dans l'énumération, il traverse la frontière RPC, il est persisté — et il ne change aucune décision.

Le mécanisme existe déjà, il n'est simplement pas câblé sur la borne

Le chauffe-eau, dans la même trame, fait précisément ce qui est demandé :

{ "level": "eco", "targetW": 1071.6,
  "decision": { "code": "ECO_FLOOR_GRID", "params": { "gridW": 1072, "regime": "measured", … } },
  "counts": { "draw.committedW": 1071.6 } }

Un plancher servi, acheté au réseau, compté dans draw.committedW, avec le surplus distribué au-dessus par le niveau comfort. C'est le même objet. La demande est donc : donner à une borne en …WithMinCurrent (et en …EcoMinWithTargetTime) un niveau eco dont la cible est le minimum de la borne.

Et le plancher n'a besoin d'AUCUN champ de contrat

minCurrent n'existe nulle part dans ChargingInfo, et c'est noté depuis longtemps chez moi comme un blocage. Ce n'en est pas un : la valeur est déjà sur la box, dans la classe du Thing.

// Integrations.GetThingClasses, classe "trydan"
{ "name": "maxChargingCurrent", "type": "Double", "minValue": 6, "maxValue": 32,
  "unit": "UnitAmpere" }
{ "name": "phaseCount", "minValue": 1, "maxValue": 3 }   // état courant : 3

6 A × 3 phases × 230 V = 4 140 W, exactement le nombre attendu par Patrick. Le moteur peut donc dériver le plancher de maxChargingCurrent.minValue × phaseCount sans qu'aucun champ nouveau ne traverse la frontière, et sans que l'app ait à saisir quoi que ce soit — ce qui est le bon partage : une limite matérielle se lit sur le matériel.

Pourquoi ça se voit maintenant et pas avant

Parce que sur ce banc le surplus n'atteint jamais le plancher, donc le mode PV et le mode Min+PV rendent le même résultat visible — rien. Sur les 97 quarts d'heure du 2026-09-06, zéro dépasse 4 140 W ; le meilleur fait 2 214 W. Tant que le « Min » n'est pas servi, les deux modes sont indiscernables à l'œil comme à la sonde, et c'est ce qui a permis de croire le mode implémenté.

Conséquence pour la borne, côté client : elle affiche « désactivé ». Ce n'est pas une panne — optimizerSuspended: true, le HEMS a pris la main sur l'optimiseur interne V2C et la laisse à chargingEnabled: false. L'utilisateur voit donc le HEMS éteindre sa borne dans le mode censé garantir une charge minimale.

Une piste séparée, à trancher par vous

La borne simulée expose desiredPhaseCount, la V2C non. En monophasé le plancher tomberait à 1 380 W, et les 2 214 W du 6 septembre auraient suffi. Si la bascule de phases est pilotable sur la Trydan, elle change la réponse pour toutes les installations à petit surplus — mais c'est un autre lot, et je ne l'ouvre pas.


2026-09-06 — état du banc laissé en place pour le week-end

Rien écrit par l'app. L'obligation était déjà posée quand j'ai regardé, et déjà à la valeur demandée :

chauffe-eau   needs { "dailyDeadline": "12:00", "minEnergyWhPerDay": 2000 }

Relevé à 08:55 heure locale (cycle 2026-09-06T06:55:00Z), pour que les traces de lundi soient lisibles :

niveau eco targetW: 0 — plancher atteint
progress deliveredWh: 31 375, targetWh: 2 000, purchasedWh: 126,8, regime: measured
measuredSince 2026-09-05T10:00:00Z
niveau comfort targetW: 3 500, SURPLUS_SETPOINT
draw.committedW 0

Une confirmation que la donnée donne gratuitement

measuredSince vaut 10:00 UTC = 12:00 Europe/Paris, soit exactement l'échéance d'hier : la base a donc bien été reprise à la bascule de période, et pas laissée courir depuis l'ouverture. Et deliveredWh = 31,4 kWh ≈ 21 h × 1 500 W, ce qui recoupe la fenêtre « hier 12:00 → maintenant » au watt près. La logique de période se vérifie ainsi de l'extérieur, sans lire le code — un troisième recoupement, après Σ counts == targetW et gridW == remainingWh / remainingS.

Le plancher est atteint depuis longtemps dans cette période (2 000 Wh dus, 31 375 livrés), donc le week-end ne se passera pas à acheter : purchasedWh est à 127 Wh et draw.committedW à 0.


2026-09-05 — le début de journée doit être PUBLIÉ, pas recalculé

+etm55 vérifié sur la box (posé le 04/09 à 18:21:08). Le taux constant est en ligne, la maquette porte le bon chiffrage — ×4,75 sur une fenêtre quotidienne, ln(T·P/E) + 1 — et les deux interdits sont écrits à côté : sous unmeasurable, l'écran ne dira ni que l'achat va s'intensifier, ni que atMaximum: false signifie que tout va bien. Il signifie qu'il n'y a aucun étalement, donc rien qui puisse cesser.

Votre troisième nature d'heure touche du code déjà livré, et il ne peut pas se corriger seul

La journée de reporting suit l'installation — d'accord, et c'est bien la bonne réponse. Mais nos deux calculs journaliers prennent minuit du téléphone : fetchDailyBalance() par DateTime(y, m, d), et la baseline des ratios par un index de jour local. Sur .75 en Europe/London vue d'un téléphone français, entre minuit et 1 h l'app compte une journée que la box compte encore dans la précédente.

Et l'app ne peut pas le corriger avec ce qui est publié. System.GetTime donne le fuseau par nom IANA ; un décalage ne s'en déduit pas sans base de données de fuseaux. Embarquer cette base pour recalculer une frontière que la box connaît déjà serait le mauvais geste — et il resterait faux les jours de changement d'heure, où la journée fait 23 ou 25 heures.

Ce qu'on demande, et c'est un champ : le début de la journée de reporting en cours, en epoch, publié à côté du fuseau. L'app soustrait alors deux points de log sur la bonne fenêtre sans rien dériver. C'est la même règle que counts a réglée pour les compteurs : publier la frontière, ne pas la faire recalculer par chaque client — trois clients la recalculeraient de trois façons, et la seule qui compte est la vôtre.

Un décalage UTC seul suffirait presque, mais pas les jours de bascule. Le début de journée, lui, est juste par construction.

D'ici là la divergence est écrite dans le code, aux deux endroits, pour que personne ne lise « minuit du téléphone » comme un choix — et elle est bornée à une heure sur ce banc.

Noté sans conclure

Le banc n'a aucune charge unmeasurable : la loi du taux constant est déployée mais pas exerçable de bout en bout, et elle tient sur votre test unitaire et sa contre-épreuve. Je ne conclurai donc rien de son absence à l'écran — c'est encore l'homogénéité du banc, et c'est exactement le cas où une mesure manquante ressemble à une mesure réussie.


2026-09-04 (soir) — deux natures d'heure murale : c'est une règle de STOCKAGE, pas d'affichage

La décision « publier le fuseau, ni imposer ni déclarer forfait » est prise, et le Changed avec — état vérifié : +etm53 sur la box, aucune clé de fuseau dans GetLoadConfig pour l'instant, donc rien à câbler.

Votre distinction va plus loin que ce qu'elle annonce, et je crois qu'elle change ce qu'il faut persister, pas seulement ce qu'il faut afficher.

Le fuseau est une propriété du CHAMP, pas de l'installation

champ d'où vient son fuseau "HH:MM" nu suffit-il ?
échéance client (« avant 7 h ») l'installation — c'est une habitude, elle suit la maison oui : si la box change de fuseau, l'habitude doit suivre
plage d'heures creuses le fournisseur, Europe/Paris non — et c'est là que ça achète au mauvais moment

Pour l'échéance, réinterpréter « 07:00 » dans le nouveau fuseau est le comportement juste : le client veut son eau chaude avant de se lever, où que vive sa box. Pour la plage tarifaire, la même réinterprétation est un défaut silencieux : la fenêtre est définie ailleurs, elle ne bouge pas parce que la box a bougé, et rien dans la donnée ne permet de s'en apercevoir.

Donc une plage tarifaire ne peut pas être stockée en "HH:MM" nu. Elle doit porter son fuseau de référence — sans quoi le piège d'aujourd'hui revient, à l'identique, sur un champ où il coûte de l'argent au lieu d'une phrase d'écran.

Et surtout : ne nous laissez pas coder Europe/Paris en dur

Ce serait reproduire le défaut d'un cran plus haut. Un fournisseur allemand définit ses plages en Europe/Berlin ; l'app choisirait alors une horloge à la place de quelqu'un, exactement ce que la décision d'aujourd'hui refuse. Publiez le fuseau de référence avec la configuration tarifaire, comme vous publiez celui de l'installation avec la sienne. Nous ne connaîtrons jamais le bon par déduction.

L'avertissement à la saisie compare deux choses différentes selon le champ

Même forme, opérandes différents — et il faut le dire, sinon quelqu'un implémentera une seule comparaison pour les deux :

  • échéance : fuseau de l'installation ≠ fuseau de l'appareil → « vous tapez 07:00 sur une horloge qui n'est pas celle de la maison » ;
  • plage tarifaire : fuseau du fournisseur ≠ fuseau de l'installation → « cette plage commence à 23:00 chez votre fournisseur, soit 00:00 chez vous ».

Le second est le seul qui puisse faire acheter au mauvais moment, et c'est aussi le seul qu'un installateur ne peut pas deviner. Avertir à la saisie, jamais au résultat : au résultat, l'heure est passée.


2026-09-04 (suite) — la baisse, mesurée en lecture seule, et le fuseau qui la datait mal

Banc laissé tel quel — l'obligation est la vôtre, je n'ai rien écrit.

Le critère positif est atteint, et deux invariants tiennent avec lui

cycle livré reste gridW acheté restS
05:48Z 575,0 2 425 466 183,3 18 719
05:49Z 600,0 2 400 463 191,1 18 659
… +25/min −25/min ↓ +7,6/min −60/cycle
05:55Z 750,0 2 250 443 236,5 18 299

Huit cycles consécutifs, huit fois les deux invariants, zéro écart.

deliveredWh monte de 25 Wh/min, soit 1 500 W — exactement ce que le compteur ECS annonce. remainingWh descend d'autant, et gridW baisse. Ce n'est plus l'absence de mur, c'est la décroissance.

Deux vérifications qui ne coûtent rien et que l'écran fera à chaque trame :

  • deliveredWh + remainingWh == targetWh — 3 000,0000000000364 puis 2 999,999583 pour une cible de 3 000. L'identité que vous aviez épinglée tient sur données réelles, et c'est ici qu'elle mord : sous measured, elle n'est plus le corollaire trivial qu'elle est sous unmeasurable.
  • gridW == remainingWh / remainingS — 466 == 466, puis 463 == 463. La loi d'étalement est donc vérifiable de l'extérieur, sans lire le code. Un écart futur serait un défaut, pas une approximation.

Et un troisième recoupement, celui qui permet à l'écran de chiffrer le coût sans l'inventer : purchasedWh gagne 53,2 Wh en sept minutes, soit 456 W de moyenne, contre une moyenne de gridW de 454,6 W sur les mêmes cycles — 0,3 % d'écart. L'acheté est donc cohérent avec la consigne, cycle après cycle.

purchasedWh 183 → 236 pour 575 → 750 Wh livrés : le plancher acheté reste le tiers du consommé. La paire diverge, et c'est la divergence qui informe — le second usage, exercé.

Le fuseau : l'app peut être honnête aujourd'hui, sans attendre votre arbitrage

remainingS le confirme de notre côté : 18 719 s à 06:48 heure box mènent à 12:00 heure box, pas 12:00 heure client. Et le fuseau franchit déjà la frontière RPC :

System.GetTime -> {"automaticTime": true, "time": 1788500953, "timeZone": "Europe/London"}

L'écran n'a donc pas besoin de la décision pour cesser de mentir : il dira l'horloge au lieu de la choisir — fuseau de la box affiché à côté du champ d'échéance, divergence signalée quand celui de l'appareil diffère. C'est écrit dans CLAUDE.md et dans la maquette.

Ce que votre lot doit trancher reste entier, et c'est autre chose : d'où vient le fuseau et ce qui se passe quand il diverge. Nous ne présumons pas de la réponse — nous rendons seulement impossible qu'un installateur lise « 12:00 » comme son 12:00.


2026-09-04 — +etm52 : la contradiction est levée, mesurée

Obligation déclarée puis retirée (chauffe-eau, 4 000 Wh avant 12:00, sept cycles, restauration depuis la capture — « 0 écart, clé par clé »). Version relue avant de sonder : 1.15.2+etm52, posée le 03/09 à 06:52:20, nymead actif.

"progress": { "regime": "measured", "targetWh": 4000, "deliveredWh": 0,
              "measuredSince": "2026-09-04T03:42:00Z", "purchasedWh": 27.52 }

progressMeasurable: true en configuration et regime: "measured" en télémétrie, sur la même charge. La contradiction est levée : l'avertissement de saisie parlera là où il faut, et l'achat à l'aveugle a cessé pour une charge instrumentée. deliveredWh est présent (0, pas absent) — la distinction est tenue dans le bon sens. measuredSince porte l'instant d'ouverture et ne bouge plus sur sept cycles.

purchasedWh et deliveredWh divergent, et c'est ce qui prouve qu'ils ne font pas doublon

Sous measured : 27,5 Wh achetés, 0 Wh livré. Sur ce banc l'explication est bénigne — ECS-Meter.totalEnergyConsumed vaut 10 depuis le 1er septembre, c'est une constante du mock, relevée deux fois à une seconde d'intervalle. Le chemin est branché et lu ; la grandeur qu'il lit ne bouge pas.

Mais la divergence elle-même est le fait utile : elle donne à l'écran un contrôle qu'il n'aurait pas su fabriquer. Sous measured, purchasedWh qui monte pendant que deliveredWh reste plat veut dire soit que la charge ne consomme pas ce qu'on achète, soit que le compteur ne compte pas. Deux causes, deux gestes, et aucune n'est inventée par l'app.

Ce qui reste inobservable ici, et je ne le conclus pas

La décroissance de remainingWh, donc l'étalement adaptatif. Elle exige un cumul qui avance : il faudrait un compteur de banc dont totalEnergyConsumed progresse, ou une charge réelle. Le comportement observé — remainingWh figé à 4 000 — est aujourd'hui identique à celui de unmeasurable, pour une raison entièrement différente et bénigne. C'est exactement le genre de coïncidence qui ferait conclure juste pour une mauvaise raison, et tenir jusqu'au jour où quelqu'un bâtit sur la raison.


2026-09-03 (suite) — avant d'écrire sessionWh sur un compteur : la garde ne peut pas être le recul

Le correctif est le bon, et l'ordre aussi. Une mise en garde avant qu'il soit écrit, parce qu'elle vient d'un relevé déjà pris — et parce que le défaut qu'elle décrit arriverait par le chemin où R6 ne protège plus.

Le « recul franc » ne distingue pas une remise à zéro d'une valeur forgée

getEcoProgress() recapture la base quand l'accumulateur recule franchement — juste pour une nouvelle session. Mais un compteur qui disparaît peut, selon son plugin, publier 0 au lieu de se taire : nous l'avons mesuré sur la V2C le 2026-08-30, currentPower passé de 4 396 W à 0 et les booléens à false à la perte du lien Modbus.

Les deux cas produisent exactement la même observation : une valeur qui s'effondre. Or ils appellent des gestes opposés — recapturer, ou ne rien faire.

Et le coût de les confondre n'est pas symétrique. Si la base est reprise à 0 pendant l'absence, puis que le compteur revient avec son cumul réel (10 kWh sur l'ECS du banc), alors deliveredWh = 10 000 − 0 d'un seul cycle : l'obligation se déclare tenue sans que rien n'ait été livré. Sous measured, R6 ne s'y oppose pas — elle n'interdit ECO_FLOOR_MET que sous unmeasurable. Le chemin mesuré est donc le seul par lequel un faux « tenue » peut sortir, et c'est celui qu'on est en train d'ouvrir.

Donc : la garde doit porter sur la JOIGNABILITÉ du compteur, pas sur la direction de la valeur. Tant que le Thing n'est pas joignable, ni recapture ni avancement — le tronçon se clôt sur la dernière valeur lue pendant que la mesure existait, exactement la règle que vous avez posée hier pour la bascule de régime. C'est aussi ce que l'app a fait de son côté le 2026-08-30 : sous connected == false, les états sont jetés à l'entrée plutôt que relayés.

Honnêteté sur la portée : le zéro forgé a été mesuré sur currentPower et les booléens, pas sur un compteur d'énergie — sessionEnergy de la V2C, lui, est resté à sa dernière valeur pendant la coupure. La politique dépend donc du plugin, et c'est précisément pourquoi un accumulateur ne devrait pas parier dessus.

Et une conséquence heureuse de votre dernière phrase

Une fois sessionWh rempli, la population « réellement non mesurable » se réduit à trois cas matériels — et deux sur trois sont réparables par l'installateur (aucun compteur désigné, compteur sans énergie). L'avertissement de saisie cesse alors d'être une fatalité affichée : il devient une consigne, et progressUnmeasurableCause porte déjà les trois gestes.


2026-09-03 — +etm49 à la sonde : purchasedWh marche, et deux champs se contredisent

Obligation déclarée puis retirée sur le banc (chauffe-eau, 4 000 Wh avant 12:00, sept cycles, capture disque → restauration depuis le fichier → « 0 écart, clé par clé »).

Ce qui est vérifié

Les cinq symboles sont dans le binaire déployé, tous en UTF-16 — ASCII à zéro partout, le mélange que vous signaliez. progressMeasurable est bien dans GetLoadConfig, en r:, sur les quatre charges, et progressUnmeasurableCause absent quand il n'y a pas de cause : la bonne forme.

purchasedWh fonctionne, et sous unmeasurable — c'est le point qui comptait :

0 → 10,78 → 21,41 → 32,25 → 43,12 Wh   (≈ 10,8 Wh/min, soit ~648 W : cohérent avec gridW)

Il se recoupe donc avec gridW × temps, ce qui donne à l'écran un contrôle interne gratuit. measuredSince et measuredGaps sont absents — correct, rien n'est mesuré ici.

Mais progressMeasurable: true et regime: "unmeasurable" sur la MÊME charge

Le chauffe-eau porte progressMeasurable: true en configuration et progress.regime: "unmeasurable" en télémétrie, au même cycle. Ce n'est pas une course : les deux champs répondent à deux questions différentes, et le code le dit.

ce que le champ teste où
progressMeasurable le matériel le permet — borne avec sessionEnergy, ou compteur désigné qui compte l'énergie nymeaenergyjsonhandler.cpp::progressMesurable()
regime le moteur le fait — sessionMeasurable, posé uniquement dans evadapter.cpp surpluscontext.h (défaut false)

Sur toute charge non-VE, le premier dit oui et le second dit non. C'est le quatrième maillon mort, vu de l'autre bout.

Et la conséquence défait la règle pour laquelle le champ a été créé : l'avertissement de saisie se déclenche sur progressMeasurable == false, donc il restera muet exactement sur les charges dont l'obligation sera achetée à l'aveugle. L'installateur ne sera pas prévenu dans le seul cas où il fallait le prévenir.

Nous ne câblons donc pas l'avertissement sur ce champ, et l'app ne peut pas rattraper : à la saisie, la charge n'est pas encore dans loads[], donc son regime n'existe pas — c'est précisément pourquoi le signal devait vivre en configuration.

Deux sorties, et la première est celle qu'on préfère :

  1. Remplir sessionWh depuis le compteur désigné pour les charges non-VE — les deux champs s'accordent alors d'eux-mêmes, et le régime devient measured là où le câblage le permet ;
  2. en attendant, faire dire à progressMeasurable ce que le moteur fait, pas ce que le matériel permet, avec une cause qui nomme le manque. Mieux vaut un installateur prévenu par une limite du moteur qu'un installateur non prévenu par une promesse de câblage.

Statut, sans reproche

L'escalade est toujours là en +etm49 — remainingWh figé à 4 000, gridW 645 → 654 sur sept cycles. Le taux constant a été retenu mais n'est pas déployé ; c'est cohérent avec ce que vous annonciez (la perte du tronçon d'abord). Noté pour ne pas le remesurer comme une découverte.


2026-09-02 — la bascule de régime : prémisse confirmée, et une perte que le code montre

Ma prémisse était juste, mais je l'avais posée sans la vérifier

Lue dans energyarbitrator.cpp : la base se prend à l'instant où la charge devient mesurable (if (!m_ecoBaseWh.contains(id)) insert(sessionWh)), et ecotarget.cpp calcule remainingWh = targetWh − deliveredWh — donc targetWh n'est pas rebasée. deliveredWh ne compte bien que depuis la bascule. J'avais énoncé ça comme un fait ; ça n'en était un qu'après lecture.

Et votre réponse tranche mieux que ma question

J'offrais deux sorties comme équivalentes — rebaser targetWh, ou publier depuis quand on mesure. measuredSince est déjà écrit, et le commentaire donne l'argument qui départage, que je n'avais pas : rebaser ferait perdre l'obligation réelle, et l'écran ne pourrait plus dire ce qui reste dû. Retenu tel quel. purchasedWh est là aussi, dans tous les régimes, avec exactement la distinction proposée — l'acheté est la commande du moteur, le livré une observation qu'il ne peut pas faire.

Mais aucun des deux n'est SUR LA BOX

strings sur le binaire déployé (libnymea_energypluginnymea.so, +etm43), dans les deux encodages — merci pour -el, le mélange est bien réel :

symbole UTF-16 ASCII
ECO_FLOOR_GRID · ECO_FLOOR_MISSED · remainingWh 0 1
atMaximum 1 1
purchasedWh 0 0
measuredSince 0 0

Ils sont au HEAD (770cef1), pas dans +etm43. Rien n'est donc à câbler dessus aujourd'hui — et sans ce contrôle j'aurais bâti l'avertissement de saisie sur deux champs muets, pour la troisième fois de la semaine.

Une perte que la lecture montre, et que la bascule rend probable

Sur measured → unmeasurable → measured dans la même période : au passage en unmeasurable, m_ecoBaseWh et m_ecoMesureDepuis sont retirés ; au retour, la base est reprise à la valeur courante de sessionWh. Donc deliveredWh repart de zéro, et l'énergie livrée pendant les tronçons mesurés antérieurs est perdue de l'avancement.

Deux conséquences, et la seconde coûte de l'argent : l'écran sous-estime l'avancement, et la passe éco rachète ce qui a déjà été livré. Ce n'est pas théorique — c'est exactement le motif de la V2C dont le lien Modbus bat : connected bascule, le régime suit.

Piste, si elle vous va : accumuler par période plutôt que par tronçon — ajouter sessionWh − base à un cumul de période au moment de quitter measured, au lieu de le jeter. Le measuredSince garde alors son sens (« mesuré depuis », avec des trous), et deliveredWh cesse de mentir par défaut.

(Lu dans le code, non mesuré : la bascule n'est pas provocable sur le banc tant que sessionWh n'est pas rempli hors borne.)


2026-09-01 (soir) — trois champs pour que l'écran puisse tenir la nuance

Vos deux règles sont prises. Il leur manque trois choses pour être affichables, et chacune bloque une phrase précise.

1. ECO_FLOOR_MISSED sous unmeasurable : ajoutez purchasedWh

La cause unmeasurable porte bien la nuance — l'écran peut dire « non vérifiable » plutôt que « non tenue », et missingWh absent est juste : on ne chiffre pas un manque qu'on ne connaît pas.

Mais il manque le coût. Sous le taux constant, le moteur aura acheté exactement la cible, et il le sait exactement : il l'a commandé. Aucun écran ne peut le reconstituer — draw.committedW est une puissance instantanée par cycle, pas un cumul d'obligation.

Et la distinction est celle de toute la semaine, appliquée à l'envers. Ce qui est acheté est un fait que le moteur connaît sans mesure — c'est sa propre commande. Ce qui est livré est ce qu'il ne peut pas vérifier. Donc purchasedWh est légitime sous unmeasurable là où deliveredWh ne l'est pas, et les deux ne se confondent pas : l'un est une comptabilité, l'autre une mesure absente.

Sans lui, votre propre règle de saisie — « ce que ça coûte par jour » — ne peut être qu'une estimation de l'app, faite à partir du taux. Nous préférerions ne pas l'inventer.

2. Le signal de déclaration doit vivre avec la CONFIGURATION, pas avec la télémétrie

Vous proposez que le moteur dise à la déclaration qu'une charge n'a pas de mesure d'énergie. Oui — mais pas dans GetLoadTelemetry : measurement n'existe que pour les charges présentes dans loads[], et une charge qu'on est en train de déclarer est justement celle qui n'y est pas encore — pas arbitrée, ou créée à l'instant. L'écran serait muet exactement au moment où il doit parler.

Il le faut donc dans GetLoadConfig, en lecture seule (r:), présent pour toute entrée configurée. Et avec sa raison, parce que le geste de l'installateur en dépend :

raison ce que l'écran propose
aucun compteur rattaché « rattachez un compteur » — c'est réparable en une manipulation
compteur sans énergie (puissance seule) « ce compteur ne compte pas l'énergie » — il faut le remplacer
borne sans sessionEnergy rien à faire — c'est le matériel

meterThingId ne suffit pas à le déduire : un compteur peut publier currentPower sans totalEnergyConsumed, et l'app croirait la charge mesurable.

3. Changement de régime en cours d'obligation : deliveredWh cesse d'être une fraction

Votre précision sur la bascule est la bonne, et elle crée un piège d'affichage que LM-1014-b décrit côté moteur mais que l'écran subit aussi.

Après un passage unmeasurable → measured en cours de fenêtre, la base est capturée à cet instant : deliveredWh ne compte donc que depuis la bascule, pendant que targetWh reste la cible entière. La paire cesse d'être une fraction — une barre d'avancement afficherait « 0,5 sur 4,0 » après une bascule où 3 kWh ont pu être achetés. Crédible, et faux.

Deux sorties, et c'est à vous : soit targetWh est rebasée à la bascule (ce qui reste à livrer devient la nouvelle cible), soit un champ dit depuis quand on mesure et l'écran affiche « mesuré depuis 09:14 » au lieu d'une fraction. Nous n'afficherons pas de barre tant que ce n'est pas tranché — une fraction fausse est pire qu'un chiffre absent.

Ce qui est acté chez nous

Taux constant, atMaximum jamais vrai, total = la cible. targetW/counts pour l'arithmétique, gridW pour la phrase. L'écran dira « non vérifiable », jamais « non tenue ». Et la saisie d'une obligation sur une charge sans mesure d'énergie portera l'avertissement — dès que le champ du point 2 existe.


2026-09-01 (suite) — acheter sans vérifier : ce que nous proposons, et pourquoi

Notre position : oui, mais à taux CONSTANT — l'escalade n'a pas de justification sous unmeasurable, et elle achète 2,5 fois l'obligation.

L'escalade est une correction calculée sur une grandeur qui n'existe pas dans ce régime

L'étalement vaut remainingWh / remainingS. Sous measured, il accélère parce qu'un retard mesuré subsiste : la correction suit une preuve. Sous unmeasurable, remainingWh est figé par contrat — c'est la règle que vous avez posée hier, et elle est juste. Mais alors le taux ne suit plus un retard : il suit l'horloge. On achète davantage parce que le temps passe, pas parce que quelque chose est en retard. C'est le motif « chiffre crédible et faux » déplacé dans une loi de commande au lieu d'un champ.

Et ce n'est pas qu'un risque de mur : la loi surachète, sans borne

Avec remainingWh figé, l'énergie achetée sur la fenêtre vaut ∫ E/(T−t) dt — elle diverge. Bornée seulement par le plafond du mécanisme. Chiffré sur notre relevé du 05:47 (E = 4 000 Wh, T = 18 779 s, plafond 3 500 W lu dans mechanism.maxStageW) :

taux au plafond 1 h 09 avant l'échéance
acheté avant le plafond 6 073 Wh
puis 4 114 s à 3 500 W 4 000 Wh
total ≈ 10,1 kWh pour une obligation de 4,0 kWh — ×2,5

Et le facteur grandit avec la fenêtre : plus l'échéance est lointaine, plus on surachète. Une obligation quotidienne sur une charge durablement non mesurable, ce n'est donc pas « la cible tous les jours » — c'est deux à trois fois la cible tous les jours.

(Arithmétique dérivée de la loi observée sur quatre cycles, pas mesurée jusqu'à l'échéance : nous n'avons pas laissé le banc aller au bout, et le relevé s'arrête à 767 W.)

Ce que nous proposons

  1. Remplir sessionWh depuis le compteur de charge — c'est le vrai correctif, et il ferme la question pour toute charge instrumentée. meterThingId est déjà là, measurement.source == "meter" dit quand c'est fiable, totalEnergyConsumed porte l'énergie. LM-1014-b s'applique telle quelle : cumul, donc base capturée à l'ouverture et reprise à la bascule.
  2. Pour ce qui reste non mesurable : figer le TAUX, pas le reste. Taux = targetWh ÷ fenêtre à l'ouverture, constant, atMaximum jamais vrai. Le total acheté vaut alors exactement la cible — on garde « viser l'obligation entière », on perd l'escalade qui ne repose sur rien. C'est le même geste que partout cette semaine : ne pas laisser une absence d'information produire un chiffre qui a l'air d'en être une.

Et la part qui nous revient, sur votre règle

Vous dites qu'une obligation non vérifiable ne devrait pas pouvoir déclencher un achat au plafond sans que l'installateur l'ait su en la déclarant. C'est un écran, donc c'est à nous : la déclaration d'une obligation sur une charge sans mesure d'énergie dira, à la saisie, qu'elle sera achetée à l'aveugle, ce qu'elle coûtera par jour, et qu'elle ne pourra jamais être annoncée tenue. Nous le construirons avec l'écran de configuration — pas avant que le §10 soit déployé, mais la maquette le portera.

Un corollaire à ne pas perdre : sous unmeasurable, ECO_FLOOR_MISSED sortira tous les jours, par construction, puisque R6 interdit ECO_FLOOR_MET. Un mécanisme dont le succès est inobservable et l'échec quotidien ne doit pas, en plus, dépenser en accélérant. Notre écran dira « non vérifiable », jamais « non tenue ».

Confirmé pour l'app

targetW / counts pour l'arithmétique, gridW pour la phrase. C'est bien ainsi que nous lisons, et l'écart entre les deux (766,81 vs 767) reste sous le watt.


2026-09-01 — §10 vu à la sonde : les six champs, et une trajectoire à trancher

Obligation déclarée sur le banc puis retirée — chauffe-eau, needs {dailyDeadline: "12:00", minEnergyWhPerDay: 4000}, sept cycles, +etm43. Capture sur disque avant la première écriture, restauration depuis le fichier, comparaison clé par clé : « 0 écart ».

La forme est celle qui était annoncée, et elle porte plus que le motif

{ "level": "eco", "funding": "grid", "targetW": 766.813994355397,
  "counts":   { "draw.committedW": 766.813994355397 },
  "progress": { "regime": "unmeasurable", "targetWh": 4000 },
  "decision": { "code": "ECO_FLOOR_GRID", "params": {
      "gridW": 767, "remainingWh": 4000, "targetWh": 4000,
      "remainingS": 18779, "regime": "unmeasurable", "atMaximum": false } } }

decision et progress au niveau, deliveredWh absent sous unmeasurable, counts qui tombe sur draw.committedW, et le motif racine qui porte "level": "comfort". R1, R5 et R7 sont vérifiés sur le fil. Les deux identités tiennent à chaque cycle.

Le dernier cycle est le cas que la maquette visait, et il valide R7 durement

À 05:47 le surplus passe à −922 W : le niveau comfort tombe à targetW: 0 avec SURPLUS_INSUFFICIENT et counts {"budget.allocatedW": 0} — destination publiée à zéro, comme demandé — pendant que le niveau eco achète 767 W au réseau.

Le motif racine de cette charge vaut alors SURPLUS_INSUFFICIENT. Un client d'avant levels[] lit donc « surplus insuffisant, rien fait » sur une charge en train d'acheter 767 W. Ce n'est pas une vue incomplète, c'est une vue fausse — et c'est l'argument le plus dur en faveur de decision.level et des deux lignes à l'écran.

La trajectoire, mesurée — et la question qu'elle pose

cycle remainingWh remainingS gridW
05:42 4 000 19 079 755
05:45 4 000 18 899 762
05:47 4 000 18 779 767

Sous unmeasurable, rien n'est soustrait : le plancher ne peut jamais se refermer, donc l'achat s'accélère mécaniquement jusqu'à l'échéance et finit en ECO_FLOOR_MISSED. Ce n'est pas un cas limite du régime, c'est sa trajectoire garantie. La maquette l'affiche désormais — reste et temps, pas seulement les watts de l'instant.

Et le point qui mérite votre arbitrage : cette charge a un compteur. ECS-Meter publie currentPower: 1500 et totalEnergyConsumed: 10 — une énergie, la grandeur exacte qui manque. Le régime sort quand même unmeasurable. Si c'est LoadContextTelemetry::sessionWh toujours déclaré et jamais rempli hors borne, alors aujourd'hui toute obligation éco achète à l'aveugle et accélère, y compris sur une charge instrumentée. Deux questions, et elles ne sont pas d'écran : faut-il remplir sessionWh depuis le compteur de charge, et faut-il acheter du tout quand on ne peut pas vérifier ? Notre écran montrera la trajectoire ; il ne la corrigera pas.

Un détail à confirmer

gridW: 767 (entier) et targetW: 766.813994355397 (double) portent le même fait, et counts s'accorde avec targetW. Nous prenons donc targetW/counts pour l'arithmétique et gridW pour la phrase. Confirmez si c'est l'intention.


2026-08-30 (soir, lien rétabli) — relevé frais : deux confirmations et une correction

chargingState figé : confirmé sur lien VIVANT, ce n'est pas de la péremption

Le doute était permis tant que le lien Modbus était coupé. Il ne l'est plus : à 14:12, la borne publie des valeurs en mouvement (currentPower 4 372 → 4 397 W, courants qui bougent sur les trois phases) et chargingState vaut toujours ChargingStateIdle. Le champ n'est pas en retard : personne ne le maintient, parce que l'arbitre a sorti la borne du waterfall en mode manuel. La demande du point 1 tient donc telle quelle — absent plutôt que figé.

La box et le matériel divergent en mode manuel, et rien ne les réconcilie

Restoring manual values of false 6 A 3 phases a été journalisé à 12:15:34, trois secondes après la perte du lien. La borne, elle, est restée sur ce que l'arbitre lui avait commandé au cycle d'avant : power: true, 7 A, et elle charge toujours à 4 372 W deux heures plus tard. Le lien est revenu depuis, et rien n'a réappliqué les valeurs manuelles : en mode manuel personne ne commande, donc l'écart entre ce que la box croit avoir posé et ce que la borne fait est permanent.

Ce n'est pas une conséquence du bug WiFi : c'est ce que « le mode manuel ne commande pas » implique dès qu'une commande se perd. Question : une entrée en mode manuel devrait-elle réappliquer ses valeurs à la reconnexion du Thing ? Sans cela, une coupure d'une seconde suffit à laisser une borne à un courant que plus personne n'a choisi, et le HEMS n'en sait rien — draw.committedW reste à 0 pendant que 4,4 kW sont tirés.

sessionEnergy : notre doc était en retard, corrigée sur relevé frais

Vous aviez raison — LM-1009 §C1 n'est pas contredite, elle ne s'applique simplement plus à cette borne. Mesuré sur charge réelle, deux échantillons à deux heures d'écart :

12:15 14:12
sessionEnergy 7,5147 kWh 15,8015 → 15,8405 kWh, en mouvement
chargeEnergy 0,3103 kWh 8,5972 → 8,6361 kWh
écart 7,2044 7,2044

L'écart est constant au dix-millième sur deux heures : c'est votre accumulateur, et il porte les incréments d'avant la dernière remise à zéro. La remise à zéro n'a pas été surprise en direct — mais l'accumulation, elle, est prouvée par cet écart, et elle ne se déduisait pas du code.

Corrigé chez nous : la maquette ne nomme plus la V2C dans l'exemple « avancement non mesurable » (le régime reste vrai pour les bornes sans cet état, wallboxNoMeter et l'une des deux classes wallbox), et le modèle porte le relevé. Aucun avancement n'est affiché pour autant : c'est une décision (D1 — en énergie livrée, et il n'y a pas encore d'écran qui la porte), pas une mesure qui manquerait.


2026-08-30 (soir) — trois constats sur la borne, et deux d'entre eux FORGENT un fait

Contexte, mesuré : V2C Trydan, Skoda branchée et en charge (4 356 W, 3 phases), passage en Boost depuis l'app à 12:15:34, lien Modbus tombé à 12:15:31 — trois secondes avant.

1. chargingState reste Idle pendant une charge réelle

En ChargingModeNormal l'arbitre sort la borne du waterfall — [Arbitre] Borne "V2C Trydan" hors waterfall (mode manuel ou aucun véhicule branché), à chaque cycle — et cesse donc de maintenir chargingState. Le champ garde sa dernière valeur, ChargingStateIdle, pendant que la borne tire 4 356 W.

Un champ que plus personne ne maintient devrait être absent, pas figé. C'est la règle que draw, counts et measurement appliquent déjà — appliquée cette fois à un champ vivant qui cesse de l'être, ce qui est le cas le plus difficile à voir : rien ne change, et c'est justement le problème. Nous ne l'affichons pas, et nous ne l'afficherons pas tant qu'il peut être périmé sans le dire.

2. À la perte du lien, les états publiés passent à ZÉRO — c'est le plus grave

Relevé état par état sur le même Thing, avant et après marking unreachable :

État Avant Après
connected true false
pluggedIn true false
charging true false
currentPower 4 396 W 0
statusMessage "" « Hors ligne depuis 30/08 12:39 — timeout Modbus — vérifier signal WiFi de la borne »

La voiture était branchée et en charge. Un client qui relaie ces états affiche donc « aucun véhicule branché, 0 W » — un fait forgé, pas une absence. Et avant les zéros, la dernière valeur connue reste publiée un moment comme si elle était actuelle : vérifié, aucun état ne bouge pendant 45 s alors que la borne est censée tirer 4 356 W.

La demande : à la perte du lien, ne pas republier pluggedIn / charging / currentPower. Les figer est déjà trompeur ; les remettre à zéro fabrique une affirmation — et c'est exactement ce que la règle « un champ absent vaut mieux qu'un champ nul ou forgé » interdit. Nous ne savons pas d'où viennent ces zéros (plugin V2C ou remise à zéro générique de nymea à la déconnexion), et c'est à vous de le dire.

Deux choses sauvent la lecture, et elles sont bien faites : connected bascule, et statusMessage porte l'heure de la coupure et la piste à suivre, déjà rédigés. Nous l'affichons tel quel.

Corrigé côté app aujourd'hui : sous connected == false, EvChargerLive met pluggedIn, charging et currentPowerW à null à l'entrée — ni le zéro forgé, ni la valeur périmée. La tuile dit « Hors ligne » et reprend votre phrase. Le mode, lui, reste affiché : il est stocké dans la box, pas lu sur la borne, donc un lien coupé ne l'invalide pas.

3. ChargingModeNormal n'est pas un boost — et notre écran le promet

Le journal est explicite :

EV charger "V2C Trydan" changed to normal mode. Restoring manual values of false 6 A 3 phases

Passer en Normal restaure les valeurs manuelles mémorisées — ici power false, 6 A — et sort la borne de l'arbitrage. Donc : ce que le mode restaure est ce qui traînait, pas un maximum ; et plus personne ne commande ensuite, donc rien ne monte le courant.

Or l'attente derrière le bouton est explicite (Patrick, 2026-08-30) : en Boost la borne doit charger à sa puissance maximale. Notre correspondance [Boost] → ChargingModeNormal (CLAUDE.md) ne la tient pas.

La question, et sa contrainte : comment une app exprime-t-elle « charge maintenant, à fond » ? Nous n'écrirons pas les actions du Thing (power, maxChargingCurrent) depuis l'écran : +etm23 a rétabli une charge, un commandeur, et un boost écrit par l'app rouvrirait exactement le défaut que ce lot a fermé — deux commandeurs, et une consigne publiée qui n'atteint pas le matériel. Il faut donc que ça vienne de vous : soit le mode fixe lui-même les valeurs manuelles au maximum de la borne, soit un mode (ou un paramètre) dédié le porte.

Tant que ce n'est pas tranché, nous ne renommons pas le bouton : l'écran dirait « Boost » sur un mode qui, selon ce qui traîne en mémoire, peut laisser la borne à l'arrêt.


2026-08-30 (suite) — draw.committedW en préalable : OUI, sans réserve

Et le refus de livrer counts sur budget.evReservedW est le bon appel. Une identité dépendante du temps est pire que pas d'identité : vraie au premier cycle après un changement, fausse après convergence, elle passerait tous les tests — qui portent sur des cycles isolés — et certifierait un total faux chez les clients. R3 existe pour empêcher une réconciliation qui pourrit sans se voir ; la livrer sur un terme tantôt registre tantôt correction aurait fabriqué exactement ça, avec en plus le sceau d'une identité publiée.

Les trois destinations, et ce que l'app peut en dire

Destination Nature Réconciliable ?
budget.allocatedW registre d'allocation (surplus) oui, aujourd'hui — mesuré sur .75
budget.evReservedW terme de correction du budget, deux natures mêlées non, et jamais
draw.committedW registre des watts achetés oui, dès qu'il existe

Conséquence, à dire telle quelle : il n'existe aujourd'hui qu'une identité vérifiable, et elle ne couvre qu'un côté. Σ targetW(surplus) == budget.allocatedW — vérifiée à la sonde le 2026-08-30 (06:10 et 06:14). Côté réseau : rien. Pas « à vérifier plus tard », pas « approximativement evReservedW » : rien, jusqu'à draw.committedW.

Nous portions la fausse affirmation, et elle part aujourd'hui

Elle est écrite chez nous en cinq endroits — « une charge financée au réseau vit dans budget.evReservedW » (CLAUDE.md ×2, le modèle ×3, la description ARB de la mention « servie au réseau »). Retirée dans le même lot, sans changement de comportement.

Et la distinction avec notre discipline de la semaine, parce qu'elle a l'air de la contredire. L'exception EV_GRID_START décrit un comportement de la box : on ne la retire qu'au vu de la sonde. « evReservedW est la destination des watts achetés » est une affirmation sur le sens d'un champ, et elle est fausse maintenant, sur la box d'aujourd'hui. Attendre une livraison pour corriger ce qu'on a compris de travers serait laisser une doc mentir en connaissance de cause. La sonde arbitre ce que fait la box, pas ce que nous en avons cru.

Trois demandes sur draw.committedW

a. Par cycle, en watts, dans la même trame que budget. Sinon on compare deux instants, et l'identité redevient dépendante du temps par un autre chemin — celui-là même qu'on vient d'éviter.

b. Ne fabriquez pas le reste de l'objet draw. Notre maquette lui donne aussi authorisedW, remainingW, binding, perPhaseBound (§10). Publier draw: { "committedW": 1400 } seul est parfait. Publier authorisedW: 0 ou binding: "connection" par défaut ferait afficher à l'écran « ordre de service » un plafond de soutirage qui n'existe pas, avec sa source — un chiffre crédible et faux, et la faute exacte que ce champ est censé rendre impossible. Absent, jamais forgé : l'écran 2 n'apparaîtra qu'avec l'objet complet.

c. Une phrase de définition pour evReservedW. Nous l'affichons sous le libellé « Réservé au véhicule » (_BudgetTable), qui affirme une réservation là où il y a une correction. Nous ne le renommerons pas au jugé — donnez la définition, nous écrirons le libellé. C'est le seul geste que cette découverte laisse en suspens côté écran.

État mesuré au 2026-08-30, et ce qu'il autorise

Sonde contre .75 après l'annonce de +etm31 : aucune clé draw dans la trame, et le paquet dit pourquoi — powersync-energy-plugin-nymea 1.15.2+etm30, aucun paquet +etm31 (code écrit, cross-build non fait). La pose est relancée ; l'exception EV_GRID_START reste dans surplusShareW jusqu'à ce que la sonde voie draw.committedW sortir. C'est la sonde qui autorise le retrait, pas l'annonce — y compris quand l'annonce est juste.

Ce qui ne bouge pas

counts reste retenu tel qu'arbitré ce matin : la forme ne change pas, seul son préalable apparaît. Et l'exception EV_GRID_START reste dans le code et dans CLAUDE.md — elle tiendra plus longtemps que prévu, et c'est très bien ainsi.


2026-08-30 — R3 : counts est retenu, et funding sort de la réconciliation

Réponse à la question du moteur sur le niveau mixte. La forme proposée est prise telle quelle : funding reste, counts s'y ajoute, Σ counts == targetW toujours, et counts est publié même à une seule destination.

Le code unique l'emporte — et l'argument contre l'omission est vérifiable, pas seulement juste

L'omission n'est pas neutre dans le code qui tourne aujourd'hui : resolvedFunding rend null quand rien n'est publié, et isSurplusFunded rend alors true (lib/models/load_telemetry.dart). Un niveau mixte sans funding serait donc rangé avec les charges du soleil, en silence. L'omission porterait déjà une affirmation — et la pire des deux.

Mais « approximation » est le mauvais mot, et le garder coûterait le champ

Tant que funding est décrit comme approximatif, quelqu'un finira par vouloir le rendre exact : part dominante, seuil de majorité. Ce serait un libellé qui bascule d'un cycle à l'autre sur 900/480, sans qu'aucune décision n'ait changé.

La règle que nous demandons d'écrire à sa place : grid l'emporte dès qu'un watt est acheté. Déterministe, indépendante des proportions, et alignée sur ce qu'un humain doit savoir — l'achat est le fait actionnable, le surplus est l'ordinaire. Elle vaudra telle quelle pour le plancher éco acheté au réseau (§10) sans nouvelle décision à prendre.

Ce qui rend l'ensemble soutenable : funding sort de la comptabilité

Avec counts toujours publié, funding ne participe plus à aucun calcul de réconciliation. Il ne dit plus que : faut-il en parler ? — et counts dit combien, et où. Aucun des deux ne se dérive de l'autre, et l'approximation qui reste n'entre plus dans un total. Côté écran, la carte d'un niveau mixte dira « en partie achetée — 480 W sur 1 380 » : funding déclenche la phrase, counts la chiffre.

Et "mixed" publié ? Non — et c'est notre propre code qui le dit

C'est bien la troisième voie, elle mérite d'être posée, et nous la refusons. Trois raisons, dans l'ordre de force :

1. Il reproduirait EXACTEMENT la panne de l'omission, sur le même chemin de code. Tout client qui teste funding == 'grid' a une branche par défaut, et cette branche est « surplus » — la nôtre l'était encore ce matin (isSurplusFunded => funding != 'grid'). Une troisième valeur y tombe sans erreur, et range au soleil un niveau dont une part est achetée. C'est le défaut que l'omission produisait, avec un champ présent en plus pour faire croire le contraire.

2. Il serait dérivable — donc il violerait votre propre règle. "mixed" ⟺ counts a plus d'une destination non nulle. Un champ publié qui se recalcule depuis un autre est précisément celui que quelqu'un supprimera un jour comme redondant, et il aura raison.

3. Il ne répond pas à la question que funding pose. funding dit d'où viennent ces watts ; "mixed" dit qu'il y a deux réponses — une réponse sur la question, pas sur les watts. Et l'information actionnable qu'elle promet (combien est acheté) est déjà dans counts, en clair.

La ligne qui sépare nos deux mixed : le nôtre est dérivé, local, jamais transporté — resolvedFunding le fabrique pour une CHARGE dont les niveaux divergent, uniquement pour empêcher une carte d'affirmer une origine unique là où il y en a deux. Il est légitime parce qu'il est dérivable ; le vôtre serait illégitime pour la même raison. On publie ce qui ne se dérive pas, on dérive ce qui se dérive — et cette règle range funding, counts et mixed chacun à sa place sans qu'aucun ne soit redondant.

Trois choses en échange, et elles sont de notre champ

a. Une clé inconnue ne doit jamais disparaître. Nous traiterons les clés de counts comme des identifiants opaques, jamais comme des chemins à parser. Si draw.committedW arrive sur une box plus récente que l'app, elle vérifiera quand même Σ counts == targetW — la règle ne dépend pas du sens des clés — mais dira « destination inconnue de cette version », jamais zéro. Corollaire pour vous : ne réutilisez jamais une clé existante pour un sens nouveau. C'est le seul cas qu'aucun client ne peut détecter.

b. Publiez counts même à zéro, avec sa destination. Mesuré sur .75 ce matin (cycle 06:14) : un niveau targetW: 0 est publié, avec le motif qui dit pourquoi. Un counts: {} y satisferait l'identité (0 == 0) tout en perdant la destination — un trou exactement sur le cas le plus fréquent, et de la même famille que celui que counts vient fermer. Une clé, valeur 0.

c. Dites qui fait foi entre counts et les params d'EV_GRID_START. Les deux porteront le même partage (budgetW / gridW). Notre lecture : counts fait foi pour la comptabilité, les params restent pour la phrase du motif — catalogue fermé, testé sur le texte. Si vous les gardez, dites-le ; s'ils divergent un jour, c'est counts que nous croirons.

Ce que ça change pour nous, et quand

R3 devient vérifiable par niveau, sans connaître le sens des compteurs — c'est plus fort que ce que nous demandions. L'exception EV_GRID_START disparaîtra du contrat le jour où la sonde la verra disparaître de la box : d'ici là elle reste écrite dans CLAUDE.md et traitée dans surplusShareW, et nous ne touchons ni l'un ni l'autre. Contrat, pas feuille de route — comme pour levels[].


2026-08-29 — RÉPONSE au §9 : les deux passes du §10, vues de l'écran

Maquette : docs/mockups/deux_passes_eco_confort_mockup.html — trois écrans, un catalogue d'états et la charge utile demandée. Ce qui suit en est le résumé ; la maquette fait foi. Lisible sans cloner le dépôt : https://claude.ai/code/artifact/95faf613-f79a-4906-b82d-e264b4a40b11 (même contenu, republiée à chaque révision du fichier).

Les trois réponses

1. La liste reste ordonnée par RANG. Deux raisons, et la seconde est la vraie :

  • c'est la surface d'édition du rang — le glisser-déposer y écrit priority. Une liste qui se réordonne sous le doigt après le geste est inutilisable ;
  • surtout, l'ordre de service n'est pas une propriété de la configuration : il dépend des planchers déjà atteints, donc il change d'un cycle à l'autre sans que personne ne touche à rien. Une liste de configuration ne peut pas avoir une colonne vertébrale qui bouge toute seule. Trier par ordre de service ne rendrait pas le rang illisible — ça rendrait la liste instable, ce qui est pire.

Et il faut le dire, donc on le dit deux fois : un bandeau permanent au-dessus de la liste (« tous les planchers éco d'abord, le confort ensuite — votre ordre est respecté à l'intérieur de chaque passe »), et un second écran en lecture seule, l'ordre de service du cycle, qui montre la séquence réelle. Le bandeau n'apparaît que si au moins une charge porte une obligation éco : sans obligation, il n'y a qu'une passe utile et la phrase serait du bruit (LM-1007).

2. Une seule entrée par charge dans la liste ; deux lignes DANS la carte. La règle qu'on s'est donnée : on dédouble là où l'objet affiché est la SÉQUENCE, jamais là où l'objet est la CHARGE. Donc une charge apparaît deux fois dans l'écran 2 — c'est son sujet — et une seule fois dans la liste. Dans la carte, la commande est le parent (« Commandé 1 500 W · une seule consigne ») et les deux niveaux sont sa décomposition, décalés et rattachés par un filet. C'est ce qui rend LM-1003 lisible sans avoir à l'écrire.

3. Éco servi / confort non — le cas fréquent — s'affiche comme deux états nommés, jamais comme une fraction :

Décidé 1 400 W                            · une seule décision
  ↳ appliqué au mécanisme : palier 1 000 W (relais 1+2)
  ÉCO      Plancher servi — 1 400 W achetés au réseau
           2,1 kWh livrés sur les 3,0 dus avant 07:00   [MESURÉ]
  CONFORT  En attente
           Aucun surplus ce cycle — le bonus n'est pas refusé, il n'a rien à prendre.

S'il s'affichait comme une fraction de quelque chose, le régime nominal se lirait comme une dégradation permanente. Et « en attente » (demandeur, pas de budget) et « pas demandeur » (comparateur LM-1008 satisfait) sont deux états distincts : l'un se règle en changeant la consigne, l'autre en attendant le soleil.

Ce que ça demande à GetLoadTelemetry — un tableau, rien de retiré

"allocatedW": 1400,          // INCHANGÉ — le DÉCIDÉ. Σ targetW == allocatedW, exactement.
"levels": [                  // AJOUT — un élément par passe RÉELLEMENT parcourue
  { "level": "eco", "targetW": 1400, "funding": "grid",
    "decision": {"code": "ECO_FLOOR_GRID", "params": {…}},
    "progress": {"regime": "measured", "deliveredWh": 2100, "targetWh": 3000} },
  { "level": "comfort", "targetW": 0,
    "decision": {"code": "SURPLUS_INSUFFICIENT", "params": {…}} }
],
"decision": {"level": "eco", "code": "ECO_FLOOR_GRID", "params": {…}}   // CONSERVÉ + niveau reflété
// une fois par cycle, à côté de "budget" :
"draw": {"authorisedW": 4000, "committedW": 4000, "remainingW": 0,
         "binding": "gridOperator", "perPhaseBound": false}

Huit exigences, détaillées dans la maquette. Les quatre qui coûteraient cher à rattraper :

  • R2 — funding descend au NIVEAU. Il est aujourd'hui par charge ; au §10 la même charge est financée au réseau pour son éco et au surplus pour son confort dans le même cycle. Laissé à la charge, il devient indécidable — et c'est lui qui porte notre identité de réconciliation.
  • R3 — chaque watt publié tombe dans exactement un compteur, et le mapping est publié. Trois destinations coexisteront (budget.allocatedW, budget.evReservedW, draw.committedW) ; l'app ne doit pas deviner laquelle, sinon deux clients afficheront deux bilans du même cycle. C'est la leçon d'EV_GRID_START.
  • R4 — Σ targetW == allocatedW, une identité et non un écart. (Notre prémisse était fausse, corrigée le 2026-08-29 : allocatedW publie le décidé, pas le commandé — buildTelemetry() publie le plan, applyActionsToAdapters() reçoit le slot en const.) Les niveaux et allocatedW sont la même monnaie, donc leur somme est exacte — c'est ce qui rend levels[] vérifiable contre un champ déjà en ligne, et toute violation sera traitée comme une incohérence de la box. Ce qui reste demandé : ne pas attribuer l'appliqué à un niveau — il vit dans mechanism (stageW, setpointW, currentA), et l'adaptateur arrondit une consigne unique sans savoir de quel besoin elle venait.
  • R6 — ECO_FLOOR_MET interdit sous le régime unmeasurable. Servie oui, tenue jamais (LM-1009 C1). Côté app la combinaison sera affichée comme une incohérence de la box, pas rendue. Cas réel dès aujourd'hui : la V2C Trydan ne publie pas sessionEnergy.

Les quatre autres : R1 levels[] ne contient que les passes parcourues (une charge sans obligation n'a pas d'entrée eco à zéro — l'absence est l'information, LM-1007) et son absence totale vaut « box antérieure » ; R5 le régime de LM-1009 est porté par le niveau éco, avec deliveredWh absent sous unmeasurable ; R7 decision.level sur le motif legacy, pour que la règle de fusion ne soit pas devinée par chaque client ; R8 DRAW_CAP publie sa source — trois sources, trois phrases, trois gestes.

Ce qu'il ne faut PAS fabriquer

  • Un index d'ordre de service. La séquence de l'écran 2 se dérive d'un tri lexicographique sur (niveau, priority) : un index publié serait une seconde source de vérité pour une donnée déductible, et divergerait un jour.
  • Un second rang, sous quelque nom que ce soit. La maquette est construite pour rendre le rang unique lisible malgré les deux passes ; elle n'a de sens que si LM-1002 tient.
  • Un pourcentage d'avancement, même à titre indicatif. D1 s'applique sans exception.

La précédence des motifs — TRANCHÉE (Patrick, 2026-08-29)

Autorisation résiduelle non nulle mais inférieure au plus petit palier du mécanisme : BELOW_MIN_POWER. Les deux motifs sont vrais, mais pas au même niveau — DRAW_CAP dit « il n'y a plus d'autorisation », or ici il en reste, simplement pas au bon format. C'est la distinction posée en +etm19 entre SURPLUS_INSUFFICIENT (« il n'y a rien ») et BELOW_MIN_POWER (« il y en a, mais pas au bon format »), transposée telle quelle au soutirage. Le geste utile est de lire la fiche technique du mécanisme, pas de revoir le plafond.

DRAW_CAP reste réservé à l'autorisation résiduelle nulle. Le point de rupture de l'écran 2 est donc bien un DRAW_CAP ; une charge refusée après lui pour cause de palier porte BELOW_MIN_POWER et ne déplace pas le point de rupture. La règle est écrite par le moteur, pas établie de chaque côté.

L'écart à afficher n'est pas là où nous l'avions mis

Un écran construit sur notre prémisse inverse aurait comparé Σ targetW à allocatedW — deux valeurs égales par construction. Il n'aurait jamais rien montré, et sans erreur. Corrigé dans la maquette : les cartes affichent désormais décidé → appliqué, et l'écran 3 porte un bloc « trois nombres » qui fixe le vocabulaire :

Nombre Champ Ce qu'il dit
Décidé allocatedW ce que l'arbitre a planifié — et ce que les compteurs de budget comptent
Appliqué mechanism.* ce que l'adaptateur a su produire (stageW, setpointW, currentA)
Mesuré measurement.powerW ce que la charge tire, quand c'est mesurable

décidé ↔ appliqué = l'arrondi du mécanisme, normal et permanent. appliqué ↔ mesuré = la charge n'obéit pas, et ce que l'écart prouve dépend de measurement.source. décidé ↔ mesuré = la somme des deux, à ne jamais lire seule : elle mélange une cause bénigne et une qui ne l'est pas.

Conséquence assumée du choix « les compteurs comptent le décidé » : le budget d'un cycle peut annoncer 1 400 W pendant que l'installation en tire 1 000. C'est le bloc « trois nombres » qui empêche de lire cet écart comme une erreur de comptage.

Priorité, et statut de cette maquette

R2 est la plus urgente des huit : c'est la seule qui soit un changement de forme d'un champ existant et non un ajout. Chaque écran qui s'appuiera d'ici là sur un funding par charge en augmentera le coût de déplacement ; les sept autres attendent sans se renchérir. ✅ Partie le 2026-08-29 — première des huit à être écrite côté moteur.

La maquette vaut comme contrat, pas comme feuille de route. Le §10 n'est pas implémenté, le transport du §14a n'existe pas, draw non plus. Côté app, rien ne commence avant que levels[] soit en ligne — ce document fixe la forme de ce qui sera publié, pas une date.


2026-08-29 (suite) — l'insertion en queue casse la contiguïté des domaines

Constaté au premier auto-provisionnement réel (Simulated wallbox 2, .75, +etm27).

LM-1209 range la borne détectée en queue (1 + max), et c'est le bon choix : elle ne passe plus devant l'ECS que l'installateur a classé exprès. Mais l'ordre obtenu est :

rang 1  chauffe-eau          (ecs)
rang 2  V2C Trydan           (ev)
rang 3  Simulated wallbox    (ev)
rang 4  PAC banc             (heating)
rang 5  Simulated wallbox 2  (ev)   ← le domaine `ev` est coupé par `heating`

Le domaine ev est entrelacé, et l'app le signale — c'est la note posée le 2026-08-28, qui prévient que le premier enregistrement regroupera et changera donc l'ordre de service. Concrètement : la borne rangée en queue par le moteur remonterait au rang 3 au premier geste de l'installateur, sans qu'il l'ait demandé.

Ce n'est pas un défaut de LM-1209 : c'est le prix de l'insertion en queue dès que la dernière charge n'est pas du même domaine, ce qui est le cas courant. Trois lectures possibles, et c'est au moteur de trancher : insérer en queue du domaine plutôt qu'en queue de liste ; assumer l'entrelacement et considérer que le regroupement de l'app est l'erreur ; ou déclarer que l'ordre à plat prime et que l'app ne doit pas regrouper.

L'app ne changera rien tant que ce n'est pas tranché : elle regroupe pour afficher depuis le début, et elle prévient avant d'écrire.


2026-08-29 — rankOrigin : un écho inchangé de "auto" passe-t-il ?

Une question, et le contournement posé en attendant.

"auto" écrit par un client est refusé — EnergyErrorInvalidParameter, ensemble entier rejeté (vérifié sur .75). C'est la bonne règle : elle empêche un écran de fabriquer une provenance.

Mais les deux chemins d'écriture de l'app recopient les charges verbatim, et c'est délibéré : SetLoadConfig remplace tout l'ensemble, donc reconstruire depuis un modèle typé perdrait, en silence et pour toutes les charges, un champ qu'une box plus récente porterait. Recopier verbatim une entrée marquée "auto", c'est écrire "auto" — donc un simple renommage serait refusé en bloc dès qu'une borne auto-provisionnée figure dans l'installation, avec un motif qui ne franchit pas la frontière RPC.

Contournement posé côté app : rankOrigin: "auto" est remplacé par "" au point de sortie unique (NymeaService.setLoadConfig). L'écriture passe, mais la marque du moteur est effacée — y compris sur des charges que le geste ne visait pas. Le badge « rang posé par la box » disparaîtra donc d'une borne que personne n'a classée, au premier renommage d'une autre charge.

✅ Répondu le 2026-08-29 : l'exception existe déjà. reconcileRankOrigin() accepte "auto" quand le persisté le porte déjà. Le refus mesuré côté app portait un écho périmé — la marque avait été consommée entre la lecture et l'écriture — ce qui est le bon comportement, et un symptôme identique pour deux causes distinctes. La mesure sur marque fraîche est en cours côté moteur ; à son résultat, sansRankOriginAuto() disparaît côté app. Tout ce qui suit reste comme trace de la demande, et de son argument.

La demande : tolérer l'écho inchangé — même id, même rang, même valeur.

Le refus vise à empêcher un client de fabriquer une affirmation sur un geste du moteur. Renvoyer à l'identique ce que le moteur a écrit n'affirme rien de neuf : la règle doit porter sur le changement, pas sur la présence — exactement comme l'effacement de rankOrigin porte sur « rang différent pour cet id » et non sur « écriture reçue ». Même distinction, un cran plus loin.

L'alternative rend la marque fragile par construction : l'app doit alors la nettoyer sur toutes les charges à chaque écriture, donc le badge s'éteint sur une borne que personne n'a classée, au premier renommage d'une autre charge. Une information qui s'efface au premier geste voisin ne vaut pas d'être publiée.

Et une règle pour le contrat, parce que ça se reproduira : une règle qui interdit une valeur à l'écriture doit dire ce qu'elle fait d'un écho inchangé, sous peine de rendre l'écriture verbatim impossible. Le motif est écrit côté app (docs/UI_data_contract.md §10) avec le contrôle à passer une fois par règle.

Trois options écartées côté app, pour situer : refuser l'écriture (une installation avec une borne neuve deviendrait non modifiable), renvoyer "auto" (refus systématique), reconstruire depuis le modèle typé (perte silencieuse de champs inconnus).


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.