CLAUDE.md le portait comme « annoncé livré, non vérifié ». Le fait est plus simple : le code est écrit côté moteur, le cross-build n'a pas été fait, aucun paquet +etm31 n'existe, la box porte +etm30 et la trame ne contient aucune clé `draw`. La pose est relancée ; l'exception `EV_GRID_START` tient jusqu'à ce que la sonde voie le champ sortir. Et la règle qui a servi entre dans les règles de modification, à côté de celle sur `JSONRPC.Introspect` : vérifier la VERSION POSÉE avant de croire une livraison annoncée — `ssh etm@192.168.1.75 'dpkg -l | grep powersync-energy'`. Un champ absent parce qu'il n'est pas déployé se lit exactement comme un champ absent parce qu'il est omis. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
969 lines
53 KiB
Markdown
969 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.
|
||
|
||
### É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é
|
||
|
||
```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.
|