Patrick Schurig 31ef03fd4b docs: §7-4 clos — l'affectation de domaine survit au redémarrage de nymead
Patrick a relancé le service à 18:23:45. LoadConfigStore recharge 2 configs depuis
/var/lib/nymea/energy-load-configuration.json et la relecture RPC est identique clé
par clé à celle d'avant l'arrêt : chauffe-eau→ecs, pac-terrain→hvac.

Le redémarrage montre aussi la reprise à froid vue du dehors (ECS-411-b) : les
adaptateurs journalisent ce qu'ils ont LU sur les contacts, avec la mention
« correspondance exacte » — l'état de départ est constaté, pas supposé.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
2026-08-25 19:25:33 +02:00

304 lines
20 KiB
Markdown

# 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.
## 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`.