Surveillance en lecture seule menée à son terme : livré 575 → 750 Wh (+25/min, soit 1 500 W), reste 2 425 → 2 250, gridW 466 → 443 en décroissance monotone. `deliveredWh + remainingWh == targetWh` et `gridW == remainingWh / remainingS` vérifiés 8 fois sur 8, aucun échec. Troisième recoupement, celui qui permet à l'écran de chiffrer le coût sans l'inventer : `purchasedWh` gagne 53,2 Wh en sept minutes — 456 W de moyenne — contre 454,6 W de moyenne pour `gridW` sur les mêmes cycles, 0,3 % d'écart. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
1548 lines
85 KiB
Markdown
1548 lines
85 KiB
Markdown
# Brief plugin — ce que les maquettes « Configurer » demandent au moteur
|
||
|
||
---
|
||
|
||
## 2026-09-04 (suite) — la baisse, mesurée en lecture seule, et le fuseau qui la datait mal
|
||
|
||
Banc laissé tel quel — l'obligation est la vôtre, je n'ai rien écrit.
|
||
|
||
### Le critère positif est atteint, et deux invariants tiennent avec lui
|
||
|
||
| cycle | livré | reste | gridW | acheté | restS |
|
||
|---|---|---|---|---|---|
|
||
| 05:48Z | 575,0 | 2 425 | **466** | 183,3 | 18 719 |
|
||
| 05:49Z | 600,0 | 2 400 | **463** | 191,1 | 18 659 |
|
||
| … | +25/min | −25/min | ↓ | +7,6/min | −60/cycle |
|
||
| 05:55Z | 750,0 | 2 250 | **443** | 236,5 | 18 299 |
|
||
|
||
**Huit cycles consécutifs, huit fois les deux invariants, zéro écart.**
|
||
|
||
`deliveredWh` monte de **25 Wh/min**, soit 1 500 W — exactement ce que le compteur ECS annonce.
|
||
`remainingWh` descend d'autant, et `gridW` **baisse**. Ce n'est plus l'absence de mur, c'est la
|
||
décroissance.
|
||
|
||
Deux vérifications qui ne coûtent rien et que l'écran fera à chaque trame :
|
||
|
||
- **`deliveredWh + remainingWh == targetWh`** — 3 000,0000000000364 puis 2 999,999583 pour une
|
||
cible de 3 000. L'identité que vous aviez épinglée tient sur données réelles, et **c'est ici
|
||
qu'elle mord** : sous `measured`, elle n'est plus le corollaire trivial qu'elle est sous
|
||
`unmeasurable`.
|
||
- **`gridW == remainingWh / remainingS`** — 466 == 466, puis 463 == 463. La loi d'étalement est
|
||
donc vérifiable **de l'extérieur**, sans lire le code. Un écart futur serait un défaut, pas une
|
||
approximation.
|
||
|
||
Et un **troisième recoupement**, celui qui permet à l'écran de chiffrer le coût sans l'inventer :
|
||
`purchasedWh` gagne 53,2 Wh en sept minutes, soit **456 W** de moyenne, contre une moyenne de
|
||
`gridW` de **454,6 W** sur les mêmes cycles — 0,3 % d'écart. L'acheté est donc cohérent avec la
|
||
consigne, cycle après cycle.
|
||
|
||
`purchasedWh` 183 → 236 pour 575 → 750 Wh livrés : le plancher acheté reste le tiers du consommé.
|
||
La paire diverge, et c'est la divergence qui informe — le second usage, exercé.
|
||
|
||
### Le fuseau : l'app peut être honnête aujourd'hui, sans attendre votre arbitrage
|
||
|
||
`remainingS` le confirme de notre côté : 18 719 s à 06:48 heure box mènent à **12:00 heure box**,
|
||
pas 12:00 heure client. Et le fuseau **franchit déjà la frontière RPC** :
|
||
|
||
```jsonc
|
||
System.GetTime -> {"automaticTime": true, "time": 1788500953, "timeZone": "Europe/London"}
|
||
```
|
||
|
||
L'écran n'a donc pas besoin de la décision pour cesser de mentir : il **dira** l'horloge au lieu
|
||
de la choisir — fuseau de la box affiché à côté du champ d'échéance, divergence signalée quand
|
||
celui de l'appareil diffère. C'est écrit dans `CLAUDE.md` et dans la maquette.
|
||
|
||
Ce que votre lot doit trancher reste entier, et c'est autre chose : **d'où vient le fuseau** et ce
|
||
qui se passe quand il diverge. Nous ne présumons pas de la réponse — nous rendons seulement
|
||
impossible qu'un installateur lise « 12:00 » comme son 12:00.
|
||
|
||
---
|
||
|
||
## 2026-09-04 — `+etm52` : la contradiction est levée, mesurée
|
||
|
||
Obligation déclarée puis retirée (`chauffe-eau`, 4 000 Wh avant 12:00, sept cycles, restauration
|
||
depuis la capture — *« 0 écart, clé par clé »*). Version relue avant de sonder : `1.15.2+etm52`,
|
||
posée le 03/09 à 06:52:20, `nymead` actif.
|
||
|
||
```jsonc
|
||
"progress": { "regime": "measured", "targetWh": 4000, "deliveredWh": 0,
|
||
"measuredSince": "2026-09-04T03:42:00Z", "purchasedWh": 27.52 }
|
||
```
|
||
|
||
**`progressMeasurable: true` en configuration et `regime: "measured"` en télémétrie, sur la même
|
||
charge.** La contradiction est levée : l'avertissement de saisie parlera là où il faut, et l'achat
|
||
à l'aveugle a cessé pour une charge instrumentée. `deliveredWh` est **présent** (0, pas absent) —
|
||
la distinction est tenue dans le bon sens. `measuredSince` porte l'instant d'ouverture et ne bouge
|
||
plus sur sept cycles.
|
||
|
||
### `purchasedWh` et `deliveredWh` divergent, et c'est ce qui prouve qu'ils ne font pas doublon
|
||
|
||
Sous `measured` : **27,5 Wh achetés, 0 Wh livré**. Sur ce banc l'explication est bénigne —
|
||
`ECS-Meter.totalEnergyConsumed` vaut **10 depuis le 1er septembre**, c'est une constante du mock,
|
||
relevée deux fois à une seconde d'intervalle. Le chemin est branché et lu ; la grandeur qu'il lit
|
||
ne bouge pas.
|
||
|
||
Mais la divergence elle-même est le fait utile : elle donne à l'écran un **contrôle qu'il n'aurait
|
||
pas su fabriquer**. Sous `measured`, `purchasedWh` qui monte pendant que `deliveredWh` reste plat
|
||
veut dire *soit* que la charge ne consomme pas ce qu'on achète, *soit* que le compteur ne compte
|
||
pas. Deux causes, deux gestes, et aucune n'est inventée par l'app.
|
||
|
||
### Ce qui reste inobservable ici, et je ne le conclus pas
|
||
|
||
**La décroissance de `remainingWh`**, donc l'étalement adaptatif. Elle exige un cumul qui avance :
|
||
il faudrait un compteur de banc dont `totalEnergyConsumed` progresse, ou une charge réelle. Le
|
||
comportement observé — `remainingWh` figé à 4 000 — est aujourd'hui **identique** à celui de
|
||
`unmeasurable`, pour une raison entièrement différente et bénigne. C'est exactement le genre de
|
||
coïncidence qui ferait conclure juste pour une mauvaise raison, et tenir jusqu'au jour où quelqu'un
|
||
bâtit sur la raison.
|
||
|
||
---
|
||
|
||
## 2026-09-03 (suite) — avant d'écrire `sessionWh` sur un compteur : la garde ne peut pas être le recul
|
||
|
||
Le correctif est le bon, et l'ordre aussi. Une mise en garde avant qu'il soit écrit, parce
|
||
qu'elle vient d'un relevé déjà pris — et parce que le défaut qu'elle décrit **arriverait par le
|
||
chemin où R6 ne protège plus**.
|
||
|
||
### Le « recul franc » ne distingue pas une remise à zéro d'une valeur forgée
|
||
|
||
`getEcoProgress()` recapture la base quand l'accumulateur recule franchement — juste pour une
|
||
**nouvelle session**. Mais un compteur qui disparaît peut, selon son plugin, publier **0** au lieu
|
||
de se taire : nous l'avons mesuré sur la V2C le 2026-08-30, `currentPower` passé de 4 396 W à 0 et
|
||
les booléens à `false` à la perte du lien Modbus.
|
||
|
||
Les deux cas produisent **exactement la même observation** : une valeur qui s'effondre. Or ils
|
||
appellent des gestes opposés — recapturer, ou ne rien faire.
|
||
|
||
**Et le coût de les confondre n'est pas symétrique.** Si la base est reprise à 0 pendant l'absence,
|
||
puis que le compteur revient avec son cumul réel (10 kWh sur l'ECS du banc), alors
|
||
`deliveredWh = 10 000 − 0` d'un seul cycle : l'obligation se déclare **tenue** sans que rien n'ait
|
||
été livré. Sous `measured`, R6 ne s'y oppose pas — elle n'interdit `ECO_FLOOR_MET` que sous
|
||
`unmeasurable`. Le chemin mesuré est donc le seul par lequel un faux « tenue » peut sortir, et
|
||
c'est celui qu'on est en train d'ouvrir.
|
||
|
||
**Donc : la garde doit porter sur la JOIGNABILITÉ du compteur**, pas sur la direction de la valeur.
|
||
Tant que le Thing n'est pas joignable, ni recapture ni avancement — le tronçon se clôt sur la
|
||
dernière valeur lue **pendant que la mesure existait**, exactement la règle que vous avez posée
|
||
hier pour la bascule de régime. C'est aussi ce que l'app a fait de son côté le 2026-08-30 : sous
|
||
`connected == false`, les états sont jetés **à l'entrée** plutôt que relayés.
|
||
|
||
> **Honnêteté sur la portée** : le zéro forgé a été mesuré sur `currentPower` et les booléens,
|
||
> **pas** sur un compteur d'énergie — `sessionEnergy` de la V2C, lui, est resté à sa dernière
|
||
> valeur pendant la coupure. La politique dépend donc du plugin, et c'est précisément pourquoi un
|
||
> accumulateur ne devrait pas parier dessus.
|
||
|
||
### Et une conséquence heureuse de votre dernière phrase
|
||
|
||
Une fois `sessionWh` rempli, la population « réellement non mesurable » se réduit à trois cas
|
||
matériels — et **deux sur trois sont réparables par l'installateur** (aucun compteur désigné,
|
||
compteur sans énergie). L'avertissement de saisie cesse alors d'être une fatalité affichée : il
|
||
devient une consigne, et `progressUnmeasurableCause` porte déjà les trois gestes.
|
||
|
||
---
|
||
|
||
## 2026-09-03 — `+etm49` à la sonde : `purchasedWh` marche, et deux champs se contredisent
|
||
|
||
Obligation déclarée puis retirée sur le banc (`chauffe-eau`, 4 000 Wh avant 12:00, sept cycles,
|
||
capture disque → restauration depuis le fichier → *« 0 écart, clé par clé »*).
|
||
|
||
### Ce qui est vérifié
|
||
|
||
Les cinq symboles sont dans le binaire déployé, **tous en UTF-16** — ASCII à zéro partout, le
|
||
mélange que vous signaliez. `progressMeasurable` est bien dans **`GetLoadConfig`**, en `r:`, sur
|
||
les quatre charges, et `progressUnmeasurableCause` **absent** quand il n'y a pas de cause : la
|
||
bonne forme.
|
||
|
||
**`purchasedWh` fonctionne, et sous `unmeasurable`** — c'est le point qui comptait :
|
||
|
||
```
|
||
0 → 10,78 → 21,41 → 32,25 → 43,12 Wh (≈ 10,8 Wh/min, soit ~648 W : cohérent avec gridW)
|
||
```
|
||
|
||
Il se recoupe donc avec `gridW × temps`, ce qui donne à l'écran un contrôle interne gratuit.
|
||
`measuredSince` et `measuredGaps` sont absents — correct, rien n'est mesuré ici.
|
||
|
||
### Mais `progressMeasurable: true` et `regime: "unmeasurable"` sur la MÊME charge
|
||
|
||
Le chauffe-eau porte `progressMeasurable: true` en configuration **et** `progress.regime:
|
||
"unmeasurable"` en télémétrie, au même cycle. Ce n'est pas une course : les deux champs répondent
|
||
à deux questions différentes, et le code le dit.
|
||
|
||
| | ce que le champ teste | où |
|
||
|---|---|---|
|
||
| `progressMeasurable` | **le matériel le permet** — borne avec `sessionEnergy`, ou compteur désigné qui compte l'énergie | `nymeaenergyjsonhandler.cpp::progressMesurable()` |
|
||
| `regime` | **le moteur le fait** — `sessionMeasurable`, posé **uniquement** dans `evadapter.cpp` | `surpluscontext.h` (défaut `false`) |
|
||
|
||
Sur toute charge non-VE, le premier dit *oui* et le second dit *non*. C'est le quatrième maillon
|
||
mort, vu de l'autre bout.
|
||
|
||
**Et la conséquence défait la règle pour laquelle le champ a été créé** : l'avertissement de saisie
|
||
se déclenche sur `progressMeasurable == false`, donc il **restera muet exactement sur les charges
|
||
dont l'obligation sera achetée à l'aveugle**. L'installateur ne sera pas prévenu dans le seul cas
|
||
où il fallait le prévenir.
|
||
|
||
**Nous ne câblons donc pas l'avertissement sur ce champ**, et l'app ne peut pas rattraper : à la
|
||
saisie, la charge n'est pas encore dans `loads[]`, donc son `regime` n'existe pas — c'est
|
||
précisément pourquoi le signal devait vivre en configuration.
|
||
|
||
Deux sorties, et la première est celle qu'on préfère :
|
||
|
||
1. **Remplir `sessionWh` depuis le compteur désigné** pour les charges non-VE — les deux champs
|
||
s'accordent alors d'eux-mêmes, et le régime devient `measured` là où le câblage le permet ;
|
||
2. en attendant, faire dire à `progressMeasurable` **ce que le moteur fait**, pas ce que le
|
||
matériel permet, avec une cause qui nomme le manque. Mieux vaut un installateur prévenu par
|
||
une limite du moteur qu'un installateur non prévenu par une promesse de câblage.
|
||
|
||
### Statut, sans reproche
|
||
|
||
L'escalade est toujours là en `+etm49` — `remainingWh` figé à 4 000, `gridW` 645 → 654 sur sept
|
||
cycles. Le taux constant a été retenu mais n'est pas déployé ; c'est cohérent avec ce que vous
|
||
annonciez (la perte du tronçon d'abord). Noté pour ne pas le remesurer comme une découverte.
|
||
|
||
---
|
||
|
||
## 2026-09-02 — la bascule de régime : prémisse confirmée, et une perte que le code montre
|
||
|
||
### Ma prémisse était juste, mais je l'avais posée sans la vérifier
|
||
|
||
Lue dans `energyarbitrator.cpp` : la base se prend **à l'instant où la charge devient mesurable**
|
||
(`if (!m_ecoBaseWh.contains(id)) insert(sessionWh)`), et `ecotarget.cpp` calcule
|
||
`remainingWh = targetWh − deliveredWh` — donc **`targetWh` n'est pas rebasée**. `deliveredWh` ne
|
||
compte bien que depuis la bascule. J'avais énoncé ça comme un fait ; ça n'en était un qu'après
|
||
lecture.
|
||
|
||
### Et votre réponse tranche mieux que ma question
|
||
|
||
J'offrais deux sorties comme équivalentes — rebaser `targetWh`, ou publier depuis quand on
|
||
mesure. `measuredSince` est déjà écrit, et le commentaire donne l'argument qui départage, que je
|
||
n'avais pas : **rebaser ferait perdre l'obligation réelle, et l'écran ne pourrait plus dire ce qui
|
||
reste dû.** Retenu tel quel. `purchasedWh` est là aussi, dans tous les régimes, avec exactement la
|
||
distinction proposée — l'acheté est la commande du moteur, le livré une observation qu'il ne peut
|
||
pas faire.
|
||
|
||
### Mais aucun des deux n'est SUR LA BOX
|
||
|
||
`strings` sur le binaire déployé (`libnymea_energypluginnymea.so`, `+etm43`), dans les deux
|
||
encodages — merci pour `-el`, le mélange est bien réel :
|
||
|
||
| symbole | UTF-16 | ASCII |
|
||
|---|---|---|
|
||
| `ECO_FLOOR_GRID` · `ECO_FLOOR_MISSED` · `remainingWh` | 0 | **1** |
|
||
| `atMaximum` | **1** | 1 |
|
||
| **`purchasedWh`** | **0** | **0** |
|
||
| **`measuredSince`** | **0** | **0** |
|
||
|
||
Ils sont au HEAD (`770cef1`), pas dans `+etm43`. **Rien n'est donc à câbler dessus aujourd'hui** —
|
||
et sans ce contrôle j'aurais bâti l'avertissement de saisie sur deux champs muets, pour la
|
||
troisième fois de la semaine.
|
||
|
||
### Une perte que la lecture montre, et que la bascule rend probable
|
||
|
||
Sur `measured → unmeasurable → measured` **dans la même période** : au passage en `unmeasurable`,
|
||
`m_ecoBaseWh` et `m_ecoMesureDepuis` sont retirés ; au retour, la base est reprise à la valeur
|
||
**courante** de `sessionWh`. Donc `deliveredWh` **repart de zéro**, et l'énergie livrée pendant les
|
||
tronçons mesurés antérieurs est perdue de l'avancement.
|
||
|
||
Deux conséquences, et la seconde coûte de l'argent : l'écran **sous-estime** l'avancement, et la
|
||
passe éco **rachète ce qui a déjà été livré**. Ce n'est pas théorique — c'est exactement le motif
|
||
de la V2C dont le lien Modbus bat : `connected` bascule, le régime suit.
|
||
|
||
Piste, si elle vous va : accumuler par **période** plutôt que par tronçon — ajouter
|
||
`sessionWh − base` à un cumul de période au moment de quitter `measured`, au lieu de le jeter. Le
|
||
`measuredSince` garde alors son sens (« mesuré depuis », avec des trous), et `deliveredWh` cesse de
|
||
mentir par défaut.
|
||
|
||
*(Lu dans le code, non mesuré : la bascule n'est pas provocable sur le banc tant que `sessionWh`
|
||
n'est pas rempli hors borne.)*
|
||
|
||
---
|
||
|
||
## 2026-09-01 (soir) — trois champs pour que l'écran puisse tenir la nuance
|
||
|
||
Vos deux règles sont prises. Il leur manque trois choses pour être **affichables**, et chacune
|
||
bloque une phrase précise.
|
||
|
||
### 1. `ECO_FLOOR_MISSED` sous `unmeasurable` : ajoutez `purchasedWh`
|
||
|
||
La cause `unmeasurable` porte bien la nuance — l'écran peut dire « non vérifiable » plutôt que
|
||
« non tenue », et `missingWh` absent est juste : on ne chiffre pas un manque qu'on ne connaît pas.
|
||
|
||
**Mais il manque le coût.** Sous le taux constant, le moteur aura acheté *exactement la cible*, et
|
||
il le sait **exactement** : il l'a commandé. Aucun écran ne peut le reconstituer —
|
||
`draw.committedW` est une puissance instantanée par cycle, pas un cumul d'obligation.
|
||
|
||
> **Et la distinction est celle de toute la semaine, appliquée à l'envers.** Ce qui est
|
||
> **acheté** est un fait que le moteur connaît sans mesure — c'est sa propre commande. Ce qui est
|
||
> **livré** est ce qu'il ne peut pas vérifier. Donc `purchasedWh` est légitime sous `unmeasurable`
|
||
> **là où `deliveredWh` ne l'est pas**, et les deux ne se confondent pas : l'un est une
|
||
> comptabilité, l'autre une mesure absente.
|
||
|
||
Sans lui, votre propre règle de saisie — « ce que ça coûte par jour » — ne peut être qu'une
|
||
**estimation de l'app**, faite à partir du taux. Nous préférerions ne pas l'inventer.
|
||
|
||
### 2. Le signal de déclaration doit vivre avec la CONFIGURATION, pas avec la télémétrie
|
||
|
||
Vous proposez que le moteur dise à la déclaration qu'une charge n'a pas de mesure d'énergie.
|
||
Oui — mais **pas dans `GetLoadTelemetry`** : `measurement` n'existe que pour les charges présentes
|
||
dans `loads[]`, et une charge qu'on est en train de déclarer est justement celle qui n'y est pas
|
||
encore — pas arbitrée, ou créée à l'instant. L'écran serait muet exactement au moment où il doit
|
||
parler.
|
||
|
||
Il le faut donc **dans `GetLoadConfig`**, en lecture seule (`r:`), présent pour **toute** entrée
|
||
configurée. Et avec sa **raison**, parce que le geste de l'installateur en dépend :
|
||
|
||
| raison | ce que l'écran propose |
|
||
|---|---|
|
||
| aucun compteur rattaché | « rattachez un compteur » — c'est réparable en une manipulation |
|
||
| compteur sans énergie (puissance seule) | « ce compteur ne compte pas l'énergie » — il faut le remplacer |
|
||
| borne sans `sessionEnergy` | rien à faire — c'est le matériel |
|
||
|
||
> `meterThingId` ne suffit pas à le déduire : un compteur peut publier `currentPower` sans
|
||
> `totalEnergyConsumed`, et l'app croirait la charge mesurable.
|
||
|
||
### 3. Changement de régime en cours d'obligation : `deliveredWh` cesse d'être une fraction
|
||
|
||
Votre précision sur la bascule est la bonne, et elle crée un piège d'affichage que LM-1014-b
|
||
décrit côté moteur mais que l'écran subit aussi.
|
||
|
||
Après un passage `unmeasurable → measured` en cours de fenêtre, la base est capturée **à cet
|
||
instant** : `deliveredWh` ne compte donc que depuis la bascule, pendant que `targetWh` reste la
|
||
cible entière. **La paire cesse d'être une fraction** — une barre d'avancement afficherait
|
||
« 0,5 sur 4,0 » après une bascule où 3 kWh ont pu être achetés. Crédible, et faux.
|
||
|
||
Deux sorties, et c'est à vous : soit `targetWh` est **rebasée** à la bascule (ce qui reste à
|
||
livrer devient la nouvelle cible), soit un champ dit **depuis quand on mesure** et l'écran affiche
|
||
« mesuré depuis 09:14 » au lieu d'une fraction. Nous n'afficherons pas de barre tant que ce n'est
|
||
pas tranché — une fraction fausse est pire qu'un chiffre absent.
|
||
|
||
### Ce qui est acté chez nous
|
||
|
||
Taux constant, `atMaximum` jamais vrai, total = la cible. `targetW`/`counts` pour l'arithmétique,
|
||
`gridW` pour la phrase. L'écran dira **« non vérifiable »**, jamais « non tenue ». Et la saisie
|
||
d'une obligation sur une charge sans mesure d'énergie portera l'avertissement — dès que le champ
|
||
du point 2 existe.
|
||
|
||
---
|
||
|
||
## 2026-09-01 (suite) — acheter sans vérifier : ce que nous proposons, et pourquoi
|
||
|
||
**Notre position : oui, mais à taux CONSTANT — l'escalade n'a pas de justification sous
|
||
`unmeasurable`, et elle achète 2,5 fois l'obligation.**
|
||
|
||
### L'escalade est une correction calculée sur une grandeur qui n'existe pas dans ce régime
|
||
|
||
L'étalement vaut `remainingWh / remainingS`. Sous `measured`, il accélère parce qu'un **retard
|
||
mesuré** subsiste : la correction suit une preuve. Sous `unmeasurable`, `remainingWh` est **figé
|
||
par contrat** — c'est la règle que vous avez posée hier, et elle est juste. Mais alors le taux ne
|
||
suit plus un retard : **il suit l'horloge**. On achète davantage parce que le temps passe, pas
|
||
parce que quelque chose est en retard. C'est le motif « chiffre crédible et faux » déplacé dans
|
||
une **loi de commande** au lieu d'un champ.
|
||
|
||
### Et ce n'est pas qu'un risque de mur : la loi surachète, sans borne
|
||
|
||
Avec `remainingWh` figé, l'énergie achetée sur la fenêtre vaut `∫ E/(T−t) dt` — **elle diverge**.
|
||
Bornée seulement par le plafond du mécanisme. Chiffré sur notre relevé du 05:47 (E = 4 000 Wh,
|
||
T = 18 779 s, plafond 3 500 W lu dans `mechanism.maxStageW`) :
|
||
|
||
| | |
|
||
|---|---|
|
||
| taux au plafond | **1 h 09 avant l'échéance** |
|
||
| acheté avant le plafond | 6 073 Wh |
|
||
| puis 4 114 s à 3 500 W | 4 000 Wh |
|
||
| **total** | **≈ 10,1 kWh pour une obligation de 4,0 kWh — ×2,5** |
|
||
|
||
Et le facteur **grandit avec la fenêtre** : plus l'échéance est lointaine, plus on surachète. Une
|
||
obligation quotidienne sur une charge durablement non mesurable, ce n'est donc pas « la cible tous
|
||
les jours » — c'est **deux à trois fois la cible tous les jours**.
|
||
|
||
*(Arithmétique dérivée de la loi observée sur quatre cycles, pas mesurée jusqu'à l'échéance : nous
|
||
n'avons pas laissé le banc aller au bout, et le relevé s'arrête à 767 W.)*
|
||
|
||
### Ce que nous proposons
|
||
|
||
1. **Remplir `sessionWh` depuis le compteur de charge** — c'est le vrai correctif, et il ferme la
|
||
question pour toute charge instrumentée. `meterThingId` est déjà là, `measurement.source ==
|
||
"meter"` dit quand c'est fiable, `totalEnergyConsumed` porte l'énergie. LM-1014-b s'applique
|
||
telle quelle : cumul, donc base capturée à l'ouverture et reprise à la bascule.
|
||
2. **Pour ce qui reste non mesurable : figer le TAUX, pas le reste.** Taux = `targetWh` ÷ fenêtre
|
||
à l'ouverture, **constant**, `atMaximum` jamais vrai. Le total acheté vaut alors **exactement
|
||
la cible** — on garde « viser l'obligation entière », on perd l'escalade qui ne repose sur
|
||
rien. C'est le même geste que partout cette semaine : ne pas laisser une absence d'information
|
||
produire un chiffre qui a l'air d'en être une.
|
||
|
||
### Et la part qui nous revient, sur votre règle
|
||
|
||
Vous dites qu'une obligation non vérifiable ne devrait pas pouvoir déclencher un achat au plafond
|
||
**sans que l'installateur l'ait su en la déclarant**. C'est un écran, donc c'est à nous : la
|
||
déclaration d'une obligation sur une charge **sans mesure d'énergie** dira, à la saisie, qu'elle
|
||
sera achetée à l'aveugle, ce qu'elle coûtera par jour, et qu'elle ne pourra **jamais** être
|
||
annoncée tenue. Nous le construirons avec l'écran de configuration — pas avant que le §10 soit
|
||
déployé, mais la maquette le portera.
|
||
|
||
Un corollaire à ne pas perdre : sous `unmeasurable`, `ECO_FLOOR_MISSED` sortira **tous les jours,
|
||
par construction**, puisque R6 interdit `ECO_FLOOR_MET`. Un mécanisme dont le succès est
|
||
inobservable et l'échec quotidien ne doit pas, en plus, dépenser en accélérant. Notre écran dira
|
||
« non vérifiable », jamais « non tenue ».
|
||
|
||
### Confirmé pour l'app
|
||
|
||
`targetW` / `counts` pour l'arithmétique, `gridW` pour la phrase. C'est bien ainsi que nous
|
||
lisons, et l'écart entre les deux (766,81 vs 767) reste sous le watt.
|
||
|
||
---
|
||
|
||
## 2026-09-01 — §10 vu à la sonde : les six champs, et une trajectoire à trancher
|
||
|
||
**Obligation déclarée sur le banc puis retirée** — `chauffe-eau`, `needs {dailyDeadline: "12:00",
|
||
minEnergyWhPerDay: 4000}`, sept cycles, `+etm43`. Capture sur disque avant la première écriture,
|
||
restauration **depuis le fichier**, comparaison clé par clé : *« 0 écart »*.
|
||
|
||
### La forme est celle qui était annoncée, et elle porte plus que le motif
|
||
|
||
```jsonc
|
||
{ "level": "eco", "funding": "grid", "targetW": 766.813994355397,
|
||
"counts": { "draw.committedW": 766.813994355397 },
|
||
"progress": { "regime": "unmeasurable", "targetWh": 4000 },
|
||
"decision": { "code": "ECO_FLOOR_GRID", "params": {
|
||
"gridW": 767, "remainingWh": 4000, "targetWh": 4000,
|
||
"remainingS": 18779, "regime": "unmeasurable", "atMaximum": false } } }
|
||
```
|
||
|
||
`decision` et `progress` **au niveau**, `deliveredWh` **absent** sous `unmeasurable`, `counts` qui
|
||
tombe sur `draw.committedW`, et le motif racine qui porte `"level": "comfort"`. R1, R5 et R7 sont
|
||
vérifiés sur le fil. Les deux identités tiennent à chaque cycle.
|
||
|
||
### Le dernier cycle est le cas que la maquette visait, et il valide R7 durement
|
||
|
||
À 05:47 le surplus passe à **−922 W** : le niveau `comfort` tombe à `targetW: 0` avec
|
||
`SURPLUS_INSUFFICIENT` et `counts {"budget.allocatedW": 0}` — destination publiée **à zéro**, comme
|
||
demandé — pendant que le niveau `eco` **achète 767 W au réseau**.
|
||
|
||
Le motif racine de cette charge vaut alors `SURPLUS_INSUFFICIENT`. **Un client d'avant `levels[]` lit
|
||
donc « surplus insuffisant, rien fait » sur une charge en train d'acheter 767 W.** Ce n'est pas une
|
||
vue incomplète, c'est une vue **fausse** — et c'est l'argument le plus dur en faveur de
|
||
`decision.level` et des deux lignes à l'écran.
|
||
|
||
### La trajectoire, mesurée — et la question qu'elle pose
|
||
|
||
| cycle | `remainingWh` | `remainingS` | `gridW` |
|
||
|---|---|---|---|
|
||
| 05:42 | 4 000 | 19 079 | 755 |
|
||
| 05:45 | 4 000 | 18 899 | 762 |
|
||
| 05:47 | 4 000 | 18 779 | 767 |
|
||
|
||
Sous `unmeasurable`, rien n'est soustrait : **le plancher ne peut jamais se refermer**, donc l'achat
|
||
s'accélère mécaniquement jusqu'à l'échéance et finit en `ECO_FLOOR_MISSED`. Ce n'est pas un cas
|
||
limite du régime, c'est sa trajectoire garantie. La maquette l'affiche désormais — *reste* et
|
||
*temps*, pas seulement les watts de l'instant.
|
||
|
||
**Et le point qui mérite votre arbitrage** : cette charge **a un compteur**. `ECS-Meter` publie
|
||
`currentPower: 1500` **et `totalEnergyConsumed: 10`** — une énergie, la grandeur exacte qui manque.
|
||
Le régime sort quand même `unmeasurable`. Si c'est `LoadContextTelemetry::sessionWh` toujours
|
||
déclaré et jamais rempli hors borne, alors **aujourd'hui toute obligation éco achète à l'aveugle et
|
||
accélère, y compris sur une charge instrumentée**. Deux questions, et elles ne sont pas d'écran :
|
||
faut-il remplir `sessionWh` depuis le compteur de charge, et faut-il acheter du tout quand on ne
|
||
peut pas vérifier ? Notre écran montrera la trajectoire ; il ne la corrigera pas.
|
||
|
||
### Un détail à confirmer
|
||
|
||
`gridW: 767` (entier) et `targetW: 766.813994355397` (double) portent le même fait, et `counts`
|
||
s'accorde avec `targetW`. Nous prenons donc **`targetW`/`counts` pour l'arithmétique** et `gridW`
|
||
**pour la phrase**. Confirmez si c'est l'intention.
|
||
|
||
---
|
||
|
||
## 2026-08-30 (soir, lien rétabli) — relevé frais : deux confirmations et une correction
|
||
|
||
### `chargingState` figé : confirmé sur lien VIVANT, ce n'est pas de la péremption
|
||
|
||
Le doute était permis tant que le lien Modbus était coupé. Il ne l'est plus : à 14:12, la
|
||
borne publie des valeurs **en mouvement** (`currentPower` 4 372 → 4 397 W, courants qui
|
||
bougent sur les trois phases) et `chargingState` vaut toujours **`ChargingStateIdle`**. Le
|
||
champ n'est pas en retard : **personne ne le maintient**, parce que l'arbitre a sorti la borne
|
||
du waterfall en mode manuel. La demande du point 1 tient donc telle quelle — absent plutôt que
|
||
figé.
|
||
|
||
### La box et le matériel divergent en mode manuel, et rien ne les réconcilie
|
||
|
||
`Restoring manual values of false 6 A 3 phases` a été journalisé à 12:15:34, **trois secondes
|
||
après** la perte du lien. La borne, elle, est restée sur ce que l'arbitre lui avait commandé au
|
||
cycle d'avant : `power: true`, **7 A**, et elle charge toujours à 4 372 W deux heures plus tard.
|
||
Le lien est revenu depuis, et **rien n'a réappliqué** les valeurs manuelles : en mode manuel
|
||
personne ne commande, donc l'écart entre ce que la box croit avoir posé et ce que la borne fait
|
||
est **permanent**.
|
||
|
||
Ce n'est pas une conséquence du bug WiFi : c'est ce que « le mode manuel ne commande pas »
|
||
implique dès qu'une commande se perd. **Question** : une entrée en mode manuel devrait-elle
|
||
réappliquer ses valeurs à la reconnexion du Thing ? Sans cela, une coupure d'une seconde suffit
|
||
à laisser une borne à un courant que plus personne n'a choisi, et le HEMS n'en sait rien —
|
||
`draw.committedW` reste à 0 pendant que 4,4 kW sont tirés.
|
||
|
||
### `sessionEnergy` : notre doc était en retard, corrigée sur relevé frais
|
||
|
||
Vous aviez raison — LM-1009 §C1 n'est pas contredite, elle ne s'applique simplement plus à
|
||
cette borne. Mesuré sur charge réelle, deux échantillons à deux heures d'écart :
|
||
|
||
| | 12:15 | 14:12 |
|
||
|---|---|---|
|
||
| `sessionEnergy` | 7,5147 kWh | 15,8015 → 15,8405 kWh, **en mouvement** |
|
||
| `chargeEnergy` | 0,3103 kWh | 8,5972 → 8,6361 kWh |
|
||
| écart | **7,2044** | **7,2044** |
|
||
|
||
**L'écart est constant au dix-millième sur deux heures** : c'est votre accumulateur, et il
|
||
porte les incréments d'avant la dernière remise à zéro. La remise à zéro n'a pas été surprise
|
||
en direct — mais l'accumulation, elle, est prouvée par cet écart, et elle ne se déduisait pas
|
||
du code.
|
||
|
||
Corrigé chez nous : la maquette ne nomme plus la V2C dans l'exemple « avancement non
|
||
mesurable » (le régime reste vrai pour les bornes sans cet état, `wallboxNoMeter` et l'une des
|
||
deux classes `wallbox`), et le modèle porte le relevé. **Aucun avancement n'est affiché pour
|
||
autant** : c'est une décision (D1 — en énergie livrée, et il n'y a pas encore d'écran qui la
|
||
porte), pas une mesure qui manquerait.
|
||
|
||
---
|
||
|
||
## 2026-08-30 (soir) — trois constats sur la borne, et deux d'entre eux FORGENT un fait
|
||
|
||
**Contexte, mesuré** : V2C Trydan, Skoda branchée et en charge (4 356 W, 3 phases), passage
|
||
en Boost depuis l'app à 12:15:34, lien Modbus tombé à 12:15:31 — trois secondes avant.
|
||
|
||
### 1. `chargingState` reste `Idle` pendant une charge réelle
|
||
|
||
En `ChargingModeNormal` l'arbitre sort la borne du waterfall — `[Arbitre] Borne "V2C Trydan"
|
||
hors waterfall (mode manuel ou aucun véhicule branché)`, à chaque cycle — et cesse donc de
|
||
maintenir `chargingState`. Le champ garde sa dernière valeur, `ChargingStateIdle`, pendant que
|
||
la borne tire 4 356 W.
|
||
|
||
**Un champ que plus personne ne maintient devrait être absent, pas figé.** C'est la règle que
|
||
`draw`, `counts` et `measurement` appliquent déjà — appliquée cette fois à un champ **vivant
|
||
qui cesse de l'être**, ce qui est le cas le plus difficile à voir : rien ne change, et c'est
|
||
justement le problème. Nous ne l'affichons pas, et nous ne l'afficherons pas tant qu'il peut
|
||
être périmé sans le dire.
|
||
|
||
### 2. À la perte du lien, les états publiés passent à ZÉRO — c'est le plus grave
|
||
|
||
Relevé état par état sur le même Thing, avant et après `marking unreachable` :
|
||
|
||
| État | Avant | Après |
|
||
|---|---|---|
|
||
| `connected` | true | **false** |
|
||
| `pluggedIn` | true | **false** |
|
||
| `charging` | true | **false** |
|
||
| `currentPower` | 4 396 W | **0** |
|
||
| `statusMessage` | "" | « Hors ligne depuis 30/08 12:39 — timeout Modbus — vérifier signal WiFi de la borne » |
|
||
|
||
La voiture était **branchée et en charge**. Un client qui relaie ces états affiche donc
|
||
« aucun véhicule branché, 0 W » — **un fait forgé, pas une absence**. Et avant les zéros, la
|
||
dernière valeur connue reste publiée un moment comme si elle était actuelle : vérifié, aucun
|
||
état ne bouge pendant 45 s alors que la borne est censée tirer 4 356 W.
|
||
|
||
**La demande** : à la perte du lien, **ne pas republier** `pluggedIn` / `charging` /
|
||
`currentPower`. Les figer est déjà trompeur ; les remettre à zéro **fabrique une
|
||
affirmation** — et c'est exactement ce que la règle « un champ absent vaut mieux qu'un champ
|
||
nul ou forgé » interdit. Nous ne savons pas d'où viennent ces zéros (plugin V2C ou remise à
|
||
zéro générique de nymea à la déconnexion), et c'est à vous de le dire.
|
||
|
||
Deux choses sauvent la lecture, et elles sont bien faites : `connected` bascule, et
|
||
`statusMessage` porte **l'heure de la coupure et la piste à suivre**, déjà rédigés. Nous
|
||
l'affichons tel quel.
|
||
|
||
> **Corrigé côté app aujourd'hui** : sous `connected == false`, `EvChargerLive` met
|
||
> `pluggedIn`, `charging` et `currentPowerW` à `null` **à l'entrée** — ni le zéro forgé, ni la
|
||
> valeur périmée. La tuile dit « Hors ligne » et reprend votre phrase. Le **mode**, lui, reste
|
||
> affiché : il est stocké dans la box, pas lu sur la borne, donc un lien coupé ne l'invalide
|
||
> pas.
|
||
|
||
### 3. `ChargingModeNormal` n'est pas un boost — et notre écran le promet
|
||
|
||
Le journal est explicite :
|
||
|
||
```
|
||
EV charger "V2C Trydan" changed to normal mode. Restoring manual values of false 6 A 3 phases
|
||
```
|
||
|
||
Passer en Normal **restaure les valeurs manuelles mémorisées** — ici *power `false`, 6 A* —
|
||
et sort la borne de l'arbitrage. Donc : ce que le mode restaure est ce qui traînait, pas un
|
||
maximum ; et **plus personne ne commande ensuite**, donc rien ne monte le courant.
|
||
|
||
Or l'attente derrière le bouton est explicite (Patrick, 2026-08-30) : **en Boost la borne
|
||
doit charger à sa puissance maximale.** Notre correspondance `[Boost] → ChargingModeNormal`
|
||
(`CLAUDE.md`) ne la tient pas.
|
||
|
||
**La question, et sa contrainte** : comment une app exprime-t-elle « charge maintenant, à
|
||
fond » ? Nous **n'écrirons pas les actions du Thing** (`power`, `maxChargingCurrent`) depuis
|
||
l'écran : `+etm23` a rétabli *une charge, un commandeur*, et un boost écrit par l'app
|
||
rouvrirait exactement le défaut que ce lot a fermé — deux commandeurs, et une consigne publiée
|
||
qui n'atteint pas le matériel. Il faut donc que ça vienne de vous : soit le mode fixe lui-même
|
||
les valeurs manuelles au maximum de la borne, soit un mode (ou un paramètre) dédié le porte.
|
||
|
||
Tant que ce n'est pas tranché, **nous ne renommons pas le bouton** : l'écran dirait « Boost »
|
||
sur un mode qui, selon ce qui traîne en mémoire, peut laisser la borne à l'arrêt.
|
||
|
||
---
|
||
|
||
## 2026-08-30 (suite) — `draw.committedW` en préalable : OUI, sans réserve
|
||
|
||
**Et le refus de livrer `counts` sur `budget.evReservedW` est le bon appel.** Une identité
|
||
*dépendante du temps* est pire que pas d'identité : vraie au premier cycle après un
|
||
changement, fausse après convergence, elle passerait tous les tests — qui portent sur des
|
||
cycles isolés — et **certifierait** un total faux chez les clients. R3 existe pour empêcher
|
||
une réconciliation qui pourrit sans se voir ; la livrer sur un terme tantôt registre tantôt
|
||
correction aurait fabriqué exactement ça, avec en plus le sceau d'une identité publiée.
|
||
|
||
### Les trois destinations, et ce que l'app peut en dire
|
||
|
||
| Destination | Nature | Réconciliable ? |
|
||
|---|---|---|
|
||
| `budget.allocatedW` | **registre d'allocation** (surplus) | **oui, aujourd'hui** — mesuré sur `.75` |
|
||
| `budget.evReservedW` | **terme de correction** du budget, deux natures mêlées | **non, et jamais** |
|
||
| `draw.committedW` | **registre** des watts achetés | oui, dès qu'il existe |
|
||
|
||
Conséquence, à dire telle quelle : **il n'existe aujourd'hui qu'une identité vérifiable, et
|
||
elle ne couvre qu'un côté.** `Σ targetW(surplus) == budget.allocatedW` — vérifiée à la sonde
|
||
le 2026-08-30 (06:10 et 06:14). Côté réseau : **rien**. Pas « à vérifier plus tard », pas
|
||
« approximativement `evReservedW` » : rien, jusqu'à `draw.committedW`.
|
||
|
||
### Nous portions la fausse affirmation, et elle part aujourd'hui
|
||
|
||
Elle est écrite chez nous en cinq endroits — « une charge financée au réseau vit dans
|
||
`budget.evReservedW` » (`CLAUDE.md` ×2, le modèle ×3, la description ARB de la mention
|
||
« servie au réseau »). Retirée dans le même lot, sans changement de comportement.
|
||
|
||
> **Et la distinction avec notre discipline de la semaine, parce qu'elle a l'air de la
|
||
> contredire.** L'exception `EV_GRID_START` décrit un **comportement de la box** : on ne la
|
||
> retire qu'au vu de la sonde. « `evReservedW` est la destination des watts achetés » est une
|
||
> **affirmation sur le sens d'un champ**, et elle est fausse *maintenant*, sur la box
|
||
> d'aujourd'hui. Attendre une livraison pour corriger ce qu'on a compris de travers serait
|
||
> laisser une doc mentir en connaissance de cause. **La sonde arbitre ce que fait la box, pas
|
||
> ce que nous en avons cru.**
|
||
|
||
### Trois demandes sur `draw.committedW`
|
||
|
||
**a. Par cycle, en watts, dans la même trame que `budget`.** Sinon on compare deux instants,
|
||
et l'identité redevient dépendante du temps par un autre chemin — celui-là même qu'on vient
|
||
d'éviter.
|
||
|
||
**b. Ne fabriquez pas le reste de l'objet `draw`.** Notre maquette lui donne aussi
|
||
`authorisedW`, `remainingW`, `binding`, `perPhaseBound` (§10). Publier
|
||
`draw: { "committedW": 1400 }` **seul** est parfait. Publier `authorisedW: 0` ou
|
||
`binding: "connection"` par défaut ferait afficher à l'écran « ordre de service » un
|
||
**plafond de soutirage qui n'existe pas, avec sa source** — un chiffre crédible et faux, et
|
||
la faute exacte que ce champ est censé rendre impossible. Absent, jamais forgé : l'écran 2
|
||
n'apparaîtra qu'avec l'objet complet.
|
||
|
||
**c. Une phrase de définition pour `evReservedW`.** Nous l'affichons sous le libellé
|
||
**« Réservé au véhicule »** (`_BudgetTable`), qui affirme une réservation là où il y a une
|
||
correction. Nous ne le renommerons pas au jugé — donnez la définition, nous écrirons le
|
||
libellé. C'est le seul geste que cette découverte laisse en suspens côté écran.
|
||
|
||
### État mesuré au 2026-08-30, et ce qu'il autorise
|
||
|
||
Sonde contre `.75` après l'annonce de `+etm31` : **aucune clé `draw` dans la trame**, et le
|
||
paquet dit pourquoi — `powersync-energy-plugin-nymea 1.15.2+etm30`, aucun paquet `+etm31`
|
||
(code écrit, cross-build non fait). **La pose est relancée ; l'exception `EV_GRID_START` reste
|
||
dans `surplusShareW` jusqu'à ce que la sonde voie `draw.committedW` sortir.** C'est la sonde
|
||
qui autorise le retrait, pas l'annonce — y compris quand l'annonce est juste.
|
||
|
||
### Ce qui ne bouge pas
|
||
|
||
`counts` reste retenu tel qu'arbitré ce matin : la **forme** ne change pas, seul son préalable
|
||
apparaît. Et l'exception `EV_GRID_START` reste dans le code et dans `CLAUDE.md` — elle tiendra
|
||
plus longtemps que prévu, et c'est très bien ainsi.
|
||
|
||
---
|
||
|
||
## 2026-08-30 — R3 : `counts` est retenu, et `funding` sort de la réconciliation
|
||
|
||
**Réponse à la question du moteur sur le niveau mixte.** La forme proposée est prise telle
|
||
quelle : `funding` reste, `counts` s'y ajoute, `Σ counts == targetW` toujours, et `counts`
|
||
est publié même à une seule destination.
|
||
|
||
### Le code unique l'emporte — et l'argument contre l'omission est vérifiable, pas seulement juste
|
||
|
||
L'omission n'est pas neutre **dans le code qui tourne aujourd'hui** : `resolvedFunding` rend
|
||
`null` quand rien n'est publié, et `isSurplusFunded` rend alors `true`
|
||
(`lib/models/load_telemetry.dart`). Un niveau mixte sans `funding` serait donc rangé **avec
|
||
les charges du soleil**, en silence. L'omission porterait déjà une affirmation — et la pire
|
||
des deux.
|
||
|
||
### Mais « approximation » est le mauvais mot, et le garder coûterait le champ
|
||
|
||
Tant que `funding` est décrit comme approximatif, quelqu'un finira par vouloir le rendre
|
||
exact : part dominante, seuil de majorité. Ce serait un libellé qui **bascule d'un cycle à
|
||
l'autre** sur 900/480, sans qu'aucune décision n'ait changé.
|
||
|
||
**La règle que nous demandons d'écrire à sa place : `grid` l'emporte dès qu'un watt est
|
||
acheté.** Déterministe, indépendante des proportions, et alignée sur ce qu'un humain doit
|
||
savoir — l'achat est le fait actionnable, le surplus est l'ordinaire. Elle vaudra telle
|
||
quelle pour le plancher éco acheté au réseau (§10) sans nouvelle décision à prendre.
|
||
|
||
### Ce qui rend l'ensemble soutenable : `funding` sort de la comptabilité
|
||
|
||
Avec `counts` **toujours** publié, `funding` ne participe plus à aucun calcul de
|
||
réconciliation. Il ne dit plus que : *faut-il en parler ?* — et `counts` dit *combien, et
|
||
où*. Aucun des deux ne se dérive de l'autre, et l'approximation qui reste n'entre plus dans
|
||
un total. Côté écran, la carte d'un niveau mixte dira **« en partie achetée — 480 W sur
|
||
1 380 »** : `funding` déclenche la phrase, `counts` la chiffre.
|
||
|
||
### Et `"mixed"` publié ? Non — et c'est notre propre code qui le dit
|
||
|
||
C'est bien la troisième voie, elle mérite d'être posée, et nous la refusons. Trois raisons,
|
||
dans l'ordre de force :
|
||
|
||
**1. Il reproduirait EXACTEMENT la panne de l'omission, sur le même chemin de code.** Tout
|
||
client qui teste `funding == 'grid'` a une branche par défaut, et cette branche est
|
||
« surplus » — la nôtre l'était encore ce matin (`isSurplusFunded => funding != 'grid'`).
|
||
Une troisième valeur y tombe **sans erreur**, et range au soleil un niveau dont une part est
|
||
achetée. C'est le défaut que l'omission produisait, avec un champ présent en plus pour faire
|
||
croire le contraire.
|
||
|
||
**2. Il serait dérivable — donc il violerait votre propre règle.** `"mixed"` ⟺ `counts` a
|
||
plus d'une destination non nulle. Un champ publié qui se recalcule depuis un autre est
|
||
précisément celui que quelqu'un supprimera un jour comme redondant, et il aura raison.
|
||
|
||
**3. Il ne répond pas à la question que `funding` pose.** `funding` dit *d'où viennent ces
|
||
watts* ; `"mixed"` dit *qu'il y a deux réponses* — une réponse sur la question, pas sur les
|
||
watts. Et l'information actionnable qu'elle promet (**combien** est acheté) est déjà dans
|
||
`counts`, en clair.
|
||
|
||
**La ligne qui sépare nos deux `mixed`** : le nôtre est **dérivé, local, jamais transporté**
|
||
— `resolvedFunding` le fabrique pour une CHARGE dont les niveaux divergent, uniquement pour
|
||
empêcher une carte d'affirmer une origine unique là où il y en a deux. Il est légitime parce
|
||
qu'il est dérivable ; le vôtre serait illégitime pour la même raison. **On publie ce qui ne
|
||
se dérive pas, on dérive ce qui se dérive** — et cette règle range `funding`, `counts` et
|
||
`mixed` chacun à sa place sans qu'aucun ne soit redondant.
|
||
|
||
### Trois choses en échange, et elles sont de notre champ
|
||
|
||
**a. Une clé inconnue ne doit jamais disparaître.** Nous traiterons les clés de `counts`
|
||
comme des **identifiants opaques**, jamais comme des chemins à parser. Si `draw.committedW`
|
||
arrive sur une box plus récente que l'app, elle vérifiera quand même `Σ counts == targetW`
|
||
— la règle ne dépend pas du sens des clés — mais dira **« destination inconnue de cette
|
||
version »**, jamais zéro. Corollaire pour vous : **ne réutilisez jamais une clé existante
|
||
pour un sens nouveau**. C'est le seul cas qu'aucun client ne peut détecter.
|
||
|
||
**b. Publiez `counts` même à zéro, avec sa destination.** Mesuré sur `.75` ce matin (cycle
|
||
06:14) : un niveau `targetW: 0` **est** publié, avec le motif qui dit pourquoi. Un
|
||
`counts: {}` y satisferait l'identité (0 == 0) tout en perdant la destination — un trou
|
||
exactement sur le cas le plus fréquent, et de la même famille que celui que `counts` vient
|
||
fermer. Une clé, valeur 0.
|
||
|
||
**c. Dites qui fait foi entre `counts` et les params d'`EV_GRID_START`.** Les deux porteront
|
||
le même partage (`budgetW` / `gridW`). Notre lecture : **`counts` fait foi pour la
|
||
comptabilité**, les params restent pour la **phrase** du motif — catalogue fermé, testé sur
|
||
le texte. Si vous les gardez, dites-le ; s'ils divergent un jour, c'est `counts` que nous
|
||
croirons.
|
||
|
||
### Ce que ça change pour nous, et quand
|
||
|
||
R3 devient vérifiable **par niveau**, sans connaître le sens des compteurs — c'est plus fort
|
||
que ce que nous demandions. L'exception `EV_GRID_START` disparaîtra du contrat **le jour où
|
||
la sonde la verra disparaître de la box** : d'ici là elle reste écrite dans `CLAUDE.md` et
|
||
traitée dans `surplusShareW`, et nous ne touchons ni l'un ni l'autre. Contrat, pas feuille
|
||
de route — comme pour `levels[]`.
|
||
|
||
---
|
||
|
||
## 2026-08-29 — RÉPONSE au §9 : les deux passes du §10, vues de l'écran
|
||
|
||
**Maquette : `docs/mockups/deux_passes_eco_confort_mockup.html`** — trois écrans, un catalogue
|
||
d'états et la charge utile demandée. Ce qui suit en est le résumé ; la maquette fait foi.
|
||
Lisible sans cloner le dépôt : <https://claude.ai/code/artifact/95faf613-f79a-4906-b82d-e264b4a40b11>
|
||
(même contenu, republiée à chaque révision du fichier).
|
||
|
||
### Les trois réponses
|
||
|
||
**1. La liste reste ordonnée par RANG.** Deux raisons, et la seconde est la vraie :
|
||
|
||
- c'est la **surface d'édition du rang** — le glisser-déposer y écrit `priority`. Une liste qui
|
||
se réordonne sous le doigt après le geste est inutilisable ;
|
||
- surtout, **l'ordre de service n'est pas une propriété de la configuration** : il dépend des
|
||
planchers déjà atteints, donc il change d'un cycle à l'autre sans que personne ne touche à
|
||
rien. Une liste de configuration ne peut pas avoir une colonne vertébrale qui bouge toute
|
||
seule. Trier par ordre de service ne rendrait pas le rang illisible — ça rendrait la **liste**
|
||
instable, ce qui est pire.
|
||
|
||
**Et il faut le dire, donc on le dit deux fois** : un bandeau permanent au-dessus de la liste
|
||
(« tous les planchers éco d'abord, le confort ensuite — votre ordre est respecté à l'intérieur
|
||
de chaque passe »), et un **second écran en lecture seule**, l'*ordre de service du cycle*, qui
|
||
montre la séquence réelle. Le bandeau n'apparaît que si au moins une charge porte une obligation
|
||
éco : sans obligation, il n'y a qu'une passe utile et la phrase serait du bruit (LM-1007).
|
||
|
||
**2. Une seule entrée par charge dans la liste ; deux lignes DANS la carte.** La règle qu'on
|
||
s'est donnée : **on dédouble là où l'objet affiché est la SÉQUENCE, jamais là où l'objet est la
|
||
CHARGE.** Donc une charge apparaît deux fois dans l'écran 2 — c'est son sujet — et une seule fois
|
||
dans la liste. Dans la carte, la **commande est le parent** (« Commandé 1 500 W · une seule
|
||
consigne ») et les deux niveaux sont sa **décomposition**, décalés et rattachés par un filet.
|
||
C'est ce qui rend LM-1003 lisible sans avoir à l'écrire.
|
||
|
||
**3. Éco servi / confort non** — le cas fréquent — s'affiche comme **deux états nommés**, jamais
|
||
comme une fraction :
|
||
|
||
```
|
||
Décidé 1 400 W · une seule décision
|
||
↳ appliqué au mécanisme : palier 1 000 W (relais 1+2)
|
||
ÉCO Plancher servi — 1 400 W achetés au réseau
|
||
2,1 kWh livrés sur les 3,0 dus avant 07:00 [MESURÉ]
|
||
CONFORT En attente
|
||
Aucun surplus ce cycle — le bonus n'est pas refusé, il n'a rien à prendre.
|
||
```
|
||
|
||
S'il s'affichait comme une fraction de quelque chose, le régime **nominal** se lirait comme une
|
||
dégradation permanente. Et « en attente » (demandeur, pas de budget) et « pas demandeur »
|
||
(comparateur LM-1008 satisfait) sont **deux états distincts** : l'un se règle en changeant la
|
||
consigne, l'autre en attendant le soleil.
|
||
|
||
### Ce que ça demande à `GetLoadTelemetry` — un tableau, rien de retiré
|
||
|
||
```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.
|