docs: journal du passage sur appareil — §7-9 et §7-5 clos, §7-6 inatteignable

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
This commit is contained in:
Patrick Schurig 2026-08-25 20:20:07 +02:00
parent 31ef03fd4b
commit 47cc0b1f72

108
RECAP.md
View File

@ -152,6 +152,114 @@ dart run tools/rpc/domain_assign.dart 192.168.1.75 --clear --yes
La charge fictive de la sonde de défaut a été **retirée**, et la configuration relue est
identique clé par clé à celle lue avant la sonde.
## Passage sur appareil — 2026-08-25 (Redmi Note 9S, USB)
Premier passage de l'app sur matériel réel. `adb` voit l'appareil, mais **MIUI refuse
l'injection d'événements** (`INJECT_EVENTS`) : captures et journaux passent, `input tap`
non. Patrick a piloté à l'écran, l'agent a observé. Une capture par étape dans
`screenshots/2026-08-25-passage-appareil/` (git-ignoré), trace complète dans son
`trace-app.log`.
### Le pont qui a rendu la session possible
`flutter run` **n'imprime pas** les traces `dart:developer log()` — elles partent au VM
Service, ni sur stdout ni dans logcat. Tout ce que `NymeaService._log()` journalise était
donc invisible. Le pont `scratchpad/vm_logs.dart` s'abonne au flux `Logging` du VM Service
et rend la trace. **Sans lui, aucun des constats ci-dessous n'était atteignable.** À
récupérer au prochain passage (attention : horodater sur `event.timestamp`, pas à la
réception — le VM Service livre l'historique bufférisé d'un coup).
### Critères
| Critère | Verdict sur appareil |
|---|---|
| **§7-9 locale** | ✅ **trois `Hello`** (19:37:59, 19:43:55, 19:50:55), tous `demandée "fr_FR" → retenue "fr_FR"`. La valeur relue vient de `m_clientLocales` côté box : c'est la box qui dit ce qu'elle a retenu, pas l'app qui se répond à elle-même. ⚠️ **La locale de l'APPAREIL ne part pas** : le téléphone est en `en-GB`, `_requestedLocale` est figé à `'fr_FR'` (`nymea_service.dart:61`) et rien ne câble l'un sur l'autre. Conforme au brief (app française seule), mais à savoir le jour où l'app parlera allemand. |
| **§7-5 télémétrie vivante** | ✅ **à l'écran, sans un geste** : entre 19:51 et 19:52, budget 943 → 2 571 W, alloué 500 → 3 000 W, motif recomposé, « dernier cycle il y a 51 s » → « 41 s ». Le recrédit anti-clignotement est **visible** (0 → 500 W : la charge consommait déjà, on lui rend avant d'arrondir). |
| **§7-6 fraîcheur** | ⚠️ **l'état « données anciennes » est INATTEIGNABLE par cette voie.** Détail ci-dessous. |
| **Écriture (§7-4 depuis l'UI)** | ✅ affectation de domaine faite au doigt → « Configuration confirmée par la box » → `pac-terrain → heating` vérifié sur la box **et** dans le fichier persisté. |
| **Acquittement ≠ application** | ✅ mesuré : `SetLoadConfig` à 19:53:46.007, `LoadConfigChanged` à **.288**, acquittement traité à **.303**. La confirmation a battu l'ack de 15 ms. |
### §7-6 — pourquoi la bascule n'a pas eu lieu
```
20:13:00 dernière trame LoadTelemetryChanged
20:13:20 coupure WiFi (adb shell svc wifi disable)
20:13:21 🔌 Connection closed
20:14:21 (T+60) → l'app est déjà sur « Installations »
20:17:47 (T+260) → toujours là. Seuil de 180 s jamais atteint à l'écran.
```
**La garde de routage évacue l'écran avant que la fraîcheur ait le temps d'expirer.**
`hasActiveConnection` tombe à faux, le routeur redirige vers `/installations`, et l'écran
qui devait virer au « données anciennes » n'existe plus.
Ce n'est pas un défaut de la fraîcheur — le compteur a été vu vivre et s'incrémenter à
l'écran (51 s → 41 s → 6 s). C'est que **la perte de lien n'est pas le chemin qui mène à cet
état**. Le seul déclencheur réel du bandeau est l'**arbitre figé socket vivante** : les
trames continuent d'arriver, le `timestamp` cesse d'avancer. C'est exactement le cas que le
battement de cœur du plugin existe pour rendre détectable — et il n'est pas provocable
depuis le réseau : il faudrait figer l'arbitre sans tuer nymead.
**Conséquence de conception à retenir :** des trois signaux de fraîcheur, seul
`sinceCycleAdvanced` peut réellement se déclencher dans l'app telle qu'elle est. Les deux
autres (`cycleAge`, `sinceLastFrame`) sont court-circuités par l'éjection.
### Défauts constatés — aucun corrigé (constat d'abord, décision de Patrick)
1. **Aucune reconnexion automatique.** `_onDone()` pose `_connected = false` et notifie,
point final. Une coupure WiFi d'une seconde éjecte vers « Installations », **fait
retomber le mode installateur**, et l'installateur doit tout refaire à la main. Observé
deux fois.
2. **`/energy/setup` décrit autre chose que l'installation.** À 19:49 l'écran affichait
« 6 / 6 rôles configurés · tout est assigné » avec un « Compteur réseau — Onduleur
SolarEdge » — alors que `.75` n'a **aucun** SolarEdge (11 things : Fronius ×3, Terra AC,
6 relais). La trace disait `SetRootMeter ignoré (sim-inverter)` : des assignations
héritées du **mode démo** avaient survécu dans la vue d'une box réelle. Et dans l'autre
sens : après réassignation, l'app annonçait « Compteur réseau — aucun appareil » quand la
box avait bien `Fronius Three Phase Meter`. L'écran **affirme** dans les deux cas.
Cinq `[LoadConfig] N descripteur(s) construits mais NON émis` pendant l'exercice.
3. **Cumuls aberrants venus de la box.** `totalReturn = 9.10e+33 kWh`,
`totalAcquisition = 7.53e+33`, `totalConsumption = -1.57e+33` — seul `totalProduction`
est sain. L'app les affiche fidèlement, sur trois lignes, en notation scientifique. Le
défaut est en amont (compteurs cumulés de l'experience-plugin energy sur ce banc), mais
une valeur physiquement impossible ne doit pas être mise en forme comme une donnée. Les
gardes de `EnergyRatiosInterim` ont tenu : ratios à `0 %` plutôt qu'une absurdité.
4. **Thème ignoré sur l'écran des charges.** Couleurs claires en dur (`Scaffold`
`0xFFF0F2F5`, AppBar blanche, textes `0xFF1A1A2E`) sur un téléphone en thème sombre :
les titres de domaine et les libellés de charge passent en sombre-sur-sombre, presque
illisibles. Reprises par mimétisme de l'écran existant — `CLAUDE.md` dit pourtant
« utiliser `app_theme.dart` systématiquement ».
5. **Trois débordements de texte** : « Palier 3 000 W sur 3 50… », « Maintien ON — 60 s
restantes (au dernier cyc… », et « Autoconsommat/ion » coupé en plein mot au dashboard.
6. **Titre d'écran dupliqué** : la clé ARB `installerLoadsTitle` vaut « Rôles & appareils »,
soit le titre de l'AUTRE écran. Deux écrans, un seul titre.
7. **`load()` hors connexion efface l'instantané.** `getLoadTelemetry()` renvoie `null` pour
`!_connected` **et** pour « méthode absente » ; le provider traite les deux pareil et pose
`_telemetry = null` → l'écran n'affiche rien, en silence, au lieu de « données anciennes »
ou de « box déconnectée ». À corriger avec la refonte : ce chemin sera emprunté chaque
fois que l'app est hors ligne.
8. **Bruit de transport.** Les notifications nymea portent un `id` : `_processMessage` les
journalise d'abord en « réponse non attendue (déjà timeout?) » avant de les traiter
correctement. Et le heartbeat interroge `Energy.GetPowerBalance` **toutes les 5 s** alors
que `Energy.PowerBalanceChanged` arrive en poussé.
### Décisions de Patrick prises pendant le passage
- **L'ordre de service se règle dans « Rôles & appareils » → « Charges pilotables »**, au
glisser-déposer. **L'écran « Ordre de service des charges » (`/energy/loads`) disparaît**,
et son bloc télémétrie part sur la carte de chaque charge.
- **Le regroupement à deux niveaux est conservé** (domaines ordonnés, charges ordonnées
dedans).
- Le chip « À déclarer dans Configurer » devient un **bouton Configurer**, qui ouvre le
détail du mécanisme : relay-router / etmvariableload / sg-ready, ses relais, ses Things.
- Conséquence assumée : le lot « label + enabled » et le lot « mécanisme + Things »
redeviennent un seul chantier — celui qui touche `sameHardware()`, donc reconstruction des
adaptateurs et réarmement des verrous à froid.
- **Cette refonte suppose que les cartes de « Charges pilotables » SOIENT les charges lues
par `GetLoadConfig`**, et non des `LoadDescriptor` reconstruits depuis des `EmsRole`. C'est
la cause racine du défaut 2 ; sans ça, un glisser-déposer n'y écrira jamais un `priority`
réel.
## Vérifié / non vérifié (lot B app)
| Critère | Statut |