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
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
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
+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