etm-powersync-app/docs/RELEVE_LOTC.md
Patrick Schurig 2a8ad95120 feat(config): lot C — un seul écran de configuration, et le grisage vient du schéma
FUSION. L'écran « Ordre de service » est supprimé ; sa route redirige. C'est son CHEMIN DE
DONNÉES (LoadConfigProvider) qui a migré dans la présentation de « Rôles & appareils »,
jamais l'inverse. 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é deux charges sur un
canal unique, ce que validateSet() refuse à raison. Les charges viennent donc de
GetLoadConfig. Les compteurs restent sur EnergySetupProvider (autre contrat, SetRootMeter).

La note « le pilote peut bousculer l'ordre les jours Tempo rouge ou à l'approche d'une
échéance » est retirée : aucun de ces comportements n'existe dans le moteur.

ANNONCE AVANT ÉCRITURE. Miroir exact de EnergyArbitrator::sameHardware(). Le piège, vérifié
sur .75 : la comparaison des relais est INDEXÉE, donc réordonner relays[] sans rien changer
d'autre reconstruit l'adaptateur — contacts ouverts (applySafeState, force) et verrous
réarmés à froid. Un glisser-déposer dans la liste des contacteurs est un geste matériel.
L'écran le nomme, avec la durée de réarmement.

GRISAGE PAR INTROSPECTION. RpcSchema lit JSONRPC.Introspect à la connexion ; trois états et
non deux — écrivable, r: (mesure, pas fonction manquante), absent — plus « inconnu » quand le
schéma n'a pas pu être lu. Chaque champ grisé porte sa raison et le nom du champ manquant.
La version affichée est celle de l'API d'expérience (NymeaEnergy 0.8) : le paquet +etm15
n'est exposé par aucune méthode.

CORRIGÉ AU PASSAGE — les modes de recharge étaient INOPÉRANTS. setChargingInfo() appelait
EnergyPlugin.SetChargingInfo (namespace inexistant) avec mode/targetSoc/endTime/minCurrent
au lieu de chargingMode/targetPercentage/endDateTime, et minCurrent n'existe dans aucun
champ. Vérifié par appel réel : « Missing required key: chargingMode ». Le refus était de
plus avalé et l'état local mis à jour quand même. Désormais l'état ne suit que si la box
accepte, et le refus remonte sans en inventer la cause.

ZONES — constaté, pas corrigé. L'hypothèse « champs grisés » est fausse : AirConditioning
publie 8 méthodes toutes en écriture. ac_screen.dart n'en appelle aucune, ses 4 pièces sont
des constantes de maquette, et .75 déclare zéro zone. getZones() ajouté + bandeau qui lit le
vrai compte et annonce la maquette. Le câblage est un lot en soi, non exerçable sans zone.

Tests 80/80, analyze 0 erreur. Relevé : docs/RELEVE_LOTC.md — dont §6, ce qui n'a PAS pu
être vérifié : l'IU n'a pas pu être pilotée sur l'appareil, MIUI refusant INJECT_EVENTS.

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

248 lines
13 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, écran « Installations » |
| 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) |
**Et c'est tout — l'IU n'a PAS pu être pilotée.** Voir §6.
---
## 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. **L'IU n'a pas pu être pilotée sur l'appareil.** `adb shell input tap` est refusé par la
box Android :
`SecurityException: Injecting to another application requires INJECT_EVENTS permission`.
C'est le verrou MIUI sur le Redmi — il se lève dans les options développeur
(« débogage USB (réglages de sécurité) »), ce qui exige un compte Xiaomi et un geste
manuel. `xdotool` n'est pas installé sur la machine, donc la voie « bureau Linux » est
fermée aussi. **Aucun écran de configuration n'a donc été vu s'afficher sur l'appareil,
ni aucune écriture déclenchée depuis l'IU.** Ce qui a été vérifié sur l'appareil est au §2,
et rien de plus.
*Pour clore ce point* : activer le réglage sur le téléphone, ou piloter l'écran à la main
en suivant §1 de ce document.
2. **Les écritures depuis l'IU** n'ont donc été exercées qu'en **tests de widgets**, sur les
dumps réels de `.75` (`merged_config_screen_test.dart`). Le rendu, l'annonce d'impact et le
grisage y sont couverts ; le transport ne l'est pas.
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.