97 Commits

Author SHA1 Message Date
Patrick Schurig
0d1732dc61 fix(ev): l'écran ne renvoie plus ailleurs pour un réglage qu'il porte lui-même
Vu à la capture du bouton de boost : la note « le mode de recharge se règle
depuis la tuile Borne EV des favoris » est visible trois centimètres au-dessus
des boutons de mode, dans le même défilement. Les boutons ont été déplacés ici
le 2026-08-28 et la phrase ne l'avait pas suivi — elle envoyait chercher ce qui
est sous les yeux.

Capture des trois modes et du bouton d'action :
docs/captures/2026-09-08_ev-modes-et-boost.png

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-08 14:58:47 +02:00
Patrick Schurig
b0b6666bc8 feat(ev): le boost est une DÉROGATION — bouton d'action, durée obligatoire, six fins
Le lot complet côté app, écrit sur le contrat vérifié dans Introspect sur .75 en
1.15.2+etm61 : SetLoadDerogation { loadId, durationS, o:targetWh } /
ClearLoadDerogation { loadId }, et derogationEnded[] { cause, loadId,
o:restoredMode } au niveau de la trame.

CE QUI EST UN BOUTON D'ACTION, PAS UN MODE. Une dérogation se superpose au mode
courant et s'efface ; elle ne le remplace pas. La mettre dans le sélecteur
ferait croire qu'on en sort en choisissant autre chose.

UNE DURÉE, TOUJOURS. Bornée [60, 21600] côté moteur. L'écran n'offre AUCUNE
option « jusqu'à annulation » — pas parce que l'appel échouerait, mais parce que
la promesse serait mauvaise : une dérogation qui ne sait pas dire quand elle
finit ne devrait pas exister (LM-1500). Le filet reste armé même avec une cible,
parce qu'une cible repose sur un instrument et qu'un instrument peut se taire.

L'ÉTAT NE SE LIT PAS DANS LE MODE. Si la borne était en manuel, le moteur force
Eco le temps de la dérogation : GetChargingInfos montrerait alors un mode que
personne n'a choisi. La vérité est sur la charge —
LoadTelemetryEntry.enDerogation.

Trois refus d'inventer, chacun figé par un test :
- entrée de télémétrie absente -> le bouton se TAIT. « Pas d'entrée » n'est pas
  « pas de boost », c'est « on ne sait pas » ;
- échéance dépassée alors que la box annonce encore la dérogation -> on la NOMME
  au lieu de rabattre à « 00:00 » : c'est une contradiction entre deux champs de
  la même trame, et le zéro l'effacerait ;
- cause de fin inconnue -> aucun libellé. Une box plus récente peut en publier
  une septième ; lui coller la phrase d'une autre serait pire.

Et `EnergyErrorInvalidParameter` sur l'arrêt ne crie pas au refus : la
dérogation a pu finir seule pendant l'appui, et une course que l'utilisateur ne
peut pas éviter n'est pas une erreur à lui montrer.

⚠️ boundBy a DEUX catalogues : drawCap|loadCeiling sur la dérogation, mais
« minimum » sur ECO_FLOOR_GRID — relevé sur la V2C aujourd'hui. Même nom, sens
différent : aucune traduction partagée.

⚠️ Écrit d'après le contrat, PAS observé : DEROGATION_ACTIVE, endsAt et boundBy
ne sont pas dans Introspect (les params d'une décision sont une carte libre), et
aucune dérogation n'a tourné pendant une sonde. Tous les champs restent
nullables. Noté aussi qu'Introspect SOUS-DÉCLARE — il annonce draw{committedW}
seul alors que la box publie authorisedW/binding/bindingPhase/perPhaseBound —
donc son silence ne prouve rien.

La feuille de choix défile : 47 px de débordement trouvés par le test sur une
surface courte, donc réels sur un téléphone à police agrandie.

358 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-08 13:59:32 +02:00
Patrick Schurig
5944d3a24a fix(dashboard): « Plan de la journée » retirée — cinquième donnée inventée, et la dernière
DayPlan.demo() fabriquait vingt-quatre heures de flux : une gaussienne solaire
et des coefficients écrits en dur — solToEcs = excédent × 0,35, solToVe × 0,3,
solToBatt × 0,15, battToHouse = déficit × 0,55 — sous le titre « Plan de la
journée · aujourd'hui », sans la moindre mention. Y compris une batterie
alimentant la maison à partir de 17 h, sur une installation qui n'a pas de
batterie.

Cinquième après les deux MPPT, les 38 % de PAC, les 82 % de part solaire et le
SOC à 62 %. Même réponse à chaque fois : il n'y a rien derrière.

Un plan suppose de savoir ce qui VA arriver — il demande une prévision PV et une
source tarifaire, dont aucune n'existe. La carte reviendra avec elles.

Le code de fabrication part AVEC la carte : DayPlan, HourlyFlow, WeatherPoint,
WeatherKind et le painter sont supprimés, pas mis de côté. Un générateur de
données plausibles laissé dans le dépôt se rebranche — c'est exactement ce qui
s'est passé quatre fois déjà.

339 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-08 13:26:30 +02:00
Patrick Schurig
5a51582729 fix(ev): « Boost » devient « Manuel » — le nom promettait le contraire du comportement
Décision de Patrick : trois modes suffisent — Manuel, PV, Min+PV. Le boost n'est
pas un quatrième mode, c'est la dérogation (+etm61).

LE FAIT. Le bouton écrivait `ChargingModeNormal`, qui sort la borne de
l'arbitrage et lui restitue ses valeurs manuelles stockées —
manualChargingEnabled, manualMaxChargingCurrent, manualDesiredPhaseCount. Sur
.75 elles valaient false / 6 A / 3 phases : mesuré le 2026-09-08 à 11:09:51,
l'appui a ARRÊTÉ la charge et armé un verrou de 300 s. Pas une régression —
c'est ce que Normal a toujours fait. C'est le nom qui mentait.

Renommé jusque dans le code (`ChargingMode.boost` -> `ChargingMode.manuel`), pas
seulement sur l'étiquette : un identifiant faux reste un piège pour le prochain
lecteur, et c'est précisément ce qui a duré des mois ici. L'ordre met Manuel en
tête, parce que ChargingModeNormal est aussi le mode d'office d'une borne
détectée.

ET LE RENOMMAGE NE SUFFISAIT PAS. « Manuel » dit QUI commande, pas ce que la
borne va faire : tant que l'app n'offre pas de régler ces valeurs stockées, le
mode veut dire « ce qui traînait la dernière fois ». Passer en manuel demande
donc confirmation, et la confirmation dit la conséquence — la charge peut
s'arrêter — AVANT, pas après.

⚠️ La confirmation ne se déclenche pas sur un ré-appui du mode déjà en place :
une confirmation qu'on apprend à cliquer sans lire ne protège plus de rien. Un
test le fige.

Vérifié sur .75 (1.15.2+etm61) : SetLoadDerogation et ClearLoadDerogation sont
dans Introspect, avec derogationEnded / durationS / restoredMode. Le contrat de
la dérogation est écrit dans CLAUDE.md — durée obligatoire bornée [60, 21600],
état lu dans decision.code == "DEROGATION_ACTIVE" et jamais dans le mode, fin au
niveau de la trame avec six causes. Aucun bouton de boost n'est écrit : c'est le
lot suivant.

Et une sonde pour le moteur, faite au passage : la V2C est bien restée en
ChargingModeNormal et elle est ABSENTE de loads[] — hors arbitrage. Leur
hypothèse est confirmée sans redémarrage.

339 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-08 12:19:39 +02:00
Patrick Schurig
4d3f35d6d0 feat(énergie): les charges d'un même domaine se nuancent — et une troisième le fait seule
La V2C et la borne simulée, toutes deux en `ev`, recevaient exactement le même
violet : indiscernables dans l'empilement, et la légende répétait la même
pastille. Vu à l'écran dès qu'une seconde borne est apparue.

`EtmTokens.nuance` décline sur le RANG dans le domaine, donc une troisième borne
prend sa teinte sans qu'on déclare quoi que ce soit. Le décalage porte sur la
LUMINOSITÉ, pas sur la teinte : glisser la teinte finirait par heurter le
domaine voisin, l'éclaircir garde la famille lisible.

⚠️ La série est bornée à quatre, et c'est explicite plutôt que subi : au-delà,
l'écart ne se voit plus, et prétendre distinguer huit nuances d'un même violet
serait une promesse que l'œil ne tient pas. Le rang 3 et le rang 9 rendent la
même couleur — le signe qu'il faut alors autre chose que la couleur pour
identifier, et c'est un choix d'écran, pas un réglage de palette. Un test le
fige.

La couleur est calculée à UN endroit, partagé par le tracé et la légende : deux
sources de couleur, c'est une légende qui finit par désigner la mauvaise bande.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-08 11:41:24 +02:00
Patrick Schurig
a76798f2d8 feat(énergie): six cartes journalières, la V2C qui commence en cours de route, et plus de simulation muette
CARTES. Elles lisaient `energyData.dayProductionWh`, qui porte en réalité le
compteur CUMULÉ depuis l'origine : « Production 62946.74… » au-dessus d'un
graphe de 24 h. Et « Gains 8789.84 € » n'avait aucune source — un prix du kWh
que personne n'a saisi. Elles lisent maintenant DailyBalance, comme le tableau
de bord, et chaque taux porte son dénominateur (« 22 % sur 1,1 kWh produits »).
Mise en page reprise de la maquette validée.

V2C. `powersync-plugin-v2c 1.15.2+etm14` lui a ajouté `smartmeterconsumer` :
vérifié à la sonde, elle rend 1 point contre 45 aux autres, son historique
commençant à 10:57. Le filtre d'alignement précédent l'aurait sortie du TRACÉ en
la laissant dans la LÉGENDE — le défaut qu'on venait de corriger sur l'autre
graphe. L'alignement passe donc par instant, et une série ne contribue que là
où elle a des points.

Avant son premier point, sa consommation est comptée dans le reste du foyer :
le total reste juste, seule la RÉPARTITION est incomplète, et c'est ce que dit
la note. Trois états, trois phrases — « pas d'historique », « mesurée depuis
HH:MM », « 0 W mesuré » — et aucune ne se dessine comme une autre.

Et le commentaire de fetchThingPowerLogs est corrigé deux fois : il était
périmé, et il nommait le mauvais portier. `smartmeter` est une interface VIDE
qui ne conditionne rien ; c'est `smartmeterconsumer` qui décide, via
EnergyManagerImpl::watchThing().

🔴 SIMULATION. L'écran faisait `if (!connected) startSimulation()` au montage.
Monté avant que la WebSocket soit établie — le cas normal — il basculait tout le
service en démo et n'en ressortait jamais. Vu à l'écran, box joignable : une
sinusoïde orange et un SOC à 75 % sur une installation SANS batterie. Rien ne
distinguait ces données inventées des vraies : ni couleur, ni mention, ni forme.
La fabrication sans même l'étiquette qui la blanchit.

Le tableau de bord avait retiré la même chose pour la même raison : la démo est
un CHOIX, pris depuis l'écran Installations, jamais un repli silencieux. L'écran
attend maintenant la connexion et se rattrape dès qu'elle arrive.

Captures : docs/captures/2026-09-08_energie-cartes-journalieres.png
           docs/captures/2026-09-08_energie-empilement-v2c.png

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-08 11:31:01 +02:00
Patrick Schurig
6d0ba26e07 feat(énergie): le graphe de flux EMPILE au lieu de superposer
Validé par Patrick. Sa proposition — production en jaune, autoconso en orange,
et des traits pleins — partait d'un bon constat, mais la couleur ne pouvait pas
régler le problème : superposées, autoconsommation et production sont égales au
watt près dès que rien n'est exporté, et deux traits pleins superposés n'en
laissent voir qu'un, quelles que soient leurs teintes. Le défaut est
géométrique, pas chromatique — et le pointillé posé avant n'était qu'un
contournement.

Empilées, le recouvrement devient impossible :

    jaune   PV -> réseau (exporté)      ┐
    orange  PV -> maison (autoconsommé) ┘  sommet = production totale

La production cesse d'être une courbe : elle EST le haut de la pile. Quand rien
n'est exporté la bande jaune a une hauteur nulle — vrai, et lisible d'un coup
d'œil. L'export devient une SURFACE au lieu d'un écart à mesurer à l'œil entre
deux traits.

Vérifié à l'écran : l'orange porte le fond de la journée, le jaune ne sort que
sur l'épisode d'export de fin d'après-midi.

Deux détails qui vont avec :
- `_aire` ne courbe pas (`isCurved: false`) — une spline peut passer sous zéro
  entre deux points, et une bande d'énergie sous l'axe affiche une grandeur qui
  n'existe pas ;
- les lignes n'ont plus de remplissage par défaut : un voile à 7 % recouvrait
  les bandes et faussait leurs teintes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-08 11:00:38 +02:00
Patrick Schurig
67bc0ca7ea fix(énergie): la légende ne promet pas une courbe qui n'existe pas
« SOC % » figurait en dur dans la légende du premier graphe. Sur une
installation sans batterie — celle du banc — l'écran annonçait donc une série
qu'aucune donnée ne pouvait remplir. Même défaut que partout ailleurs (montrer
ce qu'on n'a pas), pris par la légende au lieu du tracé.

