39 Commits

Author SHA1 Message Date
Patrick Schurig
2c9cca042e fix(borne): une borne injoignable ne montre ni le zéro forgé ni la valeur périmée
Mesuré sur `.75` le 2026-08-30 : quand le lien Modbus de la V2C tombe, la box ne
fige pas les états, elle publie des zéros — `pluggedIn` false, `charging` false,
`currentPower` 0 — alors que la Skoda était branchée et en charge à 4 356 W.
Avant ça, la dernière valeur connue reste publiée comme si elle était actuelle
(vérifié : aucun état ne bouge en 45 s pendant une charge supposée).

L'app refusait déjà d'inventer un zéro ; elle en relayait un. Sous
`connected == false`, `EvChargerLive` met `pluggedIn`, `charging` et
`currentPowerW` à `null` À L'ENTRÉE — un seul endroit, pas un test par écran — et
porte `joignable` + `messageEtat`. La tuile dit « Hors ligne » et reprend la
phrase du plugin, qui porte l'heure de la coupure et la piste WiFi.

Deux garde-fous dans les tests : `joignable == null` n'est PAS hors ligne (une
borne qui ne publie pas `connected` ne dit rien de son lien — supposer la panne
serait le symétrique du zéro forgé), et le MODE reste affiché hors ligne, parce
qu'il est stocké dans la box et non lu sur la borne.

Brief moteur : les zéros à la perte du lien, `chargingState` figé à `Idle`
pendant une charge réelle, et `ChargingModeNormal` qui n'est pas un boost — il
restaure les valeurs manuelles mémorisées (ici arrêt, 6 A) et sort la borne de
l'arbitrage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-08-30 13:43:31 +02:00
Patrick Schurig
a1ffd13107 feat(télémétrie): la réconciliation repose sur counts — l'exception EV_GRID_START tombe
`+etm32` publie `counts` par niveau : où chaque watt est compté, et combien.
Vérifié à la sonde sur `.75` avant de toucher au code — `counts` sur tous les
niveaux, destination publiée même à zéro, et les DEUX identités qui tiennent.

- `LoadLevel.counts`, `countsTotalW`, `countsCoherent`, `partSurplusW` ;
- `surplusShareW` LIT `counts["budget.allocatedW"]` au lieu d'interpréter ;
- `soutirageShareW` + `LoadTelemetry.soutirageIdentityHolds` : la seconde
  identité, qui n'existait pas ce matin faute de registre pour les watts achetés ;
- `LoadTelemetry.drawCommittedW` — absent = box antérieure à `+etm31`, jamais
  « rien acheté » ;
- `destinationsInconnues` : une clé née après cette app est NOMMÉE, jamais
  comptée zéro, et l'identité par niveau tient sans connaître le sens des clés.

L'exception au motif survit exactement là où la box ne sait pas dire le partage
autrement — formes sans `counts`. Ce n'est pas une version qui l'a retirée, c'est
une charge utile qui l'a rendue inutile ; sur une installation déjà déployée, la
retirer casserait la réconciliation.

Le rapport de bogue porte désormais les deux identités, les destinations
inconnues et les niveaux incohérents (Σ counts != targetW) — une incohérence de
la box, pas un arrondi.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-08-30 10:51:10 +02:00
Patrick Schurig
2792e07265 fix(télémétrie): funding a quitté la charge en +etm30 — le lire dans levels[]
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
2026-08-30 08:16:29 +02:00
Patrick Schurig
438f261de7 feat(config): quitter avec un brouillon demande, et « Placer en dernier » rejoint « Renommer »
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
2026-08-29 12:02:04 +02:00
Patrick Schurig
462ca4a2fe feat(config): « Placer en dernier » — le seul geste qui sort une charge de sa plage
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
2026-08-29 11:48:58 +02:00
Patrick Schurig
a00a9209e9 feat(config): un groupe devient une PLAGE — l'enregistrement ne regroupe plus
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
2026-08-29 11:06:56 +02:00
Patrick Schurig
99a24a5f3e feat(config): « Ce rang me convient » — adopter un rang sans le changer
`"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
2026-08-29 10:28:21 +02:00
Patrick Schurig
e26000945d fix(config): ne jamais renvoyer rankOrigin "auto" — un renommage aurait été refusé
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
2026-08-29 10:03:18 +02:00
Patrick Schurig
8b7bfbcded feat(config): le badge dit la provenance du rang, la note alerte sur un refus
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
2026-08-29 09:35:17 +02:00
Patrick Schurig
2c193077f7 fix(config): +etm27 refuse deux rangs identiques — l'app le voit avant la box
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
2026-08-28 16:06:11 +02:00
Patrick Schurig
e362fb7430 chore(telemetry): l'alias measuredW est retiré — +etm26 est sur .75
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
2026-08-28 15:32:02 +02:00
Patrick Schurig
1a357d25c0 feat(telemetry): le régime de mesure est LU, plus déduit — contrat measurement de +etm26
É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
2026-08-28 15:12:37 +02:00
Patrick Schurig
32d50f5d80 feat(config): la suppression se vérifie elle-même, et le sélecteur de compteur cesse d'être plus strict que la box
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
2026-08-28 14:56:21 +02:00
Patrick Schurig
51a1ab929c feat(config): retirer une charge orpheline, et régler le mode de recharge à un seul endroit
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
2026-08-28 14:36:50 +02:00
Patrick Schurig
3a8fb1586d fix(ev): l'écran « Configurer » d'une borne refusait d'enregistrer, et proposait une sonde
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
2026-08-28 14:17:03 +02:00
Patrick Schurig
e0264b5ed6 feat(ev): une borne à pleine puissance n'est pas arbitrée — les deux écrans le nomment
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
2026-08-28 13:08:01 +02:00
Patrick Schurig
e45ca67151 fix(dashboard): les trois branches inventées du Sankey s'annoncent comme estimées
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
2026-08-28 12:59:58 +02:00
Patrick Schurig
95769938fc fix(ev): la tuile des favoris devient muette — elle lit la borne, ou le dit
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
2026-08-28 12:49:28 +02:00
Patrick Schurig
33d89a8c26 fix(ev): la réserve THING_MISSING est levée par la mesure, et la carte véhicule part
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
2026-08-28 12:36:00 +02:00
Patrick Schurig
935cec269f test(config): les deux notes sont MONTÉES, pas seulement calculées
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
2026-08-28 12:18:00 +02:00
Patrick Schurig
553a182e9b fix(config): l'ordre entrelacé se voit enfin, et un appareil disparu cesse d'être un défaut à lever
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
2026-08-28 11:57:12 +02:00
Patrick Schurig
23a84fec43 feat(dashboard): le bilan du JOUR — et deux bugs d'unité trouvés en le posant
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
2026-08-28 07:57:57 +02:00
Patrick Schurig
24822ce30b feat(3g-2): la mise en garde borne tombe, sans devenir une certitude trop large
+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
2026-08-27 14:33:25 +02:00
Patrick Schurig
a44d64d6c2 feat(3g-2): les bornes sont des charges configurées — une seule liste, un seul rang
+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
2026-08-27 14:32:56 +02:00
Patrick Schurig
a1455290c9 feat(3g): BATTERY_RESERVE branché, funding lu, et l'état d'une borne passe devant
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
2026-08-27 07:38:48 +02:00
Patrick Schurig
801ba15094 feat(3g): la réserve batterie sort de l'usine — un seuil qui peut tout geler doit se voir
`batteryLevelConsideration` ne gouverne aujourd'hui que la recharge du véhicule. Une
fois la règle montée dans le budget, il gouvernera TOUTE l'installation : sous le
seuil le budget de surplus est annulé — pas réduit — donc ni ECS, ni PAC, ni bornes.

Le défaut d'usine est 0,9. Le banc `.75` est exactement dans ce cas : seuil 0,9,
batterie à 50 %. Le symptôme chez un client serait le pire qui soit — un HEMS qui ne
pilote plus rien par grand soleil, sans erreur, sans panne, rien à lire dans un
journal. Un paramètre qui peut produire ça n'est pas un paramètre d'usine.

- `BatteryReserveCard` lit et écrit le réglage, et ALERTE quand le seuil dépasse le SOC
  réellement atteint — le cas silencieux. Elle n'invente aucune valeur par défaut : si
  la box ne l'expose pas, la carte se tait plutôt que d'afficher un curseur mort.
- La portée future est dite sur la carte : régler ce seuil « pour la voiture »
  couperait le reste demain.
- Clé ARB `decisionBatteryReserve` posée, et `kCodesReserveBatterie` volontairement
  VIDE côté `telemetry_text` : le crochet attend le code que publiera le moteur, on ne
  devine pas son nom.

Vérifié sur appareil contre `.75` : réglage lu (0.9 / SOC 50 %), carte affichée,
campagne verte. L'assertion des cartes de charge fait désormais DÉFILER avant de
compter — dans un `ListView` ce qui n'est pas à l'écran n'est pas construit, et
chercher sans défiler ne prouvait que la hauteur de l'écran.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
2026-08-26 12:01:30 +02:00
Patrick Schurig
449c6ac50a feat(3g): les valeurs de borne sont du RUNTIME — deux natures derrière un même mot
MINIMUM. « Plancher » désigne désormais deux choses qu'il ne faut surtout pas ranger
ensemble :

- le plancher DÉCLARÉ d'une charge modulable — propriété de l'appareil, saisie une fois,
  stable, avec un champ de configuration à venir ;
- le minimum d'une BORNE — max(borne, voiture), qui dépend du véhicule branché et change
  avec lui. Il n'aura jamais de champ de configuration : il n'y a rien à y régler.

Les rendre modifiables de la même façon ferait croire qu'on peut régler ce qui se constate.
Le bloc « plancher » ne vit que dans la section etmvariableload, ce qui écarte les bornes
structurellement — elles ne sont pas des LoadConfig et n'ouvrent pas cet écran — et son
libellé le dit désormais explicitement.

PHASES. Le plancher effectif d'une borne dépend du nombre de phases visé : ~1,4 kW en
monophasé, ~4,1 kW en triphasé, pour le MÊME courant. La valeur est affichée telle que la
box la donne, sans conversion ni recalcul côté app. Si l'app convertissait, elle devrait
deviner laquelle des deux règles s'applique — et se tromperait un jour. C'est exactement le
miroir qui vient d'être retiré pour la combinatoire des paliers ; on ne va pas en
réintroduire un.

PARTAGE. Le budget ne se divise pas entre bornes : aujourd'hui chacune reçoit l'enveloppe
entière et ce sont le compteur puis la protection de surcharge qui rattrapent ; après 3g
une seule démarrera. Avec deux bornes maintenant déclarées sur le banc, le laisser
implicite invitait à déduire un partage qui n'existe ni avant ni après. Une note l'énonce
dès qu'il y en a plus d'une.

Le bloc de runtime d'une borne reste MUET tant que la recharge n'est pas arbitrée — la box
ne publie rien pour elle, et c'est la bonne réponse, pas un trou. Il affichera phases,
courant, minimum et présence du véhicule le jour où ils arriveront, en lecture seule.

Tests 105/105, analyze 0 erreur.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
2026-08-26 11:37:54 +02:00
Patrick Schurig
ab2a27bd30 feat(etm19): BELOW_MIN_POWER, compteurs rattachés depuis l'écran, glissement exercé
MOTIF BELOW_MIN_POWER — premier ajout au catalogue depuis qu'il existe.

Le sens porté est précis et facile à perdre : il Y A du surplus, mais pas au bon format —
sous le plus petit cran commandable, la charge ne prend rien et le budget file à la
suivante. À distinguer de SURPLUS_INSUFFICIENT et SG_NORMAL, qui ne sortent plus que pour
un budget ≤ 0, « il n'y a rien ». Les confondre ferait chercher un défaut de production là
où il n'y a qu'une granularité. Deux variantes, départagées par la présence de `state`.

Les deux phrases voisines sont reprises en conséquence : SG_NORMAL peut enfin dire « aucun
surplus » sans risque, ce qu'elle ne pouvait pas avant, et SURPLUS_INSUFFICIENT ne parle
plus de « palier non payé » — elle dit qu'il n'y a rien à distribuer.

LE REPLI AURAIT TENU, et c'est vérifié plutôt qu'affirmé : sur BELOW_MIN_POWER privé de
`minPowerW`, l'app rend le code et ses paramètres triés au lieu de fabriquer une phrase
approximative. C'était exactement le cas prévu, et c'est le premier vrai à se présenter.

GLISSEMENT — le test POSE désormais sa condition au lieu de la subir. Il regroupe les deux
charges dans un même domaine, exerce, puis REND le classement d'origine. Deux fois de suite
il s'était contenté d'annoncer « non exercé » parce que le banc était revenu à une charge
par domaine : un test qui dépend de l'état du terrain déclare forfait au moment où on
aurait besoin de lui. Exercé sur appareil : [chauffe-eau, pac-terrain] →
[pac-terrain, chauffe-eau], classement rétabli {ecs, heating}.

COMPTEURS — rattachés pour de vrai depuis le chemin de l'app, et non par une sonde.
measuredW apparaît par charge, chacune portant SON compteur. L'affichage met la mesure à
côté de l'allocation, jamais à sa place : l'une est ce que le waterfall a décidé, l'autre
ce qui passe réellement.

Mon assertion initiale était fautive : j'exigeais des mesures DISTINCTES pour prouver le
rattachement par charge. Deux compteurs à l'arrêt lisent légitimement 0 W tous les deux —
le test échouait donc sur une installation saine. Ce qui prouve le rattachement, c'est que
chaque charge porte un compteur différent. Vérifié aussi, en détachant puis rattachant :
une charge sans compteur ne publie RIEN — null n'est pas 0, et afficher un zéro ferait
passer une absence de mesure pour une absence de consommation.

PLANCHER — préparé AVANT son arrivée au schéma, précisément à cause de ce qui vient de se
passer avec meterThingId et sensorThingId : le grisage dérivé les a activés tout seuls,
mais sur un placeholder mort. Le bloc est donc câblé maintenant, reste grisé tant que le
champ n'existe pas, et fonctionnera le jour où il apparaîtra. La box publie déjà
mechanism.minPowerW en télémétrie : on l'affiche en lecture, ce qui donne la valeur en
vigueur même sans réglage ouvert.

Tests 101/101, analyze 0 erreur.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
2026-08-26 11:31:35 +02:00
Patrick Schurig
cd76df3a73 feat(etm16): s'aligner sur le nouveau contrat — table de paliers, stagesW, compteur et sonde
RELAIS : mon avertissement de reconstruction sur un réordonnancement est devenu FAUX, et
il est corrigé. `+etm16` compare la TABLE que les relais produisent (deriveStages), plus la
liste par index. Le miroir Dart suit, y compris le détail qui compte : les ThingIds d'une
combinaison se comparent comme un ENSEMBLE — collectés dans l'ordre de déclaration, fermer
{K1,K2} ou {K2,K1} est le même geste électrique. Mais l'appariement palier par palier
reste positionnel, ce qui préserve le seul cas où l'ordre signifie encore quelque chose :
deux relais de MÊME puissance, où il décide quel contact sert ce palier.

Conséquence pour l'écran : réordonner des contacteurs de puissances distinctes n'avertit
plus de rien. Avertir sur un geste devenu gratuit serait aussi trompeur que de se taire sur
un geste coûteux.

PALIERS : l'app ne dérive plus la combinatoire pour l'affichage — la box les publie
(mechanism.stagesW). Vérifié sur appareil : [0, 500, 1000, 1500, 2000, 2500, 3000, 3500].
deriveStages reste côté app pour la seule annonce d'impact, qui se fait avant l'écriture.
Tant que le brouillon n'a pas touché aux contacteurs, c'est la table PUBLIÉE qui s'affiche ;
dès qu'il y touche elle devient périmée, et l'écran montre une prévision annoncée comme
telle plutôt qu'une table qui décrit l'état d'avant.

COMPTEUR ET SONDE : ils ne « vont pas arriver », ils SONT au schéma de .75
(o:meterThingId, o:sensorThingId). Le grisage dérivé les a donc activés seul, sans nouvelle
version de l'app — exactement ce qui était promis. Mais un bloc actif sur un placeholder
mort est pire qu'un bloc grisé : les deux sont désormais câblés sur un vrai sélecteur,
filtré par INTERFACE (smartmeter, temperaturesensor) et jamais par nom de plugin.

Ni l'un ni l'autre n'entre dans sameHardware() : les rattacher ne reconstruit rien. Ni dans
le contrôle d'unicité : deux charges peuvent partager une sonde, un compteur divisionnaire
en couvrir plusieurs — le sélecteur ne grise donc rien ici, contrairement aux contacteurs.
Et la mesure sert à VÉRIFIER, jamais à décider : elle n'entre pas dans le budget, la charge
étant déjà comptée dans le racine dont elle n'est qu'une décomposition.

COMPTEURS CUMULÉS : la base purgée, les valeurs sont saines (55 730 / 50 / 6 327 / 62 007
kWh) et la tuile « compteur box invalide » a disparu d'elle-même. Le plafond de
plausibilité reste : un zéro réel s'affiche « 0,0 kWh », jamais « — ».

hasNeverRun était déjà traité comme un état distinct — « l'absence de cycle est dite pour
elle-même, jamais rendue par un âge de zéro ».

Vérifié sur appareil contre .75 : compteur racine relu, « à configurer » vide, deux bornes
listées (Simulated wallbox + Terra AC Charger), stagesW de la box, les deux champs
writable, 5/5 sections de l'écran SG-Ready. Tests 98/98, analyze 0 erreur.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
2026-08-26 10:54:16 +02:00
Patrick Schurig
aada8e2ba5 fix(config): glissement, sélecteur de mécanisme, chip de domaine, thème — les 4 points ouverts
1) GLISSEMENT — diagnostic d'abord, et il a donné DEUX réponses.

   Le geste n'est PAS avalé par le ListView parent : les deux montages du test
   (section seule / section imbriquée) glissent identiquement. La cause du symptôme était
   bien que .75 n'a qu'une charge par domaine — rien à réordonner.

   Mais l'enquête a trouvé un vrai défaut au passage : le centre de la poignée tombait dans
   le vide de 3 px séparant l'icône de la pastille de rang, et ReorderableDragStartListener
   défère le test de collision à ses enfants. Une bande morte invisible au milieu d'une
   poignée qui paraît d'un seul tenant. Corrigé par un Container transparent.

   Tentative faite et ANNULÉE : rendre les domaines glissables aussi. Deux
   ReorderableListView imbriqués se disputent le geste et l'extérieur gagne — le
   glissement des cartes cassait. Les domaines gardent leurs flèches, et une charge seule
   dans son domaine n'offre plus de prise qui mènerait nulle part (avec l'infobulle qui
   renvoie au bon geste).

   Vérifié SUR L'APPAREIL, contre .75, avec les deux charges mises dans le même domaine :
   [chauffe-eau, pac-terrain] → [pac-terrain, chauffe-eau], brouillon abandonné sans rien
   écrire. Classement ecs/hvac rétabli ensuite.

2) SÉLECTEUR DE MÉCANISME — actif, et c'était le morceau délicat.

   LoadConfig est une union discriminée : écrire `adapter` sans nettoyer le reste produit
   une configuration que la box refuse EN BLOC, et le refus emporte toutes les charges de
   l'appel. D'où basculerMecanisme(), qui retire les clés du mécanisme quitté et pose
   celles du nouveau, en récupérant ce qui peut l'être — les deux premiers contacts
   deviennent SG1/SG2 dans l'encodage standard, les relais gardent leur puissance.

   Ce qui n'est PAS transposé : l'estimation d'un état SG-Ready n'est pas la puissance
   d'une résistance. La charge produite est alors incomplète, et c'est voulu — le
   formulaire réclame plutôt que d'inventer une valeur de plaque.

   La bascule franchit sameHardware() : confirmation préalable qui annonce la
   reconstruction et la durée de réarmement (900 s par défaut vers SG-Ready — la valeur
   d'une PAC réelle, pas celle du banc).

   Le mode fixed d'etmvariableload reste LISIBLE sans être proposé : bloc « Configuration
   héritée » en lecture seule, et conversion seulement sur geste explicite, avec le
   plafond que la déclaration en paliers ne porte pas. Ouvrir l'écran ne convertit personne.

3) CHIP DE DOMAINE — il ne rendait qu'une icône muette : le domaine d'une charge était
   illisible sur sa carte, alors qu'il décide de son groupe et de son rang. Il porte
   maintenant son libellé, et sa couleur suit le thème.

4) THÈME — les 11 derniers sites (pas 9) basculés. Trois cas : contexte disponible
   (main, ac_screen) ; peintre sans contexte, à qui la couleur est passée résolue
   (energy_flow_widget) ; tables statiques, où la teinte devient nullable et se résout au
   rendu (categoryInfoMap, favoris). Plus AUCUN jeton hors thème dans lib/.

5) ZONES — non entreprises, comme convenu.

Tests 93/93, analyze 0 erreur.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
2026-08-26 08:49:20 +02:00
Patrick Schurig
3d2aab2b42 fix(config): les écrans suivent enfin la maquette — sélecteurs, table d'états, budget, borne
Je n'avais pas ouvert docs/mockups/ : les écrans ont été construits depuis le texte du brief
seul, et l'écart est franc. Les maquettes du 2026-08-25 font autorité, elles sont désormais
suivies.

ÉCRANS DE MÉCANISME, refaits :
- sélecteur de Things PAR NOM, filtré sur l'interface nymea (power / etmvariableload).
  Taper un UUID au doigt est une erreur garantie ET muette : la box accepte un UUID valide
  qui ne désigne rien, et la charge ne commande jamais rien. Le sélecteur grise les Things
  déjà pris — la box refuse en bloc un même Thing sur deux lignes — et signale en rouge un
  ThingId configuré qui n'existe plus sur la box ;
- routeur de relais : ajout/retrait de contacteurs, puissance par ligne, et les paliers
  atteignables AFFICHÉS. Ils sont déduits (2^N combinaisons, doublons écartés), jamais
  enregistrés — la maquette est explicite là-dessus, ma note précédente disant l'inverse
  était une sur-interprétation du brief ;
- SG-Ready : SG1/SG2 se choisissent EN HAUT, une fois, et la table des quatre états devient
  éditable — ce qui s'y coche est fermé/ouvert, et c'est ça qui devient relays[]. Les
  étiquettes SG1/SG2 sont une convention de l'écran (SG1 = fermé en état 1, SG2 = en état 3)
  et elles TOMBENT sur un encodage non standard, où les contacts s'affichent tels quels ;
- modulable : plafond requis > 0, et l'avertissement que changer d'appareil crée une autre
  charge puisque l'identifiant EST le ThingId ;
- temporisations minOnS/minOffS (et minStateHoldS pour la PAC) en pas de 30/60 s ;
- « Compteur dédié » présent et GRISÉ, avec sa raison : aucun champ n'existe côté box, et
  inventer meterThingId ferait rejeter l'enregistrement de TOUTES les charges ;
- « Inclure dans l'arbitrage » avec rang et domaine, et ce que la désactivation fait
  vraiment selon le mécanisme ;
- validation AVANT envoi : relays[] vide, powerW ≤ 0, Thing en double, état 2 absent,
  maxPowerW ≤ 0. SetLoadConfig refuse en bloc et sans motif — laisser partir une config
  invalide condamne l'installateur à un refus muet qui perd tout l'appel.

LISTE :
- l'en-tête d'arbitrage et le tableau de budget, PERDUS à la fusion, sont rétablis. Sans
  eux « Alloué 3 000 W » ne se rattache à rien ;
- le rang passe en pastille près de la poignée, et le sous-titre devient lisible :
  « Routeur de relais · 3 relais · min ON 60 s » au lieu de « Routeur de relais · rang 1 » ;
- section « Recharge véhicule » : la borne n'est pas une LoadConfig, elle n'apparaissait donc
  NULLE PART — ce qu'un installateur qui vient de la régler lit comme une perte.

MENU : l'entrée « Ordre de service des charges » est retirée ; elle pointait sur une
redirection, donc sur le même écran sous deux noms.

Tests 80/80, analyze 0 erreur.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
2026-08-25 23:27:24 +02:00
Patrick Schurig
2a8ad95120 feat(config): lot C — un seul écran de configuration, et le grisage vient du schéma
FUSION. L'écran « Ordre de service » est supprimé ; sa route redirige. C'est son CHEMIN DE
DONNÉES (LoadConfigProvider) qui a migré dans la présentation de « Rôles & appareils »,
jamais l'inverse. EmsRole fige six rôles dont dhw ET heatPump séparés — or LM-201 dit qu'une
PAC sans ballon séparé est UNE charge : le modèle des rôles aurait exigé deux charges sur un
canal unique, ce que validateSet() refuse à raison. Les charges viennent donc de
GetLoadConfig. Les compteurs restent sur EnergySetupProvider (autre contrat, SetRootMeter).

La note « le pilote peut bousculer l'ordre les jours Tempo rouge ou à l'approche d'une
échéance » est retirée : aucun de ces comportements n'existe dans le moteur.

ANNONCE AVANT ÉCRITURE. Miroir exact de EnergyArbitrator::sameHardware(). Le piège, vérifié
sur .75 : la comparaison des relais est INDEXÉE, donc réordonner relays[] sans rien changer
d'autre reconstruit l'adaptateur — contacts ouverts (applySafeState, force) et verrous
réarmés à froid. Un glisser-déposer dans la liste des contacteurs est un geste matériel.
L'écran le nomme, avec la durée de réarmement.

GRISAGE PAR INTROSPECTION. RpcSchema lit JSONRPC.Introspect à la connexion ; trois états et
non deux — écrivable, r: (mesure, pas fonction manquante), absent — plus « inconnu » quand le
schéma n'a pas pu être lu. Chaque champ grisé porte sa raison et le nom du champ manquant.
La version affichée est celle de l'API d'expérience (NymeaEnergy 0.8) : le paquet +etm15
n'est exposé par aucune méthode.

CORRIGÉ AU PASSAGE — les modes de recharge étaient INOPÉRANTS. setChargingInfo() appelait
EnergyPlugin.SetChargingInfo (namespace inexistant) avec mode/targetSoc/endTime/minCurrent
au lieu de chargingMode/targetPercentage/endDateTime, et minCurrent n'existe dans aucun
champ. Vérifié par appel réel : « Missing required key: chargingMode ». Le refus était de
plus avalé et l'état local mis à jour quand même. Désormais l'état ne suit que si la box
accepte, et le refus remonte sans en inventer la cause.

ZONES — constaté, pas corrigé. L'hypothèse « champs grisés » est fausse : AirConditioning
publie 8 méthodes toutes en écriture. ac_screen.dart n'en appelle aucune, ses 4 pièces sont
des constantes de maquette, et .75 déclare zéro zone. getZones() ajouté + bandeau qui lit le
vrai compte et annonce la maquette. Le câblage est un lot en soi, non exerçable sans zone.

Tests 80/80, analyze 0 erreur. Relevé : docs/RELEVE_LOTC.md — dont §6, ce qui n'a PAS pu
être vérifié : l'IU n'a pas pu être pilotée sur l'appareil, MIUI refusant INJECT_EVENTS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
2026-08-25 22:17:16 +02:00
Patrick Schurig
dee20896c5 feat(télémétrie): lot B app — consommation de la télémétrie d'arbitrage
Le lot plugin B-bis (1.15.2+etm15) est sur .75 et GetLoadConfig/GetLoadTelemetry
concordent : les critères 4 à 8 du brief deviennent atteignables.

- models/load_telemetry.dart : vue en lecture. timestamp/budget/mechanism/lock/
  faultCode nullables — omis porte un sens que zéro ne porterait pas.
- services/telemetry_text.dart : rendu français des CODES (19 motifs, 3 défauts,
  3 verrous, 4 mécanismes). Repli lisible sur un code inconnu ET sur un code connu
  privé d'un paramètre : une phrase trouée a l'air d'une donnée, le code brut a
  l'air de ce qu'il est.
- providers/load_telemetry_provider.dart : instantané + LoadTelemetryChanged,
  fraîcheur à trois signaux (âge du timestamp, silence des trames, timestamp qui
  cesse d'avancer), machine à états de ClearLoadFault qui ne conclut JAMAIS sur
  l'acquittement du RPC.
- load_config_provider : assignDomain() en brouillon, énumération fermée.
- load_order_screen : budget, fraîcheur, mode dégradé, bloc par charge, encart
  défaut, menu de domaine.
- nymea_service : getLoadTelemetry(), clearLoadFault(), aiguillage de la
  notification. Une box sans la méthode s'annonce comme telle, pas comme en panne.

Seuil de fraîcheur à 180 s : le battement de cœur du plugin est de 60 s et le
timestamp avance d'un cycle par minute. Un seuil plus court crierait « données
anciennes » sur une installation vivante.

Fixtures v3 prises sur .75 après déploiement, dont un cycle porteur d'un verrou —
« lock » étant optionnel, un dump pris au mauvais moment ne l'éprouverait pas.

57 tests (30 avant), flutter analyze inchangé à 25 issues.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
2026-08-25 19:20:30 +02:00
Patrick Schurig
4ade3e4151 tools(rpc): sondes JSON-RPC + fixtures du lot télémétrie déployé sur .75
Range dans tools/rpc/ les sondes qui vivaient dans un scratchpad volatil :
probe (appel RPC quelconque), notifications (abonnement + comptage),
locale et locale_diff (comportement o:locale et son effet réel),
loadconfig_roundtrip (aller-retour neutre §7-1). README avec usages.

loadconfig_roundtrip est le SEUL qui écrit sur la box : garde --yes ajouté
pour qu'un SetLoadConfig ne parte jamais par réflexe de flèche haut.

analysis_options : tools/** et les classes l10n générées sortent de
l'analyse — flutter analyze revient à 25 issues (ligne de base).

Fixtures : le lot plugin télémétrie EST déployé sur .75 depuis ce matin
(20 méthodes / 14 notifications). Capturé avant que ça ne se reperde :
- loadtelemetry_hems75.json : dump réel de GetLoadTelemetry
- loadconfig_hems75_v2.json : GetLoadConfig avec la clé 'domain'
- schema_hems75_v2.json     : schémas SET + télémétrie introspectés

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 14:40:03 +02:00
Patrick Schurig
7ca42973f2 feat(installateur): ordre de service des charges + locale + ossature i18n
Lot B côté app, partie NON bloquée par le lot plugin (voir §Limites).

§1 Locale (indépendant) — JSONRPC.Hello portait {} : l'app était en en_US de
fait alors que nymea gère la locale PAR CONNEXION (o:locale). Envoi de la
locale à chaque Hello, donc aussi après reconnexion subie (m_clientLocales
repart au défaut de la box à chaque socket). Locale demandée ET retenue
journalisées : un .qm introuvable rend exactement comme une absence de
traduction, sans cette trace un catalogue mal packagé passe inaperçu.

§2 i18n — flutter_localizations + intl + ARB (français seul). Aucune chaîne
en dur dans le nouvel écran. Les codes reçus de la box (domaine, mécanisme)
sont des clés ARB avec repli explicite sur code brut si l'app ne les connaît
pas : une box en avance sur l'app est un cas normal en parc déployé.

§3 Modèle — LoadConfigEntry est une VUE en lecture au-dessus de la map brute ;
l'écriture repart de la map d'origine (patched()). Pas de couche de filtrage
préventive : l'aller-retour neutre passe contre .75, donc l'écho verbatim est
sûr par construction (§7-1).

§4 Priorité à deux niveaux — groupByDomain / flattenToPayload. Le rang de
domaine se déduit de l'ordre d'apparition à plat ; la box ne sait rien des
groupes. Config non contiguë : signalée, ordre réel affiché, normalisation
seulement sur confirmation explicite. Miroir client de validateSet().

§5 Écritures asynchrones — LoadConfigProvider ne conclut jamais sur
l'acquittement du RPC : pending → LoadConfigChanged → confirmed|diverged.

NymeaEnergy.GetLoadConfig / SetLoadConfig deviennent RÉELS (ils étaient des
stubs journalisés). Conséquence traitée : EnergySetupProvider.persist()
n'émet plus setLoadConfig(buildLoadDescriptors()) — SetLoadConfig remplace
TOUT l'ensemble, et _commit() l'appelait à chaque changement d'UI : une
reconstruction depuis le modèle typé aurait écrasé la config réelle de la
box sans geste explicite.

Écran /energy/loads, gaté installateur (drawer + garde de routeur).

Limites — le lot plugin n'est PAS déployé sur .75 : GetLoadTelemetry,
LoadTelemetryChanged et o:domain sont absents (19 méthodes / 13 notifications
inchangées). Toute la moitié télémétrie du brief est donc hors d'atteinte, et
o:domain n'étant pas au schéma, la box rejetterait la clé (jsonvalidator.cpp:143).
Fixtures multi-domaines synthétiques : .75 n'a qu'une charge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 11:34:53 +02:00
Patrick Schurig
40ac3b2c8e test(fixtures): dump réel LoadConfig + schéma introspecté de hems .75
Préalable §0 du lot 1 : la charge utile réelle fait foi pour le modèle Dart.

- loadconfig_hems75.json : NymeaEnergy.GetLoadConfig sur 192.168.1.75
  (nymea 1.15.2, experience NymeaEnergy 0.8). Conserve volontairement les
  charges utiles VIDES des mécanismes inutilisés (sgReady:{}, powerLevels:[],
  needs à défaut) — sérialisation par méta-objet côté plugin.
- loadconfig_schema_hems75.json : JSONRPC.Introspect du schéma SetLoadConfig
  réellement accepté par la box, et du type LoadConfig renvoyé par GET.

Le schéma est committé parce que nymea REJETTE toute clé inconnue
(jsonvalidator.cpp:143) : c'est la liste exhaustive de ce qui peut être
réécrit, et donc la référence de l'aller-retour lire→modifier→réécrire.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 10:37:59 +02:00
Patrick Schurig
3821bf3fdb refactor(energy): retire le calcul de ratios app-side, isole l'interim derrière EnergyRatiosInterim
- supprime selfRate/autoRate de _parsePowerBalance, le getter autoconsommationW, les .abs() interim
- ratios dérivés par Δ-de-cumuls (§2.1/§8.1) dans lib/services/energy_ratios.dart
- reseed jour-roulant & Δ non-monotone ; gardes : dén≤0→null, pas de NaN, clamp [0,100]
- selfConsumptionPower net-signé sans .abs() (§2.2) ; signes nymea bruts préservés (§7.1)
- seam swappable vers un state plugin en Phase 2 (une seule fonction)

Interim (Phase 1). Gestion du signe d'affichage renvoyée à la Phase 4 (§1.1).
Réf. UI_DATA_CONTRACT.md rev.5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 11:51:50 +02:00
Patrick Schurig
c638ec6c52 feat: connexions multi-HEMS + rôles & appareils (beta add/config)
Lot Connexions multi-HEMS :
- modèle Installation + InstallationStore (persistance par UUID ;
  métadonnées SharedPreferences, token via flutter_secure_storage)
- ConnectionManager au-dessus de nymea_service : mono-connexion, switchTo,
  connectNew, enterDemo (démo = active factice), contrôle d'identité DHCP
- nymea_service : connect() réveillé (ws://4444, token par UUID), capture
  Hello (uuid/name/initialSetupRequired), authenticate/createUser, resetState
- écran Installations (3 états) + déverrouillage installateur depuis la racine
- écran Connexion : auth 2 branches (JSONRPC.Authenticate / CreateUser),
  détection auto via initialSetupRequired, segmenteur de repli
- gate de routage (redirect + refreshListenable merge[cm,svc]) ;
  hasActiveConnection = isSimulation || (connected && authenticated)
- en-tête drawer cliquable → Installations
- invalidation des caches au switch (service.resetState + clearForSwitch
  rôles/scheduler/tariff), sans persist()

Lot Rôles & appareils :
- EnergySetupProvider refondu en tri-état (present/absent/non-configuré)
- écran roles_devices_screen (3 zones, drag-priorité, inférence solaire/batterie)
- LoadDescriptor (etmvariableload, rév.2 : PAC exclue → SG-Ready) ;
  Energy.SetRootMeter réel, Get/SetLoadConfig en stub loggé

Correctifs :
- bug signe énergie : production +, consommation NÉGATIVE sur nymea 1.15.2
  → consumptionW/home en .abs() (autoconso n'est plus clouée à 0)
- UUID Hello brace-wrapped normalisé

Tests : flutter analyze 0 erreur ; gate + zéro-fuite au switch (5/5 verts).
Validé en vrai : login auth .120, .75 ouvert, switch, déverrouillage installateur.
Note : embarque aussi le WIP dashboard/thème déjà présent dans l'arbre.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 08:39:08 +02:00
etm
d5dc0c7ca5 Initial commit : Flutter app nymea energy monitor
- NymeaService : auth complète (Hello → Authenticate → SetNotificationStatus)
- Token top-level dans chaque requête JSON-RPC (fix critique GetThings)
- Persistance token via shared_preferences par hôte
- Dashboard : champs utilisateur/mot de passe dans le dialog de connexion
- ThingDetailScreen : renommer, réglages (settingsTypes) et supprimer
- NymeaThingClass : champ settingsTypes parsé depuis l'API
- NymeaThing : copyWith(name) + settingValue()
- Fix overflow _StateChip dans ThingsScreen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 16:57:46 +01:00