Le moteur l'a trouvé en câblant `counts` : `budget.evReservedW` est un terme de CORRECTION du budget, pas un registre d'allocation. Il mêle la correction « commandé non encore mesuré » à la part réseau d'`EV_GRID_START`, et une borne servie au réseau qui tire déjà ce qu'on lui a commandé y contribue 0 pour un `targetW` de 7 000 W. `Σ counts == targetW` y serait vraie au premier cycle après un changement et fausse ensuite : une identité dépendante du temps, qui passe tous les tests et certifie un total faux. Ne pas livrer était le bon appel. Réponse : `draw.committedW` en préalable, oui, sans réserve — et trois demandes : par cycle, en watts, dans la même trame que `budget` ; publié SEUL, les quatre autres champs de `draw` absents et jamais à 0 (un `authorisedW` forgé afficherait un plafond de soutirage qui n'existe pas) ; et une définition d'`evReservedW`, que nous affichons sous « Réservé au véhicule » — un libellé qui affirme une réservation là où il y a une correction, et que nous ne renommerons pas au jugé. Nous portions la fausse affirmation en cinq endroits (`CLAUDE.md` ×2, le modèle ×3, la description ARB) : retirée ici, sans changement de comportement. Elle ne relève pas de la discipline « attendre la sonde » — celle-ci arbitre ce que fait la box, pas ce que nous en avions cru, et la phrase est fausse aujourd'hui. Il n'existe donc qu'UNE identité vérifiable, et elle ne couvre qu'un côté : Σ targetW(surplus) == budget.allocatedW. Côté réseau, rien — pas « à vérifier plus tard », rien, jusqu'à `draw.committedW`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
961 lines
53 KiB
Markdown
961 lines
53 KiB
Markdown
# Brief plugin — ce que les maquettes « Configurer » demandent au moteur
|
||
|
||
---
|
||
|
||
## 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.
|
||
|
||
### 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é
|
||
|
||
```jsonc
|
||
"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 :
|
||
|
||
```json
|
||
{ "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 :
|
||
|
||
```json
|
||
{"params":{"energyError":"EnergyErrorInvalidParameter"},"status":"success"}
|
||
```
|
||
|
||
…et rien d'autre. Le motif reste au journal de la box :
|
||
|
||
```
|
||
W | NymeaEnergy: [NymeaEnergy] SetLoadConfig rejetée:
|
||
"Config \"SONDE-TEST-A-SUPPRIMER\" invalide : etmvariableload fixed : powerLevels requis (non vide)"
|
||
```
|
||
|
||
**Pourquoi ça compte maintenant.** Les six écrans « Configurer » écrivent tous par
|
||
`SetLoadConfig`, et le refus est **en bloc** : une seule charge invalide fait échouer
|
||
l'enregistrement de toutes. L'installateur apprendra qu'on a refusé, jamais pourquoi ni sur
|
||
quelle charge — devant un client, avec un tableau électrique ouvert.
|
||
|
||
**Ce que l'app peut faire seule, et sa limite.** Rejouer `validateSet()` côté client couvre
|
||
l'unicité des ids et des Things. Elle ne couvre pas les règles de mécanisme
|
||
(`powerLevels` commençant par 0, `powerW > 0`, `relays[]` interdit en modulable, `mode`
|
||
imposé…) sans dupliquer `LoadConfig::validate()` — deuxième implémentation, deuxième
|
||
divergence.
|
||
|
||
**Demande.** Le même remède que la télémétrie : un **code** et des **paramètres**, pas une
|
||
phrase. Par exemple, à côté de `energyError` :
|
||
|
||
```json
|
||
{"energyError":"EnergyErrorInvalidParameter",
|
||
"rejection":{"loadId":"chauffe-eau","code":"POWERLEVELS_REQUIRED","params":{"mode":"fixed"}}}
|
||
```
|
||
|
||
Le libellé français est fabriqué par l'app, comme pour `decision.code`.
|
||
|
||
---
|
||
|
||
## 3. `WRITE_FAILED` masque `THING_MISSING`
|
||
|
||
**Constat**, provoqué délibérément sur `.75` (`tools/rpc/fault_probe.dart`) : une charge
|
||
`etmvariableload` dont le ThingId n'existe pas publie **`WRITE_FAILED`**, pas
|
||
`THING_MISSING`. L'écriture est tentée, échoue faute de Thing, l'échelle s'épuise, et
|
||
`m_faulted` prend le pas — l'ordre des `if` dans `etmvariableloadadapter.cpp:148-150` :
|
||
|
||
```cpp
|
||
if (m_faulted) v.faultCode = "WRITE_FAILED";
|
||
else if (!thingManager->findConfiguredThing(...)) v.faultCode = "THING_MISSING";
|
||
```
|
||
|
||
**Pourquoi ça compte.** Les deux codes appellent **deux gestes différents** chez le client.
|
||
« Échec d'écriture » envoie chercher un défaut de liaison — bus Modbus, alimentation,
|
||
contacteur. « Appareil absent » envoie dans la configuration nymea. L'app rend fidèlement ce
|
||
que la box dit, et la box envoie au mauvais endroit.
|
||
|
||
**Demande.** Faire primer la cause structurelle : un Thing absent est un `THING_MISSING`,
|
||
même si des écritures ont échoué entre-temps. Ou publier les deux.
|
||
|
||
---
|
||
|
||
## 4. Les paliers atteignables ne sont pas exposés
|
||
|
||
**Constat.** `mechanism` (kind `relay`) publie `stageW` et `maxStageW` — **la liste des
|
||
paliers atteignables n'existe dans aucun champ** :
|
||
|
||
```
|
||
mechanism = kind · o:stageW · o:maxStageW · o:setpointW · o:percent · o:maxPowerW
|
||
· o:state · o:estimatedPowerW · o:chargingEnabled · o:currentA
|
||
· o:phaseCount · o:pluggedIn
|
||
```
|
||
|
||
**Ce que la maquette affiche** (écran ①, routeur de relais) :
|
||
|
||
> 3 relais → paliers atteignables : **0 · 500 · 1 000 · 1 500 · 2 000 · 2 500 · 3 000 · 3 500 W**
|
||
|
||
C'est l'aide la plus utile du formulaire : elle dit à l'installateur ce que son câblage
|
||
permet, avant qu'il n'enregistre.
|
||
|
||
**Le risque.** Faute d'exposition, l'app **dérivera** cette liste — donc réimplémentera la
|
||
combinatoire de `relayrouter.cpp` (toutes les combinaisons 2^N, doublons écartés, plafond
|
||
16 relais). Deux implémentations d'une même règle divergeront : à la première correction
|
||
côté moteur, l'app annoncera des paliers que la box n'atteint plus.
|
||
|
||
**⚠️ Tranché par la rév. 3 du contrat, arrivée pendant la rédaction de ce brief.**
|
||
`INTERFACE_etmvariableload.md` §10 dit désormais : l'assistant calcule et **affiche** les
|
||
paliers atteignables côté app, et ces `powerLevels` ne sont **pas** envoyés — ils sont
|
||
« dérivés côté plugin ». La duplication est donc **voulue et contractuelle** : l'app dérive
|
||
pour montrer, le routeur dérive pour exécuter.
|
||
|
||
**Ce qui reste, et qui devient la vraie demande :** un moyen de vérifier que les deux
|
||
dérivations n'ont pas divergé. Il existe déjà à moitié — la télémétrie publie
|
||
`mechanism.maxStageW`. L'app peut donc comparer **le maximum de sa propre liste** à celui que
|
||
la box annonce, et se taire si ça correspond, alerter sinon. Ça ne couvre pas les paliers
|
||
intermédiaires, mais ça attrape le cas qui compte : une règle de combinatoire changée d'un
|
||
côté seulement.
|
||
|
||
**Demande minimale** : confirmer que `maxStageW` reste bien le maximum atteignable et non un
|
||
plafond de sécurité — sinon la comparaison ne vaut rien. **Demande confortable** : publier la
|
||
liste complète (`mechanism.stagesW: [...]`), et l'app cessera de dériver.
|
||
|
||
---
|
||
|
||
## 5. `enabled: false` — ce que deviennent les contacts
|
||
|
||
**Ce que l'app sait déjà**, et qui est bon : la charge quitte l'arbitrage, ne consomme aucun
|
||
budget, et disparaît de `loads[]` **au cycle suivant** — pas dans la seconde.
|
||
|
||
**Ce qu'il reste à établir côté app :** l'état matériel. Le relevé du lot B-bis (§4) répond
|
||
sur `sg-ready` — contacts K1/K2 mesurés ouverts après désactivation, l'état sûr passe bien
|
||
par `applySafeState()` avant retrait de l'adaptateur. **Reste à confirmer pour
|
||
`relay-router` et `etmvariableload`**, sur matériel.
|
||
|
||
**Pourquoi ça compte.** La maquette écrit aujourd'hui « à la désactivation, le retrait passe
|
||
par l'état sûr : tous les relais ouverts ». Si c'est faux pour un mécanisme, l'écran laisse
|
||
croire à une coupure qui n'a pas eu lieu — sur un chauffe-eau, c'est une promesse de
|
||
sécurité.
|
||
|
||
**Demande.** Confirmer, par mécanisme, ce que produit `enabled: false` sur le matériel. Une
|
||
ligne de journal par adaptateur suffit ; c'est déjà le cas pour SG-Ready.
|
||
|
||
---
|
||
|
||
## 6. `estimatedPowerW` SG-Ready reste fictif sur `.75`, et il alimente le recrédit
|
||
|
||
**Constat, déjà relevé côté plugin** (`RELEVE_LOTBbis.md` §7) : les valeurs {état 3 : 1500 W,
|
||
état 4 : 3000 W} et `minStateHoldS: 300` sont reconduites du bloc codé en dur supprimé, où
|
||
elles décrivaient une PAC **inventée**. Aucune PAC n'est sous tension sur K1/K2.
|
||
|
||
**Ce que ça change côté app.** L'écran SG-Ready (écran ②) présente `estimatedPowerW` comme
|
||
une **donnée d'installation**, saisie par l'installateur d'après la plaque signalétique. Sur
|
||
le banc, c'est une valeur de test — et elle sert de base au **recrédit de budget** et au
|
||
choix d'état, donc elle décale l'allocation de tout le waterfall en aval.
|
||
|
||
**Demande.** Rien à changer dans le code : c'est un point de **données de banc**. Mais tant
|
||
qu'il n'est pas clos, aucune capture de `.75` ne doit servir d'exemple d'installation, et le
|
||
§4 de `TEST_TERRAIN.md` reste non transposable.
|
||
|
||
---
|
||
|
||
## Ce que l'app livre sans rien attendre
|
||
|
||
Pour situer ce qui est bloqué et ce qui ne l'est pas :
|
||
|
||
| Écran | État |
|
||
|---|---|
|
||
| Liste « Charges pilotables » à deux niveaux, télémétrie sur carte | livrable |
|
||
| Configurer · routeur de relais | livrable — sauf l'aperçu des paliers (point 4) |
|
||
| Configurer · modulable | livrable |
|
||
| Configurer · SG-Ready | livrable |
|
||
| Configurer · borne de recharge | **bloqué sur le point 1** pour le mode Min+PV |
|
||
| Zones HVAC + sélecteur d'appareils | livrable côté RPC — **aucun matériel HVAC sur `.75`** |
|
||
| Raccordement réseau / protection de surcharge | livrable, mais voir ci-dessous |
|
||
|
||
**Et une question de périmètre, pas un défaut.** La protection de surcharge ne déleste
|
||
aujourd'hui **que les bornes de recharge** (`m_evChargers` dans
|
||
`SmartChargingManager::verifyOverloadProtection`). Le chauffe-eau, la PAC et les charges
|
||
modulables ne sont pas réduits par elle. L'écran « Raccordement » le dit explicitement plutôt
|
||
que de promettre une protection générale. **Étendre le délestage au waterfall est une
|
||
décision produit** — si elle est prise, l'app aura besoin d'un ordre de délestage, qui n'est
|
||
pas l'ordre de service : le premier retire de la puissance en surcharge, le second en
|
||
répartit en surplus. Deux listes qui peuvent légitimement différer.
|