Le titre, le sous-titre et l'entrée de légende suivent maintenant la présence
d'une batterie, et la note d'absence passe SOUS ce graphe-ci : c'est là que le
SOC aurait été tracé, donc c'est là qu'il manque.

`_sansBatterie` exige que les Things soient chargés : tant qu'ils ne le sont
pas, « pas de batterie » et « on ne sait pas encore » sont indiscernables, et
annoncer l'absence trop tôt serait une absence côté APP présentée comme une
absence côté BOX.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-08 10:43:12 +02:00
Patrick Schurig
9c19791e9a feat(énergie): les deux graphes — flux corrigé, et l'empilement par consommateur
Maquette validée par Patrick (couleurs comprises). Deux graphes, et trois
défauts d'affichage trouvés en chemin — dont deux à l'écran seulement.

GRAPHE 1 — les trois « on ne voit pas » avaient trois causes.

  production : publiée NÉGATIVE (−103 W). La magnitude est prise AU RENDU, pas
  au transport : le champ reste brut (§7.1), et `.abs()` est licite sur une
  production, dont le signe ne porte aucune information — contrairement au
  réseau, où le signe dit le sens (d'où gridImportW/gridExportW là-bas).

  autoconsommation : `production − export` clampé à 0, donc TOUJOURS zéro avec
  une production négative. Même racine, corrigée par la même magnitude.

  SOC : correctement absent — aucune batterie sur l'installation. Ce qui
  manquait, c'était de le DIRE ; c'est maintenant une note sous le graphe.

  ⚠️ Et un deuxième empêchement, vu seulement à l'écran : le signe réparé, la
  production restait invisible SOUS l'autoconsommation. Tant que rien n'est
  exporté les deux sont égales au pixel près, et la verte était tracée par
  dessus. L'autoconso passe en pointillé — des tirets sur la bande ambre disent
  « tout est consommé sur place », leur décrochage dit l'export. Deux courbes
  qui coïncident doivent pouvoir se VOIR coïncider.

  ⚠️ Ce correctif en créait un autre : le SOC était aussi vert pointillé. Sur
  une installation AVEC batterie les deux auraient été indiscernables — collision
  qu'aucun banc sans batterie ne pouvait montrer. Le SOC passe en indigo.

GRAPHE 2 — qui consomme, empilé.

  `Energy.GetThingPowerLogs`, et la liste des appareils vient de GetLoadConfig :
  chaque charge déclarée porte son `meterThingId`, son label et son domaine —
  vérifié renseigné sur les quatre charges de .75. Rien n'est deviné d'un nom
  d'appareil.

  Un appareil demandé sans réponse sort dans `sansHistorique` et est NOMMÉ sous
  le graphe, jamais tracé à zéro : la V2C ne déclare pas `smartmeter`, donc elle
  n'a pas de passé — ce qui n'est pas « elle n'a rien tiré ».

  Le RESTE du foyer est licite ici, contrairement au Sankey qui l'avait fait
  interdire : chaque part est un compteur dédié et le total est le compteur du
  foyer, donc la soustraction ne porte que du mesuré. Et un reste NÉGATIF n'est
  pas raboté — c'est la preuve qu'un sous-compteur n'est pas dans le périmètre
  du total, et l'écraser effacerait la seule chose qui le dise.

  🔴 Le signe des sous-compteurs n'est PAS vérifié : les trois lisent 0 W à tous
  les pas, donc aucun échantillon non nul n'existe. Le compteur PV publie en
  négatif — rien n'autorise à supposer. La valeur est transportée brute et
  `signeIndetermine` le dit à l'écran.

Couleurs par consommateur ajoutées au thème (pac ambre, ecs sarcelle, ev
indigo) : distinctes des couleurs de FLUX, qui disent d'où vient l'énergie, là
où celles-ci disent qui la prend.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-08 10:25:16 +02:00
Patrick Schurig
1dece8eb33 fix(brouillon): la barre suit — et le déplacement a révélé un défaut de contrainte
La barre d'action passe en pied d'écran fixe (`bottomNavigationBar`), parce
qu'un correctif juste mais hors du champ reste inaccessible : le geste se fait
en haut de la liste, la barre était un écran entier plus bas. C'est le défaut
d'hier sous une autre forme — cause de disposition au lieu de cause de logique.

`bottomNavigationBar` plutôt qu'une superposition : il RÉDUIT la hauteur du
corps au lieu de le recouvrir, donc rien ne se cache derrière la barre.

⚠️ ET LE DÉPLACEMENT A CASSÉ L'ÉCRAN. Le `Column` d'`ImpactSaveBar` était en
`MainAxisSize.max` — le défaut. Dans un `ListView` la hauteur disponible est
infinie et le Column se contente de ses enfants : le défaut ne pouvait pas se
voir. En pied d'écran la contrainte est lâche mais BORNÉE, le Column a pris
toute la hauteur, le corps a été écrasé à zéro. Écran vide sous une barre
géante, vu en release sur appareil — les 319 tests étaient verts.

Un widget qui ne se contraint pas lui-même hérite du contexte : le déplacer
change ses contraintes, donc sa taille, sans qu'une ligne de son code ait bougé.

Le test ajouté monte la barre dans un `bottomNavigationBar` et mesure sa
hauteur. Éprouvé par son contraire : sans `mainAxisSize: min` il rend 600 sur
600 et échoue. Aucun test précédent ne pouvait mordre — tous montaient la barre
dans une hauteur infinie, où `max` se comporte comme `min`.

Vérifié sur appareil, chemin des flèches : brouillon réel -> barre en bas avec
Annuler/Enregistrer, corps intact ; ordre rétabli -> « L'ordre est revenu à
celui de la box » avec le seul « Terminer » ; Terminer -> barre absente. Banc
inchangé, aucune écriture.

Et la note gardée sur `draftIsNoOp` : « rien à enregistrer » veut dire « rien
par rapport à ce que tu as vu ». Comparer à l'état vivant conclurait à la place
de l'utilisateur sur des faits qu'il n'a pas vus. Le référentiel de la
CONCLUSION et celui de l'ÉCRITURE sont deux choses, et il faut les deux.

Capture : docs/captures/2026-09-08_barre-en-pied-terminer.png

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-08 08:30:11 +02:00
Patrick Schurig
c015bff63b docs(ordre): la géométrie mesurée — le seuil est en pixels, pas en nombre de charges
Relevé à uiautomator sur le banc (Redmi Note 9S, 3 domaines, 4 charges, blocs
dépliés) :

  viewport de la liste             1 912 px
  Véhicule électrique (2 charges)  1 355 px
  Eau chaude sanitaire (1 charge)  1 171 px
  Chauffage (1 charge)             1 029 px

Le doigt parcourt la hauteur de chaque bloc dépassé. D'où :

  échange de deux blocs voisins    au pire 1 355 px  <  1 912  -> passe
  remonter de deux rangs d'un coup       2 526 px    >  1 912  -> impossible

Le défaut est donc DÉJÀ atteignable à quatre charges, contrairement à ce qu'on
m'a attribué : ce n'est pas cinq charges qui l'ouvrent, et ce n'est pas un seuil
en nombre de charges du tout. Il se compte en pixels, et une charge de plus dans
un domaine déjà servi ne coûte pas la même chose qu'un domaine de plus.

Ce qui le rend peu grave n'est pas son inaccessibilité mais son contournement :
deux échanges voisins successifs font le même déplacement, et replier les blocs
aussi.

Et la citation de favorites_screen.dart est corrigée. L'écran utilise bien
CustomScrollView + SliverReorderableList, mais ce n'est pas un précédent
transposable : là-bas la liste est plate et posée directement dans les slivers.
Ici elle est deux composants plus bas — il faudrait que ControllableLoadsSection
cesse de rendre une Column pour émettre des slivers, donc changer le contrat du
widget et convertir tout le corps de roles_devices_screen. Un montage à écrire,
pas à copier.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-08 07:42:45 +02:00
Patrick Schurig
e76d01160d fix(brouillon): « j'ai décidé de ne rien changer » est un état, et il se conclut
Le geste sans repli de cet écran — déplacer une plage — puis le geste inverse
laissaient un brouillon qui rend exactement la configuration de la box. L'écran
le traitait comme n'importe quel brouillon, et n'offrait plus pour en sortir que
des portes mal nommées. Quatre messages, tous faux au même instant :

  la barre                 « Modifications en attente »
  la sortie d'écran        « vos changements n'ont pas été envoyés »
  adopter un rang          « enregistrez ou annulez vos modifications »
  retirer une charge       idem

Il n'y avait plus rien à enregistrer, ni rien à perdre.

`draftIsNoOp` nomme l'état : un brouillon dont les charges utiles brutes, clés
triées, sont identiques à celles de la box — ce qui couvre l'ordre ET un patch
de champ. `hasSubstantiveChanges` garde désormais les trois portes ; la barre,
elle, continue de s'afficher, parce que c'est là qu'est la sortie.

Elle dit maintenant « L'ordre est revenu à celui de la box — il n'y a rien à
enregistrer » et n'offre qu'un geste, « Terminer ». Pas « Enregistrer » :
réécrire tout l'ensemble pour n'y rien changer reconstruirait les charges et
réarmerait les verrous, au prix de zéro.

⚠️ Le remède n'est PAS d'effacer le brouillon dès qu'il devient sans effet : la
barre disparaîtrait sous le doigt à l'instant où l'utilisateur repose la plage,
et « j'ai décidé » redeviendrait « il ne s'est rien passé ». Un état se conclut,
il ne s'évapore pas. Un test le fige.

Et la note sur le glissement est REPRISE. J'y affirmais un défaut ; le test au
doigt montre que le réordonnancement fonctionne, liste dépliée comprise. Le
mécanisme du shrinkWrap reste écrit — il est probablement juste — mais comme une
LIMITE CONNUE non reproduite, avec le protocole en trois gestes (A témoin,
B discriminant, C cause), le contournement (replier les blocs) et le remède
nommé. On ne réécrit pas un écran qui marche sur la foi d'une lecture de code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-08 07:30:12 +02:00
Patrick Schurig
dfa327cbcd docs(ordre): pourquoi le glissement ne peut pas défiler — établi par construction
Le moteur soupçonnait un ReorderableListView en conflit avec un défilement
parent. C'est exact, et le mécanisme se lit dans le code sans avoir à toucher
l'écran :

  roles_devices_screen.dart:113   body: ListView(...)        <- le parent défile
  controllable_loads_section:104  ReorderableListView.builder(
                                    shrinkWrap: true,
                                    physics: NeverScrollableScrollPhysics())

En `shrinkWrap`, le viewport de la liste vaut la hauteur de son contenu : son
`maxScrollExtent` est NUL. Or l'auto-défilement du glisser-déposer agit sur le
Scrollable de la liste réordonnable, jamais sur un parent. Il n'a donc nulle
part où aller, et le ListView extérieur n'est pas prévenu.

Conséquence : on ne réordonne que ce qui tient à l'écran en même temps. Un bloc
de domaine déplié fait plus d'un écran, donc le geste qui compte — remonter un
domaine par-dessus un autre — est justement celui qui ne peut pas aboutir.
Replier les blocs le rend possible : contournement immédiat, et contrôle qui
prouve le diagnostic.

Remède structurel : un seul Scrollable, CustomScrollView + SliverReorderableList
— le montage déjà en place dans favorites_screen.dart. PAS fait tant que le
geste n'est pas éprouvé au doigt : l'entrée synthétique ne reproduit pas un
appui-glissé, donc aucun test de ce dépôt ne peut montrer ce défaut ni prouver
sa correction.

Et une piste abandonnée plutôt que remontée : j'ai soupçonné les écritures de
l'app d'effacer `rankOrigin`. Faux — l'aller-retour est verbatim sur les charges
utiles brutes (LIRE -> PATCHER -> RÉÉCRIRE), rien n'est reconstruit depuis le
modèle typé.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-08 07:05:56 +02:00
Patrick Schurig
0df80b5b9b fix(écran): un instant d'un autre jour porte son jour, et le motif ne rapetisse plus la grandeur
Deux défauts vus sur appareil après installation, tous deux d'affichage.

1. « mesuré depuis 16:37 » à 06:15, pour un `measuredSince` de la VEILLE. Le
   lecteur comprend 16:37 aujourd'hui — une heure qui n'est pas encore arrivée —
   et « 2,5 kWh achetés » juste à côté devient incompréhensible avec elle.
   Même défaut que l'heure UTC, d'un cran plus haut : là on perdait le fuseau,
   ici le jour. `heureLisible` rend « HH:MM » dans la journée du lecteur,
   « hier HH:MM » la veille, « le JJ/MM à HH:MM » au-delà — et la comparaison
   porte sur le jour civil, pas sur 24 h : à 00:05, un instant de 23:50 est hier
   même s'il a quinze minutes.

2. « Autoconsommation · sans production » rendu à la moitié de la taille
   d'« Autonomie » à côté. Cause : un FittedBox reçoit des contraintes de
   largeur infinies, donc son enfant tient sur une ligne aussi large qu'il veut
   et c'est tout le bloc qui rétrécit — `maxLines: 2` n'y sert à rien.
   Rallonger le texte d'un suffixe rapetissait donc le NOM de la grandeur, qui
   est ce qu'on lit en premier. Le motif passe sur sa propre ligne.

Les tests qui figeaient « HH:MM » dérivent maintenant de `heureLisible`, la
fonction même de l'écran — un attendu recomposé à la main aurait gelé la forme
d'avant, exactement comme les « 03:42 » en dur de vendredi.

Captures : docs/captures/2026-09-08_taux-sans-production.png
           docs/captures/2026-09-08_instant-hier.png

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-08 06:31:52 +02:00
Patrick Schurig
0c5f0ee30d docs(ev): Min+PV sert bien le plancher — prouvé par deux comptages, et boundBy ne prouve rien
+etm58 posé (au-delà de +etm57 annoncé). Trois vérifications.

1. ANCRAGE DES INSTANTS : livré et observable. Les trois champs ISO de la trame
   portent le marqueur Z, `command.since` compris —
   `{divergent, expectedW, observedW, since: "2026-09-07T14:37:00Z"}`.

2. MIN+PV : livré. La voiture a chargé toute la journée sans jamais quitter
   ChargingModeEcoWithMinCurrent, et deux comptages sans source commune
   concordent à 4 % :
     surplus MAX du jour        536 W — 0 quart d'heure sur 71 au-dessus de 4140
     fenêtre 08:45 -> 15:15     16,2 kWh dont 14,2 au-dessus de la base (539 W)
     sessionEnergy de la V2C    1,06 -> 14,72 kWh, soit +13,7 kWh
   Le surplus n'ayant jamais dépassé 536 W, tout a été acheté au réseau : c'est
   le plancher demandé.

3. `boundBy` : ABSENT, et cette absence ne prouve rien — le seul ECO_FLOOR_GRID
   de la trame est celui du chauffe-eau, dont le plancher vient d'une obligation
   quotidienne et non d'un minimum matériel ; et la borne est sortie de loads[]
   (pluggedIn: false). Un `grep -c boundBy` valant 0 aurait fait conclure
   « non livré » à l'instant où la mesure prouve le contraire. À revoir voiture
   branchée.

Et une affirmation de CLAUDE.md devenue FAUSSE, corrigée : le plafond de
soutirage du §10 existe désormais —
  "draw": { "authorisedW": 6000.4, "committedW": 103.2, "remainingW": 5897.2,
            "binding": "connection", "bindingPhase": "A", "perPhaseBound": true }
`binding` nomme ce qui contraint et `bindingPhase` la phase qui mord : le
plafond se dit donc avec sa source, la condition qu'on avait posée pour
l'afficher. Aucun écran ne le lit encore, et rien n'est câblé — l'écran du §10
n'est pas validé.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-07 16:49:34 +02:00
Patrick Schurig
a430fb00c8 fix(instants): un seul lecteur d'horodatage de box — et ma cause d'hier était FAUSSE
Le contrat porte désormais la règle : tout horodatage publié est en UTC avec son
marqueur, ou en epoch. Trois champs, deux formats, rien ne l'exigeait.

`instantDeLaBox` (lib/models/box_instant.dart) devient l'unique lecteur pour ce
que la box publie — epoch ou ISO, marqué ou nu — et requalifie la forme nue en
UTC avant de rendre l'heure du lecteur. Politique valable pour la BOX seule :
`Installation.lastSeen` est écrite en local sans marqueur et relue pareil, donc
la lire en UTC la décalerait. Deux héritages opposés sous une même forme, d'où
l'absence délibérée d'un « parser une date » unique.

Le `.toLocal()` posé hier dans telemetry_text est retiré : le modèle est seul
propriétaire de la conversion. Le rejouer serait sans effet mais laisserait
croire qu'elle peut manquer ailleurs.

CORRECTION. J'avais écrit que le défaut avait survécu « parce que box et
téléphone partageaient l'heure ». C'est faux, et la fausseté est utile : l'écart
est entre UTC et l'APPAREIL, donc visible sur tout téléphone hors UTC+0 quel que
soit le fuseau de la box. Mesuré ce matin avec les deux en Europe/Paris — la box
publie 04:33:28Z, l'APK d'hier affiche « 04:33 », il fallait lire 06:33. La
bascule en Europe/London n'a pas donné la visibilité, elle a donné le MOTIF de
regarder. La version fausse fait conclure « les fuseaux coïncident, donc c'est
réglé » et laisse le défaut en place.

`GetVariableLoadState` n'existe pas sur .75 (absente d'Introspect) et l'app ne
lit aucun `since` : rien à corriger de ce côté. Le champ existe en revanche dans
GetLoadTelemetry — `loads[].command {divergent, expectedW, observedW, since}` —
et il porte bien le Z, vérifié à la sonde. Aucun écran ne le consomme encore.

