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
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
+etm23 supprime le second commandeur : adjustEvChargers() ne commande plus, l'arbitre est
seul (9 commandes à la borne, 9 par l'arbitre, 0 par le proxy). allocationIsCommand est donc
retiré, avec les branches qui en dépendaient — l'étiquette « l'arbitre a décidé X W » et sa
ligne d'avertissement redeviennent « Alloué X W », et l'écart mesuré/commandé se dit à
nouveau sur une borne.
Ce que le getter est remplacé par : rien, délibérément. Le garder à true partout inviterait
à lire « commande » comme « réalité ». Car ce qui devient vrai, c'est que personne d'autre
ne commande — pas que la borne fait ce qu'on lui dit. Un plafond matériel, un véhicule qui
refuse, un câble débranché feront toujours diverger les deux. La borne garde donc sa ligne
d'état matériel, non plus parce qu'un autre commandeur la contredirait, mais parce que
mechanism (chargingEnabled, currentA, pluggedIn) est le seul endroit où cet écart se lit.
Deux motifs neufs : EV_GRID_START {budgetW, floorW, gridW} et PHASE_LIMIT {limitW,
requiredW}. Le premier mène par les watts ACHETÉS — c'est ce chiffre qui fait de la ligne
une décision et non un effet de bord. Le second doit écarter le surplus explicitement :
deux causes, deux gestes, et confondre les deux envoie chercher du soleil là où il faut
lire un compteur. EV_GRID_START n'a JAMAIS été vu sur machine : la clé est prête, rien
n'est bâti autour.
Et une correction que le brief signale en passant, qui aurait cassé en silence la
réconciliation posée hier : EV_GRID_START est le seul motif dont l'allocation se PARTAGE
entre les deux compteurs du budget — budgetW au surplus, gridW au réseau, budgetW + gridW
== allocatedW. Filtrer sur funding == "surplus" comptait cette ligne pour zéro et creusait
un trou de la taille de budgetW. surplusShareW la traite à part ; sans budgetW il rend zéro
plutôt qu'une part devinée, un chiffre faux étant pire qu'un chiffre manquant dans un
contrôle d'identité.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
+etm23 rend à chaque borne détectée une entrée GetLoadConfig, et l'invariant
loads[] ⊆ GetLoadConfig est rétabli. Sur .75 : 2 entrées → 4. L'app s'appuyait déjà sur cet
invariant pour résoudre libellé, domaine et rang de toute charge publiée.
Vérifié sur machine avant d'écrire quoi que ce soit :
- l'aller-retour verbatim reste NEUTRE avec les quatre entrées (loadconfig_roundtrip.dart,
verdict IDENTIQUE) ;
- une entrée evcharger porte id/label/domain/priority/enabled, mode dynamic, et AUCUNE
charge utile de mécanisme.
Trois défauts que ce contrat révèle côté app :
1. isVariableLoad était défini par défaut (« ni relais ni sg-ready »), donc une borne y
tombait. L'écran de mécanisme lui proposait une puissance nominale, des paliers et des
temporisations — des réglages qui n'existent pas pour elle et dont l'écriture partirait
quand même. Pire, le sélecteur offrait de la basculer en routeur de relais : une bascule
que basculerMecanisme() sait construire et qu'aucun écran ne sait défaire. Une borne a
maintenant sa section, en lecture seule, qui dit pourquoi il n'y a rien à régler.
2. BorneSection existait pour rattraper des bornes qui n'apparaissaient nulle part. Elles
apparaissent maintenant dans leur domaine : la section les afficherait DEUX fois, en
affirmant qu'elles n'ont « ni rang ni domaine » et qu'elles ne passent pas par
SetLoadConfig. Trois affirmations devenues fausses. Retirée ; son contenu de runtime
vit désormais dans l'écran de mécanisme, où il a un sens.
3. Le rang par défaut d'une borne est un ARTEFACT, et l'écran le disait comme un réglage.
À la création le plugin donne « le plus petit rang existant moins un, borné à 1 » ;
le chauffe-eau occupant déjà le rang 1, l'intention « en tête » dégénère en égalité —
trois charges à 1 sur le banc. Le tri reste total (l'id départage), donc l'ordre est
reproductible, et c'est précisément ce qui le rend crédible : l'installateur lit un
classement plausible et n'y touche pas, validant un ordre que personne n'a posé. Une
note le nomme, et disparaît dès le premier vrai réglage.
Le glissement, lui, n'a demandé aucun cas particulier — c'était tout l'intérêt d'avoir une
seule liste. Vérifié par test, y compris l'inversion borne / charge pilotée.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
La box promet le contraire de ce qu'elle fait, et par écrit : sa propre description dans
JSONRPC.Introspect dit « Only given properties will be set, others will be untouched ».
ChargingInfo est en réalité reconstruit entier à chaque appel, et un champ absent retombe
sur son défaut C++ — 80 pour targetPercentage.
Reproduit sur .75 le 2026-08-27 (+etm22), borne 88160e45 : le champ valait 0, un
SetChargingInfo portant le seul chargingMode l'a laissé à 80, réponse EnergyErrorNoError.
Banc restauré.
L'app envoyait targetPercentage uniquement avec une échéance. Changer de mode de charge
reposait donc une cible de 80 % à l'insu de l'utilisateur, sans que rien ne le signale.
Désormais : GetChargingInfos, patch, réécriture de l'objet complet — sans chargingState,
qui est r: au schéma et ferait rejeter l'appel. Sans échéance, on repose explicitement la
valeur que la box portait déjà ; si elle n'en portait aucune, le neutre est 0, pas 80.
Et la cible n'est plus affichée hors échéance : 80 peut être l'empreinte d'une écriture qui
ne parlait pas de cible du tout, la montrer en ferait une intention.
Deux notes de contrat au passage :
- fetchPowerBalanceLogs porte enfin le piège qui a fait croire à un défaut de la box côté
plugin — les entrées de log ne portent PAS le préfixe currentPower. Réutiliser les clés
de GetPowerBalance ne produit aucune erreur, tout tombe sur 0. Vérifié ici avec les clés
de l'app : 7 960 échantillons sur 90 jours en SampleRate15Mins, dont 4 975 de production
non nulle. Rien ne bloque l'historique 90 jours.
- le défaut d'usine de batteryLevelConsideration est passé de 0,9 à 0,2. La carte reste
nécessaire : le nouveau défaut ne rattrape pas une box qui a déjà persisté 0,9, et le
seuil reste réglable jusqu'à 1,0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
Deux constats du brief moteur, qui touchent le même fichier de lecture.
1. kCodesReserveBatterie était vide, et les clés cherchées n'ont JAMAIS existé côté
plugin : batteryLevel/soc et threshold/batteryLevelConsideration contre socPercent,
reservePercent, withheldW. Le motif sortait donc en repli — c'est-à-dire en anglais
brut — sur le seul cas que le client ne peut déduire d'aucune mesure : « il y a du
surplus, une règle l'interdit ». Vérifié contre decisionreason.h.
Les trois paramètres sont des entiers DÉJÀ en pourcentage : reservePercent vaut 40, pas
0,4 — à ne pas confondre avec batteryLevelConsideration, qui est la fraction du réglage.
Et withheldW mène la phrase, comme côté plugin : le moteur en fait la condition
d'émission du motif, parce qu'un motif à 0 W dirait « une règle vous bloque » là où la
vérité est « il n'y a pas de surplus ».
Le motif n'a PAS pu être observé en vrai sur .75 : la bascule du seuil à 0,9 a produit
surplusW à −3 551 W, donc rien à confisquer, donc garde du plugin active et motif tu —
ce qui est le comportement juste. Il faut du surplus réel sous le seuil. Seuil remis
à 0,4.
2. funding (« surplus » / « grid ») est lu, et avec lui l'identité que le plugin déclare :
somme des allocatedW financés au surplus == budget.allocatedW. Sommer sans filtrer
compte une borne servie au réseau et creuse un écart qu'on ne peut pas interpréter.
Absent = surplus, pas zéro : c'est le seul financement qui existait avant ce champ.
3. Les bornes sont entrées dans loads[] (+etm22), et adjustEvChargers() re-décide après le
waterfall : pour une borne, allocatedW et son motif sont la DÉCISION DE L'ARBITRE, pas
l'état de la borne. L'écran affiche donc d'abord ce que le matériel fait
(mechanism.chargingEnabled / currentA, qui viennent du Thing), ensuite ce que le moteur
a voulu, étiqueté comme tel. L'écart mesuré/commandé se tait sur une borne — « sous la
consigne » y supposerait une consigne. La réserve ne touche PAS l'ECS ni la PAC, où
l'allocation publiée est bien la commande : elle est portée par allocationIsCommand, et
tombe avec le lot plugin 3g-2.
EV_SURPLUS, EV_ECO_MIN et EV_IDLE ne sont plus émis. Les branches restent : les retirer
rendrait illisible un journal antérieur, pas l'app plus juste.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
`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
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
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
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
1) « Vers réseau » — TROIS défauts, dont un seul était visible.
La valeur aberrante (9,10184409403335e+33 kWh) ne vient PAS de l'app : Energy.GetPowerBalance
la publie telle quelle sur .75. Trois des quatre compteurs cumulés sont corrompus
(totalReturn 9,1e+33, totalAcquisition 7,5e+33, totalConsumption -1,6e+33) alors que
totalProduction reste sain, et que les états du Thing compteur sont tous plausibles
(1429 kWh importés, 88 kWh exportés). C'est donc l'accumulation de l'experience-plugin
énergie qui déraille, côté box — signalé, pas corrigé ici.
Les deux autres défauts étaient bien dans l'app :
- « Depuis réseau » lisait l'OPPOSÉ du compteur d'export au lieu de totalAcquisition,
qui a son propre compteur. Dès que l'export était positif, la tuile affichait
0,0 kWh en permanence, quelle que soit l'électricité réellement achetée ;
- les deux tuiles étaient libellées « aujourd'hui » alors que ces compteurs sont
CUMULÉS depuis l'origine, jamais remis à zéro.
Corrigé : EnergyData porte gridExportKwh / gridImportKwh, transportés tels quels — l'app
ne maquille pas une donnée que la box publie fausse. Un plafond de plausibilité
(10 GWh, ~3000 ans de consommation) distingue « mesure » de « compteur cassé » : au-delà
la tuile affiche « — · compteur box invalide ». Imprimer le nombre le présenterait comme
une mesure ; l'effacer laisserait croire à zéro.
2) Thème : 126 sites basculés des constantes claires vers les variantes contextuelles
(EtmTokens.bg → bgOf(context), card → surfaceOf, ink → inkOf, muted → mutedOf,
faint → faintOf, line → lineOf) dans ac_screen, things_screen, energy_screen,
favorites_screen, main_drawer, ev_charging_card, energy_flow_widget. Ces écrans
restaient en palette claire quel que soit le sélecteur.
9 sites RESTENT en constante, et c'est délibéré : ce sont des tables statiques et des
helpers de haut niveau (categoryInfoMap, _options des favoris, _modeColor, peintres du
flux) où la couleur est une identité d'icône et où aucun BuildContext n'est en portée.
Les faire suivre le thème demande de threader le contexte jusqu'à ces tables — un
refactor à part entière, pas un correctif de fin de soirée.
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
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
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
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>
- 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>
NymeaService
- batterySOCSource : retourne {'thingId': 'sim-battery', 'stateName': 'soc'}
en simulation au lieu de null — le SOC est désormais fetchable
- fetchHistory en simulation : données SOC réalistes en % (0-100) avec
courbe charge solaire 8h-15h (20→85%) puis décharge soir (85→20%)
au lieu de valeurs sinus 100-900 W incorrectes
EnergyScreen
- initState : appelle startSimulation() si non connecté (comme les autres écrans)
- leftTitles : interval explicite (yMax/4) + SideTitleWidget pour ancrage correct
- gridData : horizontalInterval aligné sur yInterval
- rightTitles (SOC %) : SideTitleWidget + interval aligné
- bottomTitles : SideTitleWidget sur les deux graphes
- barChart leftTitles : interval + SideTitleWidget
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Persistance (FavoriteWidget)
- FavoriteWidget.fromJson / toJson ajoutés dans nymea_models.dart
- NymeaService : constructeur appelle _loadFavorites() au démarrage
- _saveFavorites() appelé après addFavorite / removeFavorite / reorderFavorites
- Clé SharedPreferences : 'etm_favorites_v1'
- Résistance aux données corrompues (try/catch sur fromJson)
Restyle (favorites_screen.dart)
- AppTheme → EtmTokens sur toutes les couleurs et typographies
- Valeurs en IBM Plex Mono (size 36 pour les grandes métriques)
- Cartes avec EtmTokens.cardShadow + border radius 22
- _EmptyState : bouton vert + étoile + message
- _AddSheet : DraggableScrollableSheet restyled, sans emoji, items visuellement
distingués (ajouté vs disponible)
- ReorderableDelayedDragStartListener pour le drag discret
- Simulation auto-démarrée si non connecté (comme Dashboard/Things)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>