Chaîne complète vérifiée sur l'appareil : schéma lu, écran fusionné monté, charges réelles affichées ([chauffe-eau, pac-terrain]) avec leur télémétrie, écriture confirmée par LoadConfigChanged, restauration. Le dernier obstacle était le plus instructif : /energy/setup est une route réservée au mode installateur, et le routeur la renvoie vers / tant qu'il est verrouillé. Le symptôme était « aucune charge lue » — on aurait cherché un défaut de lecture RPC là où le verrou faisait son travail. C'est le contrôle de montage d'écran ajouté au harnais qui a tranché. Le test déverrouille avec le PIN par défaut et reverrouille APRÈS la pause d'observation : l'ordre inverse passait les assertions mais renvoyait l'écran au tableau de bord. Relevé : docs/RELEVE_LOTC.md §2 et §6 mis à jour. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
282 lines
15 KiB
Markdown
282 lines
15 KiB
Markdown
# RELEVÉ — lot C / app : écrans de configuration par charge
|
|
|
|
Box : **`.75` (`hems`)**, plugin **`1.15.2+etm15`**, nymea `1.15.2+202606191336~trixie1`,
|
|
API `NymeaEnergy 0.8` · `AirConditioning 1.1` · Appareil : **Redmi Note 9S**, Android 11,
|
|
USB · Date : **2026-08-25**.
|
|
|
|
> **La colonne « où » est ce qui compte.** Un extrait de test unitaire ne vaut pas
|
|
> vérification machine et est annoncé comme tel. Ce qui n'a **pas** pu être vérifié est au
|
|
> §6, nommément, avec la raison.
|
|
|
|
---
|
|
|
|
## 0. Ce que la box dit d'elle-même — et deux points du brief à corriger
|
|
|
|
`JSONRPC.Introspect` sur `.75` : **20** méthodes `NymeaEnergy.*`, **8** méthodes
|
|
`AirConditioning.*`.
|
|
|
|
**(a) Le `CLAUDE.md` était déjà corrigé — le CODE ne l'était pas.** Le brief signale que les
|
|
noms de `ChargingInfo` écrits dans `CLAUDE.md` ne sont pas ceux de la box. Vérification
|
|
faite : `CLAUDE.md` porte bien `chargingMode` / `targetPercentage` / `endDateTime`, exacts.
|
|
C'est `nymea_service.dart` qui était resté en arrière, et bien plus gravement — voir §3.
|
|
|
|
**(b) Les zones ne sont pas une surface réduite à griser.** L'hypothèse du brief était que
|
|
la box n'expose pas grand-chose. C'est l'inverse : `AirConditioning` publie **huit méthodes,
|
|
toutes en écriture** — `AddZone`, `RemoveZone`, `SetZoneName`, `SetZoneThings`,
|
|
`SetZoneStandbySetpoint`, `SetZoneSetpointOverride`, `SetZoneWeekSchedule`, `GetZones`. Une
|
|
zone porte thermostats, vannes, sondes intérieures/extérieures/ouvrants, consigne de veille,
|
|
dérogation (mode + durée) et programme hebdomadaire complet.
|
|
|
|
Les champs de `ZoneInfo` sont tous `r:`, mais **ce n'est pas « lecture seule » au sens
|
|
fonctionnel** : ils se règlent par les setters dédiés. Confondre les deux aurait grisé un
|
|
écran en réalité pleinement pilotable. **Il n'y a rien à griser côté zones : c'est le
|
|
câblage de l'écran qui manque** (§5).
|
|
|
|
**(c) La version du paquet n'est pas sur le fil.** `1.15.2+etm15` n'est exposée par aucune
|
|
méthode — elle ne se lit que par `dpkg`. Ce que le fil porte, c'est la version de nymea et
|
|
les **versions d'API d'expérience** (`NymeaEnergy 0.8`). C'est de toute façon le bon
|
|
discriminant : il décrit le contrat, pas le paquet. Les écrans annoncent donc celle-là.
|
|
|
|
---
|
|
|
|
## 1. Vérifié sur `.75` — comportements d'écriture
|
|
|
|
### (a) La notification précède l'accusé — confirmé, systématiquement
|
|
|
|
Quatre `SetLoadConfig` consécutifs, en mesurant l'ordre d'arrivée **sur le fil** :
|
|
|
|
| Écriture | `energyError` | accusé | `LoadConfigChanged` | notification avant accusé |
|
|
|---|---|---|---|---|
|
|
| libellé (en place) | NoError | 262 ms | 262 ms | **oui** |
|
|
| `relays[]` réordonnés | NoError | 267 ms | 267 ms | **oui** |
|
|
| `enabled: false` | NoError | 264 ms | 264 ms | **oui** |
|
|
| restauration | NoError | 264 ms | 264 ms | **oui** |
|
|
|
|
La règle §5-2 est donc confirmée : **l'enregistrement se confirme sur `LoadConfigChanged`**,
|
|
et le provider était déjà écrit ainsi.
|
|
|
|
### (b) Réordonner `relays[]` reconstruit la charge — le piège du lot
|
|
|
|
Même ensemble de contacteurs, mêmes Things, mêmes puissances, **seul l'ordre change**
|
|
(500/1000/2000 → 2000/1000/500). Journal de la box :
|
|
|
|
```
|
|
[RelayRouter] chauffe-eau — reprise au démarrage : palier 0 W [correspondance exacte] …
|
|
[EnergyArbitrator] relay-router construit depuis config: "chauffe-eau" ( 3 relais)
|
|
[RelayRouter] "chauffe-eau" → … (force) | "Charge désactivée — mise en état sûr … (ECS-413)"
|
|
[EnergyArbitrator] 2 charge(s) active(s) — 1 créée(s), 1 inchangée(s), 1 retirée(s)
|
|
```
|
|
|
|
La comparaison de `sameHardware()` est **indexée**, pas ensembliste. Un glisser-déposer dans
|
|
la liste des contacteurs est donc un **geste matériel** : contacts ouverts, verrous réarmés à
|
|
froid. C'est le cas le moins intuitif du lot, et l'écran l'annonce nommément.
|
|
|
|
**Corollaire mesuré** : revenir en arrière coûte une **seconde** reconstruction. L'annulation
|
|
n'est pas gratuite.
|
|
|
|
### (c) `enabled: false` ouvre bien les contacts — mais pas par le drapeau
|
|
|
|
Premier passage **inconcluant** et signalé comme tel : les contacts étaient déjà ouverts (PAC
|
|
en état 2, surplus négatif). Repris en attendant un état 4 réel :
|
|
|
|
```
|
|
20:56:19 état=4 surplus=8252 W contacts={'Relay4': True, 'Relay5': True}
|
|
# SetLoadConfig enabled:false → EnergyErrorNoError
|
|
# contacts APRÈS : {'Relay4': False, 'Relay5': False}
|
|
# VERDICT : contacts ouverts par enabled:false ? OUI
|
|
```
|
|
|
|
**Nuance tenue dans l'UI** : ce n'est pas le drapeau qui protège, c'est le **retrait de
|
|
l'adaptateur** (ECS-413) — la charge sort d'une configuration vivante et passe par
|
|
`applySafeState()`. Ce chemin n'existe pas au redémarrage du service, et pas non plus pour une
|
|
charge déjà désactivée. L'infobulle de l'interrupteur dit exactement cela.
|
|
|
|
### (d) `loads[]` ne suit qu'au cycle suivant
|
|
|
|
Juste après `enabled: false`, `GetLoadTelemetry` contient **encore** la PAC : le `Get` rend le
|
|
dernier cycle **publié**, il ne recalcule pas. Elle en sort au cycle suivant. Un client qui
|
|
attend une disparition immédiate se trompe de contrat — c'est écrit sur la carte.
|
|
|
|
### (e) Les paliers atteignables ne sont PAS exposés
|
|
|
|
`GetLoadConfig` rend `"powerLevels": []` sur `chauffe-eau` (trois contacteurs), et
|
|
`GetLoadTelemetry` n'expose que `maxStageW` et le `stageW` courant. **La liste des paliers
|
|
dérivés n'est nulle part sur le RPC.** Conformément au brief, l'app **ne les recalcule pas** :
|
|
elle affiche la somme câblée et dit franchement que le détail n'est pas publié. Deux
|
|
implémentations de la même combinatoire divergeraient, et c'est l'app qui aurait tort.
|
|
|
|
---
|
|
|
|
## 2. Vérifié sur l'appareil (USB)
|
|
|
|
| Ce qui a été fait | Résultat |
|
|
|---|---|
|
|
| `flutter build apk --debug` (android-arm64) | ✔ construit |
|
|
| `adb install` sur le Redmi Note 9S | ✔ installé |
|
|
| Lancement de l'app | ✔ démarre |
|
|
| Le téléphone (192.168.1.121) joint le banc | ✔ ping 4-20 ms |
|
|
| L'app liste `nymea-dev-rpi` → `nymea://192.168.1.75:4444` | ✔ capture d'écran |
|
|
| **L'app se connecte à `.75` depuis le téléphone** | ✔ `connecté à 1.15.2+202606191336~trixie1` |
|
|
| **`SchemaProvider` lit `JSONRPC.Introspect` sur l'appareil** | ✔ `nymea 1.15.2+… · NymeaEnergy 0.8 · AirConditioning 1.1` |
|
|
|
|
Les deux dernières lignes comptent : elles prouvent **sur l'appareil et contre la vraie
|
|
box** que le schéma est lu à la connexion — donc que le grisage n'est pas une théorie.
|
|
|
|
### Le pilotage de l'IU : FAIT, de bout en bout
|
|
|
|
`adb shell input tap` était refusé par MIUI
|
|
(`SecurityException: … requires INJECT_EVENTS permission`). La voie retenue est
|
|
`integration_test` : le test s'exécute **dans le processus de l'app** et tape sur les
|
|
widgets depuis l'intérieur, sans injection d'événements Android. Chaîne complète, sur
|
|
l'appareil, contre `.75` :
|
|
|
|
```
|
|
[appareil] schéma lu — nymea 1.15.2+202606191336~trixie1 · NymeaEnergy 0.8 · AirConditioning 1.1
|
|
[appareil] mode installateur déverrouillé
|
|
[appareil] écran de configuration monté
|
|
[appareil] charges lues : [chauffe-eau, pac-terrain]
|
|
[appareil] écriture → LoadSaveState.confirmed
|
|
[appareil] libellé restauré : chauffe-eau
|
|
```
|
|
|
|
Sont donc vérifiés **sur l'appareil** : la lecture du schéma, l'affichage de l'écran
|
|
fusionné avec les charges réelles et leur télémétrie, une **écriture confirmée par
|
|
`LoadConfigChanged`** (et non par l'accusé du RPC), et sa restauration.
|
|
|
|
Quatre obstacles ont été levés en chemin, tous instructifs :
|
|
|
|
1. `GoRouter.of()` cherché sur le contexte du `MaterialApp`, **au-dessus** du Router ;
|
|
2. le test supposait une installation déjà enregistrée — il la **pose lui-même**
|
|
(`connectNew`), car `flutter test integration_test` réinstalle l'app et vide son
|
|
stockage ;
|
|
3. `INSTALL_FAILED_USER_RESTRICTED` — MIUI avait remis à zéro « Installer via USB » à la
|
|
reconnexion du câble ; réactivé à la main sur le téléphone ;
|
|
4. **`/energy/setup` est une route réservée au mode installateur.** Le routeur la renvoie
|
|
vers `/` tant qu'il est verrouillé. Le symptôme était « aucune charge lue » — un
|
|
diagnostic parfaitement trompeur, qui aurait fait chercher un défaut de lecture RPC là
|
|
où le verrou faisait simplement son travail. C'est le contrôle de **montage d'écran**
|
|
ajouté au harnais qui a tranché.
|
|
|
|
> Le test déverrouille avec le PIN par défaut (`1234`), et **reverrouille après** — dans
|
|
> cet ordre. L'inverse passait tous les tests mais renvoyait l'écran au tableau de bord
|
|
> avant la capture : le verrou marchait trop bien.
|
|
|
|
---
|
|
|
|
## 3. ⚠️ Trouvé au passage : les boutons de mode de recharge étaient INOPÉRANTS
|
|
|
|
Hors du périmètre annoncé du lot, mais dans le chemin direct du §4 (« aucun champ grisé ne
|
|
part dans une écriture »). Constaté, **puis corrigé**, parce que le lot touchait à ce même
|
|
appel.
|
|
|
|
`nymea_service.setChargingInfo()` cumulait **trois** erreurs, chacune suffisante :
|
|
|
|
| Envoyé | Attendu par la box |
|
|
|---|---|
|
|
| namespace `EnergyPlugin.SetChargingInfo` | `NymeaEnergy.SetChargingInfo` — `EnergyPlugin.*` n'existe pas |
|
|
| `mode: "Eco"` | `chargingMode: "ChargingModeEco"` (énumération complète) |
|
|
| `targetSoc`, `endTime`, `minCurrent` | `targetPercentage`, `endDateTime` — et **`minCurrent` n'existe pas** |
|
|
|
|
Vérifié par appel réel sur `.75`, charge utile de l'app telle qu'elle était :
|
|
|
|
```json
|
|
{"error": "Invalid params: Missing required key: chargingMode in
|
|
NymeaEnergy.SetChargingInfo, param chargingInfo.chargingMode",
|
|
"status": "error"}
|
|
```
|
|
|
|
…et la charge utile corrigée : `{"energyError": "EnergyErrorNoError"}`.
|
|
|
|
**Les modes de recharge ne fonctionnaient donc pas du tout contre `+etm15`.** Le refus était
|
|
en plus avalé silencieusement (`catch { _log }`) et l'état local mis à jour quand même :
|
|
l'écran affichait un mode que la borne n'avait jamais reçu. Corrigé — l'état local ne suit
|
|
que si la box accepte, et un refus remonte en `SnackBar` sans en inventer la cause.
|
|
|
|
**`minCurrent` est un cas de grisage, pas un oubli.** La box publie bien les modes
|
|
`ChargingModeEcoWithMinCurrent` et `ChargingModeEcoMinWithTargetTime`, mais **aucun champ de
|
|
courant** : le mode se choisit, son paramètre non. L'écran le dit au lieu d'offrir un réglage
|
|
sans effet.
|
|
|
|
> Le banc a été remis **exactement** dans son état initial après ces essais :
|
|
> `chargingMode: ChargingModeNormal`, `targetPercentage: 0`.
|
|
|
|
---
|
|
|
|
## 4. Ce qui a été livré
|
|
|
|
| Écran / brique | État |
|
|
|---|---|
|
|
| Fusion « Ordre de service » → « Rôles & appareils » | ✔ l'écran clair est **supprimé**, sa route redirige |
|
|
| Glisser-déposer deux niveaux (domaines, puis charges) | ✔ |
|
|
| Télémétrie sur la carte de chaque charge | ✔ (widgets extraits, non dupliqués) |
|
|
| `label`, `enabled`, `o:domain`, `priority` en écriture | ✔ |
|
|
| Bouton **Configurer** par charge | ✔ vers l'écran du mécanisme |
|
|
| Routeur de relais / Modulable / SG-Ready | ✔ trois formes distinctes, un seul écran hôte |
|
|
| Grisage dérivé de `JSONRPC.Introspect` | ✔ `RpcSchema` + `SchemaGated`, chargé à la connexion |
|
|
| Annonce d'impact avant écriture (`sameHardware`) | ✔ miroir exact du moteur, 12 tests |
|
|
| Note « Tempo rouge / échéance » | ✔ **supprimée** — comportement inexistant dans le moteur |
|
|
| Bandeau « tout est assigné » | ✔ conservé, c'est un état assumé |
|
|
|
|
**Le modèle des rôles ne survit pas comme source de vérité pour les charges.** `EmsRole` fige
|
|
six rôles dont `dhw` **et** `heatPump` séparés — or LM-201 dit qu'une PAC sans ballon séparé
|
|
est **une** charge. Le modèle des rôles aurait exigé d'en déclarer deux pour une machine,
|
|
c'est-à-dire deux charges sur un canal unique, ce que `validateSet()` refuse à raison. Les
|
|
charges viennent donc de `GetLoadConfig`. Les **compteurs**, « à configurer » et « sans
|
|
appareil » restent sur `EnergySetupProvider` : ce sont des rôles inférés des Things, et le
|
|
compteur principal relève d'un autre contrat (`Energy.SetRootMeter`).
|
|
|
|
Tests : **80 passent, 0 échec**, `flutter analyze` **0 erreur** sur les fichiers touchés.
|
|
|
|
---
|
|
|
|
## 5. Zones de climatisation — constaté, pas corrigé
|
|
|
|
`ac_screen.dart` (1009 lignes) n'appelle **aucune** méthode `AirConditioning.*` : les quatre
|
|
pièces affichées sont des **constantes de maquette**. Et `.75` déclare **zéro zone**.
|
|
|
|
Le brief demandait des champs grisés ; §0-(b) montre qu'il n'y a rien à griser. Câbler
|
|
l'écran sur les huit méthodes réelles est un travail à part entière, et **non exerçable en
|
|
l'état** puisqu'aucune zone n'existe sur le banc. Je ne l'ai donc **pas** entrepris.
|
|
|
|
Ce qui a été fait, minimal et honnête : `getZones()` ajouté au service, et un bandeau en tête
|
|
d'écran qui lit le **vrai** nombre de zones et annonce que les pièces affichées sont des
|
|
exemples de maquette. Un écran qui invente quatre pièces sur une installation qui n'en déclare
|
|
aucune est indistinguable d'une panne de lecture — c'était le risque immédiat.
|
|
|
|
**À décider** : câbler les zones est un lot en soi. Il demande au minimum une zone réelle sur
|
|
le banc pour être vérifié.
|
|
|
|
---
|
|
|
|
## 6. Ce qui n'a PAS été vérifié, et pourquoi
|
|
|
|
1. ~~Les écrans de configuration sur l'appareil~~ — **fait**, voir §2.
|
|
2. **Les écritures MATÉRIELLES depuis l'IU** (réordonner des contacteurs, désactiver une
|
|
charge) n'ont pas été déclenchées sur l'appareil : elles ouvrent des contacts et
|
|
réarment des verrous, et le banc n'appartient pas au test. Seul un renommage — sans
|
|
effet matériel — a été écrit puis restauré. L'annonce d'impact reste donc couverte en
|
|
tests de widgets sur les dumps réels (`merged_config_screen_test.dart`).
|
|
3. **Le grisage n'a pas été observé sur une box « ancienne »** — il n'y en a pas sous la main.
|
|
Le comportement en champ absent est couvert en test à partir d'un schéma tronqué, pas d'une
|
|
vraie box antérieure.
|
|
4. **Batterie** : aucune charge `battery` n'existe sur `.75` (aucun `BatteryAdapter` côté
|
|
moteur). Le point §6 du brief la concernant n'a pas pu être exercé.
|
|
|
|
---
|
|
|
|
## 7. Signalé au moteur — hors périmètre app, non corrigé
|
|
|
|
**Renommer une charge ne parvient pas à l'adaptateur.** `label` n'est pas dans
|
|
`sameHardware()`, ce qui est correct — mais la mise à jour en place
|
|
(`updateSoftConfig()`) ne porte que `priority` et `needs`. Un changement de libellé seul
|
|
laisse donc l'adaptateur **inchangé** :
|
|
|
|
```
|
|
[EnergyArbitrator] 2 charge(s) active(s) — 0 créée(s), 0 mise(s) à jour, 2 inchangée(s)
|
|
```
|
|
|
|
`GetLoadConfig` rend bien le nouveau libellé (et c'est ce que l'app affiche), mais le
|
|
**journal de la box continue d'afficher l'ancien** jusqu'à la prochaine reconstruction.
|
|
Pour un installateur qui suit `journalctl` pendant une mise en service, c'est trompeur.
|
|
Constaté sur `.75`, non corrigé : c'est du ressort du moteur.
|