Contrôle négatif de la ligne d'horloge : même binaire qu'hier, box repassée en
Europe/Paris, la ligne a disparu sous le champ d'échéance.
docs/captures/2026-09-07_fuseau-box-paris-ligne-absente.png

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-07 07:34:24 +02:00
Patrick Schurig
92495c0e87 fix(dashboard): « sans production » n'est pas « pas mesurable »
Signalé sur appareil de nuit : tuile Autoconsommation « — · pas mesurable »,
sur une installation dont les quatre compteurs marchent.

D'où vient le nombre : la tuile lit `DailyBalance`, pas `GetEnergyRatios`.
`selfConsumptionRate` y rend `null` dès que `productionKwh <= 0` — production
nulle depuis minuit, donc 0/0.

Le « — » est JUSTE et ne bouge pas. Un « 0 % » affirmerait qu'aucune production
n'a été autoconsommée : une phrase sur une production qui n'existe pas, soit un
zéro forgé de plus. Ce qui était faux, c'est le motif écrit dessous — il
accusait la mesure là où il n'y avait simplement rien à mesurer.

Les deux causes d'un taux absent sont désormais séparées :
- pas de bilan (échantillons, cumuls, compteur en recul) → « pas mesurable » ;
- dénominateur nul → « sans production » / « sans consommation ».

