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
15 KiB
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 :
GoRouter.of()cherché sur le contexte duMaterialApp, au-dessus du Router ;- le test supposait une installation déjà enregistrée — il la pose lui-même
(
connectNew), carflutter test integration_testréinstalle l'app et vide son stockage ; 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 ;/energy/setupest 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 :
{"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
Les écrans de configuration sur l'appareil— fait, voir §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). - 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.
- Batterie : aucune charge
batteryn'existe sur.75(aucunBatteryAdaptercô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.