10 Commits

Author SHA1 Message Date
Patrick Schurig
01fdad73c5 fix(ergonomie): les cibles tactiles étaient à 26 px — trouvé au premier passage sur appareil
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
2026-08-27 15:25:23 +02:00
Patrick Schurig
da0c87e303 test(appareil): le harnais des surfaces 3g-2, et le cadrage des zones de clim
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
2026-08-27 14:54:27 +02:00
Patrick Schurig
801ba15094 feat(3g): la réserve batterie sort de l'usine — un seuil qui peut tout geler doit se voir
`batteryLevelConsideration` ne gouverne aujourd'hui que la recharge du véhicule. Une
fois la règle montée dans le budget, il gouvernera TOUTE l'installation : sous le
seuil le budget de surplus est annulé — pas réduit — donc ni ECS, ni PAC, ni bornes.

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

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

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

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

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

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

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

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

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

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

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

Tests 101/101, analyze 0 erreur.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

5) ZONES — non entreprises, comme convenu.

Tests 93/93, analyze 0 erreur.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
2026-08-26 08:49:20 +02:00
Patrick Schurig
15a52637a5 test(appareil): l'IU de configuration tourne sur le téléphone, contre .75
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
2026-08-25 23:00:58 +02:00
Patrick Schurig
98e2874ecd test(appareil): diagnostic de montage d'écran + relevé — MIUI bloque l'installation USB
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
2026-08-25 22:48:09 +02:00
Patrick Schurig
f0aa0fd804 test(appareil): harnais integration_test — l'IU sur le téléphone, contre la vraie box
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
2026-08-25 22:42:09 +02:00