C'est la distinction habituelle du dépôt prise par l'autre bout : d'ordinaire on
empêche une absence de se dire zéro ; ici on l'empêche d'accuser la mesure.

Mesuré sur .75 au même instant : production 0 W, `selfConsumptionRate` absent de
GetEnergyRatios sur trois relevés, `autonomyRate: 0` présent — la box fait la
même distinction, sur la même arithmétique.

Le premier jet du test asserait `find.text('0 %'), findsNothing` : il mordait
sur le 0 % de l'Autonomie, qui est une mesure. Corrigé en le voyant échouer, et
la raison est dans le test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-07 06:42:17 +02:00
Patrick Schurig
b843d8936a fix(fuseau): un INSTANT se rend chez le lecteur — « mesuré depuis » affichait l'heure UTC
Trouvé sur appareil pendant la bascule volontaire de .75 en Europe/London, en
capturant la ligne d'horloge de l'échéance : la box publie
`measuredSince: "2026-09-06T14:38:00Z"` et l'écran affichait « mesuré depuis
14:38 » — ni l'heure de la box (15:38 BST), ni celle du téléphone (16:38 CEST).
L'heure de personne.

`DateTime.tryParse` d'une chaîne en `Z` rend un DateTime `isUtc: true` ; lire
`.hour` sans `toLocal()` donne l'heure UTC. Le défaut est identiquement
invisible tant que box et appareil partagent l'offset : il a fallu DEUX fuseaux
pour qu'il se voie, et c'est la couture ouverte pour la capture qui l'a montré.

Deux natures d'heure, désormais séparées dans CLAUDE.md :
- un RÉGLAGE (`dailyDeadline`) est lu par la box → on nomme son horloge ;
- un INSTANT (`measuredSince`) est absolu → il se rend chez le lecteur, sans
  mention.

Les deux tests qui gardaient « 03:42 » en dur gelaient l'heure UTC et validaient
le défaut : leur attendu se dérive maintenant de `.toLocal()`. Le contrôle
négatif ne mord que hors UTC, et le test le dit au lieu de laisser lire un vert
comme une preuve.

Capture de la ligne d'horloge : docs/captures/2026-09-06_fuseau-box-london.png

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-06 19:57:38 +02:00
Patrick Schurig
cb114b62bd fix(tableau de bord): les DEUX providers, pas un seul
Troisième tentative sur le même symptôme, et la bonne : le détail de consommation
croise la TÉLÉMÉTRIE (la mesure) et la CONFIGURATION (le domaine), et ni l'un ni
l'autre n'était attaché ailleurs que sur l'écran des rôles. Sur le tableau de
bord, `telemetry` était null ET `entries` vide.

Les deux correctifs précédents ne traitaient que la configuration — la moitié que
le premier diagnostic avait désignée. C'est la recherche auto-confirmante en
version « je n'ai regardé qu'un provider » : j'ai vérifié la partie que je venais
d'écrire, jamais l'autre, et l'écran a redit trois fois la même chose.

