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
248 lines
13 KiB
Markdown
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.
|