Patrick Schurig b60c74d2e4 docs(recap): la journée du 2026-08-27 — et le mot qui manquait : JOURNALIER
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
2026-08-27 19:49:22 +02:00

754 lines
48 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-27.** Branche **`feature/beta-add-config`**, **39 commits d'avance,
> NON POUSSÉS** — la forge est à terre (`192.168.1.113` injoignable, front public en 503).
> Bundle de secours vérifié :
> `~/bundles/etm-powersync-app_feature-beta-add-config_2026-08-27.bundle`.
> La journée du **27** est en tête ; celle du 26 suit.
---
## Journée 2026-08-27 — deux lots moteur, le premier passage écran, et trois défauts trouvés
La box est passée de `+etm22` à `+etm24` dans la journée. Fil conducteur : **ce que l'app
affiche doit avoir été dit par la box** — et ce qu'aucun test unitaire ne peut voir, seul
l'appareil le montre.
| Commit | Contenu |
|---|---|
| `a145529` | **3g-1** — `BATTERY_RESERVE` branché, `funding` lu, l'état d'une borne passe devant |
| `a92e9b2` | `SetChargingInfo` n'est PAS une écriture partielle — lire-patcher-réécrire |
| `a44d64d` | **3g-2** — les bornes sont des charges configurées, une seule liste, un seul rang |
| `24822ce` | **3g-2** — la mise en garde borne tombe, sans devenir une certitude trop large |
| `f7fd91e` | Le SOC véhicule inventé disparaît — la grandeur n'existe pas |
| `f33b8c2` | Le rapport de bug pré-rempli, repoussé depuis le premier jour |
| `da0c87e` | Le harnais d'appareil, et le cadrage des zones de clim |
| `01fdad7` | **Les cibles tactiles étaient à 26 px** — trouvé au premier passage sur appareil |
| `2701a9c` | L'arbitrage descend en bas, et les blocs se replient |
### Les trois défauts que seul l'appareil a montrés
1. **La poignée de glissement mesurait 26 × 27 px** (minimum Material : 48). Le `Container`
transparent posé la veille avait supprimé la *bande morte*, jamais agrandi la *cible* :
le geste accrochait quand on visait juste et ratait sinon.
2. **Les flèches de rang mesuraient 18 × 18 px** — la taille de l'icône, sans marge de
touche.
3. **La correction naïve était laide** : deux cibles de 44 px empilées font 88 px de haut
pour deux icônes de 18, et la colonne de rang devenait une colonne vide. Le test disait
« 44 px, c'est bon » ; c'est la **capture** qui a dit que c'était laid. Passées côte à
côte.
Le harnais lui-même a demandé trois corrections avant de dire la vérité, et chacune se
reproduira : il regardait le haut de l'écran (dans un `ListView`, ce qui n'est pas visible
n'est pas construit) ; `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) ; la
course de glissement était devinée (~150) là où elle vaut **384 px mesurés**.
### Ce que le moteur a changé, et ce que l'app en a fait
- **`loads[] ⊆ GetLoadConfig` rétabli** : toute borne détectée reçoit une entrée
`LoadConfig` (`.75` passe de 2 à 4). Aller-retour verbatim vérifié **neutre** sur machine.
- **La mise en garde borne tombe** — mais *personne d'autre ne commande* n'est pas *la
borne obéit*. `allocationIsCommand` est **supprimé** plutôt que mis à `true` partout : le
garder inviterait à lire « commande » comme « réalité ».
- **`EV_GRID_START` aurait cassé la réconciliation en silence.** C'est le seul motif dont
l'allocation se PARTAGE entre les deux compteurs du budget ; filtrer sur
`funding == "surplus"` le comptait zéro. Trouvé en lisant le brief plutôt que son résumé.
- **Le rang par défaut d'une borne est un artefact** (trois charges à `priority = 1` sur le
banc). L'écran le nomme au lieu de le présenter comme un classement.
### Ce qui a été RETIRÉ parce que c'était faux
- **Le SOC véhicule à 62 % en dur.** Vérifié sur les 58 classes de `.75` : aucune classe
`evcharger` ne déclare d'état de charge. Le brief moteur a tranché ensuite, et c'est pire
qu'une mesure manquante — `carBatteryLevel` est une valeur que **le moteur écrit
lui-même**, publiée sous un nom de mesure. Décision LM-1009 : l'avancement s'affichera en
**énergie livrée**, jamais en pourcentage.
- **`BorneSection`** : les bornes ont désormais un rang et un domaine, la section les
affichait une seconde fois en affirmant le contraire.
- **Les deux flèches de domaine** : doublon de la poignée, désormais vérifiée au doigt.
### 🔴 Aucun bilan JOURNALIER n'existe dans l'app
Constaté en répondant à une question sur le tableau de bord. Les quatre tuiles sous le flux
énergétique (`features/dashboard/widgets/kpi_row.dart`) mêlent **deux natures de grandeur**,
et **aucune n'est journalière** :
| Tuile | Ce qu'elle montre RÉELLEMENT |
|---|---|
| Autoconsommation | **% instantané** |
| Autonomie | **% instantané** |
| Vers réseau · cumulé | **kWh cumulés depuis l'origine** |
| Depuis réseau · cumulé | **kWh cumulés depuis l'origine** |
Les deux dernières portaient « aujourd'hui », et c'était faux : `totalReturn` n'est pas remis
à zéro chaque nuit. Le libellé dit « · cumulé » depuis. **Le bilan du jour reste à faire** —
il passe par `GetPowerBalanceLogs` avec des bornes à minuit local, jamais par les totaux de
`GetPowerBalance`. Voir `TODO.md`.
### ❓ Question ouverte au moteur — « ECS d'abord, batterie ensuite »
Demandé par Patrick : que le surplus aille d'abord à l'eau chaude, et à la batterie
seulement ensuite. **Ce n'est pas exprimable aujourd'hui**, et le réglage existant fait
l'inverse : `batteryLevelConsideration` annule le budget *sous* le seuil, donc la batterie
est prioritaire jusque-là. Le mettre à 0 ne donne pas la priorité à l'ECS — il retire
seulement le blocage, et c'est l'onduleur qui arbitre.
La cause est structurelle : **la batterie n'est pas dans le waterfall**. Elle n'a aucune
entrée `GetLoadConfig`, et `enableCharging` / `chargingRate` / `enableDischarging`
n'apparaissent nulle part dans le plugin ETM. Le levier existe pourtant — la classe
**SunSpec Storage** de `.75` déclare ces cinq actions. Question à porter au moteur : *le
stockage est-il une charge arbitrable, ou reste-t-il hors du budget ?*
---
## Journée 2026-08-26 — se recaler sur la box, trois fois de suite
La box a bougé trois fois dans la journée (`+etm16`, `+etm19`, puis `3g`). Le fil conducteur
de tout ce qui suit 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é.**
| Commit | Contenu |
|---|---|
| `aada8e2` | Les 4 points ouverts du RECAP : glissement, sélecteur de mécanisme, chip de domaine, thème |
| `7089148` | Le harnais vérifie lui-même le chip et l'écran de mécanisme |
| `cd76df3` | **`+etm16`** — table de paliers, `stagesW`, compteur et sonde |
| `ab2a27b` | **`+etm19`** — `BELOW_MIN_POWER`, compteurs rattachés depuis l'écran, glissement exercé |
| `449c6ac` | **`3g`** — les valeurs de borne sont du RUNTIME |
| `801ba15` | **`3g`** — la réserve batterie sort de l'usine |
### Ce qui a été corrigé, et pourquoi ça avait échoué
- **Le glissement ne marchait pas** — et c'était ma régression : j'avais imbriqué un
`ReorderableListView` dans un autre, les deux se disputaient le geste. Le classement est
désormais **inversé à dessein** : on glisse les *domaines*, et les flèches ordonnent *dans*
un domaine. Restait une **zone morte de 3 px** dans la poignée : `ReorderableDragStartListener`
délègue le test de toucher à ses enfants, un `Container` transparent la referme.
- **Le test du glissement déclarait forfait** deux fois sur trois : le banc revient
régulièrement à une charge par domaine, et sans domaine partagé il n'y a rien à glisser. Le
test **pose maintenant sa propre condition** — il regroupe, exerce, puis rend le classement
d'origine. Un test qui dépend de l'état du terrain ne teste rien.
- **« la config du rootmeter est perdue »** — elle ne l'était pas : la box l'avait toujours,
l'app ne la **lisait** jamais (`syncRootMeter()`). Le symptôme et la cause étaient à deux
écrans de distance.
- **`/energy/setup` est gardé par le mode installateur** : le routeur renvoyait ailleurs, et le
symptôme affiché était « aucune charge lue ». Le harnais déverrouille au PIN `1234` et
**reverrouille après** le maintien d'écran — l'ordre inverse passait au vert en montrant le
tableau de bord.
### Les trois recalages de contrat
**`+etm16` — ce que la box compare vraiment.** `sameHardware()` est public, et la comparaison
porte sur la **table de paliers**, pas sur la liste de relais : réordonner `relays[]` ne
reconstruit plus rien (vérifié au banc — « 2 inchangée(s) », aucun état sûr). Mon premier
miroir comparait des listes et criait au loup ; corrigé après lecture de
`RelayStageTable::operator==` — chaque combinaison se compare **comme un ensemble**, mais les
paliers s'apparient par position. Les paliers dérivés arrivent maintenant en `stagesW` : un
miroir de moins à faire diverger. Les trois champs optionnels (`meterThingId`, `sensorThingId`,
plancher) sont traités **comme un seul objet**, grisage dérivé du schéma, jamais anticipés dans
le modèle.
**`+etm19` — `BELOW_MIN_POWER`.** Deux variantes, à distinguer de `SURPLUS_INSUFFICIENT` et
`SG_NORMAL` qui ne se déclenchent plus que sur budget ≤ 0. Le repli aurait tenu : un code
inconnu n'a jamais affiché de vide.
**`3g` — deux valeurs de runtime, et un réglage qui change de portée.** « Minimum » désigne
deux objets différents : le **plancher déclaré** d'une charge modulable (une propriété de
l'appareil, qui se règle) et le **minimum d'une borne**, `max(borne, voiture)`, qui dépend du
véhicule branché et du nombre de phases — de l'ordre de 1,4 kW en monophasé contre 4,1 kW en
triphasé, **pour le même courant**. L'app affiche la valeur telle que la box la donne : aucune
conversion côté app, sinon il faudrait deviner laquelle des deux règles s'applique. Et le
budget **ne se partage pas** entre bornes : aujourd'hui chacune reçoit l'enveloppe entière.
### La réserve batterie — le point à retenir de la journée
`batteryLevelConsideration` vaut **0,9** en usine. Le banc `.75` a sa batterie à **50 %** : le
seuil est au-dessus de ce que la batterie atteint. Aujourd'hui ça ne gèle que la recharge du
véhicule ; **quand la règle montera dans le budget, `.75` ne pilotera plus rien** — ni ECS, ni
PAC, ni bornes — par grand soleil, sans erreur ni panne, rien à lire dans un journal.
Un paramètre qui peut produire ce symptôme n'est pas un paramètre d'usine.
`BatteryReserveCard` le lit, l'écrit, **alerte quand le seuil dépasse le SOC réellement
atteint**, et dit la portée à venir : régler ce seuil « pour la voiture » couperait le reste
demain. Elle n'invente aucun défaut — box muette, carte muette.
Le motif d'arbitrage est **préparé, pas anticipé** : la clé ARB `decisionBatteryReserve` existe
et `kCodesReserveBatterie` est un `Set` **volontairement vide** dans `telemetry_text.dart`. Le
crochet attend le code que publiera le moteur ; deviner son nom aurait produit un affichage qui
ne se déclenche jamais.
### Vérifié sur l'appareil, contre `.75`
Campagne verte, zéro exception. La notification précède l'accusé (4/4 écritures).
`enabled:false` ouvre bien les contacts (ECS-413). Les deux compteurs **PAC-Meter** et
**ECS-Meter** sont rattachés **depuis l'écran**, et `measuredW` est désormais **distinct** par
charge — chauffe-eau 1 500 W, PAC 800 W, cette dernière affichant « Mesuré 800 W · sous la
consigne » face à son estimation de 3 000 W.
> Deux pièges de méthode, notés parce qu'ils se représenteront :
> j'ai d'abord **affirmé des `measuredW` distincts** pour prouver le rattachement — deux
> compteurs au repos lisent légitimement 0,0 W tous les deux ; l'assertion porte maintenant sur
> des `meterThingId` distincts plus un détachement montrant `null` ≠ 0. Et une assertion
> cherchait les cartes **sans faire défiler** : dans un `ListView`, ce qui n'est pas à l'écran
> n'est pas construit — elle ne prouvait que la hauteur de l'écran.
### Reste ouvert
- `minPowerW == null` **sans véhicule branché** : pas exercé sur appareil, la borne n'entre pas
dans `loads[]` avant `3g`.
- `kCodesReserveBatterie` à câbler quand le moteur publiera le code du motif.
- **Les zones** (`ac_screen.dart` est encore de la donnée fictive, `.75` n'en déclare aucune) —
explicitement **à ne pas entreprendre**.
---
## Soirée 2026-08-25 — LOT C (ci-dessous)
---
## Soirée 2026-08-25 — LOT C : les écrans de configuration par charge
### Ce qui a été livré
| Commit | Contenu |
|---|---|
| `2a8ad95` | **Lot C** — fusion des deux écrans, écrans de mécanisme, grisage par introspection |
| `f0aa0fd` | Harnais `integration_test` — l'IU sur le téléphone, contre `.75` |
| `98e2874` | Diagnostic de montage d'écran (MIUI bloquait l'installation) |
| `15a5263` | **L'IU tourne sur l'appareil** : chaîne complète vérifiée contre `.75` |
| `38475e0` | Compteurs réseau honnêtes + le sélecteur clair/sombre s'applique partout |
| `3d2aab2` | **Les écrans suivent enfin la maquette** — sélecteurs, table d'états, budget, borne |
- **L'écran « Ordre de service » a disparu** ; son chemin de données (`LoadConfigProvider`) a
migré dans « Rôles & appareils », jamais l'inverse. L'entrée de menu est retirée.
Les charges viennent de `GetLoadConfig`, plus de `LoadDescriptor` reconstruits depuis des
`EmsRole` — LM-201 interdit de toute façon de déclarer `dhw` **et** `heatPump` pour une PAC
sans ballon séparé.
- **Trois écrans de mécanisme** (routeur de relais / modulable / SG-Ready) avec sélecteurs de
Things **par nom** filtrés sur l'interface nymea, table d'états SG-Ready éditable
(SG1/SG2 choisis une fois, en haut), temporisations, « Compteur dédié » grisé avec sa
raison, « Inclure dans l'arbitrage », et **validation avant envoi**.
- **Grisage dérivé de `JSONRPC.Introspect`**, jamais codé en dur : trois états et non deux —
écrivable, `r:` (mesure, pas fonction manquante), absent — plus « inconnu » quand le schéma
n'a pas pu être lu.
- **`setChargingInfo()` était INOPÉRANT** : namespace `EnergyPlugin.*` inexistant, champs
`mode`/`targetSoc`/`endTime`/`minCurrent` au lieu de
`chargingMode`/`targetPercentage`/`endDateTime`. Vérifié par appel réel :
`Missing required key: chargingMode`. Corrigé, et le refus remonte désormais au lieu d'être
avalé pendant que l'état local mentait.
- **Tableau de bord** : « Depuis réseau » lisait l'**opposé** du compteur d'export au lieu de
`totalAcquisition` — 0,0 kWh en permanence. Et les deux tuiles disaient « aujourd'hui » sur
des compteurs cumulés. Corrigé, avec un plafond de plausibilité contre les valeurs
corrompues que la box publie (cf. brief plugin §1).
- **Thème** : 126 sites basculés vers les variantes contextuelles ; 9 restent en constante
(tables statiques sans `BuildContext`).
### Vérifié SUR L'APPAREIL, contre `.75`
```
[appareil] schéma lu — nymea 1.15.2+… · NymeaEnergy 0.8 · AirConditioning 1.1
[appareil] écran de configuration monté
[appareil] charges lues : [chauffe-eau, pac-terrain]
[appareil] écriture → LoadSaveState.confirmed
[appareil] libellé restauré : chauffe-eau
```
`adb shell input` est refusé par MIUI ; la voie qui marche est `integration_test`, qui
s'exécute **dans le processus de l'app**. Détail et pièges : `docs/RELEVE_LOTC.md` §2.
### L'erreur de la soirée, et sa leçon
**Les écrans ont d'abord été construits sans ouvrir `docs/mockups/`.** Les maquettes — dont
celle des mécanismes, mise à jour le soir même — étaient dans le dépôt et décrivaient
précisément tout ce qui manquait ensuite. Tout a été à refaire (`3d2aab2`).
**`docs/mockups/*.html` fait autorité sur la forme des écrans**, au même titre que
`INTERFACE_etmvariableload.md` sur le contrat.
### Ouvert — repris le 2026-08-26 au matin
1. **Le glissement (drag & drop)** ne semble rien produire. À départager : le geste est-il
avalé par le `ListView` parent, ou n'y a-t-il simplement rien à déplacer ? Sur `.75` il n'y
a **qu'une charge par domaine** — le test décisif est de mettre les deux dans le même
domaine.
2. **Le sélecteur de mécanisme est non cliquable**, et c'était mon choix — à tort : la maquette
le donne comme un champ enregistré (`enregistré · adapter`). À rendre actif, avec la
bascule de charge utile et l'avertissement de reconstruction.
3. Le chip de domaine s'affiche **sans libellé** sur la carte de charge.
4. Les **9 sites de couleur** hors thème (tables statiques, peintres).
5. **Zones de climatisation** : `ac_screen.dart` n'appelle aucune méthode `AirConditioning`,
ses 4 pièces sont des constantes de maquette, et `.75` déclare zéro zone. Le câblage est un
lot en soi — la box, elle, expose **8 méthodes toutes en écriture**.
### Pour l'agent plugin
Sept constats côté moteur : `BRIEF_agent_plugin.md` dans le dépôt **plugin**. Le plus visible
est le §1 — les compteurs cumulés d'`Energy.GetPowerBalance` sont corrompus (9,1 × 10³³ kWh),
alors que le Thing compteur est sain.
---
## 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`.