La donnée était pourtant sans ambiguïté à chaque étape : `chauffe-eau` → `ecs`,
`pac-terrain` → `heating`, et `{powerW: 0, source: "meter"}` sur les deux. Rien
ne manquait côté box ; tout manquait côté app, à l'endroit où on va chercher.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-06 14:58:30 +02:00
Patrick Schurig
b3da81d277 fix(tableau de bord): charger la configuration quand la box répond, pas au montage
Le correctif précédent — charger au montage — n'a rien changé, vérifié à l'écran :
PAC et « Eau chaude » affichaient toujours « non publié » alors que la box publie
`{powerW: 0, source: "meter"}` pour les deux.

La raison est le moment : l'écran se construit avant que la connexion soit
établie, l'appel échoue en silence, et RIEN ne réessaie. Le tick existait, mais à
cinq minutes.

Deux accroches donc, et la première suffit dans le cas normal : un chargement dès
que la box devient joignable (une seule fois, gardé par un drapeau), et un
rattrapage sur le tick si la configuration est encore vide.

C'est la troisième fois aujourd'hui que le même motif se présente — une absence
côté APP présentée comme une absence côté BOX — et la deuxième fois qu'un
correctif à ce motif échoue parce qu'il était posé au mauvais instant. Le
symptôme se corrige où la donnée manque ; la cause vit dans le moment où on va la
chercher.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-06 14:42:14 +02:00
Patrick Schurig
cd202840f1 fix(flux): la production restait négative dans le DÉTAIL, et le tableau de bord ignorait la config
Deux défauts trouvés en vérifiant la correction précédente à l'écran.

1. « -1.3 kW · Solaire » dans « Production — détail ». La correction du
   2026-09-05 n'avait redressé QUE le nœud de la vue d'ensemble : la même
   grandeur est rendue à deux endroits, et le grep qui avait servi cherchait
   `pvPower.abs` — donc il montrait la ligne déjà corrigée et cachait l'autre.
   Un test couvre désormais le détail, en éprouvant l'ABSENCE du signe.

2. PAC et « Eau chaude » affichaient « non publié » alors que la box publie
   `{powerW: 0, source: "meter"}` pour les deux. `LoadConfigProvider.load()`
   n'était appelé que par l'écran des rôles : sur le tableau de bord la
   configuration est vide, aucun `domain` ne se rattache, et la carte reste sans
   valeur. Une absence CÔTÉ APP présentée comme une absence côté box — le défaut
   qu'on venait de corriger, reproduit un cran plus loin, et trompeur de la même
   façon : on en conclurait que la box ne publie rien.

Vérifié à l'écran entre les deux : « non publié » est lisible dans les cercles,
« Autres » a disparu, et le détail production ne montre plus d'onduleurs
fabriqués mais « Détail par source non publié par cette box ».

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-06 13:24:53 +02:00
Patrick Schurig
12c3e912b0 fix(flux): les détails ne fabriquent plus de décomposition
Signalé sur banc, et les deux vues détaillées inventaient.

CONSOMMATION — PAC = 38 % de la maison, eau chaude = 18 %, autres = 22 %. Des
ratios posés à la maquette, marqués « estimé »… et affichés tels quels le
2026-09-06 alors que les compteurs ECS et PAC publiaient `{powerW: 0, source:
meter}`. Un compteur à zéro dit « rien ne chauffe » ; l'écran répondait 720 W.

La mention « estimé » était le problème autant que le remède : sous un nombre
inventé, elle le rend acceptable. Les branches viennent désormais de
`GetLoadTelemetry.measurement`, par domaine, et une charge sans mesure publiée
affiche « non publié ». « Autres » disparaît — c'était un reste calculé sur des
parts inventées, donc inventé lui aussi : sans mesure exhaustive, il n'y a pas de
reste connu.

PRODUCTION — « Onduleur 1 » et « Onduleur 2 » portaient chacun la MOITIÉ de la
production, avec l'état `ok`, donc sans même la marque « estimé » des branches de
consommation. Deux appareils qui n'existent nulle part : le banc n'a qu'un seul
compteur de production. La vue dit maintenant « détail par source non publié par
cette box ».

Et un débordement introduit en chemin, attrapé en test : « non publié » ne tient
pas dans un nœud dimensionné pour « 1.2 kW » — 7 px de trop, donc du texte coupé
sur l'appareil. `FittedBox(scaleDown)`, comme pour l'étiquette des tuiles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-06 13:05:43 +02:00
Patrick Schurig
a14adcfcc3 fix(bilan): un cumul ABSENT n'est pas un cumul nul
`double d(String k) => (m[k] as num?)?.toDouble() ?? 0.0` : un champ manquant
devenait 0,0 au décodage, deux bornes à zéro donnaient une différence à zéro, et
l'écran affichait « 0,0 kWh · Vers réseau » comme une mesure. C'est le zéro forgé
que nous traquons depuis une semaine, cette fois de NOTRE côté.

Le cas est réel et n'attend pas sa cause : un compteur racine parfaitement en
place, la puissance instantanée qui arrive, et les cumuls qui n'arrivent pas.

`PowerBalanceEntry.totalsPresent` porte la distinction, et `fetchDailyBalance`
refuse de présenter comme mesure un bilan bâti là-dessus — au même titre qu'un
compteur corrompu. « Rien échangé aujourd'hui » et « la box ne publie pas ses
cumuls » sont deux états, et le second n'a pas le droit de porter le premier.
Le repli à 0 reste, pour ne pas propager des nullables dans tout l'écran Énergie,
mais il est désormais ACCOMPAGNÉ du fait qu'il est un repli.

Et `abs()` sur la production devient un choix DOCUMENTÉ plutôt qu'un correctif
tacite : la production n'est pas signée, c'est le compteur qui l'est, donc la
valeur absolue masquerait un compteur câblé à l'envers dont le signe serait la
seule trace. Le correctif appartient à la source. On la garde en connaissance de
cause — un signe faux se voit tous les jours, un câblage inversé ne se voit
jamais — et on ne revient pas en arrière sans savoir ce que la box publiera.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-06 12:41:10 +02:00
Patrick Schurig
a6dd319ef2 fix(rôles): le compteur RÉSEAU n'est plus détecté comme compteur solaire
Signalé sur banc après changement de matériel : Fronius SunSpec retiré, Eastron
SDM72 posé en rootMeter et SDM120 en compteur de production. L'écran affichait le
SDM72 dans les DEUX rôles, avec « auto · lecture seule » — donc sans moyen de le
corriger.

La box était juste (`GetRootMeter` rend bien le SDM72) et les interfaces
distinguaient les deux sans ambiguïté :

  Grid / sdm72          : energymeter, smartmeter, connectable
  PV   / sdm120Producer : smartmeterproducer, smartmeter, connectable

C'est notre liste qui ne discriminait pas : le rôle solaire acceptait
`energymeter`, `meter` et `smartmeter` — portées par TOUT compteur, réseau
compris — et ignorait `smartmeterproducer`, la seule qui dise « ce compteur
mesure une production ».

Deux corrections, la seconde tenant même si une future classe déclare des
interfaces trop larges :

- le rôle solaire exige désormais une interface de production ;
- le Thing acté comme compteur RACINE n'est plus candidat à aucun autre rôle.

Reste ouvert, et c'est le vrai fond du signalement : une détection automatique
ne devrait pas être un cul-de-sac. Les lignes de compteur n'ont aujourd'hui
AUCUN geste — pas de sélecteur, quel que soit l'état. Le badge « lecture seule »
est honnête, mais une inférence fausse doit pouvoir se corriger.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-06 12:31:59 +02:00
Patrick Schurig
b1c5336bbe docs: le test de fuseau porte sur le DÉCALAGE — ne pas le réutiliser pour un tarif
Europe/Berlin et Europe/Paris ne divergeront jamais : la ligne d'échéance restera
muette, et à raison — elle qualifie une horloge, et deux fuseaux au même décalage
donnent la même heure. Avertir parce que les NOMS diffèrent alors que les HEURES
coïncident serait le bruit que la condition existe pour éviter.

Mais une plage tarifaire suit le FOURNISSEUR, pas la box : « même décalage, autre
autorité » y reste une divergence réelle, et le jour où l'un des deux change de
règle d'heure d'été, la plage bouge sans que la box ait bougé. Le lot tarifaire
aura donc besoin d'une comparaison de provenance et d'un fuseau de référence
publié avec la configuration tarifaire.

Noté dans la doc du getter, là où quelqu'un ira le réutiliser.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-06 09:26:19 +02:00
Patrick Schurig
71bb26c81b feat(saisie): l'horloge se qualifie, elle ne s'offre pas en réglage
« 12:00 heure de la box (Europe/London) — pas celle de cet appareil », et non
« Fuseau : Europe/Paris » : sous un champ, un intitulé de réglage laisse croire
qu'on choisit le fuseau DE CETTE échéance. Il n'y en a qu'un, il est système, et
il déplace d'un coup toutes les heures murales de l'installation — échéances,
frontière de la journée de reporting, et demain les plages tarifaires
(LM-1005-b-iii).

