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
`"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
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
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
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
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
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
Le fil conducteur des six commits du jour tient en une phrase : l'app ne recalcule pas
ce que la box publie, et n'anticipe pas ce qu'elle n'a pas encore publié.
Le point à retenir est le seuil de réserve batterie : 0,9 en usine, batterie du banc à
50 %. Quand la règle montera dans le budget, `.75` ne pilotera plus rien par grand
soleil, sans erreur ni panne. Le RECAP le dit, et le TODO garde les trois crochets
laissés ouverts — dont le `Set` volontairement vide qui attend le code du moteur.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff