etm-powersync-app/docs/RELEVE_LOTC.md
Patrick Schurig 98e2874ecd test(appareil): diagnostic de montage d'écran + relevé — MIUI bloque l'installation USB
Le run rebranché est allé plus loin que jamais : connexion à .75 et lecture du schéma sur
l'appareil, puis blocage sur la lecture des charges. Un contrôle de montage de l'écran est
ajouté pour distinguer « navigation non faite » de « lecture en échec » — mais il n'a pas
pu s'exécuter : MIUI a remis à zéro « Installer via USB » à la reconnexion et refuse
désormais toute installation (INSTALL_FAILED_USER_RESTRICTED, sans dialogue). L'app n'est
de ce fait plus installée sur le téléphone.

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

16 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 : la voie trouvée, le run pas terminé

adb shell input tap est refusé par MIUI (SecurityException: Injecting to another application requires INJECT_EVENTS permission). La voie qui contourne cela est integration_test : le test s'exécute dans le processus de l'app et tape sur les widgets depuis l'intérieur, sans passer par l'injection d'événements d'Android. Le harnais est écrit (integration_test/config_on_device_test.dart) et a effectivement démarré l'app, connecté la box et lu le schéma — les deux lignes du tableau ci-dessus en viennent.

Il n'a pas été mené jusqu'au bout. Trois obstacles, dans l'ordre où ils sont apparus :

  1. GoRouter.of() cherché sur le contexte du MaterialApp, qui est au-dessus du Router — corrigé (on prend un contexte sous lui) ;

  2. le test supposait une installation déjà enregistrée sur le téléphone — corrigé, il enregistre la box lui-même (connectNew), voir l'encadré ci-dessous ;

  3. le téléphone s'est déconnecté du bus USB (adb: device 'dee659e2' not found, plus rien dans lsusb), et l'ADB sans fil n'est pas activé (connect 192.168.1.121:5555 → refusé) ;

  4. rebranché, le run est allé plus loin que jamais — connexion, schéma lu — et a buté sur la lecture des charges. Un diagnostic de montage d'écran a été ajouté pour trancher entre « navigation non faite » et « lecture en échec »… mais il n'a pas pu s'exécuter : MIUI refuse désormais toute installation par USB.

    adb: failed to install app-debug.apk:
         Failure [INSTALL_FAILED_USER_RESTRICTED: Install canceled by user]
    

    Aucune boîte de dialogue à l'écran : le refus est silencieux. Le réglage « Installer via USB » des options développeur MIUI s'est remis à zéro à la reconnexion — comportement connu de MIUI, et il exige un compte Xiaomi pour être réactivé. Conséquence immédiate : l'application n'est plus installée du tout (pm list packages ne la trouve plus), l'installation ayant échoué après la désinstallation.

C'est là que ça s'est arrêté, et le blocage n'est pas logiciel.

⚠️ Effet de bord causé au téléphone, et assumé. flutter test integration_test réinstalle l'application, ce qui vide shared_preferences et le stockage sécurisé : les deux installations enregistrées (nymea-dev-rpi, nymea-dev-kutz) ont été effacées. L'écran affiche donc « Aucune installation enregistrée », et l'ajout manuel est réservé au mode installateur. Le harnais a été corrigé pour ne plus dépendre de cet état — il enregistre la box lui-même, ce qui la restaurera au premier run mené à terme. nymea-dev-kutz (192.168.1.120), lui, est à rajouter à la main.

Reste donc à faire, dès que le téléphone est rebranché : un seul run de integration_test/config_on_device_test.dart, qui couvre l'écran fusionné affichant les charges réelles, une écriture confirmée par LoadConfigChanged et sa restauration.


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

  1. Les écrans de configuration n'ont pas été vus s'afficher sur l'appareil, et aucune écriture n'y a été déclenchée depuis l'IU. La connexion et la lecture du schéma, elles, l'ont été (§2). Le blocage final n'est pas logiciel : le téléphone s'est déconnecté du bus USB en cours de campagne. Pour clore ce point : réactiver « Installer via USB » dans les options développeur du téléphone (MIUI l'a remis à zéro), puis flutter test integration_test/config_on_device_test.dart -d dee659e2. Le harnais est écrit et instrumenté ; il ne reste qu'à le faire tourner. Activer au passage « Débogage USB (réglages de sécurité) » déverrouillerait aussi adb shell input, utile mais non nécessaire ici.
  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.