Affichage CONDITIONNEL : seulement quand les deux horloges divergent. C'est la
règle déjà appliquée deux fois cette semaine — ligne des ratios instantanés,
avertissement « partiel » du bilan — et sa raison tient en une phrase : un texte
permanent cesse d'être lu le jour exact où il devient vrai.

La divergence se DÉDUIT de ce que la box publie, sans base de fuseaux :
`periodStartedAt` est minuit de l'installation en epoch, donc rendu dans le
fuseau de l'appareil il tombe sur 00:00 si et seulement si les deux partagent le
même décalage. Exact pour l'instant présent, y compris les jours à 23 ou 25
heures — et c'est justement pourquoi nous avions demandé cette frontière plutôt
qu'un nom IANA.

Quatre silences testés : fuseaux identiques, échéance vide, box qui ne publie pas
la date, et l'intitulé « Fuseau : » qui ne doit jamais apparaître.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-06 09:22:33 +02:00
Patrick Schurig
42eef05c3b fix(carte): le parent domine, et la phrase éco cesse de se répéter
Deuxième passage sur appareil, écran des charges. Trois défauts, dont deux de
notre fait et un que les fixtures ne pouvaient pas produire.

1. LA FRACTION ÉTAIT ÉCRITE DEUX FOIS — `ECO_FLOOR_MET` porte déjà livré et
   cible dans son motif, et la ligne d'avancement les réécrivait dessous :
   « Plancher atteint — 29,9 kWh sur 3,0 kWh livrés » puis « 29,9 kWh sur 3,0
   kWh livrés · mesuré depuis 10:00 ». C'est le défaut de la ligne des ratios de
   la veille, refait à l'identique : déclencher sur ce qu'on SAIT plutôt que sur
   ce qui se VOIT. La ligne ne garde plus que ce que le motif ne dit pas.

2. « X SUR Y » N'EST PAS UNE FRACTION une fois le plancher dépassé — le compteur
   continue de courir après que l'obligation est tenue, et le numérateur finit à
   dix fois le dénominateur. Aucune fixture ne le montrait : toutes avaient un
   livré inférieur à la cible. Reformulé « 3,0 kWh dus, 30,2 kWh livrés ».

3. LE PARENT NE DOMINAIT PAS — à taille et graisse égales, l'allocation et les
   deux niveaux se lisaient comme trois lignes sœurs, et les pastilles colorées
   tiraient l'œil davantage que la commande qu'elles expliquent : le contresens
   exact que la forme doit empêcher (LM-1003). L'allocation passe en 13,5 gras,
   les pastilles perdent leur fond, et un filet vertical rattache chaque niveau
   à la commande.

Vérifié à l'écran après correction, et le repli l'était déjà : V2C et Simulated
wallbox tiennent sur une seule ligne, sans étiquette orpheline ni vide résiduel.

Et le signe du solaire est CONFIRMÉ, ce qui n'avait pas pu l'être hier faute de
production : la box publie -4 376 W, l'écran affiche « 4.4 kW ».

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-06 08:11:22 +02:00
Patrick Schurig
b3000ca2d5 fix(écran): trois défauts trouvés sur appareil, dont un de forme et un de moi
Passage sur le Redmi contre `.75`. Aucun des trois n'était visible en test.

1. LA PRODUCTION S'AFFICHAIT NÉGATIVE — « -8.9 kW · Solaire » sous 9 kW de
   soleil. `currentPowerProduction` est publié négatif par nymea et le service
   garde les signes bruts par politique ; la carte imprimait la valeur telle
   quelle. Ce qui rend le défaut net plutôt qu'opinable : la même carte
   normalisait DÉJÀ le réseau et la batterie par `.abs()` — la branche solaire
   était la seule oubliée, donc un oubli et non une convention.

2. LE LIBELLÉ SE COUPAIT AU MILIEU DU MOT — « Autoconsommat / ion », alors que
   le commentaire du code disait vouloir l'éviter. La cause n'est pas la
   longueur mais l'ÉCHELLE du texte : police système au-dessus de 100 %, et
   `maxLines: 2` ne protège de rien puisque Flutter coupe un mot long sans
   espace, sans même afficher l'ellipse. `FittedBox(scaleDown)` garde le mot
   entier et ne fait rien quand la place suffit.

3. MA LIGNE DES RATIOS RÉPÉTAIT LES TUILES — « Instantané depuis 09:23 —
   autoconsommation 52 %, autonomie 54 % » sous des tuiles affichant 52 % et
   54 %. Les deux chemins étaient corrects et les valeurs coïncidaient vraiment
   (bilan 51,8/54,1 sur 00:00→16:45, box 51,9/53,7 depuis 09:23). Rien de faux,
   et illisible quand même : je déclenchais sur l'ORIGINE, alors qu'un lecteur
   compare des NOMBRES. La ligne sort désormais quand l'origine diffère ET que
   l'écart dépasse 2 points ; le relevé du jour est devenu un test.

Vérifié à l'écran après correction : le libellé tient sur une ligne, la ligne des
ratios se tait. Le signe, lui, N'A PAS pu être revu — la production du banc était
retombée à 0 W à 17:03, et `abs(0) == 0`. Une correction qu'on ne peut pas voir
demande une garde qui se voit : `flux_signe_test.dart` la pose.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-05 17:07:20 +02:00
Patrick Schurig
763df68a2a feat(bilan): deux grandeurs, deux phrases — les ratios de la box à côté de la journée
Les tuiles disent la JOURNÉE, par différence de compteurs sur la fenêtre publiée.
La nouvelle ligne dit ce que la BOX publie, qui part de la baseline — et elle ne
s'affiche QUE lorsque cette origine diffère du début de journée.

Le cas est arrivé de lui-même : `nymead` redémarré à 09:23, sonde à 09:26, et la
box publiait 100 % d'autoconsommation. Vrai sur trois minutes ; absurde intitulé
« aujourd'hui ». La ligne le rend lisible — « Instantané depuis 09:23 » — au lieu
de le laisser passer pour la journée.

Vérifié avec le MODÈLE de l'app et non à l'œil sur le JSON : `ratios_check.dart`
importe `EnergyRatiosSnapshot` (idiome de `measurement_check.dart`) et rend
`baselineRepriseEnJournee: true`, ratios couvrant 3 minutes. Le banc est passé en
Europe/Paris entre-temps, et `periodStartedAt` suit sans que le code bouge —
c'est l'intérêt d'une frontière publiée.

Quand les deux coïncident, rien n'est ajouté : une phrase permanente cesse d'être
lue, comme un avertissement permanent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-05 09:31:44 +02:00
Patrick Schurig
bcf5b87e43 feat(bilan): la journée vient de la BOX — periodStartedAt, et non minuit du téléphone
Sondé avant de câbler (`+etm56`) : `periodStartedAt` = 1788562800 = 00:00 Londres
= 01:00 Paris, `measuredSince` = 07:04 Londres, `timeZone` = Europe/London. Sept
heures d'écart entre les deux bornes.

- `EnergyRatiosSnapshot` — les deux instants et le fuseau. `baselineRepriseEnJournee`
  dit quand les ratios de la box ne parlent que depuis un redémarrage : c'est le
  silence que `measuredSince` existe pour fermer, et quand les deux coïncident —
  le cas normal — il n'y a rien à dire de plus ;
- `fetchEnergyRatios()`, et `fetchDailyBalance()` qui prend désormais la frontière
  publiée. Repli sur minuit local pour une box antérieure, écrit comme tel.

Et un effet de bord attrapé avant qu'il ne parte : `startsLate` comparait à minuit
du TÉLÉPHONE. Avec la frontière de la box, il aurait crié « partiel » tous les
jours sur un bilan complet — un avertissement permanent cesse d'être lu. Il se
compare maintenant à `periodStart`, porté par le bilan, et un test fige le piège.

Les taux journaliers de `DailyBalance` sont donc corrigés du même geste : ils se
calculent déjà par différence de compteurs sur la fenêtre, c'était la fenêtre qui
était fausse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-05 09:20:31 +02:00
Patrick Schurig
cdffb9d804 feat(§10): l'avertissement de saisie — une consigne, pas une fatalité affichée
Lot configuration. La section « Obligation quotidienne » porte l'énergie due, son
échéance, et l'avertissement qui rend la déclaration honnête.

Pourquoi à la saisie : au résultat l'heure est passée, l'achat a eu lieu. Et sous
`unmeasurable` l'obligation ne peut JAMAIS être close — ni annoncée tenue (R6
interdit `ECO_FLOOR_MET`), ni chiffrée dans son manque (`missingWh` absent).
`ECO_FLOOR_MISSED` sort tous les jours par construction. Un mécanisme dont le
succès est inobservable et l'échec quotidien ne doit pas, en plus, dépenser en
silence.

Et c'est une consigne : les trois causes portent trois gestes — rattacher un
compteur (le plus fréquent, réparable), en mettre un qui totalise l'énergie, ou
constater que c'est le matériel. Une cause inconnue se dit sans inventer de
geste.

Le coût est CHIFFRÉ, pas estimé : le taux est constant depuis +etm55, donc le
total acheté vaut exactement la cible.

Trois silences délibérés, chacun testé : aucune obligation déclarée (personne ne
paiera), `progressMeasurable` absent (box antérieure à +etm49 — on ne conclut pas
d'une absence), et le nom du fuseau tant que l'app ne l'a pas lu. L'échéance dit
en revanche qu'elle est à l'heure de l'INSTALLATION, ce qui est vrai sans le
fuseau.

Revérifié à la sonde avant d'écrire, plutôt que cru : `progressMeasurable: true`
et `regime: measured` sortent bien sur la même charge — la contradiction du
2026-09-03 est fermée.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-05 08:40:35 +02:00
Patrick Schurig
58bf57e67f feat(§10): la carte de charge porte ses deux niveaux — une commande, deux lignes
Premier lot d'écran, sur des champs tous posés (+etm55) et tous vus à la sonde.
L'allocation reste le parent ; les niveaux en sont la décomposition. Une charge
reçoit UNE commande par cycle quelles que soient les passes (LM-1003) : deux
lignes décrivent une seule décision.

- `EcoProgress` — regime, targetWh, deliveredWh (absent sous `unmeasurable`,
  jamais 0), purchasedWh (publié dans TOUS les régimes : c'est la commande du
  moteur, donc un fait connu sans mesure), measuredSince, measuredGaps ;
- `LoadLevel` gagne `decision` et `progress` : chaque passe porte son motif.
  Lire la racine seule afficherait « rien fait » sur une charge qui achète —
  relevé du 2026-09-01 05:47, `SURPLUS_INSUFFICIENT` en racine et 767 W achetés
  au niveau éco ;
- `ecoDecisionText` / `ecoProgressText` : les phrases des trois motifs éco et de
  leurs quatre causes. Aucun nom de paramètre n'est deviné — `cause`,
  `missingWh`, les quatre valeurs et `measuredGaps` sont vérifiés dans le binaire
  posé. Seule la FORME de `measuredGaps` reste non observée, donc on n'en lit que
  la présence ;
- garde R6 à l'écran : `ECO_FLOOR_MET` sous `unmeasurable` est DÉNONCÉ comme une
  incohérence de la box, pas rendu. Servie oui, tenue jamais.

Deux interdits écrits dans le code, pas seulement dans la maquette : sous
`unmeasurable` on n'annonce pas que l'achat va s'intensifier (le taux est
constant depuis +etm55), et `atMaximum: false` n'y veut pas dire « on n'est pas
au plafond » mais « il n'y a aucun étalement, donc rien qui puisse cesser ». Un
booléen à false se lit spontanément comme rassurant.

Les charges utiles des tests viennent de `.75` — le cas éco-servi/confort-en-
attente du 2026-09-01, le cas mesuré du 2026-09-04 — pas d'une lecture de
contrat.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-05 07:54:22 +02:00
Patrick Schurig
cd8dd02989 docs: la journée de reporting suit l'installation — et l'app prend celle du téléphone
+etm55 vérifié (posé le 04/09 18:21:08). Le taux constant est en ligne : la
maquette porte le bon chiffrage — ×4,75 sur une fenêtre quotidienne, facteur
ln(T·P/E) + 1 — et les deux interdits du régime `unmeasurable` : ni annoncer que
l'achat va s'intensifier, ni lire `atMaximum: false` comme « tout va bien », qui
signifie qu'il n'y a aucun étalement, donc rien qui puisse cesser.

La troisième nature d'heure murale touche du code livré : `fetchDailyBalance()`
et la baseline des ratios prennent minuit du TÉLÉPHONE, alors qu'une journée de
reporting suit l'installation. Sur `.75` en Europe/London vue d'un téléphone
français, entre minuit et 1 h l'app compte une journée que la box compte encore
dans la précédente.

L'app ne peut pas le corriger seule : `System.GetTime` publie un nom IANA, et un
décalage ne s'en déduit pas sans base de fuseaux — qu'il faudrait embarquer pour
recalculer une frontière que la box connaît, et qui resterait fausse les jours de
changement d'heure. Demandé au moteur : publier le DÉBUT de la journée de
reporting en cours, en epoch, à côté du fuseau. La divergence est écrite aux deux
endroits du code en attendant.

Noté sans conclure : le banc n'a aucune charge `unmeasurable`, donc la loi est
déployée mais pas exerçable de bout en bout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-05 07:40:26 +02:00
Patrick Schurig
7eefb93c6c docs: relevé frais sur charge réelle — sessionEnergy corrigé, chargingState confirmé
Lien Modbus rétabli, Skoda en charge, valeurs en mouvement.

- `sessionEnergy` : notre doc était EN RETARD, pas contredite. La V2C la publie
  depuis le plugin +etm13 et elle bouge (15,8015 → 15,8405 kWh). L'écart avec
  `chargeEnergy` vaut 7,2044 kWh aux deux échantillons pris à deux heures
  d'écart — constant au dix-millième : c'est l'accumulateur du plugin, et il
  prouve l'accumulation que le code seul ne montrait pas. LM-1009 C1 reste vraie
  pour les bornes sans cet état ; la V2C n'en fait plus partie. La maquette ne la
  nomme donc plus dans l'exemple « non mesurable ».
- `chargingState` reste `Idle` sur un lien VIVANT, pendant que la borne tire
  4 372 W : ce n'est pas de la péremption, personne ne maintient le champ.

Et un constat qui n'est pas dû au WiFi : la box a journalisé « Restoring manual
values of false 6 A » trois secondes après la coupure ; la borne est restée à
`power: true`, 7 A, et le lien est revenu sans que rien ne réapplique. En mode
manuel personne ne commande, donc l'écart est permanent — 4,4 kW tirés,
`draw.committedW` à 0. Question posée au moteur.

Aucun avancement n'est affiché pour autant : c'est une décision (D1), pas une
mesure qui manquerait.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-08-30 15:15:49 +02:00
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
47866ddee3 docs(sondes): défaire son action n'est pas restaurer l'état
Payé le 2026-08-30 en cherchant à provoquer EV_GRID_START. Trois erreurs, aucune
n'a produit de code d'erreur :

- `connect` bascule `connected` (la liaison du Thing), pas `pluggedIn` — lecture
  seule, qu'aucune action n'écrit. La borne n'est jamais entrée dans `loads[]` ;
- le `finally` défaisait le GESTE, pas l'ÉTAT : `connected` valait déjà `true`
  avant, donc la « restauration » a laissé la borne déconnectée dix minutes ;
- `ThingErrorNoError` est un VRAI succès, et c'est ce qui rend le cas plus
  vicieux que `status: "success"` : l'action a réussi, elle ne faisait pas ce
  qu'on croyait. Aucun code ne dira « tu t'es trompé d'action » — le seul verdict
  est l'état relu.

La règle que `fault_probe.dart` tenait déjà pour la configuration est généralisée
à `ExecuteAction` : capturer avant, restaurer VERS la capture, comparer état par
état.

