Arbitrage rendu au moteur avant qu'il n'écrive R3. La forme proposée est prise
telle quelle : `funding` reste, `counts` s'ajoute, `Σ counts == targetW`
toujours, publié même à une seule destination. L'exception `EV_GRID_START`
disparaît du contrat, remplacée par une règle vérifiable par niveau.
Trois apports de notre côté :
- « approximation » est le mauvais mot et coûterait le champ : quelqu'un
voudrait le rendre exact, et un libellé par part dominante basculerait d'un
cycle à l'autre sur 900/480. Règle demandée : **`grid` l'emporte dès qu'un
watt est acheté** — déterministe, et vraie sans nouvelle décision pour le
plancher éco du §10 ;
- `"mixed"` publié est refusé, et c'est notre propre code qui le dit : une
troisième valeur tombe dans la branche par défaut de tout client — « surplus »
— donc reproduit la panne de l'omission, avec un champ présent en plus pour
faire croire le contraire. Et il serait dérivable de `counts`, donc supprimé
un jour à juste titre. Notre `mixed` reste dérivé, local, jamais transporté ;
- ce que l'app promet : clés de `counts` traitées comme identifiants opaques,
une clé inconnue dit « destination inconnue de cette version », jamais zéro.
Contrat, pas feuille de route : `CLAUDE.md` et `surplusShareW` gardent
l'exception écrite tant que la sonde ne l'a pas vue disparaître de la box.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
La prémisse inverse était écrite dans CLAUDE.md et dans le modèle, et c'est elle
qui a produit l'erreur de la maquette §10 : un écran cherchant l'arrondi entre
`Σ targetW` et `allocatedW` compare deux valeurs égales par construction — il ne
montre jamais rien, et sans erreur.
Trois nombres nommés à leur place : décidé (`allocatedW`), appliqué
(`mechanism.*`), mesuré (`measurement.powerW`) — et ce que chaque écart prouve.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
R2 est livrée côté moteur (1.15.2+etm30 sur .75) : `funding` ne figure plus à la
racine d'une entrée de `loads[]`, il vit dans `levels[]`, un par passe. La rupture
est effective, pas seulement contractuelle — `entry.funding` est désormais `null`
partout, donc la mention « servie au réseau » avait disparu de la carte et une
charge achetée au réseau aurait été comptée comme surplus dans l'identité. Une
panne muette : rien n'échoue, tout se lit de travers.
- `LoadLevel` — une passe : `level`, `targetW` (du DÉCIDÉ, comme `allocatedW`),
`funding` ;
- `resolvedFunding` / `isGridFunded` résolvent les DEUX formes ; `levels[]` vide
= box antérieure, pas « aucune passe » ;
- `'mixed'` est NOMMÉ plutôt que replié : impossible aujourd'hui (une seule passe
publiée), courant au §10, et l'affirmer d'un côté serait une demi-vérité ;
- `surplusShareW` somme les `targetW` financés au surplus, l'exception écrite
`EV_GRID_START` (part de surplus = `params.budgetW`) passant avant.
Vérifié à la sonde contre `.75`, pas en test : 3 500 + 3 000 == 6 500 ==
`budget.allocatedW` au cycle 06:10, puis 1 500 == 1 500 à 06:14. Le niveau publié
est « comfort », et c'est déjà le bon nom.
Aucun écran à deux niveaux n'est commencé : le reste de la maquette attend R1 et
R3 à R8, et le §10 n'est pas implémenté.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
La maquette `docs/mockups/deux_passes_eco_confort_mockup.html` répond aux trois
questions du brief plugin §9 (= DESIGN_10 8.5), et sert de contrat au moteur.
- la liste reste ordonnée par RANG : l'ordre de service n'est pas une propriété
de la configuration — il change d'un cycle à l'autre selon les planchers déjà
atteints, donc une liste triée par lui bougerait sous le doigt ;
- une entrée par charge, deux lignes dans la carte : on dédouble là où l'objet
affiché est la SÉQUENCE, jamais là où c'est la CHARGE — d'où un second écran,
« ordre de service du cycle », en lecture seule ;
- « éco servi / confort non » s'affiche en deux états NOMMÉS : en fraction, le
régime nominal se lirait comme une dégradation permanente.
Huit exigences pour `GetLoadTelemetry`, dont R2 (`funding` descend au niveau) et
R4, dont la prémisse a été corrigée par le moteur : `allocatedW` publie le
DÉCIDÉ, donc `Σ targetW == allocatedW` est une identité, et l'écart à afficher
vit entre le décidé et `mechanism.*` (appliqué). D'où le bloc « trois nombres ».
Contrat, pas feuille de route : aucun écran ne se construit avant `levels[]`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
Deux retours d'usage, tous deux sur la même carte.
1. « Placer en dernier » poussait « Configurer » hors de sa place. Il rejoint « Renommer »
sur une ligne à part : ce sont les deux gestes qui ne touchent pas au mécanisme, et un
bouton d'action principale ne se fait pas déplacer par un geste secondaire.
2. Quitter l'écran avec des modifications non enregistrées ne demandait rien — et ce
silence cachait pire qu'une perte : le brouillon vit dans LoadConfigProvider, pas dans
l'écran, donc il SURVIT à la navigation. On repartait en laissant une configuration
modifiée invisible, qui réapparaissait au retour sans contexte, ou disparaissait à la
première relecture de la box (LoadConfigChanged, reconnexion) sans que personne ait
choisi. Les deux issues sont mauvaises pour la même raison : l'utilisateur ne sait pas
ce qu'il laisse derrière lui.
GardeBrouillon (PopScope) s'interpose sur les DEUX écrans qui partagent ce brouillon — la
liste des charges et l'écran de mécanisme. L'alerte dit l'état de la BOX, pas seulement
celui de l'écran : « vos changements n'ont pas été envoyés, elle porte toujours la
configuration d'avant ». Deux issues, toutes deux explicites : rester, ou quitter en
abandonnant — un brouillon fantôme qui ressort trois écrans plus loin est pire qu'une
modification perdue qu'on a accepté de perdre.
Aucune écriture n'est proposée depuis la boîte de dialogue : enregistrer depuis une sortie
ferait partir une configuration que personne n'a relue.
Quatre tests, dont le contrôle négatif — sans brouillon, on sort sans être interrogé. Une
alerte qui se déclenche pour rien apprend à passer outre sans lire.
235 tests (+4), flutter analyze inchangé à 27 remarques.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
Manque trouvé en usage, pas en test : les flèches déplacent une charge DANS son bloc, le
glissement déplace le bloc ENTIER. Rien ne sortait une charge de son groupe. Une borne
regroupée avec les autres devenait donc prisonnière du bloc — et depuis que « Regrouper par
domaine » existe, ce cas se produit d'un simple geste.
Le bouton la renvoie en queue, seule dans sa plage. C'est la position qu'une borne détectée
reçoit d'office depuis +etm27 (1 + max), donc un repère connu — et une fois seule, le
glissement de plage la porte partout ailleurs. Ça rend la liberté de placement sans
inventer un glisser-déposer entre blocs, qui demanderait de viser un point d'insertion au
pixel près sur un téléphone.
Brouillon seulement : rien ne part avant save(). Masqué quand la charge y est déjà — un
bouton qui ne fait rien use la confiance dans ceux qui font quelque chose. Le contrôle
porte sur l'état AVANT retrait : après, la plage d'origine a pu disparaître et la
comparaison ne veut plus rien dire (première version fausse, prise par son propre test).
⚠️ Et une assertion Flutter trouvée par les tests de glissement, dont la cause est à trois
widgets de son symptôme : `TextButton.styleFrom(textStyle:)` porte `inherit: true` là où le
style du thème porte `inherit: false`, et Flutter refuse d'interpoler les deux pendant une
animation — « Failed to interpolate TextStyles with different inherit values ». Le style va
donc sur le LIBELLÉ, pas dans le ButtonStyle. Les deux boutons posés hier (« Regrouper par
domaine », « Ce rang me convient ») portaient la même bombe à retardement : corrigés aussi.
231 tests (+5), flutter analyze inchangé à 27 remarques.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
Le moteur a tranché l'entrelacement, et son argument va plus loin que le mien : grouper à
l'affichage oui, à l'écriture non. Un regroupement appliqué à l'enregistrement est une
machine qui réordonne ce qu'un humain a décidé — exactement ce que LM-1209 interdit au
moteur. Et la contiguïté n'a jamais été un invariant : le waterfall trie sur le rang seul,
`domain` n'est pas lu par l'arbitre.
Il ne suffisait donc pas d'adoucir la note : c'est le modèle d'affichage qui la produisait.
Un groupe était un SEAU par domaine, rassemblant les charges où qu'elles soient dans
l'ordre — donc l'écran ne pouvait pas représenter un ordre entrelacé, et comme
l'enregistrement réaplatit ce qui est affiché, TOUT enregistrement regroupait. La PAC
reculait du rang 2 au rang 5 sans que personne l'ait demandé.
Un groupe est maintenant une PLAGE CONTIGUË. L'affichage épouse l'ordre réel — deux plages
`ev` séparées par `heating` s'affichent comme deux blocs — et réaplatir rend exactement ce
qui a été lu. Test à l'appui : la PAC garde sa position, et l'ordre à plat écrit est
identique à celui lu sur la box.
Le regroupement reste possible, mais devient un GESTE : bouton « Regrouper par domaine »,
qui pose un brouillon — visible, annulable, rien ne part avant save(). Ce que
l'enregistrement faisait en douce, quelqu'un le demande désormais.
Et la note cesse d'alerter : l'ordre non groupé est une information, pas un défaut.
L'annoncer comme une anomalie poussait à réparer un ordre que personne n'avait cassé —
d'autant que depuis +etm27 une borne détectée entre en queue, ce qui produit un ordre non
groupé sur une installation parfaitement saine.
Trois conséquences trouvées en chemin, toutes dues au fait qu'un domaine peut désormais
occuper plusieurs plages :
· la clé de la liste réordonnable portait le DOMAINE → deux plages d'un même domaine
partageaient une clé, et la liste cassait sur une assertion de sémantiques, loin de la
cause. Elle porte l'id de la première charge de la plage.
· le repli suivait le domaine → deux plages se repliaient ensemble.
· reorderWithinDomain(domain) désignait la première plage venue → reorderWithinGroup(index).
· assignDomain rejoint la DERNIÈRE plage du domaine, pas la première : remonter la charge
dans la première la ferait sauter des rangs que personne n'a touchés.
Retiré au passage : sansRankOriginAuto(). L'écho verbatim inchangé passe — mesuré côté
moteur sur marque fraîche — donc la marque survit désormais aux gestes qui ne la concernent
pas, et le badge ne s'éteint plus sur une borne que personne n'a classée.
Noté au TODO : supprimer l'entrée d'une borne dont le Thing existe encore ne la recrée pas
(défaut moteur en cours de correction). Le bouton réservé aux THING_MISSING protège déjà de
ce cas — ne pas l'élargir.
226 tests, flutter analyze inchangé à 27 remarques.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
L'agent plugin a mesuré l'inverse sur la même box : écho verbatim, "auto" compris,
EnergyErrorNoError. L'exception existe déjà côté moteur — reconcileRankOrigin() accepte
"auto" quand le persisté le porte déjà, écrite exprès pour ne pas rompre la neutralité
Get → Set.
Ce que j'avais mesuré est autre chose : entre ma lecture et mon écriture, la marque avait
été consommée par une manipulation concurrente. Mon écho portait donc un "auto" que le
persisté ne portait plus — un écho périmé, correctement refusé. Deux cas distincts, un seul
symptôme, et c'est précisément pourquoi une écriture qui ne relit pas juste avant ne prouve
rien. Le contrôle de la suppression, lui, relit avant de conclure ; ma sonde jetable, non.
§10 corrigé : la ligne du tableau dit maintenant ce qu'elle mesure vraiment, et ce qui
reste à mesurer (un écho inchangé sur une marque FRAÎCHE). sansRankOriginAuto() est
documentée comme un contournement EN SURSIS — elle part au résultat de cette mesure, et la
marque survivra alors aux gestes qui ne la concernent pas. Rien n'est retiré d'ici là.
Ce qui reste vrai de mon relevé : "user" s'écrit, ne déplace pas le rang, survit aux gestes
voisins. Et l'effacement de "auto" que j'ai observé est le fait de l'APP — de son
nettoyage — pas d'un refus de la box.
── Deux pièges de sonde, consignés dans tools/rpc/README.md ─────────────────
1. Un refus arrive en `status: "success"`, verdict dans params.energyError. Une sonde qui ne
teste que `status` annonce « accepté » sur une écriture rejetée.
2. Les identifiants ne s'écrivent pas pareil d'un appel à l'autre — le §9, documenté la
VEILLE, et la sonde y est quand même tombée : elle rendait des verdicts sur une charge
qu'elle ne regardait pas.
La leçon n'est pas « écrire des sondes plus soigneusement » : c'est que la sonde ne partage
pas les garde-fous de l'app. Quand la question porte sur ce que l'app fera, importer son
modèle vaut mieux que relire le JSON ; quand elle porte sur la box, croiser la réponse RPC
avec journalctl avant de conclure.
Et la raison lisible d'un refus n'existe QUE dans ce journal — côté RPC il ne reste qu'un
code. Silence de refus ouvert depuis le premier jour, rencontré une fois de plus, et raison
d'être des miroirs clients comme validateSet.
Rien de touché côté entrelacement : ça attend le moteur.
226 tests inchangés, analyze inchangé à 27.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
Seconde wallbox simulée appairée sur .75 après +etm27. L'entrée n'apparaît pas
instantanément : ma première lecture montrait 4 charges, la suivante 5. Puis tout se
confirme — rankOrigin "auto", rang 5 (insertion en queue), badge sur elle seule, alerte
d'ex æquo muette.
Le coût du §10 est constaté, et il est exactement celui annoncé :
écho de "auto" par un client → EnergyErrorInvalidParameter, config INCHANGÉE
"auto" → "" (ce que fait l'app) → accepté, LA MARQUE EST EFFACÉE
"user" sur la seule borne visée → accepté, rang inchangé, seul ce champ change
deux renommages voisins ensuite → "user" SURVIT
Donc le badge a bien disparu d'une borne que personne n'avait classée, au renommage d'une
autre charge — et la fragilité ne touche que "auto".
⚠️ TROUVAILLE 1 — un refus arrive en `status: "success"`, le verdict étant dans
params.energyError. Le service de l'app teste bien les deux ; une sonde jetable écrite le
jour même, non — elle a annoncé « ACCEPTÉ » sur une écriture que la box venait de rejeter,
et c'est le journal de la box qui a rétabli les faits. La raison lisible n'existe que là :
« la charge "Simulated wallbox 2" prétend un rankOrigin "auto" que le moteur n'a pas posé ».
Côté RPC il ne reste que le code. §10 mis à jour avec la table et ce piège.
⚠️ TROUVAILLE 2 — l'insertion en queue CASSE la contiguïté des domaines : ecs, ev, ev,
heating, ev. La note d'ordre entrelacé s'allume donc à juste titre, et prévient que le
premier enregistrement regrouperait — remontant au rang 3 une borne que le moteur avait
rangée en queue. Ce n'est pas un défaut de LM-1209, c'est son prix dès que la dernière
charge n'est pas du même domaine. Trois lectures possibles, portées au brief moteur ; l'app
ne change rien tant que ce n'est pas tranché.
Et une erreur de ma part, dans une sonde jetable et pas dans l'app : j'ai comparé un id de
borne SANS accolades à un id de charge qui en porte — le piège du §9, que j'avais écrit
moi-même la veille. Les chemins de l'app normalisent (chargingModeOf) ou comparent l'id à
lui-même (adoptRank) ; le script, lui, ne trouvait jamais sa cible.
État du banc laissé : libellés restaurés, borne 2 en rankOrigin "user" au rang 5. Le "auto"
est consommé — il faudra un nouvel appairage pour revoir le badge.
226 tests inchangés.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
`"user"` n'est pas « j'ai vu le badge ». C'est « ce rang me va, je l'assume » : un jugement
porté sur l'ordre de service, et la seule voie pour éteindre le badge SANS changer
l'arbitrage. Les trois conséquences sont dans le code, parce qu'elles sont faciles à perdre.
1. LE GESTE EST EXPLICITE. Un bouton sur le badge, appelé de là et de nulle part ailleurs.
Jamais une écriture au passage, jamais l'effet de bord d'un autre enregistrement :
écrire "user" parce que l'utilisateur a touché à autre chose affirmerait un jugement
qu'il n'a pas porté — le reproche exact qu'on fait à un "auto" fabriqué côté client.
2. IL NE S'ANNULE PAS TOUT SEUL. Une fois posé, seul le moteur le lève, quand un rang
différent arrive pour cet id. L'app ne le retire jamais.
3. IL NE REMPLACE PAS LE DÉPLACEMENT, et le libellé le dit : « Adopter ce rang ne le change
pas. Pour en changer, glissez la charge. » Sans cette phrase, un installateur valide un
ordre qu'il voulait changer — et n'y reviendra pas, puisque le badge s'est éteint.
L'écriture emprunte le chemin de la suppression, pour la même raison : SetLoadConfig
remplace tout l'ensemble. Lire (frais) → patcher le SEUL rankOrigin de la SEULE charge visée
→ réécrire verbatim → relire et comparer clé par clé. Le rang ne bouge pas : « ce rang me
va » ne peut pas porter sur un rang qu'on vient de changer. Garde-fou des rangs à égalité
inclus, et refus si un brouillon est en cours.
Vérifié au passage, comme demandé : sansRankOriginAuto() laisse passer "user" — le confondre
avec "auto" aurait rendu le bouton silencieusement inopérant. Test dédié, en plus de celui
qui existait.
Quatre tests de plus : le bouton est là sur "auto" avec sa phrase, absent sur "user" et sur
champ vide, et la charge utile d'une adoption ne diffère que par rankOrigin.
⚠️ Non vérifiable sur `.75` : les deux bornes y sont antérieures à `+etm27`, donc aucun
"auto" n'y est observable. À reprendre au premier appairage réel d'une borne.
226 tests (+4), flutter analyze inchangé à 27 remarques.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
§10 du contrat de données, à côté du §9. Le §9 disait que deux réponses de la même box
peuvent employer deux conventions sans le signaler ; celui-ci dit que deux RÈGLES posées
séparément, chacune juste, peuvent se contredire au point de rendre une écriture
impossible. Symptôme identique : muet.
L'app écrit verbatim (sinon un champ qu'une box plus récente porte serait perdu, en silence
et pour toutes les charges) ; le moteur refuse rankOrigin: "auto" à tout client (sinon un
écran fabriquerait une affirmation sur un geste du moteur). Leur produit : un simple
renommage rejeté en bloc dès qu'une installation porte une borne auto-provisionnée.
La règle doit porter sur le CHANGEMENT, pas sur la PRÉSENCE — même distinction que
l'effacement de rankOrigin, qui porte sur « rang différent pour cet id » et non sur
« écriture reçue ». Sinon la marque est fragile par construction : nettoyée à chaque
écriture, 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.
Le §10 porte aussi le contrôle à passer une fois par règle d'écriture : l'app peut-elle lire
la valeur (alors elle la renverra) · l'écho inchangé est-il tranché par écrit (sinon c'est
un trou) · que devient l'information au premier geste voisin (si elle disparaît, la publier
est un leurre).
Et le motif derrière le motif, parce qu'il s'est répété : TROIS FOIS EN DEUX JOURS, le
chemin exposé n'était pas celui qu'on regardait — l'écriture du mode visait la première
borne (trouvé en déplaçant les boutons), le refus sur rangs doublés menaçait la suppression
et non le glisser-déposer, le refus de "auto" menace le renommage et non la gestion des
rangs. Le point commun n'est pas la malchance : ces chemins TRANSPORTENT des données qu'ils
ne regardent pas, donc ils sont exposés à tout ce qui a été ajouté après eux.
Le brief moteur passe de la question à la demande argumentée ; le nettoyage côté app reste,
documenté comme contournement — le jour où l'écho passe, la fonction disparaît.
222 tests inchangés, analyze inchangé.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
Trouvé en relisant .75 après le déploiement d'+etm27, pas en écrivant le badge.
`rankOrigin: "auto"` écrit par un client est refusé : EnergyErrorInvalidParameter,
ensemble entier rejeté (vérifié sur machine). Or les deux chemins d'écriture de l'app
recopient les charges VERBATIM — c'est leur règle, et elle est bonne. Recopier verbatim une
entrée marquée "auto", c'est écrire "auto".
Conséquence, qu'aucun test ne montrait et que le banc ne peut pas produire aujourd'hui
(aucune borne auto-provisionnée depuis +etm27) : dès qu'une installation cliente portera
une borne détectée par le moteur, un SIMPLE RENOMMAGE aurait été refusé en bloc — et le
motif ne franchit pas la frontière RPC. Même famille que le refus sur rangs doublés d'hier,
même symptôme muet.
Le nettoyage est posé au point de sortie UNIQUE (NymeaService.setLoadConfig) plutôt que sur
chaque chemin : un chemin ajouté demain hériterait sinon du refus sans que rien ne
l'annonce. `"user"` n'est pas touché — il est permis — et une clé absente le reste : on n'en
fabrique pas.
⚠️ Ce que ça coûte est dit dans le code et remonté au brief : la marque du moteur est
EFFACÉE sur les charges concernées, y compris celles 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. Question posée au moteur : un écho inchangé de "auto"
peut-il être toléré ? Trois options écartées, avec leurs raisons.
Et le relevé du jour précise la lecture : depuis +etm27 la clé sort TOUJOURS, à `""` quand
rien n'est déclaré — même convention que `domain` et `meterThingId`. Les quatre charges du
banc portent `rankOrigin: ""`. La traiter comme une valeur afficherait un badge sur toute
l'installation ; le getter la lit comme un silence, et quatre tests le figent.
222 tests (+4), flutter analyze inchangé à 27 remarques.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
Deux natures, donc deux messages — et la fin d'une redondance : la note d'ex æquo et le
badge disaient tous deux « ce rang n'est le choix de personne », ce qui garantissait que
ni l'un ni l'autre ne serait lu.
── Le badge PREND ce rôle, et le fait mieux ─────────────────────────────────
`rankOrigin == "auto"` (+etm27) → « Rang posé par la box, pas un choix », sur la carte de
la charge concernée. La note ne pouvait que le DÉDUIRE d'une égalité de rangs, donc elle
était muette sur une borne arrivée seule là où son rang ne heurtait personne — le cas qui
devient la norme, puisque les bornes auto-provisionnées entrent désormais en queue.
Ce que le badge n'affirme pas : il n'apparaît que sur "auto". Un champ ABSENT ne dit rien —
ni choix humain, ni pose d'office — et une box antérieure à +etm27 rend exactement l'écran
d'avant. Même règle que measurement.source. "user" n'affiche rien non plus : quelqu'un a
regardé ce rang, même sans déplacer la charge.
── La note CHANGE de nature : constat → alerte avec remède ──────────────────
Depuis +etm27, deux charges au même rang font refuser TOUT l'ensemble. En voir deux à
l'écran ne dit donc plus quelque chose sur l'ordre de service : ça dit que cette
configuration est héritée d'avant +etm27 — le plugin lui-même créait ces égalités — et
qu'elle sera rejetée à la première écriture, y compris pour un simple renommage, sans motif
lisible puisque le refus ne franchit pas la frontière RPC.
Elle passe donc en rouge, annonce le rejet, et donne le seul geste qui répare : des rangs
distincts. Le garde-fou de la suppression le disait déjà au moment du geste ; l'écran le dit
maintenant avant qu'on le tente.
Cinq tests montés : badge sur "auto" seulement, rien sur "user", rien sur champ absent,
l'alerte porte bien un refus ET un remède, et elle se tait sur des rangs distincts.
Reste au TODO : écrire `rankOrigin: "user"` depuis l'app — « ce rang me va » sans déplacer
la charge. Sans ce geste, le badge reste affiché pour dire une chose que l'installateur a
déjà tranchée.
218 tests (+5), flutter analyze inchangé à 27 remarques.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
Livré AVANT le déploiement, parce que ce refus-là est muet : le motif ne franchit pas la
frontière RPC, et le symptôme au doigt aurait été « mon glissement ne s'enregistre pas »,
sans cause visible à l'écran. Même famille que le §9 du contrat de données.
Trois points vérifiés, dans l'ordre demandé :
1. LE RÉAPLATISSAGE REND TOUJOURS DES RANGS DISTINCTS. flattenToPayload numérote 1..n sur
l'ordre courant, donc par construction — mais « par construction » ne se vérifie pas
tout seul : le test le refait après CHAQUE permutation de domaines et après un
déplacement intra-domaine, en partant de la config du banc qui porte déjà deux charges
au rang 1. Les états intermédiaires du glissement ne partent pas : seul `save()` écrit.
2. RIEN NE PART SI LE BROUILLON N'EST PAS COHÉRENT. validateSet() — le miroir client de
LoadConfigStore::validateSet() — apprend le contrôle des rangs et rend un conflit
`tiedPriority` qui NOMME les deux charges et le rang partagé. L'écran distingue
désormais les trois refus (thing revendiqué deux fois, identifiant en double, rangs à
égalité) là où il annonçait « deux charges revendiquent le même appareil » pour tout.
3. ⚠️ LE CHEMIN DE SUPPRESSION ÉTAIT LE PLUS EXPOSÉ, et rien ne l'aurait montré. Il
réécrit les survivantes VERBATIM — c'est sa règle, et elle est bonne — donc il
transporte les rangs tels que la box les porte. Or c'est le plugin lui-même qui a créé
des égalités avant +etm27 (borne détectée au rang 1 d'une installation déjà réglée).
Une suppression légitime aurait donc été refusée en bloc, sans motif. Elle refuse
maintenant côté app, avec la raison — plutôt que de renumérer en douce : un geste de
nettoyage qui redistribue les rangs devient un geste d'arbitrage.
Deux tests de load_priority_test construisaient leurs entrées sans `priority` : les deux
tombaient donc à égalité, et le nouveau contrôle masquait la règle qu'ils éprouvaient.
Rangs distincts posés, avec la raison en commentaire.
Les deux autres nouveautés d'+etm27 sont au TODO, non codées : `o:rankOrigin` (badge « rang
par défaut, pas un choix », avec la règle absent = rien d'affirmé) et l'entrée en queue des
bornes auto-provisionnées — qui devrait faire taire d'elle-même la note d'ordre entrelacé.
213 tests (+8), flutter analyze inchangé à 27 remarques.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
· Sankey — la vue consommation se branchera sur `measurement` PAR CHARGE, pas sur des
ratios ni sur l'ancien `measuredW` que l'entrée citait encore. Deux règles héritées du
contrat, à ne pas perdre dans la refonte : une charge en `none` n'a pas de branche (pas
une branche à zéro), et une charge absente de loads[] ne se dessine pas — elle ne dit
rien.
· Totalisateur — l'entrée parlait de « déclarer smartmeter ». Sa vraie portée est plus
large : un cumul d'énergie ouvre le logging natif de nymea sur la borne, et avec lui les
rapports mensuels. C'est le même manque qui prive l'historique de recharge d'une courbe.
· Journal de sessions — noté comme attendu du moteur, avec la raison de ne rien bâtir
avant : un journal reconstruit côté app divergerait du sien à la première interruption.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
Les règles d'affichage de `measurement` sont figées par test sur des trames fabriquées.
Cette sonde les éprouve sur les trames réelles de la box — en important le modèle de l'app
(LoadTelemetryEntry.fromJson), pas en relisant le JSON à sa façon : une lecture parallèle
ne prouverait rien sur ce que l'écran fera.
Elle montre ce qu'un écran regardé de loin ne distingue pas : `none` (bloc masqué, « pas
mesurable ») d'une charge simplement absente de loads[] (rien à conclure), et la portée
d'un écart selon `meter` ou `device`.
Relevé du 2026-08-28 sur .75 en 1.15.2+etm26 :
measuredW encore publié ? false
chauffe-eau source=meter → Mesuré 1500 W · mesure indépendante : concordance = preuve
pac-terrain source=meter → Mesuré 800 W · idem
V2C Trydan absente de loads[] — pas `none`
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
Vérifié par sonde avant de couper : `measuredW` a disparu de la charge utile,
`measurement` est en ligne. L'alias n'aura vécu que le temps de l'intervalle qu'il devait
couvrir, et `MeasurementSource.legacy` part avec lui.
Ce qui reste, `MeasurementSource.inconnue`, ne le remplace pas : il couvre une box PLUS
RÉCENTE que l'app — la même règle que partout ailleurs dans ce fichier, un code inconnu
tombe sur un repli lisible plutôt que sur une phrase inventée. On affiche la valeur, on ne
prétend rien sur ce qu'un écart prouverait.
── La garantie qui manquait à ce que j'avais écrit ──────────────────────────
`measurement` est présent pour toute charge de `loads[]`, et pour elle seule. C'est elle
qui rend la lecture sûre, et elle est maintenant au contrat : son absence ne peut PAS
vouloir dire « pas mesurable » — ça, c'est `none`, un état publié — elle ne peut vouloir
dire que « charge hors de loads[] », cas où l'on ne conclut rien.
J'avais fait la distinction par prudence sur l'écran ; elle est désormais fondée sur une
garantie, pas sur une intuition.
── Les sources du banc ne sont pas celles annoncées, et c'est instructif ────
chauffe-eau meter, 1 500 W porte ECS-Meter
pac-terrain meter, 800 W porte PAC-Meter — pas `none`
V2C Trydan absente de loads[] (pluggedIn: false)
`pac-terrain` en `meter` ne contredit pas LM-1105-b : cette règle décrit ce qu'une PAC
publie SANS compteur dédié. On lui en a posé un — ce que le brief recommandait — et le
compteur explicite l'emporte toujours. Et la V2C illustre la distinction sur machine :
absente, pas `none`. Elle porte `currentPower` et publiera sa mesure au cycle où une
voiture y sera branchée.
⚠️ Le test sur appareil exigeait `measuredW == null` après détachement du compteur.
C'était vrai avant `+etm26`, c'est faux depuis : le moteur retombe sur l'appareil quand il
publie lui-même sa puissance. Ce qui doit cesser, c'est que la source vaille `meter` sans
compteur désigné — pas que la mesure disparaisse.
CLAUDE.md porte le contrat complet et les deux pièges, avec l'illustration du banc.
205 tests, flutter analyze inchangé à 27 remarques.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
Écrit AVANT le déploiement du moteur, pour qu'il n'y ait pas de trou.
`measuredW` portait trois régimes sous un seul nom et jetait la distinction : une borne
rendait une mesure, un routeur de relais une mesure OU son palier nominal selon que ses
relais mesurent, un SG-Ready une puissance toujours déclarée. C'est la condition C2 de
LM-1009. Le régime se publie désormais :
measurement: { source: "meter" | "device" | "none", powerW }
Les trois pièges d'affichage du §7, figés par test :
· `none` est un état POSITIF — « cette charge n'est pas mesurable ». Il autorise à MASQUER
le bloc au lieu d'afficher un vide, et sépare enfin « pas mesurable » de « compteur en
panne ». Aucune puissance nominale n'est allée la chercher dans mechanism.* pour combler :
elle produirait un écart identiquement nul à tous les cycles, soit un « tout concorde »
permanent qui ne signale plus rien.
· {source: "device", powerW: 0} est une MESURE. Une borne branchée qui ne charge pas mesure
zéro watt. Si la source basculait à l'extinction, le bloc apparaîtrait et disparaîtrait au
rythme du thermostat.
· L'écart n'a pas la même valeur selon la source, et l'écran le DIT : sous `device`, il
montre la désobéissance mais une concordance ne prouve rien — plusieurs wallbox renvoient
leur consigne ; sous `meter`, la mesure est indépendante de l'organe commandé et la
concordance devient une preuve.
Le compteur dédié se décide sur `measurement.source`, JAMAIS sur `adapter` :
source == device → réglage avancé, replié (l'appareil publie déjà)
sinon → proposé, visible
La règle couvre SG-Ready sans avoir à le connaître — `none` par construction, LM-1105-b : un
contact de signal ne voit pas le courant de la machine, et pour une PAC le compteur dédié est
la SEULE voie vers une mesure. Elle couvre aussi la topologie de relais mixte, `none` elle
aussi, qu'un « masqué dès que la charge mesure, sauf SG-Ready » aurait manquée. Redéduire le
régime du mécanisme remettrait ici la déduction que le moteur vient d'en retirer.
⚠️ Une distinction que le contrat n'a pas à porter mais que l'écran doit faire : une charge
ABSENTE de la télémétrie n'est pas `none`. `none` est publié ; l'absence ne dit rien. Les
confondre annoncerait « seule voie vers une mesure » sur une borne qui publiera très bien la
sienne au prochain cycle.
⏳ Alias temporaire `measuredW` en repli, avec sa date de retrait : dès que +etm26 est sur
.75 (entrée TODO, sonde nommée). `measurement` gagne quand les deux sont là.
204 tests (+16), flutter analyze inchangé à 27 remarques. Prêt pour le déploiement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
Deux suites directes du geste réel du 2026-08-28 sur le banc.
── 1. Une vérification qui arrive trop tard ne prouve rien ───────────────────
Sur le banc, la suppression a été vérifiée à la main, APRÈS un autre geste : entre-temps un
« Enregistrer » avait regroupé et renuméroté (chauffe-eau 1 → 3). Il est alors devenu
impossible de dire laquelle des deux écritures avait déplacé les rangs. Le journal de la box
montrait deux persistances à deux minutes d'écart, rien de plus.
`removeLoad` relit donc la configuration IMMÉDIATEMENT après l'écriture — sans attendre
LoadConfigChanged, qui laisserait une fenêtre où un autre geste s'intercale — et compare
clé par clé ce qu'elle a envoyé à ce que la box porte (diffPayloads, fonction pure). Les
survivantes doivent être identiques, rangs compris : c'est le contrôle de l'aller-retour
neutre appliqué à une écriture qui RETIRE.
Le message le dit : « Relu sur la box : les autres sont identiques clé par clé, rangs
compris », ou l'écart en rouge, nommé champ par champ. Sur le seul geste de l'app qui
détruit de la configuration, « la box a dit oui » ne suffisait pas.
La comparaison est structurelle : relays, sgReady et powerLevels sont des listes et des
objets, que `==` compare par référence — l'alerte aurait crié à chaque suppression, donc
plus personne ne l'aurait lue.
── 2. Le sélecteur de compteur était plus restrictif que la box ─────────────
Il filtrait sur l'interface smartmeter. Le moteur n'en regarde AUCUNE : findConfiguredThing
puis hasState("currentPower") (attachMeasurements, LM-1101). Sur .75, la classe wallbox
déclare smartmeter, la classe trydan non — la V2C était donc impossible à désigner comme
son propre compteur, alors que la box l'aurait accepté et que c'est le seul moyen de voir
« mesuré » à côté de « alloué » sur sa carte.
Le sélecteur accepte maintenant, en plus des smartmeter, les Things d'une interface
élargie qui publient l'état exigé par le moteur — ici evcharger + currentPower. Il s'élargit
là où la box est plus large, pas partout : une borne qui ne publie aucune puissance reste
dehors, sans quoi on écrirait une configuration muette sans erreur.
⚠️ Ce n'est pas la fin de l'écart : la classe trydan devrait déclarer smartmeter, ce qui
suppose un CUMUL d'énergie qu'elle ne publie pas — même manque que celui qui a bloqué
LM-1009. Lot plugin noté au TODO avec cette raison.
── Banc ──────────────────────────────────────────────────────────────────────
Chauffe-eau remis au rang 1 (lire → patcher → réécrire, seul `priority` touché, relecture
vérifiée : la box porte exactement ce qui a été envoyé). Ordre courant : chauffe-eau,
V2C, wallbox simulée, PAC.
188 tests (+7), flutter analyze inchangé à 27 remarques.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
Deux manques trouvés en usage réel sur téléphone, contre .75.
── 1. Retirer une charge dont l'appareil n'existe plus ────────────────────────
Le plugin crée d'office une entrée par borne détectée mais ne la retire pas quand le Thing
s'en va : la Terra AC supprimée de nymea tenait encore le rang 4 deux jours plus tard, et
l'app savait le dire sans savoir y remédier.
Le bouton n'apparaît que sur THING_MISSING. Sur une charge vivante le geste n'aurait pas de
sens — le plugin recrée l'entrée au cycle suivant, et un bouton qui se défait tout seul
ferait douter de la box.
Le chemin est celui qui était demandé, et pour la raison qui était donnée :
· LIRE (GetLoadConfig, frais — pas l'état en mémoire, la box a pu changer d'avis)
· PATCHER (payloadWithoutLoad : les autres charges recopiées VERBATIM, champ par champ)
· RÉÉCRIRE (SetLoadConfig, qui remplace l'ensemble)
Jamais une reconstruction depuis le modèle typé : un champ qu'une box plus récente porterait
et que l'app ne connaît pas serait perdu pour TOUTES les charges d'un coup, en silence. Les
rangs ne sont pas renumérotés — le trou laissé (1,2,3,5) ne change l'ordre de personne,
alors qu'une renumérotation réécrirait le rang de charges que personne n'a touchées.
Rien à moitié écrit : un refus laisse la configuration locale intacte (aucune adoption avant
confirmation) et la box refuse l'ensemble ou l'accepte en entier. Le geste est refusé tant
qu'un brouillon est en cours — sinon la suppression emporterait des modifications non
enregistrées. La confirmation annonce ce que l'utilisateur ne peut pas deviner : retirer une
charge réécrit toutes les autres.
── 2. Le mode de recharge, un seul lieu d'écriture ───────────────────────────
Les trois boutons quittent la tuile des favoris pour l'écran « Configurer » de la borne :
c'est l'écran de mise en service, celui où on découvre qu'une borne neuve est en
ChargingModeNormal. Deux endroits qui écrivent SetChargingInfo seraient deux comportements
qui divergent.
La tuile des favoris garde la LECTURE et l'avertissement « hors arbitrage » : c'est le
client qui ouvre ses favoris, et il doit voir que sa borne n'est pas pilotée même s'il doit
aller ailleurs pour le corriger.
⚠️ Bug latent trouvé en déplaçant : setChargingInfo écrivait toujours sur la PREMIÈRE borne
configurée (_findEvChargerId). Sur le banc qui en porte trois, régler la V2C depuis son
propre écran aurait réglé la wallbox simulée. La borne est maintenant visée explicitement,
et evChargerFor() réconcilie les deux graphies d'identifiant (§9 du contrat de données).
181 tests (+12), flutter analyze inchangé à 27 remarques.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
Une ligne par recharge : début, fin, énergie livrée ; plus tard le véhicule et
la part surplus / réseau que funding distingue déjà.
La raison est un manque, pas un confort : il n'existe aucun rapport, nulle part
— ni par session, ni par charge, ni par mois. Le bilan journalier s'arrête à la
journée.
Au moteur et pas à l'app : c'est lui qui voit le début et la fin d'une session
et connaît la ventilation surplus / réseau ; l'app ne peut que la reconstituer.
Pas en intégrant une courbe : ce serait ré-estimer une somme que le plugin
connaît exactement — sessionEnergy de la V2C est accumulé au watt-heure près
(validé le 2026-08-28 : 9,04023 + 0,182573 = 9,222803, somme exacte).
Ne remplace pas l'enregistrement de currentPower, qui couvre la consommation
sur une période indépendamment des sessions — y compris une recharge lancée à
la main sur la borne, hors pilotage.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A41CTzMvir1Y5ABPXW8xpB
Deux défauts trouvés sur le téléphone en dix minutes d'usage, contre .75. Aucun des 164
tests ne pouvait les voir : le premier vit dans une méthode privée de widget, le second est
une question de sens, pas de valeur.
1. « La puissance nominale est requise et doit être supérieure à 0 W » — sur une BORNE.
_valider() faisait tomber adapter: evcharger dans la branche des charges modulables, donc
l'écran refusait d'enregistrer un simple renommage, deux blocs sous sa propre carte qui
explique qu'une borne ne porte ni contacts, ni paliers, ni puissance nominale. La règle
n'est pas supprimée, elle est rendue à son périmètre — contrôle négatif à l'appui.
2. Une SONDE DE TEMPÉRATURE sur une borne de recharge. sensorThingId est générique au
schéma — la box l'accepte sur n'importe quelle charge — mais le moteur ne le lit que pour
publier une température (attachMeasurements, LM-1103), et une borne ne rend aucun service
thermique : le champ ne menait nulle part. Retiré pour les bornes, gardé partout ailleurs.
Le compteur dédié, lui, RESTE sur une borne, et l'écran dit enfin à quoi il sert : c'est le
seul chemin par lequel measuredW apparaît dans la télémétrie (le moteur lit currentPower du
Thing désigné par meterThingId, et de personne d'autre). Sur une borne qui publie
elle-même currentPower, on la désigne comme son propre compteur — c'est ce qui fait
apparaître « mesuré » à côté de « alloué » sur sa carte.
Et l'écran cesse de renvoyer vers la carte véhicule du tableau de bord, supprimée ce matin :
le mode de recharge se règle depuis la tuile « Borne EV » des favoris, seul endroit de l'app
qui écrit SetChargingInfo.
Cinq tests de widget montent l'écran sur la configuration réelle du banc. Piège de harnais
noté dedans : l'écran est un ListView, qui ne construit que son viewport — sur 600 px, la
moitié des cartes n'existe pas, et chercher une absence y réussit toujours. Surface de test
portée à 4000 px.
169 tests, flutter analyze inchangé à 27 remarques.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
Trouvé sur téléphone, pas en test : l'app installée en release rendait une « OS exception »
à la connexion, et google_fonts échouait en amont sur un errno 7 (pas de résolution DNS).
Le paquet installé ne demandait AUCUNE permission — dumpsys package le dit sans détour.
android.permission.INTERNET n'était déclarée que dans src/debug/ et src/profile/, les deux
manifestes que le modèle Flutter fournit pour le développement. Le release ne retient que
src/main/, qui ne la portait pas.
Donc TOUT APK release jamais produit depuis ce dépôt était sans réseau — une app dont le
seul rôle est de parler JSON-RPC à une box en ws://<box>:4444. Rien ne pouvait le montrer :
les 164 tests tournent sur l'hôte, la passe sur appareil installe un APK de DEBUG (qui,
lui, hérite de la permission), et la mise en ligne se fait en web où la question ne se pose
pas. Il fallait installer un release sur un vrai téléphone et essayer de se connecter.
Le commentaire du manifeste garde la raison à côté de la ligne : sans elle, l'échec est au
niveau OS, donc muet côté Flutter — exactement le genre de panne silencieuse qu'on
documente ailleurs dans ce dépôt (docs/UI_data_contract.md §9).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
Le piège des accolades n'était pas un détail de chargingModeOf(). C'est la deuxième
occurrence en une semaine d'un motif qui se répétera : deux appels JSON-RPC de la même box,
portant le même objet, n'emploient pas la même convention, et RIEN ne le dit. Écrit comme
tel dans docs/UI_data_contract.md §9, avec renvoi depuis les deux sites de code et depuis
tools/rpc/README.md — là où quelqu'un croise deux réponses brutes.
Les occurrences, leur symptôme :
· GetPowerBalance (currentPowerProduction…) × GetPowerBalanceLogs (production…) →
tout tombe à zéro, l'historique paraît vide sur 7 960 échantillons bien présents
· GetChargingInfos (evChargerId sans accolades) × GetLoadConfig (id avec) →
« mode non lu » sur une borne parfaitement lue, et la note « hors arbitrage » muette
· famille voisine, même silence : currentPower* en W × total* en kWh, facteur 1000
Ce qui en fait un motif et pas trois anecdotes : le symptôme n'est JAMAIS une exception,
c'est une valeur neutre plausible, et un test unitaire qui construit ses deux côtés avec la
même convention passe aussi. D'où les trois gestes écrits au §9 — sonder les deux appels
côte à côte sur les charges utiles brutes, éprouver le rapprochement sur les deux formes
réelles, et se méfier d'un vide qui ne coûte rien à expliquer.
Et le lien avec une règle qu'on suivait déjà sans la relier à ça : une valeur neutre issue
d'un croisement raté est indiscernable d'une vraie absence. « Non lu » laisse une chance de
trouver le bug ; « 0 » l'enterre.
── Campagne V2C arrêtée, et la réserve sessionEnergy avec elle ────────────────
La Trydan non-Pro est en WiFi ESP32 sans repli filaire, lien inexploitable à deux mètres du
point d'accès : 70 % de scrutations avortées, 12 bascules en injoignable en vingt minutes.
Le modèle sort du catalogue ETM ; seule la Trydan Pro (port Ethernet) y reste.
La trace qui lève C1 — chargeEnergy à zéro AVEC un sessionEnergy qui continue — n'a donc
pas été prise et ne viendra pas de cette borne. Les notes restent en place telles quelles,
avec la raison mise à jour : elles seront levées sur une Trydan Pro. Aucun avancement de
charge affiché d'ici là.
164 tests, flutter analyze inchangé à 27 remarques.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
Geste de mise en service, signalé par le moteur : toute borne détectée reçoit d'office
ChargingModeNormal, c'est-à-dire pleine puissance HORS arbitrage. Une borne neuve n'est
donc jamais pilotée tant que personne n'a posé un mode éco, et rien ne le disait —
l'installateur pose une borne, elle charge à fond, et il cherche du côté du HEMS un défaut
qui n'existe pas.
La note apparaît aux deux endroits qui comptent, mot pour mot (kEvUnmanagedNote) :
· la carte de configuration, celle qu'on ouvre à la mise en service ;
· la tuile des favoris, où les trois boutons de mode sont juste à côté.
⚠️ Elle nomme l'ÉTAT, pas une négligence. La box publie le même chargingMode pour un boost
délibéré et pour une borne jamais réglée : l'app ne peut pas les distinguer, donc elle dit
« pleine puissance, hors arbitrage » et rappelle que c'est aussi le mode d'office. Même
règle que la note de rangs ex æquo — reproductible n'est pas choisi, et on ne fait pas dire
à un écran ce que la donnée ne porte pas. Un mode NON LU ne déclenche rien non plus : on ne
conclut pas d'une absence.
Le piège qui aurait rendu la note invisible est couvert par un test : GetLoadConfig rend
l'id de charge AVEC accolades ({4d1adafd-…}), GetChargingInfos rend l'evChargerId SANS.
Comparer les deux tels quels ne rend jamais rien — en silence : l'écran aurait conclu
« mode non lu » sur une borne parfaitement lue. chargingModeOf() réconcilie les deux.
Six tests montés (trois par écran), dont les deux contrôles négatifs.
Et la réserve sessionEnergy est documentée à sa juste place : le champ est CONSTRUIT par
le plugin V2C 1.15.2+etm13 — il accumule les incréments de chargeEnergy, détecte les
remises à zéro, ne repart qu'à l'ouverture d'une session — donc l'égalité observée hier
s'explique d'elle-même. La réserve tient jusqu'à la trace que la campagne produira
(chargeEnergy à zéro, sessionEnergy qui continue) : aucun avancement affiché d'ici là.
Noté aussi, pour la suite : sessionEnergy n'est PAS historisé (suggestLogging écrasé par
things.conf sur un Thing déjà appairé ; la V2C n'y a que connected, charging, power). La
valeur courante est juste, il n'y aura pas de courbe — le choix des états enregistrés est
côté app, entrée au TODO.
164 tests, flutter analyze inchangé à 27 remarques.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
Marquage, pas refonte. PAC = 38 % de la consommation maison, eau chaude = 18 %, autres
= 22 % : trois ratios posés à la maquette, jamais remplacés, et un seul (« Autres »)
portait le cercle pointillé. Les trois le portent maintenant, leurs flux passent en
pointillé, et une légende sous le schéma nomme ce qui est quoi :
« Répartition estimée : PAC, eau chaude et autres usages sont des parts types, pas des
mesures. Seule la voiture est mesurée à la borne. »
Le trait pointillé seul ne suffisait pas : quatre chiffres en kW se ressemblent tous et se
lisent tous comme des mesures. Nommer les branches évite de faire chercher lesquelles.
Brancher les trois sur GetLoadTelemetry.measuredW reste le vrai lot — la source existe
(chauffe-eau 1500 W, PAC 800 W au relevé du jour), mais c'est une refonte de l'écran
d'accueil et le banc est pris par la campagne V2C.
Quatre tests montés sur la vue, dont deux qui figent ce que la voiture affiche : « non lu »
sans borne — jamais 0,0 kW, qui échangerait une fiction contre une autre — et la mesure
quand il y en a une. Deux pièges de harnais notés dans le fichier : pumpAndSettle est
inutilisable (les particules tournent en boucle, disableAnimations ne change que la valeur
lue), et toucher un nœud à la première trame le rate en silence — les nœuds entrent en
ScaleTransition depuis une échelle nulle.
PREPA_BANC_20260828.md corrigé en parallèle (dépôt plugin) : trois de ses affirmations sont
tombées avec le déploiement du matin.
158 tests, flutter analyze inchangé à 27 remarques.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
Elle reste : la retirer aurait laissé un trou dans une liste que le client a composée
lui-même. Elle affiche maintenant ce que la box publie, et là où il n'y a rien à lire,
elle le dit.
Ce qui est lu, et sa source
· branché / en charge ← états pluggedIn, charging du Thing
· puissance ← état currentPower du Thing
· mode ← GetChargingInfos + notification ChargingInfoChanged
Absent = « non publié », jamais zéro. Un mode que l'app ne connaît pas se lit « mode non
reconnu (X) » plutôt que d'être deviné, et aucun bouton n'est allumé tant que la box n'a
pas dit ce qu'elle porte.
L'écriture restitue le refus, sinon elle n'existe pas. Les pastilles de 7 à 10 px qui
avalaient les codes d'erreur de setChargingInfo passent à 44 px, montrent l'attente, et
affichent le refus tel que la box le nomme. Et l'app ne tient plus d'état de mode local :
après une écriture acceptée, elle RELIT GetChargingInfos. Elle se souvenait de ce qu'elle
avait demandé, ce qui n'est pas ce que la borne porte.
La fiction perd sa porte d'entrée. chargingMode, chargingPower (3,6 kW) et
solarSourcePercent (82 %) sont SUPPRIMÉS de EnergyData : trois valeurs par défaut que
_updateEnergyData ne touchait jamais. Ce que la box publie sur une borne vit désormais
dans EvChargerLive, champ par champ, nullable.
⚠️ Et le Sankey du tableau de bord en tirait un flux « Voiture » permanent à 3,6 kW,
voiture débranchée. Il est branché sur la mesure de la borne — « non lu » quand il n'y en
a pas. Mais la même vue invente encore PAC = 38 % de la maison, Eau chaude = 18 %,
Autres = 22 %, dont un seul marqué « estimé » : entrée 🔴 au TODO, avec la source réelle
(GetLoadTelemetry publie measuredW par charge : 1500 W et 800 W ce midi). Brancher le
Sankey dessus est une refonte de l'écran d'accueil, pas un correctif — à décider.
Deux attentes tombent, une reste (relevé Integrations.GetThings sur .75, cet après-midi)
· currentL1/L2/L3 est DÉPLOYÉ (5,86 / 5,94 / 6,02 A) — powerL1/L2/L3 a disparu
· phaseCount EXISTE sur la Trydan (3) — la note qui le disait absent était en retard
· sessionEnergy existe (4,229 kWh) MAIS vaut exactement chargeEnergy : c'est ce qu'on
attend d'une session sans interruption, donc ça ne prouve rien. LM-1009 §C1 reste
entière tant qu'une remise à zéro de chargeEnergy n'a pas été vue avec un
sessionEnergy qui continue.
Le brief moteur dit désormais du masquage WRITE_FAILED que ce n'est pas « ne se produit
pas » mais « ne s'est pas encore produit » : il attend une charge variable qui perd son
Thing, et le banc n'a pas eu ce cas.
154 tests (8 nouveaux sur la tuile, dont les cibles tactiles et la restitution du refus),
flutter analyze à 27 remarques, aucune nouvelle.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
Deux gestes que le relevé du banc a tranchés, dans les deux sens.
1. THING_MISSING — MESURÉ, la réserve tombe
GetLoadTelemetry sur .75, 2026-08-28 10:29 UTC : la Terra AC supprimée est publiée
available=false · faultCode=THING_MISSING · decision=LOAD_UNAVAILABLE{frozenW:0} ·
mechanism.kind=evcharger. Le code SORT. Le test de rendu ne repose plus sur une trame
composée mais sur cette capture (test/fixtures/loadtelemetry_hems75_thingmissing.json).
Le masquage relevé mardi (etmvariableloadadapter.cpp:148-150, m_faulted testé avant que
THING_MISSING ne puisse s'exprimer) concerne l'adaptateur des charges VARIABLES — relais
et SG-Ready. L'adaptateur evcharger n'a pas ce chemin, d'où la différence. Il n'est donc
pas corrigé, seulement contourné par le hasard du chemin de code : remonté au brief
moteur avec la trame, parce que les deux codes envoient le client faire deux gestes
différents — « écriture échouée » fait démonter un coffret, « appareil absent » fait
rouvrir nymea.
2. EVChargingCard — elle part, et la monter aurait publié de la fiction
Trois des grandeurs qu'elle affiche ne sont JAMAIS lues sur la box : chargingMode,
chargingPower (3,6 kW) et solarSourcePercent (82 %) sont des valeurs par défaut de
EnergyData — _updateEnergyData ne les touche pas, et le mode n'est écrit qu'en optimiste
après une commande, jamais relu par GetChargingInfos. Sa feuille « Paramètres de charge »
proposait en plus un courant minimum, qui n'existe dans aucun contrat, et son bouton
« Enregistrer » ne faisait que Navigator.pop : rien n'était écrit. Ce n'était pas du code
mort à ranimer, c'était un écran à réécrire depuis le modèle.
⚠️ Et la surface vivante est pire que la morte : _EVWidget (favorites_screen.dart:419)
affiche les MÊMES valeurs par défaut comme si elles étaient mesurées — « 3600 W »,
« 82 % solaire », « Mode PV » — et écrit le mode sur des pastilles de 7 à 10 px sans
restituer un refus. Entrée 🔴 au TODO, avec ce qu'une carte de recharge peut dire
aujourd'hui : le mode lu par GetChargingInfos, la puissance mesurée du Thing, l'échéance
comme intention saisie. Ni SOC (n'existe pas), ni chargeEnergy (remis à zéro à chaque
interruption), ni courant minimum, ni phaseCount.
146 tests passent. flutter analyze : 27 remarques, deux de moins qu'avant — les deux
étaient dans le fichier supprimé.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
Vérification demandée après le relevé V2C du banc : « ta note de rangs ex æquo doit
s'afficher — vérifie qu'elle le fait ». Elle s'affiche. Mais rien ne le prouvait, et
c'est exactement le défaut qui a coûté isNotGrouped : la contiguïté était calculée
depuis le premier jour, testée en fonction, exposée par le provider — et affichée nulle
part. Un test de fonction ne peut pas voir ça.
Quatre tests de widgets sur la configuration réelle du 2026-08-28
(loadconfig_hems75_v2c.json), montés dans le harnais existant :
· la note de rangs ex æquo est à l'écran ET nomme les charges (chauffe-eau, V2C
Trydan) — « des rangs sont à égalité » sans dire lesquelles laisse chercher dans
cinq cartes ;
· la note d'ordre entrelacé est à l'écran ET annonce ce que l'enregistrement fera
(REGROUPERA) — l'annoncer après serait un constat, pas un avertissement ;
· contrôle négatif : sur un ordre groupé et classé, aucune des deux n'apparaît. Sans
lui, deux notes toujours affichées deviennent du décor ;
· THING_MISSING se lit « l'appareil n'existe plus dans nymea » + « réinstallez-le, ou
retirez cette charge », et n'offre RIEN à lever — ni le bouton, ni l'aide qui
l'accompagne.
⚠️ La télémétrie du quatrième test est COMPOSÉE, pas capturée : le banc publiait
WRITE_FAILED sur cette charge au dernier relevé (l'adaptateur épuise son échelle
d'écriture avant que THING_MISSING ne sorte). Le test éprouve le rendu du code sur le
loadId réel de la Terra AC supprimée, pas la présence du code sur la box.
Et deux traces de la campagne V2C, qui ne se câblent pas :
· tools/rpc/evcharger_watch.dart entre au dépôt, documenté. C'est la sonde qui a
produit les deux constats bloquants — une borne hors arbitrage n'apparaît pas dans
telemetry_watch, et c'est pendant ces transitions qu'il faut la regarder.
· ev_charging_card.dart affirmait que « les deux bornes du banc publient sessionEnergy ».
Faux depuis l'arrivée de la V2C : elle publie chargeEnergy, remis à zéro à chaque
interruption de charge. La note renvoie désormais à LoadMechanism et redit C1 de
LM-1009 — « pas mesurable », jamais zéro.
TODO : entrée d'attente pour sessionEnergy et le renommage currentL1/L2/L3.
146 tests passent, flutter analyze sans remarque nouvelle.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
L'arrivée de la V2C sur .75 a révélé deux défauts que le banc ne pouvait pas montrer avant,
plus deux pièges de contrat à documenter.
1. ⚠️ isNotGrouped ÉTAIT CALCULÉ ET AFFICHÉ NULLE PART. GroupedOrder détecte depuis le
premier jour qu'un ordre n'est pas groupé par domaine, le provider l'expose, et aucune
vue ne le lisait — le cas ne s'était jamais produit, chaque domaine du banc ayant ses
charges d'un seul tenant.
Il se produit maintenant : la V2C, créée d'office au rang 1 que le chauffe-eau occupait
déjà, donne l'ordre à plat « ecs · ev · heating · ev · ev ». Le domaine ev est coupé en
deux par le chauffage.
Ce n'était pas un détail d'affichage. L'écran groupe pour afficher, donc ce qu'on voit
n'est plus l'ordre réel de la box — et le premier enregistrement réaplatit EN GROUPANT,
ce qui change l'arbitrage sans que personne ne l'ait demandé. Chiffré par test sur la
configuration réelle : la PAC passerait du rang 2 au rang 5. C'est exactement ce que le
commentaire de GroupedOrder interdit depuis le début ; le garde-fou existait, il n'était
pas branché.
2. « L'APPAREIL N'EXISTE PLUS » N'EST PAS UN DÉFAUT À LEVER. La Terra AC a été supprimée de
nymea ; son entrée LoadConfig survit, occupe le rang 4, et s'affiche en THING_MISSING
avec un bouton « Lever le défaut ». ClearLoadFault y répondrait EnergyErrorNoError sans
rien changer, et le défaut reviendrait au cycle suivant — un bouton qui ne peut pas
tenir sa promesse fait douter de la box au lieu de désigner la cause.
Le bouton disparaît sur ce code, remplacé par ce qu'il faut faire : réinstaller
l'appareil, ou retirer la charge. Et le libellé cesse de se lire « votre configuration
est cassée » : le cas normal est un appareil remplacé, le plugin crée d'office une
entrée par borne détectée mais ne la retire pas quand le Thing s'en va.
3. Deux pièges du Thing V2C, documentés là où quelqu'un serait tenté de les câbler :
chargeEnergy est EXACT pendant la charge — sa croissance recoupe currentPower à mieux
d'un pour cent — et REMIS À ZÉRO à l'arrêt de la charge, pas au débranchement. Mesuré :
0,3456 kWh → 0 dès que charging passe à faux, 119 secondes avant que le câble ne bouge.
Sur du pilotage par surplus il repartirait de zéro à chaque nuage, et après coup une
nuit entière vaudrait 0 — soit « rien livré » affiché là où la vérité est « pas
mesurable ». Le plugin publiera un sessionEnergy construit ; rien n'est bâti en
attendant, et surtout aucune accumulation côté app.
powerL1/L2/L3 sont des ampères mal nommés, renommage currentL* en cours côté plugin.
Ni affichage, ni conversion : une conversion écrite ici survivrait au correctif et le
contredirait.
Fixture ajoutée : la configuration réelle à cinq entrées, avec ses deux enseignements de
terrain — deux rangs à 1, et une entrée orpheline qui consomme un rang.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
L'entrée 🔴 du TODO. Aucune des quatre tuiles n'était journalière : deux pourcentages
instantanés, deux compteurs cumulés depuis l'origine dont deux portaient le mot
« aujourd'hui ». Elles montrent désormais la journée, sous un en-tête qui le dit une fois.
MÉTHODE : une DIFFÉRENCE de compteurs entre minuit local et maintenant, jamais une
intégration des puissances. La première est une mesure, prise sur les mêmes compteurs que
ceux du fournisseur ; la seconde est une estimation qui suppose que chaque échantillon
représente son intervalle. Mesuré sur .75 : l'intégration rend 77,70 kWh là où la différence
rend 78,31 — 0,8 % d'écart, systématiquement à la baisse. Il n'y a donc PAS de repli
silencieux de l'une vers l'autre : quand la mesure n'est pas exploitable, le type le dit.
DEUX BUGS D'UNITÉ, trouvés en cherchant à savoir ce que valaient les compteurs. Aucune
documentation ne tranchait, donc recoupement sur machine : l'intégration des puissances (en
W) rend 77 697 Wh quand la différence de totalProduction rend 78,310. Le rapport est mille —
LES COMPTEURS SONT EN kWh, et le modèle Dart les nommait Wh.
1. L'écran Énergie divisait ces kWh par mille pour les afficher : une journée de 78 kWh
s'y lisait « 78 Wh », sous un axe intitulé « Bilan énergétique (Wh) ». Invisible sans
connaître l'ordre de grandeur attendu.
2. La simulation accumulait des Wh : un écran juste en simulation aurait cassé d'un
facteur mille contre une vraie box.
LE CONTRÔLE QUI VALIDE LE BILAN : production + soutiré == consommation + injecté. C'est le
seul recoupement disponible sur des compteurs qu'on ne peut pas vérifier autrement, et il
tient EXACTEMENT sur .75 — 112,244 des deux côtés sur 21,75 h. Faux, il désigne un compteur
cassé ou une remise à zéro, et la carte le signale au lieu de présenter comme une mesure ce
qui ne se recoupe pas.
Les trois pièges du TODO n'ont pas été repayés : bornes à minuit LOCAL via
GetPowerBalanceLogs · décodage délégué à fetchPowerBalanceLogs, qui porte déjà les bonnes
clés sans préfixe currentPower · plausibilité vérifiée sur les DEUX bornes avant de
soustraire, parce que deux valeurs absurdes peuvent avoir une différence d'apparence saine.
Plus un quatrième trouvé en chemin : un compteur qui RECULE (redémarrage en milieu de
fenêtre) donne un bilan négatif, et un max(0, …) le rendrait faux en silence.
Rien ne s'affiche jamais à zéro quand c'est inconnu. Sans production, le taux
d'autoconsommation est null et non 0 % — à 3 h du matin, « 0 % » se lirait comme un défaut
alors qu'il n'y a rien à autoconsommer.
VÉRIFIÉ SUR APPAREIL contre .75, en lecture seule (le banc est en préparation V2C, rien n'a
été écrit) : 32 échantillons depuis minuit, prod 28,37 · conso 26,55 · import 11,95 ·
export 13,77 kWh, identité OK, et les tuiles affichent 51 % / 55 % / 13,8 / 11,9.
Le passage sur appareil a trouvé ce qu'aucun des 138 tests unitaires ne pouvait voir : LES
QUATRE LIBELLÉS DÉBORDAIENT — « Autoconsomm… », « Vers réseau · aujou… ». Le suffixe
« aujourd'hui » répété quatre fois mangeait le nom de la grandeur ; il est dit une fois en
en-tête. Le harnais d'appareil échoue désormais sur tout libellé finissant par « … ».
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
Trois corrections de Patrick sur la section batterie, plus le brief séparé.
1. LE BOOST CHANGE DE NATURE, et ce n'est pas un détail de libellé. 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 7,5 ; le réseau donne 6, la batterie comble l'écart, le
disjoncteur ne saute pas.
Le réglage devient donc une PUISSANCE DE CHARGE VISÉE, pas une cible de SOC batterie, et
la première des trois fins devient « la voiture a atteint sa cible » — le plancher
batterie reste, mais comme garde-fou et non comme objectif. On ne cherche pas à vider la
batterie, on cherche à finir la charge plus tôt.
Ma réserve « le boost pourrait acheter en cachette » tombe, et elle tombe à l'envers : ce
mécanisme existe précisément pour NE PAS dépasser ce qu'on a le droit de soutirer.
Écrit dans la note : ce bloc appartient au délestage de LM-1006-1 vu par l'autre bout —
au lieu de réduire les charges pour tenir sous le plafond, on complète par la batterie.
Même objet, pas un mécanisme parallèle. Et c'est un argument de vente réel, donc il est
dit à l'écran : recharger plus vite sans passer de 6 à 9 kVA.
2. L'INTERRUPTEUR DE DÉCHARGE PROMETTAIT CE QUE LE MÉCANISME NE TIENT PAS. Il disait « la
maison continue d'être servie ; seule la voiture ne l'est pas ». enableDischarging est
binaire et l'onduleur ne voit qu'une consommation nette : coupé, le réfrigérateur passe au
réseau comme la voiture. Reformulé en « geler la batterie », avec le mécanisme expliqué.
La version modulée — plafonner dischargingRate sur la consommation hors borne, recalculé
à chaque cycle — part au brief comme question, pas à l'écran comme réglage.
C'est exactement la classe de défaut traquée depuis trois jours, et je l'avais écrite
moi-même.
3. LA RÉSERVE ADAPTATIVE DEVIENT UN PLANCHER DE SECOURS. Mon avertissement sur Battery Life
était juste mais incomplet : il disait ce que le bloc n'est pas sans dire ce qu'il est.
Combien d'autonomie garder pour une coupure est une préférence de RISQUE — elle dépend du
congélateur, du télétravail, de la fiabilité du réseau local. Héos ne pourra jamais la
décider à la place du client, ce qui la qualifie exactement selon le critère qui a fait
sortir les quatre autres blocs.
Deux détails de cohérence : la batterie est la seule charge sans compteur dédié possible —
elle publie currentPower elle-même — et c'est DIT plutôt qu'omis en silence comme dans les
cinq autres écrans. Et chargingRate trouve sa place : plafond de soutirage sur la charge
réseau, même motif que la recharge accélérée à l'envers.
Le brief moteur est à part, dans BRIEF_plugin_depuis_maquettes.md : les trois questions de
mécanisme (interdit actif, bail qui expire, controlHealthy), plus quatre que la recharge
accélérée soulève — la boucle à la minute contre un disjoncteur en secondes, la marge
dimensionnée contre le pire démarrage non piloté, le filet qui est la BORNE et non la
batterie, et la latence du Fronius à mesurer sur le banc. Avec la conséquence honnête écrite
noir sur blanc : en 6 kVA le gain réel est d'un à deux kilowatts, pas de 1,5 garanti.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
Quatrième passe, même logique que la précédente : ce qui demande un calcul revient à Héos,
pas à un formulaire. Démarrer assez tard pour arriver à 100 % juste avant la pointe suppose
de savoir ce que le soleil va faire — sans prévision ça ne marche pas, et une heure saisie à
la main serait un pari présenté comme un réglage.
Le gain reste écrit dans la note, parce qu'il justifie que le moteur s'en occupe : pic PV de
midi libéré pour l'ECS et la voiture, moins de temps passé à 100 % donc moins de
vieillissement calendaire, et aucun kWh sacrifié — la même énergie, stockée plus tard.
La frise du point de non-retour n'est PAS supprimée : elle remonte dans l'encart du motif
commun, où elle illustre ce que les trois cas partagent au lieu d'illustrer un réglage. C'est
même sa vraie place — une prévision n'engage personne, un point de non-retour si.
Et l'encart gagne une distinction qui manquait : quels paramètres sont calculés (charge
différée → Héos) et lesquels sont saisis (charge réseau, échéance véhicule). Le motif tient
dans les deux cas, ce qui renforce l'argument : le moteur porte la forme, Héos ou
l'utilisateur la remplit.
L'écran de configuration batterie ne garde donc que quatre blocs : le nom, le rang en lecture,
la décharge pendant recharge VE, la réserve adaptative, et la charge réseau manuelle. Quatre
passes, quatre retraits.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
Troisième passe de Patrick, et elle enlève encore. Le tableau « qui peut être servi par la
batterie » n'a pas lieu d'être : décider en général qui mérite d'être servi n'est pas un
réglage d'installateur, c'est un arbitrage — et il reviendra à Héos (l'optimiseur MILP) ou à
une décision par le prix, quand l'un ou l'autre existera. Offrir la question à la main
aujourd'hui figerait dans un écran ce qui doit se calculer.
Reste UN interrupteur : bloquer la décharge pendant la recharge du véhicule. C'est le seul
cas qu'aucun rang ne peut exprimer — la nuit il n'y a pas de surplus à répartir, donc aucune
position dans une file ne dit « ne vide pas la batterie dans la voiture ».
Le CSS des lignes de permission est retiré avec elles, plutôt que laissé mort dans la
feuille.
Trois passes, et l'écran n'a fait que rétrécir : un stepper de rang qui doublonnait la liste,
une colonne de permission qui doublonnait le rang, puis le tableau entier. Ce qui reste est
ce qu'aucun autre mécanisme ne sait dire.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
Deux corrections de Patrick, dont une qui rattrape une incohérence de ma part.
1. LE RANG N'A RIEN À FAIRE SUR CET ÉCRAN. J'y avais mis un stepper « Position −/+ ». Aucun
des six autres écrans de mécanisme n'en porte : le rang se règle par glisser-déposer dans
la liste des charges, et l'écran réel (load_mechanism_screen.dart) ne fait qu'« Inclure
dans l'arbitrage ». Deux réglages de rang auraient fini par diverger.
Il devient une ligne de lecture, qui rappelle le défaut — batterie en première position de
charge — et où il se règle vraiment.
2. « QUI A LE DROIT AVANT LA BATTERIE » N'EST PAS UN RÉGLAGE : c'est le rang. Mon tableau
avait deux colonnes, Solaire et Batterie ; la première disait exactement ce que la
position dit déjà. Retirée.
Ce qui reste est d'une autre nature, et c'est le seul cas qu'aucun rang ne peut exprimer :
la nuit, il n'y a pas de surplus à répartir, donc « ne vide pas la batterie dans la
voiture » n'est pas une position dans une file, c'est une permission de source. Un seul
interrupteur par charge : peut être servi PAR la batterie, ou non.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
La batterie devient une charge du waterfall, donc son bouton Configurer ouvre un écran de
mécanisme comme les cinq autres. Ajouté à la planche existante plutôt qu'à part : c'est
précisément la comparaison côte à côte qui avait révélé « six écrans, trois contrats » avant
qu'une ligne ne soit écrite.
RIEN DE CET ÉCRAN N'EXISTE. Ni BatteryAdapter, ni entrée LoadConfig pour le stockage, ni
prévision PV. Les seuls leviers réels de .75 sont les cinq actions de la classe SunSpec
Storage, et l'état réel affiché se limite à ce que le Thing publie — tout le reste porte un
badge « à créer », y compris là où ça alourdit la maquette.
Le rang par défaut est la batterie EN PREMIER, et ce n'est pas un choix de conception : c'est
la reproduction du comportement actuel. Une mise à jour ne change jamais l'arbitrage sans
geste ; la nouveauté est qu'il devient réglable.
Quatre blocs dans Configurer, dont deux qui ont demandé à être reformulés avant d'être
dessinés :
- Les blocages ne sont PAS une question de rang mais de SOURCE. « Ne pas vider la batterie
dans la voiture la nuit » ne s'exprime pas par un ordre de priorité — la nuit il n'y a pas
de surplus à répartir. La formulation juste est « quelles charges ont le droit d'être
servies par la batterie », réglée charge par charge. Le contrat porte déjà
Source { Solar, GridSource } ; il manque le troisième terme et le droit de le refuser.
- La réserve adaptative est montrée AVEC sa réserve : Victron a conçu Battery Life pour du
plomb, et sur du LFP le problème traité — la sulfatation — n'existe quasiment pas. Si le
mécanisme est repris, ce sera pour un motif à nommer, pas par mimétisme.
- La charge différée montre le POINT DE NON-RETOUR, pas la prévision. C'est lui qui garantit
qu'une prévision fausse ne coûte rien.
- La charge réseau pose le mécanisme seul. Ni prix, ni Tempo, ni marché de gros : le jour où
une source tarifaire existera, elle remplacera le doigt de l'utilisateur sans que le bloc
ne change.
Le boost vers le véhicule est HORS Configurer, sur la carte du tableau de bord : c'est le
seul cas où la batterie est une source et non une charge, et c'est un geste immédiat, pas une
intention durable. Trois fins possibles, toutes obligatoires — une action qui ne sait pas
s'arrêter n'a pas sa place sur un écran client.
Et le motif que la planche fait ressortir : charge différée, charge réseau et échéance
véhicule (LM-1009) ont la MÊME forme — une cible, une échéance, un point de non-retour. Trois
colonnes côte à côte le montrent ; le moteur peut l'implémenter une fois.
Trois questions écrites pour le moteur, pas résolues ici : bloquer demande un interdit ACTIF
et non une allocation à zéro, puisque la batterie agit d'elle-même · cet interdit doit
EXPIRER, sinon un HEMS qui tombe laisse la batterie inerte · et controlHealthy == false doit
sortir la batterie de l'arbitrage, comme available == false pour une charge en défaut.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
Formulée depuis une demande simple de Patrick : que le surplus aille d'abord à l'eau chaude,
et à la batterie seulement ensuite. Elle n'est pas exprimable aujourd'hui, et le brief dit
pourquoi avec ce qui a été vérifié plutôt qu'avec ce qui est supposé.
Quatre constats : batteryLevelConsideration donne la priorité à la BATTERIE (le budget est
annulé sous le seuil, pas au-dessus) ; la batterie n'est dans aucun GetLoadConfig ;
enableCharging et ses quatre voisins ont zéro occurrence dans energyplugin/ ; et pourtant la
classe SunSpec Storage de .75 les déclare comme actions exécutables. Plus un indice :
domain: "battery" est déjà accepté par SetLoadConfig sans que rien n'en fasse quoi que ce
soit.
Quatre sous-questions, dont la première est la leçon de 3g-2 retournée vers eux : l'onduleur
Fronius a sa propre logique de charge, donc avant d'ajouter un commandeur il faut savoir qui
commande déjà. C'est littéralement le défaut d'adjustEvChargers(), et il serait absurde de le
réintroduire sur la batterie le jour où ils viennent de le supprimer sur les bornes.
Et ce que nous nous interdisons en attendant, dit explicitement : une règle Rules.* qui
couperait enableCharging fonctionnerait, et nous l'écartons — ce serait un second commandeur
sans arbitre. Mieux vaut dire « pas réglable aujourd'hui » qu'offrir un réglage dont personne
ne garantit l'effet.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
RECAP.md porte les neuf commits du jour et les trois défauts que seul l'appareil a montrés :
deux cibles tactiles hors norme (26 px, 18 px), et une correction naïve laide que le test
validait — c'est la capture qui a tranché, pas l'assertion.
Ce qui manquait dans le suivi, et qui est écrit noir sur blanc :
AUCUN BILAN JOURNALIER N'EXISTE DANS L'APP. Les quatre tuiles du tableau de bord mêlent deux
natures de grandeur, et aucune n'est journalière — Autoconsommation et Autonomie sont des
pourcentages instantanés, Vers réseau et Depuis réseau des kWh cumulés depuis l'origine. Les
deux dernières portaient « aujourd'hui » : totalReturn n'est pas remis à zéro chaque nuit,
d'où le « · cumulé ». Le bilan du jour reste à écrire, et le TODO porte les trois pièges de
sa mise en œuvre — bornes à minuit local, clés de log SANS le préfixe currentPower, et les
cumulés corrompus de .75.
Et la question ouverte au moteur, telle qu'elle s'est posée : « ECS d'abord, batterie
ensuite » n'est pas exprimable aujourd'hui, et batteryLevelConsideration fait l'inverse — il
annule le budget SOUS le seuil, donc la batterie passe avant. La cause est structurelle : la
batterie n'est dans aucun GetLoadConfig et le plugin ne la commande nulle part. Le levier
existe pourtant, la classe SunSpec Storage de .75 déclare enableCharging, chargingRate et
enableDischarging.
Deux entrées du TODO étaient devenues fausses : kCodesReserveBatterie est fait, et « Zones —
à ne pas entreprendre » est revu puisque le lot est cadré.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
Trois changements demandés après le passage sur téléphone.
1. L'ARBITRAGE EN DIRECT PASSE TOUT EN BAS. Il était en tête avec cet argument : « Alloué
3 000 W ne veut rien dire tant qu'on ne sait pas s'il y avait 3 100 ou 12 000 W à
distribuer ». L'argument reste juste pour LIRE une allocation — mais cet écran sert à
CONFIGURER, et faire descendre l'installateur sous un pavé de télémétrie à chaque
ouverture met la lecture devant le geste.
La note de rangs ex æquo, elle, RESTE EN TÊTE : elle prévient que l'ordre affiché juste
en dessous n'en est pas un, et la lire après les cartes arriverait trop tard.
2. LES DEUX FLÈCHES DE DOMAINE DEVIENNENT UN CHEVRON DE REPLI. Elles faisaient doublon
avec la poignée de glissement, désormais vérifiée au doigt sur appareil (48 px,
réordonnancement mesuré, 0 px de défilement parasite). Deux chemins pour le même geste,
dont l'un sans repli, valaient moins qu'un chemin sûr plus une commande qui manquait.
Replié, le bloc dit COMBIEN de charges il contient : sinon replier reviendrait à faire
disparaître des charges, et une charge qu'on ne voit plus se lit comme une charge
perdue. Le repli est clé par CODE de domaine, pas par index — un domaine déplacé emporte
son repli au lieu de replier son voisin.
3. LA CARTE RÉSERVE BATTERIE SE REPLIE AUSSI, avec une réserve qui n'est pas cosmétique :
repliée, elle garde le pourcentage ET l'alerte de seuil inatteignable. Replier abrège,
ça ne fait pas taire — c'est le réglage qui peut geler toute l'installation sans erreur
ni panne, et le cacher ramènerait le paramètre d'usine invisible que cette carte existe
pour supprimer. Jamais repliée d'office.
Aucun repli n'est persisté, délibérément : c'est une commodité de lecture, pas une
configuration. La rendre durable ferait retrouver un écran à moitié caché des semaines plus
tard, sans savoir pourquoi.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
Le test d'appareil passe. Il a trouvé deux défauts réels que 129 tests unitaires ne
pouvaient pas voir, parce qu'aucun ne rend ces widgets à une taille d'écran réelle.
1. LA POIGNÉE DE GLISSEMENT MESURAIT 26 × 27 px, pour un minimum Material de 48. Le
Container transparent posé lors de l'enquête précédente avait supprimé la bande morte
entre l'icône et le reste, mais il n'avait jamais AGRANDI la cible : le geste accrochait
quand on visait juste et ratait sinon. C'est le seul geste de cet écran sans repli —
sans lui, l'ordre des domaines ne se change pas du tout.
2. LES FLÈCHES DE RANG MESURAIENT 18 × 18 px, la taille de l'icône, sans aucune marge de
touche. Elles n'ont pas de repli non plus : réordonner DANS un domaine ne se fait que
par elles.
Portées à 48 et 44. Et la capture d'appareil a montré le coût de la correction naïve :
empilées, deux cibles de 44 font 88 px de haut pour deux icônes de 18, et la colonne de
rang devient une colonne vide. Côte à côte, la hauteur retombe à 44 et la largeur ne coûte
rien au libellé. La cible tactile est la contrainte ; la disposition ne l'est pas.
Le harnais a lui-même demandé trois corrections avant de dire la vérité, et chacune est un
piège qui se reproduira :
- il regardait le HAUT de l'écran. Dans un ListView, ce qui n'est pas visible n'est pas
construit : les cibles n'étaient pas mesurables faute d'être montées.
- `find` trouve un widget POSÉ MAIS HORS VIEWPORT, et getCenter en rend une coordonnée qui
n'existe pas — mesuré y=1129 sur un écran de 825 px. Le geste partait dans le vide, avec
un symptôme identique à un glissement ignoré.
- la course était devinée. Une liste réordonnable ne permute qu'une fois passé le milieu du
suivant, et un bloc de domaine porte ses cartes : 384 px mesurés, contre les ~150 devinés.
Elle se calcule maintenant depuis l'écart réel entre deux poignées.
Le test mesure aussi le défilement de la page pendant le glissement : si le ListView
extérieur gagnait l'arène, le symptôme serait le même que celui d'une poignée qui
n'accroche pas, pour une cause opposée. Mesuré à 0 px — c'est bien la poignée qui prend.
Aucun débordement de mise en page sur cet écran. Deux captures d'appareil jointes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
Le test d'appareil est ÉCRIT ET NON PASSÉ : l'installation est refusée par MIUI
(INSTALL_FAILED_USER_RESTRICTED — « Installer via USB » remis à zéro, troisième fois). Ni
adb install, ni pm install depuis /data/local/tmp ne passent : c'est une restriction
utilisateur, elle demande le toggle sur le téléphone. Rien de ce lot n'a donc été vu à
l'écran, et c'est dit plutôt que contourné.
Ce qu'il ira chercher, et qu'aucun test unitaire ne montrera :
- les débordements de mise en page, RÉCOLTÉS tous ensemble via FlutterError.onError plutôt
qu'échoués au premier — un « overflowed by » ne fait échouer aucun test de ce dépôt,
parce qu'aucun ne rend ces widgets à une taille d'écran réelle ;
- la taille des cibles tactiles, mesurée sur l'écran réel, avec un plancher dur sur la
poignée de glissement : c'est le seul geste de cet écran qui n'a pas de repli ;
- le glissement AU DOIGT, en appui maintenu puis mouvements successifs — un tester.drag
simple « passe » sans rien réordonner, ce qui avait déjà fait croire à un geste avalé.
Exercé puis ABANDONNÉ : le geste est ce qu'on veut voir, pas l'écriture.
Il n'exerce pas la carte véhicule, et pour une raison qu'il fallait constater : EVChargingCard
n'est montée par AUCUN écran. Le tableau de bord utilise features/dashboard/widgets/. Le SOC
inventé à 62 % que j'ai retiré hier n'était donc affiché nulle part — le défaut était réel,
sa portée non.
Cadrage des zones de clim, avant toute maquette. Le constat qui commande le lot : le banc ne
peut pas héberger une zone, et ce n'est pas « il manque un appareil ». Sur les 58 classes de
.75, AUCUNE ne porte thermostat, closablesensor, temperaturesensor ni notifications — aucun
plugin installé ne sait en créer une. Deux paquets du dépôt apt du banc suffisent, et le
document donne l'exigence d'interface par emplacement, lue dans verifyThingIds() plutôt que
devinée. Avec une réserve trouvée en chemin : valves est au schéma mais n'est PAS vérifié par
la box — un emplacement qui accepte n'importe quoi sans le contrôler.
L'écran ac_screen.dart n'appelle pas rien : il appelle GetZones, et seulement pour compter.
Ses quatre pièces restent de la maquette, avec huit températures crédibles affichées comme
des mesures — le même défaut que le SOC à 62 %, en plus grand.
Et le brief plugin, complété entre-temps, tranche la question de la cible aveugle : le
carBatteryLevel auquel targetPercentage se compare est une valeur que le MOTEUR écrit
lui-même, publiée sous un nom de mesure. L'avancement s'affichera en énergie livrée.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
/bug-report était un stub. Un champ libre seul produit « ça ne marche pas », et l'aller-retour
pour obtenir versions, charges et état d'arbitrage coûte des jours. Tout ce que l'app SAIT
est donc joint d'office : version du paquet, hôte et état de connexion, ligne de versions du
schéma, les charges déclarées avec adaptateur / rang / domaine, et le dernier cycle publié.
Deux choix qui ne sont pas cosmétiques :
- LES DEUX IDENTITÉS DU BUDGET sont dans le rapport, avec leur verdict. Elles valent souvent
plus que la description du symptôme : portées fausses, elles désignent le moteur ; portées
vraies, elles désignent l'écran. Sans elles, le premier échange consiste à les demander.
- Aucun secret n'y entre : ni PIN installateur même haché, ni jeton, ni mot de passe. Un
rapport voyage — courriel, capture, fil de discussion — et ce qui y entre en sort. Les
UUID de Things restent : ce sont eux qui permettent de recouper avec le journal de la box,
et ils n'ont aucune valeur hors de l'installation.
Les absences gardent leur sens, comme partout ailleurs : « aucune télémétrie reçue » n'est
pas un arbitrage à zéro, « budget ABSENT » n'est pas un budget de zéro watt, et un
financement omis se dit omis.
Pas d'envoi automatique : il n'existe aucun service de collecte, et un bouton « Envoyer »
sans destinataire donnerait le sentiment que quelqu'un l'a reçu. Le rapport se copie, ce qui
est vérifiable.
package_info_plus est ajouté plutôt qu'une constante de version recopiée à la main : une
constante dérive de pubspec.yaml en silence, et un rapport qui annonce la mauvaise version
envoie chercher un défaut dans un code qui n'est pas celui-là.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
Aller-retour neutre confirmé sur les quatre entrées, glissement sans cas particulier
confirmé, correctif de description constaté en place.
Deux choses à leur rendre : EV_GRID_START aurait cassé en silence la réconciliation posée
hier (une ligne à funding grid dont la part surplus n'est pas nulle), et il n'existe aucun
SOC de véhicule dans le contrat — donc targetPercentage est une cible sans mesure
d'avancement, qu'aucun écran ne peut suivre. La question leur revient.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
_SocProgress affichait « 62 % » et « Aujourd'hui à 07:30 » en dur, sur un écran client.
Laissé de côté au lot précédent comme préexistant ; ça ne tient plus, une promesse fausse
ne se périme pas.
Le correctif attendu — brancher la vraie valeur — n'existe pas. Vérifié sur .75 : sur les
58 classes de la box, 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.
Le seul batteryLevel de l'installation est celui de la batterie de la MAISON (Fronius
Storage, 50 %, rendu visible par le correctif energystorage du plugin SunSpec). C'est une
autre grandeur : l'afficher en face d'une cible de recharge remplacerait un chiffre faux
par un chiffre faux et crédible. Retiré, avec la barre de progression — une jauge sans
grandeur mesurée est un dessin — et la carte dit maintenant pourquoi l'avancement n'est pas
affichable. Reste ce que l'app SAIT : la cible qu'elle vient elle-même d'écrire.
Le brief 3g-2 est rapatrié depuis le dépôt plugin (la forge étant à terre, il n'avait pas
pu voyager) et CLAUDE.md est recalé dessus. À noter, la description trompeuse de
SetChargingInfo signalée hier est corrigée sur la box : elle énumère désormais tous les
défauts et conclut « send it back complete ».
Le prompt 3g-2 déposé à la racine est commité tel quel, il était déjà indexé.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
+etm23 supprime le second commandeur : adjustEvChargers() ne commande plus, l'arbitre est
seul (9 commandes à la borne, 9 par l'arbitre, 0 par le proxy). allocationIsCommand est donc
retiré, avec les branches qui en dépendaient — l'étiquette « l'arbitre a décidé X W » et sa
ligne d'avertissement redeviennent « Alloué X W », et l'écart mesuré/commandé se dit à
nouveau sur une borne.
Ce que le getter est remplacé par : rien, délibérément. Le garder à true partout inviterait
à lire « commande » comme « réalité ». Car ce qui devient vrai, c'est que personne d'autre
ne commande — pas que la borne fait ce qu'on lui dit. Un plafond matériel, un véhicule qui
refuse, un câble débranché feront toujours diverger les deux. La borne garde donc sa ligne
d'état matériel, non plus parce qu'un autre commandeur la contredirait, mais parce que
mechanism (chargingEnabled, currentA, pluggedIn) est le seul endroit où cet écart se lit.
Deux motifs neufs : EV_GRID_START {budgetW, floorW, gridW} et PHASE_LIMIT {limitW,
requiredW}. Le premier mène par les watts ACHETÉS — c'est ce chiffre qui fait de la ligne
une décision et non un effet de bord. Le second doit écarter le surplus explicitement :
deux causes, deux gestes, et confondre les deux envoie chercher du soleil là où il faut
lire un compteur. EV_GRID_START n'a JAMAIS été vu sur machine : la clé est prête, rien
n'est bâti autour.
Et une correction que le brief signale en passant, qui aurait cassé en silence la
réconciliation posée hier : EV_GRID_START est le seul motif dont l'allocation se PARTAGE
entre les deux compteurs du budget — budgetW au surplus, gridW au réseau, budgetW + gridW
== allocatedW. Filtrer sur funding == "surplus" comptait cette ligne pour zéro et creusait
un trou de la taille de budgetW. surplusShareW la traite à part ; sans budgetW il rend zéro
plutôt qu'une part devinée, un chiffre faux étant pire qu'un chiffre manquant dans un
contrôle d'identité.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
+etm23 rend à chaque borne détectée une entrée GetLoadConfig, et l'invariant
loads[] ⊆ GetLoadConfig est rétabli. Sur .75 : 2 entrées → 4. L'app s'appuyait déjà sur cet
invariant pour résoudre libellé, domaine et rang de toute charge publiée.
Vérifié sur machine avant d'écrire quoi que ce soit :
- l'aller-retour verbatim reste NEUTRE avec les quatre entrées (loadconfig_roundtrip.dart,
verdict IDENTIQUE) ;
- une entrée evcharger porte id/label/domain/priority/enabled, mode dynamic, et AUCUNE
charge utile de mécanisme.
Trois défauts que ce contrat révèle côté app :
1. isVariableLoad était défini par défaut (« ni relais ni sg-ready »), donc une borne y
tombait. L'écran de mécanisme lui proposait une puissance nominale, des paliers et des
temporisations — des réglages qui n'existent pas pour elle et dont l'écriture partirait
quand même. Pire, le sélecteur offrait de la basculer en routeur de relais : une bascule
que basculerMecanisme() sait construire et qu'aucun écran ne sait défaire. Une borne a
maintenant sa section, en lecture seule, qui dit pourquoi il n'y a rien à régler.
2. BorneSection existait pour rattraper des bornes qui n'apparaissaient nulle part. Elles
apparaissent maintenant dans leur domaine : la section les afficherait DEUX fois, en
affirmant qu'elles n'ont « ni rang ni domaine » et qu'elles ne passent pas par
SetLoadConfig. Trois affirmations devenues fausses. Retirée ; son contenu de runtime
vit désormais dans l'écran de mécanisme, où il a un sens.
3. Le rang par défaut d'une borne est un ARTEFACT, et l'écran le disait comme un réglage.
À la création le plugin donne « le plus petit rang existant moins un, borné à 1 » ;
le chauffe-eau occupant déjà le rang 1, l'intention « en tête » dégénère en égalité —
trois charges à 1 sur le banc. Le tri reste total (l'id départage), donc l'ordre est
reproductible, et c'est précisément ce qui le rend crédible : l'installateur lit un
classement plausible et n'y touche pas, validant un ordre que personne n'a posé. Une
note le nomme, et disparaît dès le premier vrai réglage.
Le glissement, lui, n'a demandé aucun cas particulier — c'était tout l'intérêt d'avoir une
seule liste. Vérifié par test, y compris l'inversion borne / charge pilotée.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
CLAUDE.md portait un exemple de SetChargingInfo qui invitait à l'écriture partielle. Il
porte maintenant la règle inverse, avec la preuve machine, et les deux pièges de lecture de
GetLoadTelemetry : ne pas sommer allocatedW sans filtrer sur funding, et ne pas lire
l'allocation d'une borne comme son état.
Retour au moteur dans BRIEF_plugin_depuis_maquettes.md. Un point actionnable chez eux : la
description trompeuse vit dans nymeaenergyjsonhandler.cpp:114 et part dans Introspect chez
tout client. Elle ne se contente pas de ne pas prévenir, elle autorise explicitement
l'erreur — le prochain client la croira. Une ligne à corriger, ou une écriture à rendre
réellement partielle.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
La box promet le contraire de ce qu'elle fait, et par écrit : sa propre description dans
JSONRPC.Introspect dit « Only given properties will be set, others will be untouched ».
ChargingInfo est en réalité reconstruit entier à chaque appel, et un champ absent retombe
sur son défaut C++ — 80 pour targetPercentage.
Reproduit sur .75 le 2026-08-27 (+etm22), borne 88160e45 : le champ valait 0, un
SetChargingInfo portant le seul chargingMode l'a laissé à 80, réponse EnergyErrorNoError.
Banc restauré.
L'app envoyait targetPercentage uniquement avec une échéance. Changer de mode de charge
reposait donc une cible de 80 % à l'insu de l'utilisateur, sans que rien ne le signale.
Désormais : GetChargingInfos, patch, réécriture de l'objet complet — sans chargingState,
qui est r: au schéma et ferait rejeter l'appel. Sans échéance, on repose explicitement la
valeur que la box portait déjà ; si elle n'en portait aucune, le neutre est 0, pas 80.
Et la cible n'est plus affichée hors échéance : 80 peut être l'empreinte d'une écriture qui
ne parlait pas de cible du tout, la montrer en ferait une intention.
Deux notes de contrat au passage :
- fetchPowerBalanceLogs porte enfin le piège qui a fait croire à un défaut de la box côté
plugin — les entrées de log ne portent PAS le préfixe currentPower. Réutiliser les clés
de GetPowerBalance ne produit aucune erreur, tout tombe sur 0. Vérifié ici avec les clés
de l'app : 7 960 échantillons sur 90 jours en SampleRate15Mins, dont 4 975 de production
non nulle. Rien ne bloque l'historique 90 jours.
- le défaut d'usine de batteryLevelConsideration est passé de 0,9 à 0,2. La carte reste
nécessaire : le nouveau défaut ne rattrape pas une box qui a déjà persisté 0,9, et le
seuil reste réglable jusqu'à 1,0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
Deux constats du brief moteur, qui touchent le même fichier de lecture.
1. kCodesReserveBatterie était vide, et les clés cherchées n'ont JAMAIS existé côté
plugin : batteryLevel/soc et threshold/batteryLevelConsideration contre socPercent,
reservePercent, withheldW. Le motif sortait donc en repli — c'est-à-dire en anglais
brut — sur le seul cas que le client ne peut déduire d'aucune mesure : « il y a du
surplus, une règle l'interdit ». Vérifié contre decisionreason.h.
Les trois paramètres sont des entiers DÉJÀ en pourcentage : reservePercent vaut 40, pas
0,4 — à ne pas confondre avec batteryLevelConsideration, qui est la fraction du réglage.
Et withheldW mène la phrase, comme côté plugin : le moteur en fait la condition
d'émission du motif, parce qu'un motif à 0 W dirait « une règle vous bloque » là où la
vérité est « il n'y a pas de surplus ».
Le motif n'a PAS pu être observé en vrai sur .75 : la bascule du seuil à 0,9 a produit
surplusW à −3 551 W, donc rien à confisquer, donc garde du plugin active et motif tu —
ce qui est le comportement juste. Il faut du surplus réel sous le seuil. Seuil remis
à 0,4.
2. funding (« surplus » / « grid ») est lu, et avec lui l'identité que le plugin déclare :
somme des allocatedW financés au surplus == budget.allocatedW. Sommer sans filtrer
compte une borne servie au réseau et creuse un écart qu'on ne peut pas interpréter.
Absent = surplus, pas zéro : c'est le seul financement qui existait avant ce champ.
3. Les bornes sont entrées dans loads[] (+etm22), et adjustEvChargers() re-décide après le
waterfall : pour une borne, allocatedW et son motif sont la DÉCISION DE L'ARBITRE, pas
l'état de la borne. L'écran affiche donc d'abord ce que le matériel fait
(mechanism.chargingEnabled / currentA, qui viennent du Thing), ensuite ce que le moteur
a voulu, étiqueté comme tel. L'écart mesuré/commandé se tait sur une borne — « sous la
consigne » y supposerait une consigne. La réserve ne touche PAS l'ECS ni la PAC, où
l'allocation publiée est bien la commande : elle est portée par allocationIsCommand, et
tombe avec le lot plugin 3g-2.
EV_SURPLUS, EV_ECO_MIN et EV_IDLE ne sont plus émis. Les branches restent : les retirer
rendrait illisible un journal antérieur, pas l'app plus juste.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq