Elle reste : la retirer aurait laissé un trou dans une liste que le client a composée
lui-même. Elle affiche maintenant ce que la box publie, et là où il n'y a rien à lire,
elle le dit.
Ce qui est lu, et sa source
· branché / en charge ← états pluggedIn, charging du Thing
· puissance ← état currentPower du Thing
· mode ← GetChargingInfos + notification ChargingInfoChanged
Absent = « non publié », jamais zéro. Un mode que l'app ne connaît pas se lit « mode non
reconnu (X) » plutôt que d'être deviné, et aucun bouton n'est allumé tant que la box n'a
pas dit ce qu'elle porte.
L'écriture restitue le refus, sinon elle n'existe pas. Les pastilles de 7 à 10 px qui
avalaient les codes d'erreur de setChargingInfo passent à 44 px, montrent l'attente, et
affichent le refus tel que la box le nomme. Et l'app ne tient plus d'état de mode local :
après une écriture acceptée, elle RELIT GetChargingInfos. Elle se souvenait de ce qu'elle
avait demandé, ce qui n'est pas ce que la borne porte.
La fiction perd sa porte d'entrée. chargingMode, chargingPower (3,6 kW) et
solarSourcePercent (82 %) sont SUPPRIMÉS de EnergyData : trois valeurs par défaut que
_updateEnergyData ne touchait jamais. Ce que la box publie sur une borne vit désormais
dans EvChargerLive, champ par champ, nullable.
⚠️ Et le Sankey du tableau de bord en tirait un flux « Voiture » permanent à 3,6 kW,
voiture débranchée. Il est branché sur la mesure de la borne — « non lu » quand il n'y en
a pas. Mais la même vue invente encore PAC = 38 % de la maison, Eau chaude = 18 %,
Autres = 22 %, dont un seul marqué « estimé » : entrée 🔴 au TODO, avec la source réelle
(GetLoadTelemetry publie measuredW par charge : 1500 W et 800 W ce midi). Brancher le
Sankey dessus est une refonte de l'écran d'accueil, pas un correctif — à décider.
Deux attentes tombent, une reste (relevé Integrations.GetThings sur .75, cet après-midi)
· currentL1/L2/L3 est DÉPLOYÉ (5,86 / 5,94 / 6,02 A) — powerL1/L2/L3 a disparu
· phaseCount EXISTE sur la Trydan (3) — la note qui le disait absent était en retard
· sessionEnergy existe (4,229 kWh) MAIS vaut exactement chargeEnergy : c'est ce qu'on
attend d'une session sans interruption, donc ça ne prouve rien. LM-1009 §C1 reste
entière tant qu'une remise à zéro de chargeEnergy n'a pas été vue avec un
sessionEnergy qui continue.
Le brief moteur dit désormais du masquage WRITE_FAILED que ce n'est pas « ne se produit
pas » mais « ne s'est pas encore produit » : il attend une charge variable qui perd son
Thing, et le banc n'a pas eu ce cas.
154 tests (8 nouveaux sur la tuile, dont les cibles tactiles et la restitution du refus),
flutter analyze à 27 remarques, aucune nouvelle.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
Deux gestes que le relevé du banc a tranchés, dans les deux sens.
1. THING_MISSING — MESURÉ, la réserve tombe
GetLoadTelemetry sur .75, 2026-08-28 10:29 UTC : la Terra AC supprimée est publiée
available=false · faultCode=THING_MISSING · decision=LOAD_UNAVAILABLE{frozenW:0} ·
mechanism.kind=evcharger. Le code SORT. Le test de rendu ne repose plus sur une trame
composée mais sur cette capture (test/fixtures/loadtelemetry_hems75_thingmissing.json).
Le masquage relevé mardi (etmvariableloadadapter.cpp:148-150, m_faulted testé avant que
THING_MISSING ne puisse s'exprimer) concerne l'adaptateur des charges VARIABLES — relais
et SG-Ready. L'adaptateur evcharger n'a pas ce chemin, d'où la différence. Il n'est donc
pas corrigé, seulement contourné par le hasard du chemin de code : remonté au brief
moteur avec la trame, parce que les deux codes envoient le client faire deux gestes
différents — « écriture échouée » fait démonter un coffret, « appareil absent » fait
rouvrir nymea.
2. EVChargingCard — elle part, et la monter aurait publié de la fiction
Trois des grandeurs qu'elle affiche ne sont JAMAIS lues sur la box : chargingMode,
chargingPower (3,6 kW) et solarSourcePercent (82 %) sont des valeurs par défaut de
EnergyData — _updateEnergyData ne les touche pas, et le mode n'est écrit qu'en optimiste
après une commande, jamais relu par GetChargingInfos. Sa feuille « Paramètres de charge »
proposait en plus un courant minimum, qui n'existe dans aucun contrat, et son bouton
« Enregistrer » ne faisait que Navigator.pop : rien n'était écrit. Ce n'était pas du code
mort à ranimer, c'était un écran à réécrire depuis le modèle.
⚠️ Et la surface vivante est pire que la morte : _EVWidget (favorites_screen.dart:419)
affiche les MÊMES valeurs par défaut comme si elles étaient mesurées — « 3600 W »,
« 82 % solaire », « Mode PV » — et écrit le mode sur des pastilles de 7 à 10 px sans
restituer un refus. Entrée 🔴 au TODO, avec ce qu'une carte de recharge peut dire
aujourd'hui : le mode lu par GetChargingInfos, la puissance mesurée du Thing, l'échéance
comme intention saisie. Ni SOC (n'existe pas), ni chargeEnergy (remis à zéro à chaque
interruption), ni courant minimum, ni phaseCount.
146 tests passent. flutter analyze : 27 remarques, deux de moins qu'avant — les deux
étaient dans le fichier supprimé.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
Vérification demandée après le relevé V2C du banc : « ta note de rangs ex æquo doit
s'afficher — vérifie qu'elle le fait ». Elle s'affiche. Mais rien ne le prouvait, et
c'est exactement le défaut qui a coûté isNotGrouped : la contiguïté était calculée
depuis le premier jour, testée en fonction, exposée par le provider — et affichée nulle
part. Un test de fonction ne peut pas voir ça.
Quatre tests de widgets sur la configuration réelle du 2026-08-28
(loadconfig_hems75_v2c.json), montés dans le harnais existant :
· la note de rangs ex æquo est à l'écran ET nomme les charges (chauffe-eau, V2C
Trydan) — « des rangs sont à égalité » sans dire lesquelles laisse chercher dans
cinq cartes ;
· la note d'ordre entrelacé est à l'écran ET annonce ce que l'enregistrement fera
(REGROUPERA) — l'annoncer après serait un constat, pas un avertissement ;
· contrôle négatif : sur un ordre groupé et classé, aucune des deux n'apparaît. Sans
lui, deux notes toujours affichées deviennent du décor ;
· THING_MISSING se lit « l'appareil n'existe plus dans nymea » + « réinstallez-le, ou
retirez cette charge », et n'offre RIEN à lever — ni le bouton, ni l'aide qui
l'accompagne.
⚠️ La télémétrie du quatrième test est COMPOSÉE, pas capturée : le banc publiait
WRITE_FAILED sur cette charge au dernier relevé (l'adaptateur épuise son échelle
d'écriture avant que THING_MISSING ne sorte). Le test éprouve le rendu du code sur le
loadId réel de la Terra AC supprimée, pas la présence du code sur la box.
Et deux traces de la campagne V2C, qui ne se câblent pas :
· tools/rpc/evcharger_watch.dart entre au dépôt, documenté. C'est la sonde qui a
produit les deux constats bloquants — une borne hors arbitrage n'apparaît pas dans
telemetry_watch, et c'est pendant ces transitions qu'il faut la regarder.
· ev_charging_card.dart affirmait que « les deux bornes du banc publient sessionEnergy ».
Faux depuis l'arrivée de la V2C : elle publie chargeEnergy, remis à zéro à chaque
interruption de charge. La note renvoie désormais à LoadMechanism et redit C1 de
LM-1009 — « pas mesurable », jamais zéro.
TODO : entrée d'attente pour sessionEnergy et le renommage currentL1/L2/L3.
146 tests passent, flutter analyze sans remarque nouvelle.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
L'arrivée de la V2C sur .75 a révélé deux défauts que le banc ne pouvait pas montrer avant,
plus deux pièges de contrat à documenter.
1. ⚠️ isNotGrouped ÉTAIT CALCULÉ ET AFFICHÉ NULLE PART. GroupedOrder détecte depuis le
premier jour qu'un ordre n'est pas groupé par domaine, le provider l'expose, et aucune
vue ne le lisait — le cas ne s'était jamais produit, chaque domaine du banc ayant ses
charges d'un seul tenant.
Il se produit maintenant : la V2C, créée d'office au rang 1 que le chauffe-eau occupait
déjà, donne l'ordre à plat « ecs · ev · heating · ev · ev ». Le domaine ev est coupé en
deux par le chauffage.
Ce n'était pas un détail d'affichage. L'écran groupe pour afficher, donc ce qu'on voit
n'est plus l'ordre réel de la box — et le premier enregistrement réaplatit EN GROUPANT,
ce qui change l'arbitrage sans que personne ne l'ait demandé. Chiffré par test sur la
configuration réelle : la PAC passerait du rang 2 au rang 5. C'est exactement ce que le
commentaire de GroupedOrder interdit depuis le début ; le garde-fou existait, il n'était
pas branché.
2. « L'APPAREIL N'EXISTE PLUS » N'EST PAS UN DÉFAUT À LEVER. La Terra AC a été supprimée de
nymea ; son entrée LoadConfig survit, occupe le rang 4, et s'affiche en THING_MISSING
avec un bouton « Lever le défaut ». ClearLoadFault y répondrait EnergyErrorNoError sans
rien changer, et le défaut reviendrait au cycle suivant — un bouton qui ne peut pas
tenir sa promesse fait douter de la box au lieu de désigner la cause.
Le bouton disparaît sur ce code, remplacé par ce qu'il faut faire : réinstaller
l'appareil, ou retirer la charge. Et le libellé cesse de se lire « votre configuration
est cassée » : le cas normal est un appareil remplacé, le plugin crée d'office une
entrée par borne détectée mais ne la retire pas quand le Thing s'en va.
3. Deux pièges du Thing V2C, documentés là où quelqu'un serait tenté de les câbler :
chargeEnergy est EXACT pendant la charge — sa croissance recoupe currentPower à mieux
d'un pour cent — et REMIS À ZÉRO à l'arrêt de la charge, pas au débranchement. Mesuré :
0,3456 kWh → 0 dès que charging passe à faux, 119 secondes avant que le câble ne bouge.
Sur du pilotage par surplus il repartirait de zéro à chaque nuage, et après coup une
nuit entière vaudrait 0 — soit « rien livré » affiché là où la vérité est « pas
mesurable ». Le plugin publiera un sessionEnergy construit ; rien n'est bâti en
attendant, et surtout aucune accumulation côté app.
powerL1/L2/L3 sont des ampères mal nommés, renommage currentL* en cours côté plugin.
Ni affichage, ni conversion : une conversion écrite ici survivrait au correctif et le
contredirait.
Fixture ajoutée : la configuration réelle à cinq entrées, avec ses deux enseignements de
terrain — deux rangs à 1, et une entrée orpheline qui consomme un rang.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
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
Trois corrections de Patrick sur la section batterie, plus le brief séparé.
1. LE BOOST CHANGE DE NATURE, et ce n'est pas un détail de libellé. Ce n'est pas « vider la
batterie dans la voiture » : c'est charger la voiture plus vite que l'abonnement ne le
permet. Un client en 6 kVA veut 7,5 ; le réseau donne 6, la batterie comble l'écart, le
disjoncteur ne saute pas.
Le réglage devient donc une PUISSANCE DE CHARGE VISÉE, pas une cible de SOC batterie, et
la première des trois fins devient « la voiture a atteint sa cible » — le plancher
batterie reste, mais comme garde-fou et non comme objectif. On ne cherche pas à vider la
batterie, on cherche à finir la charge plus tôt.
Ma réserve « le boost pourrait acheter en cachette » tombe, et elle tombe à l'envers : ce
mécanisme existe précisément pour NE PAS dépasser ce qu'on a le droit de soutirer.
Écrit dans la note : ce bloc appartient au délestage de LM-1006-1 vu par l'autre bout —
au lieu de réduire les charges pour tenir sous le plafond, on complète par la batterie.
Même objet, pas un mécanisme parallèle. Et c'est un argument de vente réel, donc il est
dit à l'écran : recharger plus vite sans passer de 6 à 9 kVA.
2. L'INTERRUPTEUR DE DÉCHARGE PROMETTAIT CE QUE LE MÉCANISME NE TIENT PAS. Il disait « la
maison continue d'être servie ; seule la voiture ne l'est pas ». enableDischarging est
binaire et l'onduleur ne voit qu'une consommation nette : coupé, le réfrigérateur passe au
réseau comme la voiture. Reformulé en « geler la batterie », avec le mécanisme expliqué.
La version modulée — plafonner dischargingRate sur la consommation hors borne, recalculé
à chaque cycle — part au brief comme question, pas à l'écran comme réglage.
C'est exactement la classe de défaut traquée depuis trois jours, et je l'avais écrite
moi-même.
3. LA RÉSERVE ADAPTATIVE DEVIENT UN PLANCHER DE SECOURS. Mon avertissement sur Battery Life
était juste mais incomplet : il disait ce que le bloc n'est pas sans dire ce qu'il est.
Combien d'autonomie garder pour une coupure est une préférence de RISQUE — elle dépend du
congélateur, du télétravail, de la fiabilité du réseau local. Héos ne pourra jamais la
décider à la place du client, ce qui la qualifie exactement selon le critère qui a fait
sortir les quatre autres blocs.
Deux détails de cohérence : la batterie est la seule charge sans compteur dédié possible —
elle publie currentPower elle-même — et c'est DIT plutôt qu'omis en silence comme dans les
cinq autres écrans. Et chargingRate trouve sa place : plafond de soutirage sur la charge
réseau, même motif que la recharge accélérée à l'envers.
Le brief moteur est à part, dans BRIEF_plugin_depuis_maquettes.md : les trois questions de
mécanisme (interdit actif, bail qui expire, controlHealthy), plus quatre que la recharge
accélérée soulève — la boucle à la minute contre un disjoncteur en secondes, la marge
dimensionnée contre le pire démarrage non piloté, le filet qui est la BORNE et non la
batterie, et la latence du Fronius à mesurer sur le banc. Avec la conséquence honnête écrite
noir sur blanc : en 6 kVA le gain réel est d'un à deux kilowatts, pas de 1,5 garanti.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
Quatrième passe, même logique que la précédente : ce qui demande un calcul revient à Héos,
pas à un formulaire. Démarrer assez tard pour arriver à 100 % juste avant la pointe suppose
de savoir ce que le soleil va faire — sans prévision ça ne marche pas, et une heure saisie à
la main serait un pari présenté comme un réglage.
Le gain reste écrit dans la note, parce qu'il justifie que le moteur s'en occupe : pic PV de
midi libéré pour l'ECS et la voiture, moins de temps passé à 100 % donc moins de
vieillissement calendaire, et aucun kWh sacrifié — la même énergie, stockée plus tard.
La frise du point de non-retour n'est PAS supprimée : elle remonte dans l'encart du motif
commun, où elle illustre ce que les trois cas partagent au lieu d'illustrer un réglage. C'est
même sa vraie place — une prévision n'engage personne, un point de non-retour si.
Et l'encart gagne une distinction qui manquait : quels paramètres sont calculés (charge
différée → Héos) et lesquels sont saisis (charge réseau, échéance véhicule). Le motif tient
dans les deux cas, ce qui renforce l'argument : le moteur porte la forme, Héos ou
l'utilisateur la remplit.
L'écran de configuration batterie ne garde donc que quatre blocs : le nom, le rang en lecture,
la décharge pendant recharge VE, la réserve adaptative, et la charge réseau manuelle. Quatre
passes, quatre retraits.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
Troisième passe de Patrick, et elle enlève encore. Le tableau « qui peut être servi par la
batterie » n'a pas lieu d'être : décider en général qui mérite d'être servi n'est pas un
réglage d'installateur, c'est un arbitrage — et il reviendra à Héos (l'optimiseur MILP) ou à
une décision par le prix, quand l'un ou l'autre existera. Offrir la question à la main
aujourd'hui figerait dans un écran ce qui doit se calculer.
Reste UN interrupteur : bloquer la décharge pendant la recharge du véhicule. C'est le seul
cas qu'aucun rang ne peut exprimer — la nuit il n'y a pas de surplus à répartir, donc aucune
position dans une file ne dit « ne vide pas la batterie dans la voiture ».
Le CSS des lignes de permission est retiré avec elles, plutôt que laissé mort dans la
feuille.
Trois passes, et l'écran n'a fait que rétrécir : un stepper de rang qui doublonnait la liste,
une colonne de permission qui doublonnait le rang, puis le tableau entier. Ce qui reste est
ce qu'aucun autre mécanisme ne sait dire.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
Deux corrections de Patrick, dont une qui rattrape une incohérence de ma part.
1. LE RANG N'A RIEN À FAIRE SUR CET ÉCRAN. J'y avais mis un stepper « Position −/+ ». Aucun
des six autres écrans de mécanisme n'en porte : le rang se règle par glisser-déposer dans
la liste des charges, et l'écran réel (load_mechanism_screen.dart) ne fait qu'« Inclure
dans l'arbitrage ». Deux réglages de rang auraient fini par diverger.
Il devient une ligne de lecture, qui rappelle le défaut — batterie en première position de
charge — et où il se règle vraiment.
2. « QUI A LE DROIT AVANT LA BATTERIE » N'EST PAS UN RÉGLAGE : c'est le rang. Mon tableau
avait deux colonnes, Solaire et Batterie ; la première disait exactement ce que la
position dit déjà. Retirée.
Ce qui reste est d'une autre nature, et c'est le seul cas qu'aucun rang ne peut exprimer :
la nuit, il n'y a pas de surplus à répartir, donc « ne vide pas la batterie dans la
voiture » n'est pas une position dans une file, c'est une permission de source. Un seul
interrupteur par charge : peut être servi PAR la batterie, ou non.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
La batterie devient une charge du waterfall, donc son bouton Configurer ouvre un écran de
mécanisme comme les cinq autres. Ajouté à la planche existante plutôt qu'à part : c'est
précisément la comparaison côte à côte qui avait révélé « six écrans, trois contrats » avant
qu'une ligne ne soit écrite.
RIEN DE CET ÉCRAN N'EXISTE. Ni BatteryAdapter, ni entrée LoadConfig pour le stockage, ni
prévision PV. Les seuls leviers réels de .75 sont les cinq actions de la classe SunSpec
Storage, et l'état réel affiché se limite à ce que le Thing publie — tout le reste porte un
badge « à créer », y compris là où ça alourdit la maquette.
Le rang par défaut est la batterie EN PREMIER, et ce n'est pas un choix de conception : c'est
la reproduction du comportement actuel. Une mise à jour ne change jamais l'arbitrage sans
geste ; la nouveauté est qu'il devient réglable.
Quatre blocs dans Configurer, dont deux qui ont demandé à être reformulés avant d'être
dessinés :
- Les blocages ne sont PAS une question de rang mais de SOURCE. « Ne pas vider la batterie
dans la voiture la nuit » ne s'exprime pas par un ordre de priorité — la nuit il n'y a pas
de surplus à répartir. La formulation juste est « quelles charges ont le droit d'être
servies par la batterie », réglée charge par charge. Le contrat porte déjà
Source { Solar, GridSource } ; il manque le troisième terme et le droit de le refuser.
- La réserve adaptative est montrée AVEC sa réserve : Victron a conçu Battery Life pour du
plomb, et sur du LFP le problème traité — la sulfatation — n'existe quasiment pas. Si le
mécanisme est repris, ce sera pour un motif à nommer, pas par mimétisme.
- La charge différée montre le POINT DE NON-RETOUR, pas la prévision. C'est lui qui garantit
qu'une prévision fausse ne coûte rien.
- La charge réseau pose le mécanisme seul. Ni prix, ni Tempo, ni marché de gros : le jour où
une source tarifaire existera, elle remplacera le doigt de l'utilisateur sans que le bloc
ne change.
Le boost vers le véhicule est HORS Configurer, sur la carte du tableau de bord : c'est le
seul cas où la batterie est une source et non une charge, et c'est un geste immédiat, pas une
intention durable. Trois fins possibles, toutes obligatoires — une action qui ne sait pas
s'arrêter n'a pas sa place sur un écran client.
Et le motif que la planche fait ressortir : charge différée, charge réseau et échéance
véhicule (LM-1009) ont la MÊME forme — une cible, une échéance, un point de non-retour. Trois
colonnes côte à côte le montrent ; le moteur peut l'implémenter une fois.
Trois questions écrites pour le moteur, pas résolues ici : bloquer demande un interdit ACTIF
et non une allocation à zéro, puisque la batterie agit d'elle-même · cet interdit doit
EXPIRER, sinon un HEMS qui tombe laisse la batterie inerte · et controlHealthy == false doit
sortir la batterie de l'arbitrage, comme available == false pour une charge en défaut.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
Formulée depuis une demande simple de Patrick : que le surplus aille d'abord à l'eau chaude,
et à la batterie seulement ensuite. Elle n'est pas exprimable aujourd'hui, et le brief dit
pourquoi avec ce qui a été vérifié plutôt qu'avec ce qui est supposé.
Quatre constats : batteryLevelConsideration donne la priorité à la BATTERIE (le budget est
annulé sous le seuil, pas au-dessus) ; la batterie n'est dans aucun GetLoadConfig ;
enableCharging et ses quatre voisins ont zéro occurrence dans energyplugin/ ; et pourtant la
classe SunSpec Storage de .75 les déclare comme actions exécutables. Plus un indice :
domain: "battery" est déjà accepté par SetLoadConfig sans que rien n'en fasse quoi que ce
soit.
Quatre sous-questions, dont la première est la leçon de 3g-2 retournée vers eux : l'onduleur
Fronius a sa propre logique de charge, donc avant d'ajouter un commandeur il faut savoir qui
commande déjà. C'est littéralement le défaut d'adjustEvChargers(), et il serait absurde de le
réintroduire sur la batterie le jour où ils viennent de le supprimer sur les bornes.
Et ce que nous nous interdisons en attendant, dit explicitement : une règle Rules.* qui
couperait enableCharging fonctionnerait, et nous l'écartons — ce serait un second commandeur
sans arbitre. Mieux vaut dire « pas réglable aujourd'hui » qu'offrir un réglage dont personne
ne garantit l'effet.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
RECAP.md porte les neuf commits du jour et les trois défauts que seul l'appareil a montrés :
deux cibles tactiles hors norme (26 px, 18 px), et une correction naïve laide que le test
validait — c'est la capture qui a tranché, pas l'assertion.
Ce qui manquait dans le suivi, et qui est écrit noir sur blanc :
AUCUN BILAN JOURNALIER N'EXISTE DANS L'APP. Les quatre tuiles du tableau de bord mêlent deux
natures de grandeur, et aucune n'est journalière — Autoconsommation et Autonomie sont des
pourcentages instantanés, Vers réseau et Depuis réseau des kWh cumulés depuis l'origine. Les
deux dernières portaient « aujourd'hui » : totalReturn n'est pas remis à zéro chaque nuit,
d'où le « · cumulé ». Le bilan du jour reste à écrire, et le TODO porte les trois pièges de
sa mise en œuvre — bornes à minuit local, clés de log SANS le préfixe currentPower, et les
cumulés corrompus de .75.
Et la question ouverte au moteur, telle qu'elle s'est posée : « ECS d'abord, batterie
ensuite » n'est pas exprimable aujourd'hui, et batteryLevelConsideration fait l'inverse — il
annule le budget SOUS le seuil, donc la batterie passe avant. La cause est structurelle : la
batterie n'est dans aucun GetLoadConfig et le plugin ne la commande nulle part. Le levier
existe pourtant, la classe SunSpec Storage de .75 déclare enableCharging, chargingRate et
enableDischarging.
Deux entrées du TODO étaient devenues fausses : kCodesReserveBatterie est fait, et « Zones —
à ne pas entreprendre » est revu puisque le lot est cadré.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
Trois changements demandés après le passage sur téléphone.
1. L'ARBITRAGE EN DIRECT PASSE TOUT EN BAS. Il était en tête avec cet argument : « Alloué
3 000 W ne veut rien dire tant qu'on ne sait pas s'il y avait 3 100 ou 12 000 W à
distribuer ». L'argument reste juste pour LIRE une allocation — mais cet écran sert à
CONFIGURER, et faire descendre l'installateur sous un pavé de télémétrie à chaque
ouverture met la lecture devant le geste.
La note de rangs ex æquo, elle, RESTE EN TÊTE : elle prévient que l'ordre affiché juste
en dessous n'en est pas un, et la lire après les cartes arriverait trop tard.
2. LES DEUX FLÈCHES DE DOMAINE DEVIENNENT UN CHEVRON DE REPLI. Elles faisaient doublon
avec la poignée de glissement, désormais vérifiée au doigt sur appareil (48 px,
réordonnancement mesuré, 0 px de défilement parasite). Deux chemins pour le même geste,
dont l'un sans repli, valaient moins qu'un chemin sûr plus une commande qui manquait.
Replié, le bloc dit COMBIEN de charges il contient : sinon replier reviendrait à faire
disparaître des charges, et une charge qu'on ne voit plus se lit comme une charge
perdue. Le repli est clé par CODE de domaine, pas par index — un domaine déplacé emporte
son repli au lieu de replier son voisin.
3. LA CARTE RÉSERVE BATTERIE SE REPLIE AUSSI, avec une réserve qui n'est pas cosmétique :
repliée, elle garde le pourcentage ET l'alerte de seuil inatteignable. Replier abrège,
ça ne fait pas taire — c'est le réglage qui peut geler toute l'installation sans erreur
ni panne, et le cacher ramènerait le paramètre d'usine invisible que cette carte existe
pour supprimer. Jamais repliée d'office.
Aucun repli n'est persisté, délibérément : c'est une commodité de lecture, pas une
configuration. La rendre durable ferait retrouver un écran à moitié caché des semaines plus
tard, sans savoir pourquoi.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
Le test d'appareil passe. Il a trouvé deux défauts réels que 129 tests unitaires ne
pouvaient pas voir, parce qu'aucun ne rend ces widgets à une taille d'écran réelle.
1. LA POIGNÉE DE GLISSEMENT MESURAIT 26 × 27 px, pour un minimum Material de 48. Le
Container transparent posé lors de l'enquête précédente avait supprimé la bande morte
entre l'icône et le reste, mais il n'avait jamais AGRANDI la cible : le geste accrochait
quand on visait juste et ratait sinon. C'est le seul geste de cet écran sans repli —
sans lui, l'ordre des domaines ne se change pas du tout.
2. LES FLÈCHES DE RANG MESURAIENT 18 × 18 px, la taille de l'icône, sans aucune marge de
touche. Elles n'ont pas de repli non plus : réordonner DANS un domaine ne se fait que
par elles.
Portées à 48 et 44. Et la capture d'appareil a montré le coût de la correction naïve :
empilées, deux cibles de 44 font 88 px de haut pour deux icônes de 18, et la colonne de
rang devient une colonne vide. Côte à côte, la hauteur retombe à 44 et la largeur ne coûte
rien au libellé. La cible tactile est la contrainte ; la disposition ne l'est pas.
Le harnais a lui-même demandé trois corrections avant de dire la vérité, et chacune est un
piège qui se reproduira :
- il regardait le HAUT de l'écran. Dans un ListView, ce qui n'est pas visible n'est pas
construit : les cibles n'étaient pas mesurables faute d'être montées.
- `find` trouve un widget POSÉ MAIS HORS VIEWPORT, et getCenter en rend une coordonnée qui
n'existe pas — mesuré y=1129 sur un écran de 825 px. Le geste partait dans le vide, avec
un symptôme identique à un glissement ignoré.
- la course était devinée. Une liste réordonnable ne permute qu'une fois passé le milieu du
suivant, et un bloc de domaine porte ses cartes : 384 px mesurés, contre les ~150 devinés.
Elle se calcule maintenant depuis l'écart réel entre deux poignées.
Le test mesure aussi le défilement de la page pendant le glissement : si le ListView
extérieur gagnait l'arène, le symptôme serait le même que celui d'une poignée qui
n'accroche pas, pour une cause opposée. Mesuré à 0 px — c'est bien la poignée qui prend.
Aucun débordement de mise en page sur cet écran. Deux captures d'appareil jointes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
Le test d'appareil est ÉCRIT ET NON PASSÉ : l'installation est refusée par MIUI
(INSTALL_FAILED_USER_RESTRICTED — « Installer via USB » remis à zéro, troisième fois). Ni
adb install, ni pm install depuis /data/local/tmp ne passent : c'est une restriction
utilisateur, elle demande le toggle sur le téléphone. Rien de ce lot n'a donc été vu à
l'écran, et c'est dit plutôt que contourné.
Ce qu'il ira chercher, et qu'aucun test unitaire ne montrera :
- les débordements de mise en page, RÉCOLTÉS tous ensemble via FlutterError.onError plutôt
qu'échoués au premier — un « overflowed by » ne fait échouer aucun test de ce dépôt,
parce qu'aucun ne rend ces widgets à une taille d'écran réelle ;
- la taille des cibles tactiles, mesurée sur l'écran réel, avec un plancher dur sur la
poignée de glissement : c'est le seul geste de cet écran qui n'a pas de repli ;
- le glissement AU DOIGT, en appui maintenu puis mouvements successifs — un tester.drag
simple « passe » sans rien réordonner, ce qui avait déjà fait croire à un geste avalé.
Exercé puis ABANDONNÉ : le geste est ce qu'on veut voir, pas l'écriture.
Il n'exerce pas la carte véhicule, et pour une raison qu'il fallait constater : EVChargingCard
n'est montée par AUCUN écran. Le tableau de bord utilise features/dashboard/widgets/. Le SOC
inventé à 62 % que j'ai retiré hier n'était donc affiché nulle part — le défaut était réel,
sa portée non.
Cadrage des zones de clim, avant toute maquette. Le constat qui commande le lot : le banc ne
peut pas héberger une zone, et ce n'est pas « il manque un appareil ». Sur les 58 classes de
.75, AUCUNE ne porte thermostat, closablesensor, temperaturesensor ni notifications — aucun
plugin installé ne sait en créer une. Deux paquets du dépôt apt du banc suffisent, et le
document donne l'exigence d'interface par emplacement, lue dans verifyThingIds() plutôt que
devinée. Avec une réserve trouvée en chemin : valves est au schéma mais n'est PAS vérifié par
la box — un emplacement qui accepte n'importe quoi sans le contrôler.
L'écran ac_screen.dart n'appelle pas rien : il appelle GetZones, et seulement pour compter.
Ses quatre pièces restent de la maquette, avec huit températures crédibles affichées comme
des mesures — le même défaut que le SOC à 62 %, en plus grand.
Et le brief plugin, complété entre-temps, tranche la question de la cible aveugle : le
carBatteryLevel auquel targetPercentage se compare est une valeur que le MOTEUR écrit
lui-même, publiée sous un nom de mesure. L'avancement s'affichera en énergie livrée.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
/bug-report était un stub. Un champ libre seul produit « ça ne marche pas », et l'aller-retour
pour obtenir versions, charges et état d'arbitrage coûte des jours. Tout ce que l'app SAIT
est donc joint d'office : version du paquet, hôte et état de connexion, ligne de versions du
schéma, les charges déclarées avec adaptateur / rang / domaine, et le dernier cycle publié.
Deux choix qui ne sont pas cosmétiques :
- LES DEUX IDENTITÉS DU BUDGET sont dans le rapport, avec leur verdict. Elles valent souvent
plus que la description du symptôme : portées fausses, elles désignent le moteur ; portées
vraies, elles désignent l'écran. Sans elles, le premier échange consiste à les demander.
- Aucun secret n'y entre : ni PIN installateur même haché, ni jeton, ni mot de passe. Un
rapport voyage — courriel, capture, fil de discussion — et ce qui y entre en sort. Les
UUID de Things restent : ce sont eux qui permettent de recouper avec le journal de la box,
et ils n'ont aucune valeur hors de l'installation.
Les absences gardent leur sens, comme partout ailleurs : « aucune télémétrie reçue » n'est
pas un arbitrage à zéro, « budget ABSENT » n'est pas un budget de zéro watt, et un
financement omis se dit omis.
Pas d'envoi automatique : il n'existe aucun service de collecte, et un bouton « Envoyer »
sans destinataire donnerait le sentiment que quelqu'un l'a reçu. Le rapport se copie, ce qui
est vérifiable.
package_info_plus est ajouté plutôt qu'une constante de version recopiée à la main : une
constante dérive de pubspec.yaml en silence, et un rapport qui annonce la mauvaise version
envoie chercher un défaut dans un code qui n'est pas celui-là.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
Aller-retour neutre confirmé sur les quatre entrées, glissement sans cas particulier
confirmé, correctif de description constaté en place.
Deux choses à leur rendre : EV_GRID_START aurait cassé en silence la réconciliation posée
hier (une ligne à funding grid dont la part surplus n'est pas nulle), et il n'existe aucun
SOC de véhicule dans le contrat — donc targetPercentage est une cible sans mesure
d'avancement, qu'aucun écran ne peut suivre. La question leur revient.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
_SocProgress affichait « 62 % » et « Aujourd'hui à 07:30 » en dur, sur un écran client.
Laissé de côté au lot précédent comme préexistant ; ça ne tient plus, une promesse fausse
ne se périme pas.
Le correctif attendu — brancher la vraie valeur — n'existe pas. Vérifié sur .75 : sur les
58 classes de la box, aucune classe portant l'interface evcharger ne déclare d'état de
charge, aucune ne porte l'interface car, et les deux bornes du banc publient pluggedIn,
charging, maxChargingCurrent, phaseCount, sessionEnergy — jamais un pourcentage.
Le seul batteryLevel de l'installation est celui de la batterie de la MAISON (Fronius
Storage, 50 %, rendu visible par le correctif energystorage du plugin SunSpec). C'est une
autre grandeur : l'afficher en face d'une cible de recharge remplacerait un chiffre faux
par un chiffre faux et crédible. Retiré, avec la barre de progression — une jauge sans
grandeur mesurée est un dessin — et la carte dit maintenant pourquoi l'avancement n'est pas
affichable. Reste ce que l'app SAIT : la cible qu'elle vient elle-même d'écrire.
Le brief 3g-2 est rapatrié depuis le dépôt plugin (la forge étant à terre, il n'avait pas
pu voyager) et CLAUDE.md est recalé dessus. À noter, la description trompeuse de
SetChargingInfo signalée hier est corrigée sur la box : elle énumère désormais tous les
défauts et conclut « send it back complete ».
Le prompt 3g-2 déposé à la racine est commité tel quel, il était déjà indexé.
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
CLAUDE.md portait un exemple de SetChargingInfo qui invitait à l'écriture partielle. Il
porte maintenant la règle inverse, avec la preuve machine, et les deux pièges de lecture de
GetLoadTelemetry : ne pas sommer allocatedW sans filtrer sur funding, et ne pas lire
l'allocation d'une borne comme son état.
Retour au moteur dans BRIEF_plugin_depuis_maquettes.md. Un point actionnable chez eux : la
description trompeuse vit dans nymeaenergyjsonhandler.cpp:114 et part dans Introspect chez
tout client. Elle ne se contente pas de ne pas prévenir, elle autorise explicitement
l'erreur — le prochain client la croira. Une ligne à corriger, ou une écriture à rendre
réellement partielle.
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
1. SetChargingInfo remplace un targetPercentage absent par 80, EN SILENCE — réponse
EnergyErrorNoError, aucun avertissement, et la seule trace au journal imprime déjà 80.
L'objet est reconstruit entier à chaque écriture : un champ omis retombe sur son défaut,
il n'est pas « inchangé ». L'app doit lire-patcher-réécrire, et ne pas afficher 80 comme
une cible que l'utilisateur aurait choisie.
2. L'alerte sur SampleRate15Mins était FAUSSE et je la retire : ma sonde lisait
currentPowerProduction là où les entrées de log portent production. Vérifié dans les deux
sens — 51 échantillons non nuls par RPC, 7908 lignes en base. L'historique 90 jours n'est
bloqué par rien. Le piège réel est le nom des champs, sans préfixe currentPower.
3. loads[] contient enfin les bornes (buildTelemetry les sautait) et porte un champ funding
surplus/grid, sans lequel sommer allocatedW donne un écart ininterprétable. Avec la mise
en garde qui va avec : adjustEvChargers() re-décide après le waterfall, donc pour une
borne l'allocation publiée est une intention, pas un ordre.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
Le fil conducteur des six commits du jour tient en une phrase : l'app ne recalcule pas
ce que la box publie, et n'anticipe pas ce qu'elle n'a pas encore publié.
Le point à retenir est le seuil de réserve batterie : 0,9 en usine, batterie du banc à
50 %. Quand la règle montera dans le budget, `.75` ne pilotera plus rien par grand
soleil, sans erreur ni panne. Le RECAP le dit, et le TODO garde les trois crochets
laissés ouverts — dont le `Set` volontairement vide qui attend le code du moteur.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
`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
MINIMUM. « Plancher » désigne désormais deux choses qu'il ne faut surtout pas ranger
ensemble :
- le plancher DÉCLARÉ d'une charge modulable — propriété de l'appareil, saisie une fois,
stable, avec un champ de configuration à venir ;
- le minimum d'une BORNE — max(borne, voiture), qui dépend du véhicule branché et change
avec lui. Il n'aura jamais de champ de configuration : il n'y a rien à y régler.
Les rendre modifiables de la même façon ferait croire qu'on peut régler ce qui se constate.
Le bloc « plancher » ne vit que dans la section etmvariableload, ce qui écarte les bornes
structurellement — elles ne sont pas des LoadConfig et n'ouvrent pas cet écran — et son
libellé le dit désormais explicitement.
PHASES. Le plancher effectif d'une borne dépend du nombre de phases visé : ~1,4 kW en
monophasé, ~4,1 kW en triphasé, pour le MÊME courant. La valeur est affichée telle que la
box la donne, sans conversion ni recalcul côté app. Si l'app convertissait, elle devrait
deviner laquelle des deux règles s'applique — et se tromperait un jour. C'est exactement le
miroir qui vient d'être retiré pour la combinatoire des paliers ; on ne va pas en
réintroduire un.
PARTAGE. Le budget ne se divise pas entre bornes : aujourd'hui chacune reçoit l'enveloppe
entière et ce sont le compteur puis la protection de surcharge qui rattrapent ; après 3g
une seule démarrera. Avec deux bornes maintenant déclarées sur le banc, le laisser
implicite invitait à déduire un partage qui n'existe ni avant ni après. Une note l'énonce
dès qu'il y en a plus d'une.
Le bloc de runtime d'une borne reste MUET tant que la recharge n'est pas arbitrée — la box
ne publie rien pour elle, et c'est la bonne réponse, pas un trou. Il affichera phases,
courant, minimum et présence du véhicule le jour où ils arriveront, en lecture seule.
Tests 105/105, analyze 0 erreur.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
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
MIUI refuse l'injection d'événements adb : impossible de faire défiler ou de taper depuis
l'hôte pour contrôler visuellement. Les assertions descendent donc dans integration_test,
qui a la main sur l'IU : le chip porte bien un libellé, le sélecteur de mécanisme propose
les trois valeurs, et les sections exigées par la maquette (Relais commandés,
Temporisations, Compteur dédié, Arbitrage) sont présentes.
Une clé est posée sur le chip pour le viser sans compter des icônes.
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
Ce qui a été livré (6 commits), ce qui a été vérifié SUR L'APPAREIL contre .75, l'erreur de
la soirée — les écrans construits sans ouvrir docs/mockups/, tout à refaire — et les cinq
points repris demain matin, dont le glissement et le sélecteur de mécanisme que j'avais rendu
non cliquable à tort.
Renvoie au BRIEF_agent_plugin.md du dépôt plugin pour les sept constats côté moteur.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
Je n'avais pas ouvert docs/mockups/ : les écrans ont été construits depuis le texte du brief
seul, et l'écart est franc. Les maquettes du 2026-08-25 font autorité, elles sont désormais
suivies.
ÉCRANS DE MÉCANISME, refaits :
- sélecteur de Things PAR NOM, filtré sur l'interface nymea (power / etmvariableload).
Taper un UUID au doigt est une erreur garantie ET muette : la box accepte un UUID valide
qui ne désigne rien, et la charge ne commande jamais rien. Le sélecteur grise les Things
déjà pris — la box refuse en bloc un même Thing sur deux lignes — et signale en rouge un
ThingId configuré qui n'existe plus sur la box ;
- routeur de relais : ajout/retrait de contacteurs, puissance par ligne, et les paliers
atteignables AFFICHÉS. Ils sont déduits (2^N combinaisons, doublons écartés), jamais
enregistrés — la maquette est explicite là-dessus, ma note précédente disant l'inverse
était une sur-interprétation du brief ;
- SG-Ready : SG1/SG2 se choisissent EN HAUT, une fois, et la table des quatre états devient
éditable — ce qui s'y coche est fermé/ouvert, et c'est ça qui devient relays[]. Les
étiquettes SG1/SG2 sont une convention de l'écran (SG1 = fermé en état 1, SG2 = en état 3)
et elles TOMBENT sur un encodage non standard, où les contacts s'affichent tels quels ;
- modulable : plafond requis > 0, et l'avertissement que changer d'appareil crée une autre
charge puisque l'identifiant EST le ThingId ;
- temporisations minOnS/minOffS (et minStateHoldS pour la PAC) en pas de 30/60 s ;
- « Compteur dédié » présent et GRISÉ, avec sa raison : aucun champ n'existe côté box, et
inventer meterThingId ferait rejeter l'enregistrement de TOUTES les charges ;
- « Inclure dans l'arbitrage » avec rang et domaine, et ce que la désactivation fait
vraiment selon le mécanisme ;
- validation AVANT envoi : relays[] vide, powerW ≤ 0, Thing en double, état 2 absent,
maxPowerW ≤ 0. SetLoadConfig refuse en bloc et sans motif — laisser partir une config
invalide condamne l'installateur à un refus muet qui perd tout l'appel.
LISTE :
- l'en-tête d'arbitrage et le tableau de budget, PERDUS à la fusion, sont rétablis. Sans
eux « Alloué 3 000 W » ne se rattache à rien ;
- le rang passe en pastille près de la poignée, et le sous-titre devient lisible :
« Routeur de relais · 3 relais · min ON 60 s » au lieu de « Routeur de relais · rang 1 » ;
- section « Recharge véhicule » : la borne n'est pas une LoadConfig, elle n'apparaissait donc
NULLE PART — ce qu'un installateur qui vient de la régler lit comme une perte.
MENU : l'entrée « Ordre de service des charges » est retirée ; elle pointait sur une
redirection, donc sur le même écran sous deux noms.
Tests 80/80, analyze 0 erreur.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
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
Chaîne complète vérifiée sur l'appareil : schéma lu, écran fusionné monté, charges réelles
affichées ([chauffe-eau, pac-terrain]) avec leur télémétrie, écriture confirmée par
LoadConfigChanged, restauration.
Le dernier obstacle était le plus instructif : /energy/setup est une route réservée au mode
installateur, et le routeur la renvoie vers / tant qu'il est verrouillé. Le symptôme était
« aucune charge lue » — on aurait cherché un défaut de lecture RPC là où le verrou faisait
son travail. C'est le contrôle de montage d'écran ajouté au harnais qui a tranché.
Le test déverrouille avec le PIN par défaut et reverrouille APRÈS la pause d'observation :
l'ordre inverse passait les assertions mais renvoyait l'écran au tableau de bord.
Relevé : docs/RELEVE_LOTC.md §2 et §6 mis à jour.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
Le run rebranché est allé plus loin que jamais : connexion à .75 et lecture du schéma sur
l'appareil, puis blocage sur la lecture des charges. Un contrôle de montage de l'écran est
ajouté pour distinguer « navigation non faite » de « lecture en échec » — mais il n'a pas
pu s'exécuter : MIUI a remis à zéro « Installer via USB » à la reconnexion et refuse
désormais toute installation (INSTALL_FAILED_USER_RESTRICTED, sans dialogue). L'app n'est
de ce fait plus installée sur le téléphone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
adb shell input tap est refusé par MIUI (INJECT_EVENTS). integration_test contourne le
problème par construction : le test s'exécute DANS le processus de l'app et tape sur les
widgets depuis l'intérieur, sans injection d'événements Android.
Ce que le harnais a déjà prouvé SUR L'APPAREIL, contre .75 : l'app démarre, se connecte
(« connecté à 1.15.2+202606191336~trixie1 ») et SchemaProvider lit JSONRPC.Introspect
(« NymeaEnergy 0.8 · AirConditioning 1.1 »). Le grisage dérivé du schéma n'est donc pas
une théorie : il est alimenté sur le téléphone par la vraie box.
Le run n'est pas allé au bout. Trois obstacles : GoRouter.of() cherché au-dessus du Router
(corrigé) ; le test supposait une installation déjà enregistrée (corrigé — il enregistre la
box lui-même via connectNew) ; puis le téléphone s'est déconnecté du bus USB, et l'ADB sans
fil n'est pas activé. C'est là que ça s'est arrêté.
EFFET DE BORD ASSUMÉ : flutter test integration_test RÉINSTALLE l'app, ce qui vide
shared_preferences et le stockage sécurisé — les deux installations enregistrées sur le
téléphone ont été effacées. Le harnais ne dépend plus de cet état et restaurera .75 au
premier run mené à terme ; nymea-dev-kutz est à rajouter à la main.
Relevé mis à jour : docs/RELEVE_LOTC.md §2 et §6.
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 miroir INTERFACE_etmvariableload.md est passé en rév. 3 pendant la session (corps
identique au canonique du dépôt plugin, vérifié). Son §10 s'adresse nommément à l'agent
app — et il RATIFIE les maquettes dessinées aujourd'hui : relays[] = {thingId, powerW}
pour l'ECS relais, maxPowerW seul pour le continu, powerLevels absents de la config et
dérivés par le routeur.
Trois recalages :
- Le picker de relais filtre sur l'interface nymea « power » et présente les things par
NOM — nommé dans la maquette, il ne l'était pas.
- Le point 4 du brief plugin est tranché par la rév. 3 : la duplication de la
combinatoire est voulue (l'app dérive pour montrer, le routeur pour exécuter). La
demande devient donc un moyen de détecter la divergence — la télémétrie publie déjà
maxStageW, l'app peut y confronter le maximum de sa propre liste.
- En-tête du pont VM Service, que le commit précédent avait laissé dans sa forme brute.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
Ce miroir était resté en rév. 2 alors que le canonique du dépôt moteur est en rév. 3 depuis
le 2026-08-09. La divergence portait sur le point le plus structurant : la rév. 2 plaçait la
combinatoire watts→relais « dans le thing », ce que la rév. 3 renverse — un integration-plugin
nymea ne peut PAS piloter les things d'un autre plugin, donc la combinatoire vit côté
experience-plugin, et l'écran « Configurer » change (§10, annoncé « impact app majeur »).
Le bandeau désignait en outre un chemin canonique faux (racine au lieu de docs/).
Contenu neuf pour l'app, au-delà du rattrapage de révision :
- §4-bis — LM-201, UN CANAL DE COMMANDE, UNE CHARGE. Décide de ce que l'écran « Configurer »
doit CRÉER : une PAC chauffage+ECS sans ballon séparé = UNE charge, pas deux. Avec ballon
séparé = deux. Une PAC qui fait aussi du froid = toujours une seule.
- §4-bis — LM-202, thermostats et zones NE SONT PAS des charges : ils ouvrent une vanne, ils ne
portent pas la consommation. Leur écran, leur namespace, jamais LoadConfig.
- §4-ter — LM-200, mécanismes recevables selon l'organe, et le piège d'UI « ma PAC est pilotée
par deux relais donc relay-router » : les contacts SG-Ready SONT des things power, mais ils
signalent au lieu de porter la charge.
- domain est explicitement SANS force normative — ne construire aucune règle dessus. Le moteur
n'interdit rien : c'est l'app qui guide au moment du choix, seul endroit fiable puisqu'elle
sait quel appareil l'installateur déclare.
- Lot B-bis : loads[] de GetLoadTelemetry est désormais INCLUS dans GetLoadConfig ; la règle de
détection « loadId sans équivalent » est retirée et aucun configurable:false n'existe.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
flutter run n'imprime pas les traces dart:developer log() — elles partent au VM
Service, ni sur stdout ni dans logcat. Ce pont est ce qui a rendu le passage sur
appareil du 2026-08-25 exploitable ; il ne vivait que dans un scratchpad de session,
alors que le RECAP le donnait « à récupérer au prochain passage ».
Mode d'emploi en tête, avec le piège déjà payé une fois : le VM Service livre
l'historique bufférisé d'un coup au streamListen, donc horodater à la réception
écrase toute la chronologie sous une seule seconde. On lit event.timestamp.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
Trois maquettes, neuf écrans, après le passage sur appareil qui a montré que
/energy/setup décrit autre chose que l'installation.
- charges_pilotables_mockup.html : la liste à deux niveaux, avec le bandeau
d'arbitrage hérité de /energy/loads (qui disparaît) et la télémétrie sur la carte
de chaque charge.
- configurer_mecanismes_mockup.html : les six « Configurer » — routeur de relais,
modulable, SG-Ready, borne, zones HVAC, sélecteur d'appareils.
- raccordement_reseau_mockup.html : compteur réseau et protection de surcharge.
Décisions de conception que les images ne portent pas seules : les paliers
n'existent que chez le routeur de relais et y sont DÉDUITS des combinaisons ; le
mode fixed d'etmvariableload (legacy) n'est plus proposé mais reste lisible, parce
qu'ouvrir un écran ne doit pas convertir une config déployée ; SG1/SG2 sont une
convention d'app retrouvée par l'encodage standard, la box ne stocke qu'une liste
de relais par état ; le compteur par charge est dessiné inactif avec sa raison ; la
borne règle un mode par DÉFAUT, pas une commande.
CLAUDE.md corrigé contre JSONRPC.Introspect (plugin 1.15.2+etm15) :
- Energy.SetChargingMode n'existe pas — le « bug critique » décrivait un appel
impossible. Energy.* n'a que cinq méthodes.
- Le namespace EnergyPlugin.* n'existe pas : c'est NymeaEnergy.*, 20 méthodes et
14 notifications, toutes listées.
- ChargingInfo : chargingMode / targetPercentage / endDateTime (Uint), et non
mode / targetSoc / endTime. minCurrent n'existe nulle part.
- phasePowerLimit est en AMPÈRES par phase, et 0 coupe toute la recharge
intelligente.
- AirConditioning expose 8 méthodes, pas 3.
Toutes les méthodes citées ont été revérifiées une par une : aucune introuvable.
Règle ajoutée : contrôler ce fichier contre Introspect avant chaque lot — trois
fois aujourd'hui il a envoyé un agent dans le vide.
Et docs/BRIEF_plugin_depuis_maquettes.md : les six points que les maquettes
remontent vers le moteur, dont un seul blocage dur (pas de champ de courant
minimum pour le mode Min+PV).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
Premier passage de l'app sur matériel (Redmi Note 9S). MIUI refuse INJECT_EVENTS :
Patrick a piloté, l'agent a observé. Captures et trace dans screenshots/ (ignoré).
Acquis à l'écran :
- §7-9 : trois Hello, tous « demandée fr_FR → retenue fr_FR », la valeur relue venant
de m_clientLocales côté box. Mais la locale de l'APPAREIL (en-GB) ne part pas :
_requestedLocale est figé à fr_FR et rien ne câble l'un sur l'autre.
- §7-5 : l'écran suit l'arbitre sans un geste — budget, allocation, motif recomposé,
et le recrédit anti-clignotement visible en clair.
- Écriture de domaine faite au doigt, confirmée par la box et le fichier persisté.
- SetLoadConfig 19:53:46.007 → LoadConfigChanged .288 → ack .303 : la confirmation
a battu l'acquittement de 15 ms. Conclure sur l'ack, c'est courir après sa propre
notification.
§7-6 : l'état « données anciennes » est INATTEIGNABLE par perte de lien — la garde de
routage évacue vers /installations une seconde après la coupure, bien avant le seuil de
180 s. Le compteur de fraîcheur, lui, a été vu vivre. Seul sinceCycleAdvanced peut donc
réellement déclencher le bandeau : l'arbitre figé socket vivante.
Huit défauts constatés, aucun corrigé (constat d'abord) : pas de reconnexion
automatique, /energy/setup qui décrit une autre installation que la vraie, cumuls
aberrants venus de la box, thème ignoré, débordements, titre dupliqué, instantané
effacé hors connexion, bruit de transport.
Décisions de Patrick : l'ordre se règle dans « Charges pilotables » au glisser-déposer,
/energy/loads disparaît, deux niveaux conservés, chip « À déclarer » → bouton
Configurer ouvrant le mécanisme et ses Things.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
Patrick a relancé le service à 18:23:45. LoadConfigStore recharge 2 configs depuis
/var/lib/nymea/energy-load-configuration.json et la relecture RPC est identique clé
par clé à celle d'avant l'arrêt : chauffe-eau→ecs, pac-terrain→hvac.
Le redémarrage montre aussi la reprise à froid vue du dehors (ECS-411-b) : les
adaptateurs journalisent ce qu'ils ont LU sur les contacts, avec la mention
« correspondance exacte » — l'état de départ est constaté, pas supposé.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
Trois sondes permanentes, et le relevé de ce qu'elles ont montré sur .75.
- telemetry_watch.dart (lecture seule) : cadence du battement de cœur et avancée
du cycle. C'est la seule mesure qui distingue une installation stable d'un
moteur arrêté — les deux produisent le même flux de trames.
- domain_assign.dart (écrit, --yes) : classe des charges en important le code
d'écriture de l'app. Une sonde qui sérialiserait à sa façon ne prouverait rien
sur l'app.
- fault_probe.dart (écrit, --yes) : provoque une charge en défaut, éprouve
ClearLoadFault, restaure. La config d'origine part sur le disque AVANT la
première écriture et la restauration rejoue ce fichier verbatim — filtrer la
sonde hors d'une relecture serait une reconstruction, c'est-à-dire le chemin
qui a failli écraser la config du banc via persist(). La config relue est
ensuite comparée clé par clé, pas seulement « la sonde a disparu ».
Ce que le banc a appris, et qui ne se déduisait pas du code :
- ClearLoadFault lève RÉELLEMENT le verrou (journal : « défaut LEVÉ ») et le
cycle suivant reverrouille, la cause n'ayant pas disparu. La télémétrie ne
repasse jamais à available:true. Une app qui aurait cru l'acquittement aurait
menti deux secondes plus tard.
- Le défaut publié est WRITE_FAILED, pas THING_MISSING : l'écriture est tentée,
échoue, l'échelle s'épuise, et m_faulted masque le second code.
- SetLoadConfig refuse en bloc et le motif n'est PAS dans la réponse RPC — il est
au journal. L'app ne peut que rapporter un refus, pas l'expliquer.
- ECS-412 confirmé au journal : une écriture de domaine ne reconstruit aucun
adaptateur (« 2 inchangée(s), 0 retirée(s) »). Le §7-1bis n'est plus une
lecture de code.
.75 est laissé classé (chauffe-eau→ecs, pac-terrain→hvac) : métadonnée que
l'arbitre ne lit pas, et le multi-domaines y devient exerçable. Retour arrière :
domain_assign.dart --clear --yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
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
Le bloqueur consigné en fin de session précédente est levé : GetLoadTelemetry,
LoadTelemetryChanged et o:domain sont sur .75 (20 méthodes / 14 notifications).
Mais consigne explicite de Patrick : ne PAS enchaîner sur la moitié télémétrie
tant que l'agent plugin n'a pas fini son travail sur pac-terrain. L'écart
GetLoadTelemetry (2 charges) / GetLoadConfig (1 charge) est ce travail en
cours, pas une anomalie à remonter — coder contre cet état serait coder contre
une cible mouvante.
Consigne aussi que l'arbitre tourne bien (timestamp +1 min/cycle), ce qui fixe
le plancher du seuil de fraîcheur §7-6 à plus de 60 s.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Range dans tools/rpc/ les sondes qui vivaient dans un scratchpad volatil :
probe (appel RPC quelconque), notifications (abonnement + comptage),
locale et locale_diff (comportement o:locale et son effet réel),
loadconfig_roundtrip (aller-retour neutre §7-1). README avec usages.
loadconfig_roundtrip est le SEUL qui écrit sur la box : garde --yes ajouté
pour qu'un SetLoadConfig ne parte jamais par réflexe de flèche haut.
analysis_options : tools/** et les classes l10n générées sortent de
l'analyse — flutter analyze revient à 25 issues (ligne de base).
Fixtures : le lot plugin télémétrie EST déployé sur .75 depuis ce matin
(20 méthodes / 14 notifications). Capturé avant que ça ne se reperde :
- loadtelemetry_hems75.json : dump réel de GetLoadTelemetry
- loadconfig_hems75_v2.json : GetLoadConfig avec la clé 'domain'
- schema_hems75_v2.json : schémas SET + télémétrie introspectés
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Point d'arrêt avant redémarrage machine. Consigne l'état du lot B app, le
bloqueur (lot plugin non déployé sur .75), le partage vérifié/non-vérifié,
et la question ouverte sur la fusion /energy/setup ↔ /energy/loads.
Note l'accès ssh au banc désormais documenté dans CLAUDE.md : il lève la
limite qui rendait le critère 7-1bis (non-reconstruction des adaptateurs)
invérifiable faute de journal.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>