Et le fait mesuré, pour qui réessaiera : EV_GRID_START n'est pas provocable
depuis la frontière RPC (pas de prise véhicule exposée, pas d'`assignedCarId`).
La fenêtre, elle, s'ouvre seule — 07:46, surplus 905 W, sous le minimum de la
borne. La preuve du partage viendra donc de la suite de simulation du moteur ;
le critère ne bouge pas, et la branche reste jusqu'à sa trace.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-08-30 10:18:36 +02:00
Patrick Schurig
820e58c13e docs(sonde): +etm31 vérifié — draw.committedW est là, l'exception reste
Sonde contre `.75`, 2026-08-30, `powersync-energy-plugin-nymea 1.15.2+etm31`
confirmé par dpkg. Trois points sur quatre vérifiés :

- `draw` dans la MÊME trame que `budget`, **une seule clé** `committedW` —
  `authorisedW`, `remainingW`, `binding`, `perPhaseBound` absents, pas à zéro ;
- `draw` publié même quand rien n'est acheté (`committedW: 0`, deux cycles) —
  donc son ABSENCE vaudra « box antérieure », jamais « rien acheté ce cycle ».
  Zéro est une valeur quand la grandeur existe, l'absence quand elle n'existe
  pas ;
- identité surplus tenue : 1 200 == `budget.allocatedW`.

Le quatrième ne l'est pas, et c'est celui qui décide : `EV_GRID_START` est
**inobservable sur le banc**. Les deux bornes sont déclarées dans
`GetLoadConfig` (V2C Trydan, Simulated wallbox) et absentes de `loads[]` — hors
arbitrage, rien de branché — donc le motif ne peut pas survenir.

La branche de `surplusShareW` reste. Elle ne partira qu'à l'observation d'un
`EV_GRID_START` réel et de ses deux moitiés dans `budget.allocatedW` et
`draw.committedW`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-08-30 09:30:45 +02:00
Patrick Schurig
43fd79ad62 feat(écran): « Commandé, pas encore mesuré » remplace « Réservé au véhicule »
Le moteur a livré la définition d'`evReservedW`, et il ne pouvait pas l'écrire
avant d'en avoir sorti la part réseau d'`EV_GRID_START` : un champ à deux natures
ne se documente pas. Le libellé tranché est le sien, parce qu'il dit la chose —
la latence entre l'ordre et sa lecture au compteur.

« Réservé au véhicule » affirmait deux faussetés : que c'est réservé, et que ça
regarde le véhicule plutôt que la latence de sa mesure.

La clé ARB garde le nom du champ de la box (`budgetEvReserved`), pas celui de
l'ancienne affirmation. Et la consigne qui compte reste écrite à côté : ce n'est
pas un registre, on ne réconcilie rien dessus — la nature du champ l'interdit,
pas son état de livraison.

`draw.committedW` : annoncé livré en +etm31, NON vérifié à la sonde. La box porte
`powersync-energy-plugin-nymea 1.15.2+etm30` au 2026-08-30 et ne publie aucune
clé `draw`. L'exception `EV_GRID_START` reste donc dans `surplusShareW` : c'est
la sonde qui autorise le retrait, pas l'annonce.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-08-30 09:16:04 +02:00
Patrick Schurig
b01a716047 docs(R3): draw.committedW en préalable — et evReservedW n'est PAS un registre
Le moteur l'a trouvé en câblant `counts` : `budget.evReservedW` est un terme de
CORRECTION du budget, pas un registre d'allocation. Il mêle la correction
« commandé non encore mesuré » à la part réseau d'`EV_GRID_START`, et une borne
servie au réseau qui tire déjà ce qu'on lui a commandé y contribue 0 pour un
`targetW` de 7 000 W. `Σ counts == targetW` y serait vraie au premier cycle après
un changement et fausse ensuite : une identité dépendante du temps, qui passe
tous les tests et certifie un total faux. Ne pas livrer était le bon appel.

Réponse : `draw.committedW` en préalable, oui, sans réserve — et trois demandes :
par cycle, en watts, dans la même trame que `budget` ; publié SEUL, les quatre
autres champs de `draw` absents et jamais à 0 (un `authorisedW` forgé afficherait
un plafond de soutirage qui n'existe pas) ; et une définition d'`evReservedW`,
que nous affichons sous « Réservé au véhicule » — un libellé qui affirme une
réservation là où il y a une correction, et que nous ne renommerons pas au jugé.

Nous portions la fausse affirmation en cinq endroits (`CLAUDE.md` ×2, le modèle
×3, la description ARB) : retirée ici, sans changement de comportement. Elle ne
relève pas de la discipline « attendre la sonde » — celle-ci arbitre ce que fait
la box, pas ce que nous en avions cru, et la phrase est fausse aujourd'hui.

Il n'existe donc qu'UNE identité vérifiable, et elle ne couvre qu'un côté :
Σ targetW(surplus) == budget.allocatedW. Côté réseau, rien — pas « à vérifier
plus tard », rien, jusqu'à `draw.committedW`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-08-30 08:35:17 +02:00
Patrick Schurig
5c06e65a0e docs(télémétrie): allocatedW publie le DÉCIDÉ, pas le commandé
La prémisse inverse était écrite dans CLAUDE.md et dans le modèle, et c'est elle
qui a produit l'erreur de la maquette §10 : un écran cherchant l'arrondi entre
`Σ targetW` et `allocatedW` compare deux valeurs égales par construction — il ne
montre jamais rien, et sans erreur.

Trois nombres nommés à leur place : décidé (`allocatedW`), appliqué
(`mechanism.*`), mesuré (`measurement.powerW`) — et ce que chaque écart prouve.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-08-30 08:17:01 +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
3f5f6e0d9f docs(correction): mon « écho refusé » était un écho PÉRIMÉ — deux cas, un symptôme
L'agent plugin a mesuré l'inverse sur la même box : écho verbatim, "auto" compris,
EnergyErrorNoError. L'exception existe déjà côté moteur — reconcileRankOrigin() accepte
"auto" quand le persisté le porte déjà, écrite exprès pour ne pas rompre la neutralité
Get → Set.

Ce que j'avais mesuré est autre chose : entre ma lecture et mon écriture, la marque avait
été consommée par une manipulation concurrente. Mon écho portait donc un "auto" que le
persisté ne portait plus — un écho périmé, correctement refusé. Deux cas distincts, un seul
symptôme, et c'est précisément pourquoi une écriture qui ne relit pas juste avant ne prouve
rien. Le contrôle de la suppression, lui, relit avant de conclure ; ma sonde jetable, non.

§10 corrigé : la ligne du tableau dit maintenant ce qu'elle mesure vraiment, et ce qui
reste à mesurer (un écho inchangé sur une marque FRAÎCHE). sansRankOriginAuto() est
documentée comme un contournement EN SURSIS — elle part au résultat de cette mesure, et la
marque survivra alors aux gestes qui ne la concernent pas. Rien n'est retiré d'ici là.

Ce qui reste vrai de mon relevé : "user" s'écrit, ne déplace pas le rang, survit aux gestes
voisins. Et l'effacement de "auto" que j'ai observé est le fait de l'APP — de son
nettoyage — pas d'un refus de la box.

── Deux pièges de sonde, consignés dans tools/rpc/README.md ─────────────────

1. Un refus arrive en `status: "success"`, verdict dans params.energyError. Une sonde qui ne
   teste que `status` annonce « accepté » sur une écriture rejetée.
2. Les identifiants ne s'écrivent pas pareil d'un appel à l'autre — le §9, documenté la
   VEILLE, et la sonde y est quand même tombée : elle rendait des verdicts sur une charge
   qu'elle ne regardait pas.

La leçon n'est pas « écrire des sondes plus soigneusement » : c'est que la sonde ne partage
pas les garde-fous de l'app. Quand la question porte sur ce que l'app fera, importer son
modèle vaut mieux que relire le JSON ; quand elle porte sur la box, croiser la réponse RPC
avec journalctl avant de conclure.

Et la raison lisible d'un refus n'existe QUE dans ce journal — côté RPC il ne reste qu'un
code. Silence de refus ouvert depuis le premier jour, rencontré une fois de plus, et raison
d'être des miroirs clients comme validateSet.

Rien de touché côté entrelacement : ça attend le moteur.

226 tests inchangés, analyze inchangé à 27.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
2026-08-29 10:44:14 +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
761e1e369d docs(motif): un interdit d'écriture doit dire ce qu'il fait d'un écho inchangé
§10 du contrat de données, à côté du §9. Le §9 disait que deux réponses de la même box
peuvent employer deux conventions sans le signaler ; celui-ci dit que deux RÈGLES posées
séparément, chacune juste, peuvent se contredire au point de rendre une écriture
impossible. Symptôme identique : muet.

L'app écrit verbatim (sinon un champ qu'une box plus récente porte serait perdu, en silence
et pour toutes les charges) ; le moteur refuse rankOrigin: "auto" à tout client (sinon un
écran fabriquerait une affirmation sur un geste du moteur). Leur produit : un simple
renommage rejeté en bloc dès qu'une installation porte une borne auto-provisionnée.

La règle doit porter sur le CHANGEMENT, pas sur la PRÉSENCE — même distinction que
l'effacement de rankOrigin, qui porte sur « rang différent pour cet id » et non sur
« écriture reçue ». Sinon la marque est fragile par construction : nettoyée à chaque
écriture, le badge s'éteint sur une borne que personne n'a classée au premier renommage
d'une autre charge. Une information qui s'efface au premier geste voisin ne vaut pas d'être
publiée.

Le §10 porte aussi le contrôle à passer une fois par règle d'écriture : l'app peut-elle lire
la valeur (alors elle la renverra) · l'écho inchangé est-il tranché par écrit (sinon c'est
un trou) · que devient l'information au premier geste voisin (si elle disparaît, la publier
est un leurre).

Et le motif derrière le motif, parce qu'il s'est répété : TROIS FOIS EN DEUX JOURS, le
chemin exposé n'était pas celui qu'on regardait — l'écriture du mode visait la première
borne (trouvé en déplaçant les boutons), le refus sur rangs doublés menaçait la suppression
et non le glisser-déposer, le refus de "auto" menace le renommage et non la gestion des
rangs. Le point commun n'est pas la malchance : ces chemins TRANSPORTENT des données qu'ils
ne regardent pas, donc ils sont exposés à tout ce qui a été ajouté après eux.

Le brief moteur passe de la question à la demande argumentée ; le nettoyage côté app reste,
documenté comme contournement — le jour où l'écho passe, la fonction disparaît.

222 tests inchangés, analyze inchangé.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
2026-08-29 10:23:25 +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