Patrick Schurig 17b2f03ce0 tools(devlog): garder le pont VM Service au dépôt
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
2026-08-25 21:44:05 +02:00

472 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# RECAP — etm-powersync-app · reprise de session
> **Session 2026-08-25.** Branche **`feature/beta-add-config`**. Rien n'est poussé.
> La section « État au 2026-06-30 » plus bas reste valable pour tout ce qu'elle décrit.
---
## Session 2026-08-25 — ce qui a été fait
### Commits ajoutés
| Commit | Contenu |
|---|---|
| `7ca4297` | **Lot B app** — ordre de service des charges, locale, ossature i18n |
| `40ac3b2` | Fixtures : dump réel `GetLoadConfig` + schéma introspecté de `.75` |
### 1. Menus (demande initiale)
- « Rôles & appareils » et « Options développeur » → **section MODE INSTALLATEUR** uniquement.
- Groupes / Scènes / Médias / Garages → **masqués par défaut**. Le drawer lit désormais
`AppSettingsProvider.visibleScreens` (ces 4 écrans y étaient **déjà** à `visible: false` ;
le drawer les affichait en dur et ignorait le réglage).
- Garde de routeur `_isInstallerRoute` (`main.dart:41`) : `/settings/system*`,
`/settings/app/developer`, `/energy/setup`, `/energy/loads` → redirection vers `/` si le
mode installateur est verrouillé. Couvre l'URL directe, le deep-link et l'auto-lock 10 min.
- Liens SUPPORT corrigés : doc → `https://docs.etm-powersync.fr/`, Telegram →
`https://t.me/etm_powersync_support`, **Discord retiré** (commenté sur place).
### 2. Investigations rendues (constats factuels, pas de code)
- **Locale nymea** : gérée **PAR CONNEXION** (`o:locale` dans `Hello`,
`m_clientLocales`). Pas de forçage `LANG`/`LC_ALL` dans l'unité systemd. **Mais** sur 784
chaînes affichables de `.75`, l'allemand en change **1**, le français **0** : aucun
catalogue `.qm` fr n'est livré. Les `displayName` = identifiants anglais.
- **Frontière RPC / runtime de l'arbitre** : quasi rien ne sort. Seuls `degradedMode`
(notification `ChargingSchedulesChanged` **uniquement** — pas relisable par `Get`), les
deux ratios d'énergie, `chargingState` de l'EV et le plan de recharge franchissent.
Allocation, budget, verrous, paliers, `available`, motifs : **journal seulement**.
`SurplusContext` / `LoadContextTelemetry` n'ont aucune sérialisation sortante.
### 3. Lot B app — livré
- `models/load_config_entry.dart` — **vue en lecture** au-dessus de la map brute ; écriture
par `patched()` depuis la map d'origine, jamais de re-sérialisation.
- `services/load_priority.dart` — `groupByDomain` / `flattenToPayload` /
`applyDefaultDomainOrder` / miroir client de `validateSet()`.
- `providers/load_config_provider.dart` — séquence `pending → LoadConfigChanged →
confirmed|diverged`. Ne conclut jamais sur l'acquittement du RPC.
- `screens/energy/load_order_screen.dart` — route `/energy/loads`, gatée installateur.
- i18n : `flutter_localizations` + `intl` + `lib/l10n/app_fr.arb` + `l10n.yaml`.
Extension `context.l10n`. Français seul.
- `NymeaEnergy.GetLoadConfig` / `SetLoadConfig` deviennent **réels** (c'étaient des stubs).
- **Locale** envoyée à chaque `Hello`, demandée + retenue journalisées.
### 4. ⚠️ Piège désamorcé — à ne pas réintroduire
`EnergySetupProvider.persist()` appelait `setLoadConfig(buildLoadDescriptors())`, une
**reconstruction depuis le modèle typé**, et `_commit()` l'appelle à chaque changement d'UI.
Inoffensif tant que le RPC était un stub ; destructeur une fois réel (`SetLoadConfig`
remplace **tout l'ensemble** → la charge `chauffe-eau` de `.75` aurait été écrasée).
**Émission débranchée**, raison documentée sur place.
---
## Reprise du 2026-08-25 (soir) — l'agent plugin a terminé, la moitié télémétrie est faite
Le blocage est levé : `1.15.2+etm15` (lot plugin **B-bis**) est sur `.75`, `pac-terrain`
est devenue une `LoadConfig` ordinaire, et `GetLoadConfig` / `GetLoadTelemetry`
**concordent** (2 charges des deux côtés). La règle de détection « charge arbitrée hors
configuration » de `+etm14` est **sans objet** — ne pas la réintroduire, et **ne pas
attendre de `configurable: false`** : ce champ n'existe pas.
### Livré côté app
| Fichier | Rôle |
|---|---|
| `models/load_telemetry.dart` | Vue en lecture de `GetLoadTelemetry`. `timestamp`/`budget`/`mechanism`/`lock`/`faultCode` **nullables** : omis ≠ zéro. Seuil de fraîcheur `kTelemetryStaleAfter = 180 s`. |
| `services/telemetry_text.dart` | Rendu français des **codes**. 19 motifs, 3 défauts, 3 verrous, 4 mécanismes — et un repli obligatoire. |
| `providers/load_telemetry_provider.dart` | Instantané + `LoadTelemetryChanged`, fraîcheur à **trois signaux**, machine à états de `ClearLoadFault`. |
| `providers/load_config_provider.dart` | `assignDomain()` — affectation en **brouillon**, jamais d'écriture implicite. |
| `screens/energy/load_order_screen.dart` | En-tête budget/fraîcheur/dégradé, bloc télémétrie par charge, encart défaut, menu de domaine. |
| `services/nymea_service.dart` | `getLoadTelemetry()`, `clearLoadFault()`, aiguillage de `LoadTelemetryChanged`. |
| `tools/rpc/{telemetry_watch,domain_assign,fault_probe}.dart` | Sondes permanentes — voir leur `README.md`. |
**57 tests** (30 avant), `flutter analyze` **25 issues = ligne de base inchangée**.
### Trois choix de conception à ne pas défaire
1. **La fraîcheur ne repose pas sur le seul `timestamp`.** Trois signaux concourent :
l'âge du `timestamp` (le seul qui vaille à l'instant du premier instantané — un `Get`
rend le dernier cycle *publié*, qui peut dater d'une heure), le temps depuis la
dernière trame (immunisé contre l'écart d'horloge box↔téléphone), et le temps depuis
que le `timestamp` a **cessé d'avancer** — le cas exact que le battement de cœur
existe pour rendre détectable. Le seuil est à **180 s** : trois cycles. Un seuil ≤ 60 s
crierait « données anciennes » sur une installation vivante.
2. **Les secondes d'un verrou ne sont pas décomptées côté app.** Le plugin exclut
volontairement ce champ de sa détection de changement ; un décompte local serait une
extrapolation présentée comme une mesure. L'écran écrit « (au dernier cycle) ».
3. **Un code connu privé d'un paramètre attendu bascule en repli**, comme un code inconnu.
« Consigne de W servie par le surplus » aurait l'air d'une donnée ; le code brut a
l'air de ce qu'il est.
### Ce que le banc a appris — et qui ne se déduisait pas du code
**`ClearLoadFault` lève vraiment, et le cycle suivant reverrouille.** Passage du
2026-08-25 18:14 → 18:17 (`tools/rpc/fault_probe.dart`, journal de `.75`) :
```
18:14:00 écriture → thing non trouvé → échelle épuisée → charge EN DÉFAUT
18:15:58 [Arbitre] ClearLoadFault → "défaut LEVÉ ; la charge revient à l'arbitrage"
RPC : EnergyErrorNoError
18:16:00 cycle suivant → thing toujours non trouvé → EN DÉFAUT à nouveau
```
La télémétrie ne repasse **jamais** à `available: true`. Une app qui aurait annoncé
« défaut levé » sur le retour du RPC aurait menti deux secondes plus tard. C'est la
justification, mesurée, de l'état `pending` maintenu 130 s puis conclu `stillFaulty`.
**La reprise à froid lit le matériel, elle ne le suppose pas.** Au redémarrage de 18:23:45,
`RelayRouter` et `SgReadyAdapter` journalisent ce qu'ils ont **lu** sur les contacts —
« palier 0 W [correspondance exacte] ; relais lus : … », « état 3 (~1500 W estimés)
[correspondance exacte] ; contacts lus : … ». C'est ECS-411-b vu du dehors : l'état de
départ de l'arbitre est constaté, jamais reconstitué de mémoire.
**Le défaut publié est `WRITE_FAILED`, pas `THING_MISSING`.** L'écriture est tentée,
échoue faute de Thing, l'échelle s'épuise, et `m_faulted` masque le second code
(`etmvariableloadadapter.cpp:148-150`, l'ordre des `if`). Ne pas coder l'UI en supposant
qu'un Thing absent produit `THING_MISSING`.
**`SetLoadConfig` refuse en bloc, et le motif n'est PAS dans la réponse RPC.** La box
renvoie `EnergyErrorInvalidParameter`, point. Le motif — « `etmvariableload fixed :
powerLevels requis (non vide)` », avec le libellé de la charge — est **au journal**. C'est
la « silence de refus » que le relevé du plugin garde ouverte (§9-4) : l'app ne peut pas
expliquer un refus à l'installateur, elle ne peut que le rapporter.
### Journal de vérification — critères 4 à 8
| Critère | Verdict |
|---|---|
| **§7-4 domaine** | ✅ **sur `.75`, redémarrage compris** — écrit par le code d'écriture de l'app (`domain_assign.dart` importe `patched()`/`flattenToPayload()`), relu identique, persisté dans `/var/lib/nymea/energy-load-configuration.json`, et **survit au redémarrage de `nymead`** (18:23:45, relancé par Patrick) : `LoadConfigStore` recharge 2 configs depuis le fichier et la relecture RPC est identique **clé par clé** à celle d'avant l'arrêt. |
| **§7-5 télémétrie vivante** | ✅ **sur `.75`** — trames non sollicitées, allocation 500 → 3000 → 3500 W, `SG_NORMAL` → `LOCK_MIN_STATE_HOLD`, verrou 241 → 180 → 120 s. |
| **§7-6 fraîcheur** | ✅ **protocole mesuré sur `.75`** (battement ~59 s, cycle qui avance d'une minute) ; ⚠️ la **bascule d'affichage** est éprouvée en test unitaire, pas à l'écran — l'app n'a jamais tourné sur appareil (voir §7-9). |
| **§7-7 défaut** | ✅ **sur `.75`** — défaut provoqué, `available: false`, `faultCode` rendu en français, `ClearLoadFault` exercé et son inefficacité **constatée sur la télémétrie**. ⚠️ pas de fixture brute : le dump n'a pas été conservé (`--dump` le capturera au prochain passage). |
| **§7-8 code inconnu** | ✅ test unitaire — code absent de l'ARB **et** code connu privé d'un paramètre. Non exerçable sur `.75` : la box n'émet que des codes du catalogue. |
| **§7-1bis ECS-412** | ✅ **levé** grâce au ssh — journal de l'écriture de domaine : `0 créée(s), 0 mise(s) à jour en place, 2 inchangée(s), 0 retirée(s)`. Aucune reconstruction d'adaptateurs. |
### État du banc à la fin de la session
`.75` est **laissé classé** : `chauffe-eau` → `ecs`, `pac-terrain` → `hvac`. Le `domain`
est une pure métadonnée que l'arbitre ne lit pas, et cet état rend enfin le multi-domaines
exerçable sur la machine. Pour revenir en arrière :
```bash
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 **`tools/devlog/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.**
Mode d'emploi en tête du fichier, piège d'horodatage compris (lire `event.timestamp`, pas
l'heure de 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.
## Refonte décidée le 2026-08-25 (soir) — maquettes validées
Patrick a tranché la question ouverte du RECAP : **l'écran « Ordre de service des charges »
(`/energy/loads`) disparaît.** Le rang se règle dans « Rôles & appareils → Charges
pilotables » au glisser-déposer, le **regroupement à deux niveaux est conservé**, et le bloc
de télémétrie descend sur la carte de chaque charge.
| Maquette | Contenu |
|---|---|
| `docs/mockups/charges_pilotables_mockup.html` | La liste : bandeau arbitrage, domaines glissables, cartes avec télémétrie, défaut, bouton Configurer |
| `docs/mockups/configurer_mecanismes_mockup.html` | Les six « Configurer » : relais · modulable · SG-Ready · borne · zones HVAC · sélecteur d'appareils |
| `docs/mockups/raccordement_reseau_mockup.html` | Compteur réseau + protection de surcharge |
**Décisions de conception qui ne se déduisent pas des maquettes :**
- **Les paliers n'existent que chez le routeur de relais**, et ils y sont *déduits* des
combinaisons de contacts, jamais saisis. Le modulable ne connaît qu'un plafond
(`maxPowerW`). Le mode `fixed` d'`etmvariableload` — que le plugin qualifie lui-même de
*legacy* — **n'est plus proposé**, mais reste **lisible** : ouvrir l'écran ne doit pas
convertir une config déployée.
- **Le critère de choix entre les deux ECS**, formulé pour l'installateur : *où sont les
contacteurs ?* Sur les sorties de la box → routeur de relais. Dans l'appareil, piloté par
une consigne → modulable.
- **SG1 / SG2 n'existent pas côté box.** `sgReady.states[].relays[]` n'est qu'une liste par
état. Les étiquettes sont une convention d'app, retrouvée à la lecture par l'encodage
standard (SG1 = fermé en état 1, SG2 = fermé en état 3). Un encodage non standard perd les
noms, pas la configuration.
- **Le compteur par charge figure dans les cinq écrans, inactif**, avec la raison sur place.
Aucun champ n'existe côté box, et nymea rejette toute clé inconnue avant le handler.
- **La borne règle un mode PAR DÉFAUT**, pas une commande — le pilotage vit sur la carte
véhicule du tableau de bord.
## Ce qui remonte vers le plugin
Six points, rédigés en brief exploitable : `docs/BRIEF_plugin_depuis_maquettes.md`.
Le seul **blocage dur** est le premier — `ChargingInfo` n'a aucun champ de courant minimum,
donc le mode Min+PV reste sélectionnable mais non réglable.
Les cinq autres : motif de refus de `SetLoadConfig` qui ne franchit pas la frontière ·
`WRITE_FAILED` qui masque `THING_MISSING` · paliers atteignables non exposés (l'app devra
sinon dupliquer la combinatoire de `relayrouter.cpp`) · état matériel de `enabled:false` à
confirmer pour relais et modulable · `estimatedPowerW` du banc encore fictif.
## Contrôle de la doc contre la machine — à refaire avant chaque lot
`CLAUDE.md` a été **corrigé contre `JSONRPC.Introspect`** le 2026-08-25. Trois fois dans la
journée la doc locale était en retard sur la box :
1. le namespace `LoadConfig` cherché dans `OPTIMIZER_PROTOCOL.md`, où il n'est pas (note de
périmètre ajoutée côté plugin, commit `afaa8c2`) ;
2. le « bug critique » `Energy.SetChargingMode` — **cette méthode n'existe pas**, l'appel
échouerait sur « No such method » ;
3. les champs de `ChargingInfo` (`mode`/`minCurrent`/`targetSoc`/`endTime` au lieu de
`chargingMode`/`targetPercentage`/`endDateTime`), le namespace `EnergyPlugin.*`
inexistant, et `AirConditioning` donné pour 3 méthodes quand il en expose 8.
Toutes les méthodes citées par `CLAUDE.md` ont été revérifiées une par une : **aucune
introuvable**. La sonde tient en une ligne —
`dart tools/rpc/probe.dart 192.168.1.75 JSONRPC.Introspect`.
## Vérifié / non vérifié (lot B app)
| Critère | Statut |
|---|---|
| §7-1 aller-retour neutre | ✅ **sur `.75` en vrai** — `SetLoadConfig` verbatim → relecture identique |
| §7-1bis non-rebuild ECS-412 | ✅ **sur `.75`** — journal lu : 2 charges inchangées, 0 reconstruite |
| §7-2 aplatissement réversible | ✅ **test unitaire seul** — `.75` n'a que deux charges, désormais dans deux domaines |
| §7-3 config non contiguë | ✅ **test unitaire seul** — fixture synthétique |
| §7-4 domaine | ✅ **sur `.75`**, redémarrage de `nymead` compris |
| §7-5 télémétrie vivante | ✅ **sur `.75`** |
| §7-6 fraîcheur | ✅ protocole sur `.75` ; ⚠️ bascule d'affichage en test unitaire seul |
| §7-7 défaut | ✅ **sur `.75`** (sonde `fault_probe.dart`) |
| §7-8 code inconnu | ✅ test unitaire (non exerçable sur une box conforme) |
| §7-9 locale | ✅ protocole vérifié sur `.75` ; ⚠️ **chemin app jamais exécuté sur appareil** (adb refusé) |
| §7-10 analyze / tests / gating | ✅ 25 issues = ligne de base, 57/57 tests |
## Ce qui reste à faire sur ce lot
1. **Faire tourner l'app sur appareil** (`adb` refusé jusqu'ici). Tant que ça n'est pas
fait, §7-6 et §7-9 restent des vérifications de protocole, pas d'écran.
2. **Capturer une fixture brute de défaut** au prochain passage de `fault_probe.dart`
(`--dump`).
3. **Écran de réglage par charge** — l'agent plugin le demande explicitement :
`pac-terrain` est désormais renommable, classable, rangeable, désactivable. Le brief du
lot B limitait l'écriture à `priority` + `o:domain` ; renommer et désactiver sont
**hors de ce lot**, et attendent un arbitrage.
## Question ouverte à trancher
Le brief désignait « Rôles & appareils » (`/energy/setup`) comme écran cible, mais cet écran
est bâti sur le modèle **inverse** (il construit des `LoadDescriptor` depuis des `EmsRole`).
J'ai créé `/energy/loads` à côté et laissé `/energy/setup` intact. **Fusionner les deux est
une refonte, pas un câblage** — décision Patrick attendue.
---
# RECAP — etm-powersync-app · reprise de session
> État au 2026-06-30. Branche **`feature/beta-add-config`** (9 commits au-dessus de `master`).
> **Rien n'est poussé** (push différé). **Aucun merge** vers master/silo.
---
## 1. Où on en est (commits, du plus récent au plus ancien)
| Commit | Contenu |
|---|---|
| `b4a6c97` | **Lot C — squelette discovery** de things (⚠️ *pending SDM réel*) |
| `b7a7213` | Catalogue de things (lecture seule) + filtres (lot A) |
| `3a535e0` | Écran **Protocoles** — CRUD masters Modbus RTU |
| `0ebbde4` | chore(docs) — specs/contrats/refs rangés dans `docs/` |
| `be6694c` | chore — ignore `/screenshots` |
| `f755c72` | docs — contrat UI **rev.5** (réserve décision-2 levée) |
| `3821bf3` | refactor(energy) — retrait calcul ratios app-side → seam `EnergyRatiosInterim` |
| `b5abe42` | docs — contrat UI rev.4 |
| `c638ec6` | **Connexions multi-HEMS + Rôles & appareils** (gros commit fondateur) |
**Source de vérité contrats** : `docs/UI_data_contract.md` (rev.5), `docs/reference/jsonRPC.txt` (introspection — fait foi pour les RPC).
---
## 2. Ce qui est LIVRÉ et fonctionnel
### Connexions multi-HEMS (validé live)
- `models/installation.dart`, `services/installation_store.dart` (token **par UUID** via `flutter_secure_storage`, métadonnées SharedPreferences).
- `services/connection_manager.dart` : mono-connexion, `switchTo`, `connectNew`, `enterDemo` (démo = active factice), contrôle d'identité DHCP, invalidation des caches au switch.
- `nymea_service` : `connect()` réveillé (ws://4444, token par UUID), capture Hello (uuid/name/initialSetupRequired), `authenticate`/`createUser`, `resetState`.
- Écran **Installations** (3 états) + écran **Connexion** (auth 2 branches, détection auto).
- **Gate de routage** (`buildAppRouter`, redirect + `refreshListenable: merge([cm,svc])`), `hasActiveConnection = isSimulation || (connected && authenticated)`.
- En-tête drawer cliquable → Installations.
- **Testé en vrai** : login auth contre `.120`, `.75` ouvert, switch, déverrouillage installateur.
### Rôles & appareils
- `EnergySetupProvider` tri-état (present/absent/non-configuré), écran `roles_devices_screen`, `LoadDescriptor` (rév.2 : PAC exclue → SG-Ready), `Energy.SetRootMeter` réel, Get/SetLoadConfig en **stub loggé**.
### Energy — Phase 1 (interim)
- `services/energy_ratios.dart` = **SEAM UNIQUE** `EnergyRatiosInterim` : ratios autoconso/autonomie par Δ-de-cumuls (reseed jour/non-monotone, gardes n/a, clamp). `selfConsumptionPower` net-signé **sans `.abs()`**. Test : `test/energy_ratios_test.dart`.
- Calcul ratios app-side **retiré** de `_parsePowerBalance` + getter `autoconsommationW` supprimé.
### Installateur — Modbus RTU (validé live)
- `models/modbus_models.dart`, 5 RPC `ModbusRtu.*` dans `nymea_service`, écran `protocols_screen` (CRUD), route `/settings/system/protocols`.
### Installateur — Catalogue things lot A (validé live)
- `getVendors()` + `getAllThingClasses()`, écran `thing_catalog_screen` (liste groupée + 3 filtres : fabricant/type/recherche + détail read-only), route `/settings/system/things`.
### Installateur — Discovery lot C (SQUELETTE, NON validé matériel)
- `ThingDescriptor`, `discoverThings()`, `addThingFromDescriptor()`, `thing_discovery_screen` (form slaveAddress → scan → descriptors → AddThing).
---
## 3. ⚠️ BLOQUEURS / EN ATTENTE
- **Lot C — merge bloqué** tant qu'on n'a pas un **descriptor SDM réel** sur le bus de hems `.75` (besoin : adaptateur USB-RS485 + un vrai compteur SDM branché ; aujourd'hui le master `/dev/ttyUSB0` est `connected:false`). **3 points marqués `À REVALIDER SUR SDM RÉEL`** dans `thing_discovery_screen.dart` / `nymea_service.dart` :
1. résolution master/slave par **heuristique de nom** de paramTypes (`'master'`/`'modbus'`/`'slave'`) ;
2. **timeout** scan (`_sendRequest` = 15 s, à allonger si bus réel plus lent) ;
3. `title`/`description` du descriptor (peuvent être vides/génériques).
- **Push** : jamais fait (volontaire).
---
## 4. Lots / phases NON FAITS (file d'attente)
- **Lot B** — ajout direct `CreateMethodUser + JustAdd` (form paramTypes). Sous-ensemble = 22 classes (ABB B2x, ABB Terra AC RTU/TCP, génériques/simulés nymea). ⚠️ **Les Eastron SDM N'EN SONT PAS** (ils sont Discovery → lot C). Piège param : nom du master varie (`modbusMasterUuid` vs `rtuMaster`). Reporté **après** C.
- **Lot D** — discovery → pairing (OAuth/PushButton/UserAndPassword).
- **Connexions Étape 3** — découverte **mDNS** native (`multicast_dns`, natif only ; web = ajout manuel). Différée.
- **Connexions — branche CreateUser** (box neuve) : codée mais **non testée live** (pas de box vierge/factory-reset sous la main).
- **Energy Phase 2/3/4** (session dédiée, voir mémoire) : Phase 2 = states ratios côté energymanager (autre repo) ; Phase 3 = persistance Influx ; Phase 4 = refonte flow-card §1.1 + bandes + retrait définitif du calcul app-side. Contrat = `docs/UI_data_contract.md`.
### Bugs latents notés (hors lot)
- `dashboard_screen.dart` : `onRefresh: () => service.startSimulation()` → sur box réelle, pull-to-refresh bascule en démo. À corriger au câblage du refresh réel.
- Affichage signe : sans `.abs()`, conso reste net-signée → Maison (flow-card) & ligne conso (graphe) montrent le signe brut **jusqu'à la Phase 4** (voulu).
---
## 5. Banc de test
| Cible | Détail |
|---|---|
| **hems** `.75` | nymea 1.15.2, **ouverte** (`authenticationRequired:false`). Master RTU `/dev/ttyUSB0` présent mais bus **hors-ligne**. Sert aux sondes read-only sans auth. |
| **box 2** `192.168.1.120` | **auth requise** (`initialSetupRequired:false` → login). Vraie installation avec **SDM120 PV**. |
| **InfluxDB** | local sur `etm-powersync-dev`, **v1.6.7** (PAS Influx 3 Core), DB `nymeatest`, RP tiers déjà présentes. |
| **Tél Android** | Redmi Note 9S, **adb WiFi** `192.168.1.107:5555`. |
**PIN installateur par défaut = `1234`.**
### Comment lancer / tester
- **Réel (réseau)** : `flutter run -d 192.168.1.107:5555` (Android WiFi). **Pas Chrome** pour le réseau (CORS LAN). Voir mémoire `web-lan-cors`.
- **Linux natif** : ❌ bloqué (Flutter **snap** sans linker `ld.lld`). Web headless = page blanche (CanvasKit). Captures fiables = `adb exec-out screencap -p`.
- **Sonde RPC sans GUI** : script `dart:io` `WebSocket.connect('ws://<host>:4444')` + JSON-RPC (ex. utilisé pour valider Modbus/discovery contre `.75`).
---
## 6. Faits techniques à ne pas reperdre
- **Signes nymea** (power balance agrégé) : `production` **positif**, `consumption` **NÉGATIF**, `acquisition` **net-signé** (négatif = export). `currentPower` thing-level = **négatif pour un producteur** (convention conso). **Ne jamais deviner/`.abs()` le signe.**
- **Cumuls** (`totalAcquisition` / `totalReturn`) **séparés** → autoconso/autonomie dérivables des logs. UUID Hello **brace-wrapped** `{...}` (normalisé dans `_finishConnect`).
- **Discovery SDM** : discoveryParam = **`slaveAddress` uniquement** (Int, déf 1). Le master n'est **PAS** une entrée — il **ressort** dans `descriptor.params`. Ajout final = `AddThing(thingDescriptorId)`.
- **CreateMethod** = {User, Auto, Discovery} · **SetupMethod** = {JustAdd, DisplayPin, EnterPin, PushButton, UserAndPassword, OAuth}. Orthogonaux (une classe JustAdd peut exiger Discovery).
---
## 7. Reprise — prochaines actions probables
1. Brancher un **SDM sur `.75`** → sonde discovery réelle → lever les 3 points + **débloquer le merge du lot C**.
2. Puis **lot B** (ABB + génériques) ou **lot D** (pairing).
3. Côté energy : ouvrir la **session dédiée Phase 4** (contrat `docs/UI_data_contract.md`).
4. Décider du **push** de `feature/beta-add-config`.
> Mémoires persistantes liées : `web-lan-cors`, `energy-chart-redesign`, `deploy`.