Le fil conducteur des six commits du jour tient en une phrase : l'app ne recalcule pas ce que la box publie, et n'anticipe pas ce qu'elle n'a pas encore publié. Le point à retenir est le seuil de réserve batterie : 0,9 en usine, batterie du banc à 50 %. Quand la règle montera dans le budget, `.75` ne pilotera plus rien par grand soleil, sans erreur ni panne. Le RECAP le dit, et le TODO garde les trois crochets laissés ouverts — dont le `Set` volontairement vide qui attend le code du moteur. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
43 KiB
RECAP — etm-powersync-app · reprise de session
Session 2026-08-26. Branche
feature/beta-add-config, poussée (801ba15). La journée du 26 est en tête ci-dessous ; la soirée du 25 (lot C) suit ; la section « État au 2026-06-30 » plus bas reste valable pour tout ce qu'elle décrit.
Journée 2026-08-26 — se recaler sur la box, trois fois de suite
La box a bougé trois fois dans la journée (+etm16, +etm19, puis 3g). Le fil conducteur
de tout ce qui suit tient en une phrase : l'app ne recalcule pas ce que la box publie, et
n'anticipe pas ce qu'elle n'a pas encore publié.
| Commit | Contenu |
|---|---|
aada8e2 |
Les 4 points ouverts du RECAP : glissement, sélecteur de mécanisme, chip de domaine, thème |
7089148 |
Le harnais vérifie lui-même le chip et l'écran de mécanisme |
cd76df3 |
+etm16 — table de paliers, stagesW, compteur et sonde |
ab2a27b |
+etm19 — BELOW_MIN_POWER, compteurs rattachés depuis l'écran, glissement exercé |
449c6ac |
3g — les valeurs de borne sont du RUNTIME |
801ba15 |
3g — la réserve batterie sort de l'usine |
Ce qui a été corrigé, et pourquoi ça avait échoué
- Le glissement ne marchait pas — et c'était ma régression : j'avais imbriqué un
ReorderableListViewdans un autre, les deux se disputaient le geste. Le classement est désormais inversé à dessein : on glisse les domaines, et les flèches ordonnent dans un domaine. Restait une zone morte de 3 px dans la poignée :ReorderableDragStartListenerdélègue le test de toucher à ses enfants, unContainertransparent la referme. - Le test du glissement déclarait forfait deux fois sur trois : le banc revient régulièrement à une charge par domaine, et sans domaine partagé il n'y a rien à glisser. Le test pose maintenant sa propre condition — il regroupe, exerce, puis rend le classement d'origine. Un test qui dépend de l'état du terrain ne teste rien.
- « la config du rootmeter est perdue » — elle ne l'était pas : la box l'avait toujours,
l'app ne la lisait jamais (
syncRootMeter()). Le symptôme et la cause étaient à deux écrans de distance. /energy/setupest gardé par le mode installateur : le routeur renvoyait ailleurs, et le symptôme affiché était « aucune charge lue ». Le harnais déverrouille au PIN1234et reverrouille après le maintien d'écran — l'ordre inverse passait au vert en montrant le tableau de bord.
Les trois recalages de contrat
+etm16 — ce que la box compare vraiment. sameHardware() est public, et la comparaison
porte sur la table de paliers, pas sur la liste de relais : réordonner relays[] ne
reconstruit plus rien (vérifié au banc — « 2 inchangée(s) », aucun état sûr). Mon premier
miroir comparait des listes et criait au loup ; corrigé après lecture de
RelayStageTable::operator== — chaque combinaison se compare comme un ensemble, mais les
paliers s'apparient par position. Les paliers dérivés arrivent maintenant en stagesW : un
miroir de moins à faire diverger. Les trois champs optionnels (meterThingId, sensorThingId,
plancher) sont traités comme un seul objet, grisage dérivé du schéma, jamais anticipés dans
le modèle.
+etm19 — BELOW_MIN_POWER. Deux variantes, à distinguer de SURPLUS_INSUFFICIENT et
SG_NORMAL qui ne se déclenchent plus que sur budget ≤ 0. Le repli aurait tenu : un code
inconnu n'a jamais affiché de vide.
3g — deux valeurs de runtime, et un réglage qui change de portée. « Minimum » désigne
deux objets différents : le plancher déclaré d'une charge modulable (une propriété de
l'appareil, qui se règle) et le minimum d'une borne, max(borne, voiture), qui dépend du
véhicule branché et du nombre de phases — de l'ordre de 1,4 kW en monophasé contre 4,1 kW en
triphasé, pour le même courant. L'app affiche la valeur telle que la box la donne : aucune
conversion côté app, sinon il faudrait deviner laquelle des deux règles s'applique. Et le
budget ne se partage pas entre bornes : aujourd'hui chacune reçoit l'enveloppe entière.
La réserve batterie — le point à retenir de la journée
batteryLevelConsideration vaut 0,9 en usine. Le banc .75 a sa batterie à 50 % : le
seuil est au-dessus de ce que la batterie atteint. Aujourd'hui ça ne gèle que la recharge du
véhicule ; quand la règle montera dans le budget, .75 ne pilotera plus rien — ni ECS, ni
PAC, ni bornes — par grand soleil, sans erreur ni panne, rien à lire dans un journal.
Un paramètre qui peut produire ce symptôme n'est pas un paramètre d'usine.
BatteryReserveCard le lit, l'écrit, alerte quand le seuil dépasse le SOC réellement
atteint, et dit la portée à venir : régler ce seuil « pour la voiture » couperait le reste
demain. Elle n'invente aucun défaut — box muette, carte muette.
Le motif d'arbitrage est préparé, pas anticipé : la clé ARB decisionBatteryReserve existe
et kCodesReserveBatterie est un Set volontairement vide dans telemetry_text.dart. Le
crochet attend le code que publiera le moteur ; deviner son nom aurait produit un affichage qui
ne se déclenche jamais.
Vérifié sur l'appareil, contre .75
Campagne verte, zéro exception. La notification précède l'accusé (4/4 écritures).
enabled:false ouvre bien les contacts (ECS-413). Les deux compteurs PAC-Meter et
ECS-Meter sont rattachés depuis l'écran, et measuredW est désormais distinct par
charge — chauffe-eau 1 500 W, PAC 800 W, cette dernière affichant « Mesuré 800 W · sous la
consigne » face à son estimation de 3 000 W.
Deux pièges de méthode, notés parce qu'ils se représenteront : j'ai d'abord affirmé des
measuredWdistincts pour prouver le rattachement — deux compteurs au repos lisent légitimement 0,0 W tous les deux ; l'assertion porte maintenant sur desmeterThingIddistincts plus un détachement montrantnull≠ 0. Et une assertion cherchait les cartes sans faire défiler : dans unListView, ce qui n'est pas à l'écran n'est pas construit — elle ne prouvait que la hauteur de l'écran.
Reste ouvert
minPowerW == nullsans véhicule branché : pas exercé sur appareil, la borne n'entre pas dansloads[]avant3g.kCodesReserveBatterieà câbler quand le moteur publiera le code du motif.- Les zones (
ac_screen.dartest encore de la donnée fictive,.75n'en déclare aucune) — explicitement à ne pas entreprendre.
Soirée 2026-08-25 — LOT C (ci-dessous)
Soirée 2026-08-25 — LOT C : les écrans de configuration par charge
Ce qui a été livré
| Commit | Contenu |
|---|---|
2a8ad95 |
Lot C — fusion des deux écrans, écrans de mécanisme, grisage par introspection |
f0aa0fd |
Harnais integration_test — l'IU sur le téléphone, contre .75 |
98e2874 |
Diagnostic de montage d'écran (MIUI bloquait l'installation) |
15a5263 |
L'IU tourne sur l'appareil : chaîne complète vérifiée contre .75 |
38475e0 |
Compteurs réseau honnêtes + le sélecteur clair/sombre s'applique partout |
3d2aab2 |
Les écrans suivent enfin la maquette — sélecteurs, table d'états, budget, borne |
- L'écran « Ordre de service » a disparu ; son chemin de données (
LoadConfigProvider) a migré dans « Rôles & appareils », jamais l'inverse. L'entrée de menu est retirée. Les charges viennent deGetLoadConfig, plus deLoadDescriptorreconstruits depuis desEmsRole— LM-201 interdit de toute façon de déclarerdhwetheatPumppour une PAC sans ballon séparé. - Trois écrans de mécanisme (routeur de relais / modulable / SG-Ready) avec sélecteurs de Things par nom filtrés sur l'interface nymea, table d'états SG-Ready éditable (SG1/SG2 choisis une fois, en haut), temporisations, « Compteur dédié » grisé avec sa raison, « Inclure dans l'arbitrage », et validation avant envoi.
- Grisage dérivé de
JSONRPC.Introspect, jamais codé en dur : trois états et non deux — écrivable,r:(mesure, pas fonction manquante), absent — plus « inconnu » quand le schéma n'a pas pu être lu. setChargingInfo()était INOPÉRANT : namespaceEnergyPlugin.*inexistant, champsmode/targetSoc/endTime/minCurrentau lieu dechargingMode/targetPercentage/endDateTime. Vérifié par appel réel :Missing required key: chargingMode. Corrigé, et le refus remonte désormais au lieu d'être avalé pendant que l'état local mentait.- Tableau de bord : « Depuis réseau » lisait l'opposé du compteur d'export au lieu de
totalAcquisition— 0,0 kWh en permanence. Et les deux tuiles disaient « aujourd'hui » sur des compteurs cumulés. Corrigé, avec un plafond de plausibilité contre les valeurs corrompues que la box publie (cf. brief plugin §1). - Thème : 126 sites basculés vers les variantes contextuelles ; 9 restent en constante
(tables statiques sans
BuildContext).
Vérifié SUR L'APPAREIL, contre .75
[appareil] schéma lu — nymea 1.15.2+… · NymeaEnergy 0.8 · AirConditioning 1.1
[appareil] écran de configuration monté
[appareil] charges lues : [chauffe-eau, pac-terrain]
[appareil] écriture → LoadSaveState.confirmed
[appareil] libellé restauré : chauffe-eau
adb shell input est refusé par MIUI ; la voie qui marche est integration_test, qui
s'exécute dans le processus de l'app. Détail et pièges : docs/RELEVE_LOTC.md §2.
L'erreur de la soirée, et sa leçon
Les écrans ont d'abord été construits sans ouvrir docs/mockups/. Les maquettes — dont
celle des mécanismes, mise à jour le soir même — étaient dans le dépôt et décrivaient
précisément tout ce qui manquait ensuite. Tout a été à refaire (3d2aab2).
docs/mockups/*.html fait autorité sur la forme des écrans, au même titre que
INTERFACE_etmvariableload.md sur le contrat.
Ouvert — repris le 2026-08-26 au matin
- Le glissement (drag & drop) ne semble rien produire. À départager : le geste est-il
avalé par le
ListViewparent, ou n'y a-t-il simplement rien à déplacer ? Sur.75il n'y a qu'une charge par domaine — le test décisif est de mettre les deux dans le même domaine. - Le sélecteur de mécanisme est non cliquable, et c'était mon choix — à tort : la maquette
le donne comme un champ enregistré (
enregistré · adapter). À rendre actif, avec la bascule de charge utile et l'avertissement de reconstruction. - Le chip de domaine s'affiche sans libellé sur la carte de charge.
- Les 9 sites de couleur hors thème (tables statiques, peintres).
- Zones de climatisation :
ac_screen.dartn'appelle aucune méthodeAirConditioning, ses 4 pièces sont des constantes de maquette, et.75déclare zéro zone. Le câblage est un lot en soi — la box, elle, expose 8 méthodes toutes en écriture.
Pour l'agent plugin
Sept constats côté moteur : BRIEF_agent_plugin.md dans le dépôt plugin. Le plus visible
est le §1 — les compteurs cumulés d'Energy.GetPowerBalance sont corrompus (9,1 × 10³³ kWh),
alors que le Thing compteur est sain.
Session 2026-08-25 — ce qui a été fait
Commits ajoutés
| Commit | Contenu |
|---|---|
7ca4297 |
Lot B app — ordre de service des charges, locale, ossature i18n |
40ac3b2 |
Fixtures : dump réel GetLoadConfig + schéma introspecté de .75 |
1. Menus (demande initiale)
- « Rôles & appareils » et « Options développeur » → section MODE INSTALLATEUR uniquement.
- Groupes / Scènes / Médias / Garages → masqués par défaut. Le drawer lit désormais
AppSettingsProvider.visibleScreens(ces 4 écrans y étaient déjà àvisible: false; le drawer les affichait en dur et ignorait le réglage). - Garde de routeur
_isInstallerRoute(main.dart:41) :/settings/system*,/settings/app/developer,/energy/setup,/energy/loads→ redirection vers/si le mode installateur est verrouillé. Couvre l'URL directe, le deep-link et l'auto-lock 10 min. - Liens SUPPORT corrigés : doc →
https://docs.etm-powersync.fr/, Telegram →https://t.me/etm_powersync_support, Discord retiré (commenté sur place).
2. Investigations rendues (constats factuels, pas de code)
- Locale nymea : gérée PAR CONNEXION (
o:localedansHello,m_clientLocales). Pas de forçageLANG/LC_ALLdans l'unité systemd. Mais sur 784 chaînes affichables de.75, l'allemand en change 1, le français 0 : aucun catalogue.qmfr n'est livré. LesdisplayName= identifiants anglais. - Frontière RPC / runtime de l'arbitre : quasi rien ne sort. Seuls
degradedMode(notificationChargingSchedulesChangeduniquement — pas relisable parGet), les deux ratios d'énergie,chargingStatede l'EV et le plan de recharge franchissent. Allocation, budget, verrous, paliers,available, motifs : journal seulement.SurplusContext/LoadContextTelemetryn'ont aucune sérialisation sortante.
3. Lot B app — livré
models/load_config_entry.dart— vue en lecture au-dessus de la map brute ; écriture parpatched()depuis la map d'origine, jamais de re-sérialisation.services/load_priority.dart—groupByDomain/flattenToPayload/applyDefaultDomainOrder/ miroir client devalidateSet().providers/load_config_provider.dart— séquencepending → LoadConfigChanged → confirmed|diverged. Ne conclut jamais sur l'acquittement du RPC.screens/energy/load_order_screen.dart— route/energy/loads, gatée installateur.- i18n :
flutter_localizations+intl+lib/l10n/app_fr.arb+l10n.yaml. Extensioncontext.l10n. Français seul. NymeaEnergy.GetLoadConfig/SetLoadConfigdeviennent réels (c'étaient des stubs).- Locale envoyée à chaque
Hello, demandée + retenue journalisées.
4. ⚠️ Piège désamorcé — à ne pas réintroduire
EnergySetupProvider.persist() appelait setLoadConfig(buildLoadDescriptors()), une
reconstruction depuis le modèle typé, et _commit() l'appelle à chaque changement d'UI.
Inoffensif tant que le RPC était un stub ; destructeur une fois réel (SetLoadConfig
remplace tout l'ensemble → la charge chauffe-eau de .75 aurait été écrasée).
Émission débranchée, raison documentée sur place.
Reprise du 2026-08-25 (soir) — l'agent plugin a terminé, la moitié télémétrie est faite
Le blocage est levé : 1.15.2+etm15 (lot plugin B-bis) est sur .75, pac-terrain
est devenue une LoadConfig ordinaire, et GetLoadConfig / GetLoadTelemetry
concordent (2 charges des deux côtés). La règle de détection « charge arbitrée hors
configuration » de +etm14 est sans objet — ne pas la réintroduire, et ne pas
attendre de configurable: false : ce champ n'existe pas.
Livré côté app
| Fichier | Rôle |
|---|---|
models/load_telemetry.dart |
Vue en lecture de GetLoadTelemetry. timestamp/budget/mechanism/lock/faultCode nullables : omis ≠ zéro. Seuil de fraîcheur kTelemetryStaleAfter = 180 s. |
services/telemetry_text.dart |
Rendu français des codes. 19 motifs, 3 défauts, 3 verrous, 4 mécanismes — et un repli obligatoire. |
providers/load_telemetry_provider.dart |
Instantané + LoadTelemetryChanged, fraîcheur à trois signaux, machine à états de ClearLoadFault. |
providers/load_config_provider.dart |
assignDomain() — affectation en brouillon, jamais d'écriture implicite. |
screens/energy/load_order_screen.dart |
En-tête budget/fraîcheur/dégradé, bloc télémétrie par charge, encart défaut, menu de domaine. |
services/nymea_service.dart |
getLoadTelemetry(), clearLoadFault(), aiguillage de LoadTelemetryChanged. |
tools/rpc/{telemetry_watch,domain_assign,fault_probe}.dart |
Sondes permanentes — voir leur README.md. |
57 tests (30 avant), flutter analyze 25 issues = ligne de base inchangée.
Trois choix de conception à ne pas défaire
- La fraîcheur ne repose pas sur le seul
timestamp. Trois signaux concourent : l'âge dutimestamp(le seul qui vaille à l'instant du premier instantané — unGetrend le dernier cycle publié, qui peut dater d'une heure), le temps depuis la dernière trame (immunisé contre l'écart d'horloge box↔téléphone), et le temps depuis que letimestampa cessé d'avancer — le cas exact que le battement de cœur existe pour rendre détectable. Le seuil est à 180 s : trois cycles. Un seuil ≤ 60 s crierait « données anciennes » sur une installation vivante. - Les secondes d'un verrou ne sont pas décomptées côté app. Le plugin exclut volontairement ce champ de sa détection de changement ; un décompte local serait une extrapolation présentée comme une mesure. L'écran écrit « (au dernier cycle) ».
- Un code connu privé d'un paramètre attendu bascule en repli, comme un code inconnu. « Consigne de W servie par le surplus » aurait l'air d'une donnée ; le code brut a l'air de ce qu'il est.
Ce que le banc a appris — et qui ne se déduisait pas du code
ClearLoadFault lève vraiment, et le cycle suivant reverrouille. Passage du
2026-08-25 18:14 → 18:17 (tools/rpc/fault_probe.dart, journal de .75) :
18:14:00 écriture → thing non trouvé → échelle épuisée → charge EN DÉFAUT
18:15:58 [Arbitre] ClearLoadFault → "défaut LEVÉ ; la charge revient à l'arbitrage"
RPC : EnergyErrorNoError
18:16:00 cycle suivant → thing toujours non trouvé → EN DÉFAUT à nouveau
La télémétrie ne repasse jamais à available: true. Une app qui aurait annoncé
« défaut levé » sur le retour du RPC aurait menti deux secondes plus tard. C'est la
justification, mesurée, de l'état pending maintenu 130 s puis conclu stillFaulty.
La reprise à froid lit le matériel, elle ne le suppose pas. Au redémarrage de 18:23:45,
RelayRouter et SgReadyAdapter journalisent ce qu'ils ont lu sur les contacts —
« palier 0 W [correspondance exacte] ; relais lus : … », « état 3 (~1500 W estimés)
[correspondance exacte] ; contacts lus : … ». C'est ECS-411-b vu du dehors : l'état de
départ de l'arbitre est constaté, jamais reconstitué de mémoire.
Le défaut publié est WRITE_FAILED, pas THING_MISSING. L'écriture est tentée,
échoue faute de Thing, l'échelle s'épuise, et m_faulted masque le second code
(etmvariableloadadapter.cpp:148-150, l'ordre des if). Ne pas coder l'UI en supposant
qu'un Thing absent produit THING_MISSING.
SetLoadConfig refuse en bloc, et le motif n'est PAS dans la réponse RPC. La box
renvoie EnergyErrorInvalidParameter, point. Le motif — « etmvariableload fixed : powerLevels requis (non vide) », avec le libellé de la charge — est au journal. C'est
la « silence de refus » que le relevé du plugin garde ouverte (§9-4) : l'app ne peut pas
expliquer un refus à l'installateur, elle ne peut que le rapporter.
Journal de vérification — critères 4 à 8
| Critère | Verdict |
|---|---|
| §7-4 domaine | ✅ sur .75, redémarrage compris — écrit par le code d'écriture de l'app (domain_assign.dart importe patched()/flattenToPayload()), relu identique, persisté dans /var/lib/nymea/energy-load-configuration.json, et survit au redémarrage de nymead (18:23:45, relancé par Patrick) : LoadConfigStore recharge 2 configs depuis le fichier et la relecture RPC est identique clé par clé à celle d'avant l'arrêt. |
| §7-5 télémétrie vivante | ✅ sur .75 — trames non sollicitées, allocation 500 → 3000 → 3500 W, SG_NORMAL → LOCK_MIN_STATE_HOLD, verrou 241 → 180 → 120 s. |
| §7-6 fraîcheur | ✅ protocole mesuré sur .75 (battement ~59 s, cycle qui avance d'une minute) ; ⚠️ la bascule d'affichage est éprouvée en test unitaire, pas à l'écran — l'app n'a jamais tourné sur appareil (voir §7-9). |
| §7-7 défaut | ✅ sur .75 — défaut provoqué, available: false, faultCode rendu en français, ClearLoadFault exercé et son inefficacité constatée sur la télémétrie. ⚠️ pas de fixture brute : le dump n'a pas été conservé (--dump le capturera au prochain passage). |
| §7-8 code inconnu | ✅ test unitaire — code absent de l'ARB et code connu privé d'un paramètre. Non exerçable sur .75 : la box n'émet que des codes du catalogue. |
| §7-1bis ECS-412 | ✅ levé grâce au ssh — journal de l'écriture de domaine : 0 créée(s), 0 mise(s) à jour en place, 2 inchangée(s), 0 retirée(s). Aucune reconstruction d'adaptateurs. |
État du banc à la fin de la session
.75 est laissé classé : chauffe-eau → ecs, pac-terrain → hvac. Le domain
est une pure métadonnée que l'arbitre ne lit pas, et cet état rend enfin le multi-domaines
exerçable sur la machine. Pour revenir en arrière :
dart run tools/rpc/domain_assign.dart 192.168.1.75 --clear --yes
La charge fictive de la sonde de défaut a été retirée, et la configuration relue est identique clé par clé à celle lue avant la sonde.
Passage sur appareil — 2026-08-25 (Redmi Note 9S, USB)
Premier passage de l'app sur matériel réel. adb voit l'appareil, mais MIUI refuse
l'injection d'événements (INJECT_EVENTS) : captures et journaux passent, input tap
non. Patrick a piloté à l'écran, l'agent a observé. Une capture par étape dans
screenshots/2026-08-25-passage-appareil/ (git-ignoré), trace complète dans son
trace-app.log.
Le pont qui a rendu la session possible
flutter run n'imprime pas les traces dart:developer log() — elles partent au VM
Service, ni sur stdout ni dans logcat. Tout ce que NymeaService._log() journalise était
donc invisible. Le pont tools/devlog/vm_logs.dart s'abonne au flux Logging du VM
Service et rend la trace. Sans lui, aucun des constats ci-dessous n'était atteignable.
Mode d'emploi en tête du fichier, piège d'horodatage compris (lire event.timestamp, pas
l'heure de réception : le VM Service livre l'historique bufférisé d'un coup).
Critères
| Critère | Verdict sur appareil |
|---|---|
| §7-9 locale | ✅ trois Hello (19:37:59, 19:43:55, 19:50:55), tous demandée "fr_FR" → retenue "fr_FR". La valeur relue vient de m_clientLocales côté box : c'est la box qui dit ce qu'elle a retenu, pas l'app qui se répond à elle-même. ⚠️ La locale de l'APPAREIL ne part pas : le téléphone est en en-GB, _requestedLocale est figé à 'fr_FR' (nymea_service.dart:61) et rien ne câble l'un sur l'autre. Conforme au brief (app française seule), mais à savoir le jour où l'app parlera allemand. |
| §7-5 télémétrie vivante | ✅ à l'écran, sans un geste : entre 19:51 et 19:52, budget 943 → 2 571 W, alloué 500 → 3 000 W, motif recomposé, « dernier cycle il y a 51 s » → « 41 s ». Le recrédit anti-clignotement est visible (0 → 500 W : la charge consommait déjà, on lui rend avant d'arrondir). |
| §7-6 fraîcheur | ⚠️ l'état « données anciennes » est INATTEIGNABLE par cette voie. Détail ci-dessous. |
| Écriture (§7-4 depuis l'UI) | ✅ affectation de domaine faite au doigt → « Configuration confirmée par la box » → pac-terrain → heating vérifié sur la box et dans le fichier persisté. |
| Acquittement ≠ application | ✅ mesuré : SetLoadConfig à 19:53:46.007, LoadConfigChanged à .288, acquittement traité à .303. La confirmation a battu l'ack de 15 ms. |
§7-6 — pourquoi la bascule n'a pas eu lieu
20:13:00 dernière trame LoadTelemetryChanged
20:13:20 coupure WiFi (adb shell svc wifi disable)
20:13:21 🔌 Connection closed
20:14:21 (T+60) → l'app est déjà sur « Installations »
20:17:47 (T+260) → toujours là. Seuil de 180 s jamais atteint à l'écran.
La garde de routage évacue l'écran avant que la fraîcheur ait le temps d'expirer.
hasActiveConnection tombe à faux, le routeur redirige vers /installations, et l'écran
qui devait virer au « données anciennes » n'existe plus.
Ce n'est pas un défaut de la fraîcheur — le compteur a été vu vivre et s'incrémenter à
l'écran (51 s → 41 s → 6 s). C'est que la perte de lien n'est pas le chemin qui mène à cet
état. Le seul déclencheur réel du bandeau est l'arbitre figé socket vivante : les
trames continuent d'arriver, le timestamp cesse d'avancer. C'est exactement le cas que le
battement de cœur du plugin existe pour rendre détectable — et il n'est pas provocable
depuis le réseau : il faudrait figer l'arbitre sans tuer nymead.
Conséquence de conception à retenir : des trois signaux de fraîcheur, seul
sinceCycleAdvanced peut réellement se déclencher dans l'app telle qu'elle est. Les deux
autres (cycleAge, sinceLastFrame) sont court-circuités par l'éjection.
Défauts constatés — aucun corrigé (constat d'abord, décision de Patrick)
- Aucune reconnexion automatique.
_onDone()pose_connected = falseet notifie, point final. Une coupure WiFi d'une seconde éjecte vers « Installations », fait retomber le mode installateur, et l'installateur doit tout refaire à la main. Observé deux fois. /energy/setupdécrit autre chose que l'installation. À 19:49 l'écran affichait « 6 / 6 rôles configurés · tout est assigné » avec un « Compteur réseau — Onduleur SolarEdge » — alors que.75n'a aucun SolarEdge (11 things : Fronius ×3, Terra AC, 6 relais). La trace disaitSetRootMeter ignoré (sim-inverter): des assignations héritées du mode démo avaient survécu dans la vue d'une box réelle. Et dans l'autre sens : après réassignation, l'app annonçait « Compteur réseau — aucun appareil » quand la box avait bienFronius Three Phase Meter. L'écran affirme dans les deux cas. Cinq[LoadConfig] N descripteur(s) construits mais NON émispendant l'exercice.- Cumuls aberrants venus de la box.
totalReturn = 9.10e+33 kWh,totalAcquisition = 7.53e+33,totalConsumption = -1.57e+33— seultotalProductionest sain. L'app les affiche fidèlement, sur trois lignes, en notation scientifique. Le défaut est en amont (compteurs cumulés de l'experience-plugin energy sur ce banc), mais une valeur physiquement impossible ne doit pas être mise en forme comme une donnée. Les gardes deEnergyRatiosInterimont tenu : ratios à0 %plutôt qu'une absurdité. - Thème ignoré sur l'écran des charges. Couleurs claires en dur (
Scaffold0xFFF0F2F5, AppBar blanche, textes0xFF1A1A2E) sur un téléphone en thème sombre : les titres de domaine et les libellés de charge passent en sombre-sur-sombre, presque illisibles. Reprises par mimétisme de l'écran existant —CLAUDE.mddit pourtant « utiliserapp_theme.dartsystématiquement ». - Trois débordements de texte : « Palier 3 000 W sur 3 50… », « Maintien ON — 60 s restantes (au dernier cyc… », et « Autoconsommat/ion » coupé en plein mot au dashboard.
- Titre d'écran dupliqué : la clé ARB
installerLoadsTitlevaut « Rôles & appareils », soit le titre de l'AUTRE écran. Deux écrans, un seul titre. load()hors connexion efface l'instantané.getLoadTelemetry()renvoienullpour!_connectedet pour « méthode absente » ; le provider traite les deux pareil et pose_telemetry = null→ l'écran n'affiche rien, en silence, au lieu de « données anciennes » ou de « box déconnectée ». À corriger avec la refonte : ce chemin sera emprunté chaque fois que l'app est hors ligne.- Bruit de transport. Les notifications nymea portent un
id:_processMessageles journalise d'abord en « réponse non attendue (déjà timeout?) » avant de les traiter correctement. Et le heartbeat interrogeEnergy.GetPowerBalancetoutes les 5 s alors queEnergy.PowerBalanceChangedarrive en poussé.
Décisions de Patrick prises pendant le passage
- L'ordre de service se règle dans « Rôles & appareils » → « Charges pilotables », au
glisser-déposer. L'écran « Ordre de service des charges » (
/energy/loads) disparaît, et son bloc télémétrie part sur la carte de chaque charge. - Le regroupement à deux niveaux est conservé (domaines ordonnés, charges ordonnées dedans).
- Le chip « À déclarer dans Configurer » devient un bouton Configurer, qui ouvre le détail du mécanisme : relay-router / etmvariableload / sg-ready, ses relais, ses Things.
- Conséquence assumée : le lot « label + enabled » et le lot « mécanisme + Things »
redeviennent un seul chantier — celui qui touche
sameHardware(), donc reconstruction des adaptateurs et réarmement des verrous à froid. - Cette refonte suppose que les cartes de « Charges pilotables » SOIENT les charges lues
par
GetLoadConfig, et non desLoadDescriptorreconstruits depuis desEmsRole. C'est la cause racine du défaut 2 ; sans ça, un glisser-déposer n'y écrira jamais unpriorityréel.
Refonte décidée le 2026-08-25 (soir) — maquettes validées
Patrick a tranché la question ouverte du RECAP : l'écran « Ordre de service des charges »
(/energy/loads) disparaît. Le rang se règle dans « Rôles & appareils → Charges
pilotables » au glisser-déposer, le regroupement à deux niveaux est conservé, et le bloc
de télémétrie descend sur la carte de chaque charge.
| Maquette | Contenu |
|---|---|
docs/mockups/charges_pilotables_mockup.html |
La liste : bandeau arbitrage, domaines glissables, cartes avec télémétrie, défaut, bouton Configurer |
docs/mockups/configurer_mecanismes_mockup.html |
Les six « Configurer » : relais · modulable · SG-Ready · borne · zones HVAC · sélecteur d'appareils |
docs/mockups/raccordement_reseau_mockup.html |
Compteur réseau + protection de surcharge |
Décisions de conception qui ne se déduisent pas des maquettes :
- Les paliers n'existent que chez le routeur de relais, et ils y sont déduits des
combinaisons de contacts, jamais saisis. Le modulable ne connaît qu'un plafond
(
maxPowerW). Le modefixedd'etmvariableload— que le plugin qualifie lui-même de legacy — n'est plus proposé, mais reste lisible : ouvrir l'écran ne doit pas convertir une config déployée. - Le critère de choix entre les deux ECS, formulé pour l'installateur : où sont les contacteurs ? Sur les sorties de la box → routeur de relais. Dans l'appareil, piloté par une consigne → modulable.
- SG1 / SG2 n'existent pas côté box.
sgReady.states[].relays[]n'est qu'une liste par état. Les étiquettes sont une convention d'app, retrouvée à la lecture par l'encodage standard (SG1 = fermé en état 1, SG2 = fermé en état 3). Un encodage non standard perd les noms, pas la configuration. - Le compteur par charge figure dans les cinq écrans, inactif, avec la raison sur place. Aucun champ n'existe côté box, et nymea rejette toute clé inconnue avant le handler.
- La borne règle un mode PAR DÉFAUT, pas une commande — le pilotage vit sur la carte véhicule du tableau de bord.
Ce qui remonte vers le plugin
Six points, rédigés en brief exploitable : docs/BRIEF_plugin_depuis_maquettes.md.
Le seul blocage dur est le premier — ChargingInfo n'a aucun champ de courant minimum,
donc le mode Min+PV reste sélectionnable mais non réglable.
Les cinq autres : motif de refus de SetLoadConfig qui ne franchit pas la frontière ·
WRITE_FAILED qui masque THING_MISSING · paliers atteignables non exposés (l'app devra
sinon dupliquer la combinatoire de relayrouter.cpp) · état matériel de enabled:false à
confirmer pour relais et modulable · estimatedPowerW du banc encore fictif.
Contrôle de la doc contre la machine — à refaire avant chaque lot
CLAUDE.md a été corrigé contre JSONRPC.Introspect le 2026-08-25. Trois fois dans la
journée la doc locale était en retard sur la box :
- le namespace
LoadConfigcherché dansOPTIMIZER_PROTOCOL.md, où il n'est pas (note de périmètre ajoutée côté plugin, commitafaa8c2) ; - le « bug critique »
Energy.SetChargingMode— cette méthode n'existe pas, l'appel échouerait sur « No such method » ; - les champs de
ChargingInfo(mode/minCurrent/targetSoc/endTimeau lieu dechargingMode/targetPercentage/endDateTime), le namespaceEnergyPlugin.*inexistant, etAirConditioningdonné pour 3 méthodes quand il en expose 8.
Toutes les méthodes citées par CLAUDE.md ont été revérifiées une par une : aucune
introuvable. La sonde tient en une ligne —
dart tools/rpc/probe.dart 192.168.1.75 JSONRPC.Introspect.
Vérifié / non vérifié (lot B app)
| Critère | Statut |
|---|---|
| §7-1 aller-retour neutre | ✅ sur .75 en vrai — SetLoadConfig verbatim → relecture identique |
| §7-1bis non-rebuild ECS-412 | ✅ sur .75 — journal lu : 2 charges inchangées, 0 reconstruite |
| §7-2 aplatissement réversible | ✅ test unitaire seul — .75 n'a que deux charges, désormais dans deux domaines |
| §7-3 config non contiguë | ✅ test unitaire seul — fixture synthétique |
| §7-4 domaine | ✅ sur .75, redémarrage de nymead compris |
| §7-5 télémétrie vivante | ✅ sur .75 |
| §7-6 fraîcheur | ✅ protocole sur .75 ; ⚠️ bascule d'affichage en test unitaire seul |
| §7-7 défaut | ✅ sur .75 (sonde fault_probe.dart) |
| §7-8 code inconnu | ✅ test unitaire (non exerçable sur une box conforme) |
| §7-9 locale | ✅ protocole vérifié sur .75 ; ⚠️ chemin app jamais exécuté sur appareil (adb refusé) |
| §7-10 analyze / tests / gating | ✅ 25 issues = ligne de base, 57/57 tests |
Ce qui reste à faire sur ce lot
- Faire tourner l'app sur appareil (
adbrefusé jusqu'ici). Tant que ça n'est pas fait, §7-6 et §7-9 restent des vérifications de protocole, pas d'écran. - Capturer une fixture brute de défaut au prochain passage de
fault_probe.dart(--dump). - Écran de réglage par charge — l'agent plugin le demande explicitement :
pac-terrainest désormais renommable, classable, rangeable, désactivable. Le brief du lot B limitait l'écriture àpriority+o:domain; renommer et désactiver sont hors de ce lot, et attendent un arbitrage.
Question ouverte à trancher
Le brief désignait « Rôles & appareils » (/energy/setup) comme écran cible, mais cet écran
est bâti sur le modèle inverse (il construit des LoadDescriptor depuis des EmsRole).
J'ai créé /energy/loads à côté et laissé /energy/setup intact. Fusionner les deux est
une refonte, pas un câblage — décision Patrick attendue.
RECAP — etm-powersync-app · reprise de session
État au 2026-06-30. Branche
feature/beta-add-config(9 commits au-dessus demaster). Rien n'est poussé (push différé). Aucun merge vers master/silo.
1. Où on en est (commits, du plus récent au plus ancien)
| Commit | Contenu |
|---|---|
b4a6c97 |
Lot C — squelette discovery de things (⚠️ pending SDM réel) |
b7a7213 |
Catalogue de things (lecture seule) + filtres (lot A) |
3a535e0 |
Écran Protocoles — CRUD masters Modbus RTU |
0ebbde4 |
chore(docs) — specs/contrats/refs rangés dans docs/ |
be6694c |
chore — ignore /screenshots |
f755c72 |
docs — contrat UI rev.5 (réserve décision-2 levée) |
3821bf3 |
refactor(energy) — retrait calcul ratios app-side → seam EnergyRatiosInterim |
b5abe42 |
docs — contrat UI rev.4 |
c638ec6 |
Connexions multi-HEMS + Rôles & appareils (gros commit fondateur) |
Source de vérité contrats : docs/UI_data_contract.md (rev.5), docs/reference/jsonRPC.txt (introspection — fait foi pour les RPC).
2. Ce qui est LIVRÉ et fonctionnel
Connexions multi-HEMS (validé live)
models/installation.dart,services/installation_store.dart(token par UUID viaflutter_secure_storage, métadonnées SharedPreferences).services/connection_manager.dart: mono-connexion,switchTo,connectNew,enterDemo(démo = active factice), contrôle d'identité DHCP, invalidation des caches au switch.nymea_service:connect()réveillé (ws://4444, token par UUID), capture Hello (uuid/name/initialSetupRequired),authenticate/createUser,resetState.- Écran Installations (3 états) + écran Connexion (auth 2 branches, détection auto).
- Gate de routage (
buildAppRouter, redirect +refreshListenable: merge([cm,svc])),hasActiveConnection = isSimulation || (connected && authenticated). - En-tête drawer cliquable → Installations.
- Testé en vrai : login auth contre
.120,.75ouvert, switch, déverrouillage installateur.
Rôles & appareils
EnergySetupProvidertri-état (present/absent/non-configuré), écranroles_devices_screen,LoadDescriptor(rév.2 : PAC exclue → SG-Ready),Energy.SetRootMeterréel, Get/SetLoadConfig en stub loggé.
Energy — Phase 1 (interim)
services/energy_ratios.dart= SEAM UNIQUEEnergyRatiosInterim: ratios autoconso/autonomie par Δ-de-cumuls (reseed jour/non-monotone, gardes n/a, clamp).selfConsumptionPowernet-signé sans.abs(). Test :test/energy_ratios_test.dart.- Calcul ratios app-side retiré de
_parsePowerBalance+ getterautoconsommationWsupprimé.
Installateur — Modbus RTU (validé live)
models/modbus_models.dart, 5 RPCModbusRtu.*dansnymea_service, écranprotocols_screen(CRUD), route/settings/system/protocols.
Installateur — Catalogue things lot A (validé live)
getVendors()+getAllThingClasses(), écranthing_catalog_screen(liste groupée + 3 filtres : fabricant/type/recherche + détail read-only), route/settings/system/things.
Installateur — Discovery lot C (SQUELETTE, NON validé matériel)
ThingDescriptor,discoverThings(),addThingFromDescriptor(),thing_discovery_screen(form slaveAddress → scan → descriptors → AddThing).
3. ⚠️ BLOQUEURS / EN ATTENTE
- Lot C — merge bloqué tant qu'on n'a pas un descriptor SDM réel sur le bus de hems
.75(besoin : adaptateur USB-RS485 + un vrai compteur SDM branché ; aujourd'hui le master/dev/ttyUSB0estconnected:false). 3 points marquésÀ REVALIDER SUR SDM RÉELdansthing_discovery_screen.dart/nymea_service.dart:- résolution master/slave par heuristique de nom de paramTypes (
'master'/'modbus'/'slave') ; - timeout scan (
_sendRequest= 15 s, à allonger si bus réel plus lent) ; title/descriptiondu descriptor (peuvent être vides/génériques).
- résolution master/slave par heuristique de nom de paramTypes (
- Push : jamais fait (volontaire).
4. Lots / phases NON FAITS (file d'attente)
- Lot B — ajout direct
CreateMethodUser + JustAdd(form paramTypes). Sous-ensemble = 22 classes (ABB B2x, ABB Terra AC RTU/TCP, génériques/simulés nymea). ⚠️ Les Eastron SDM N'EN SONT PAS (ils sont Discovery → lot C). Piège param : nom du master varie (modbusMasterUuidvsrtuMaster). Reporté après C. - Lot D — discovery → pairing (OAuth/PushButton/UserAndPassword).
- Connexions Étape 3 — découverte mDNS native (
multicast_dns, natif only ; web = ajout manuel). Différée. - Connexions — branche CreateUser (box neuve) : codée mais non testée live (pas de box vierge/factory-reset sous la main).
- Energy Phase 2/3/4 (session dédiée, voir mémoire) : Phase 2 = states ratios côté energymanager (autre repo) ; Phase 3 = persistance Influx ; Phase 4 = refonte flow-card §1.1 + bandes + retrait définitif du calcul app-side. Contrat =
docs/UI_data_contract.md.
Bugs latents notés (hors lot)
dashboard_screen.dart:onRefresh: () => service.startSimulation()→ sur box réelle, pull-to-refresh bascule en démo. À corriger au câblage du refresh réel.- Affichage signe : sans
.abs(), conso reste net-signée → Maison (flow-card) & ligne conso (graphe) montrent le signe brut jusqu'à la Phase 4 (voulu).
5. Banc de test
| Cible | Détail |
|---|---|
hems .75 |
nymea 1.15.2, ouverte (authenticationRequired:false). Master RTU /dev/ttyUSB0 présent mais bus hors-ligne. Sert aux sondes read-only sans auth. |
box 2 192.168.1.120 |
auth requise (initialSetupRequired:false → login). Vraie installation avec SDM120 PV. |
| InfluxDB | local sur etm-powersync-dev, v1.6.7 (PAS Influx 3 Core), DB nymeatest, RP tiers déjà présentes. |
| Tél Android | Redmi Note 9S, adb WiFi 192.168.1.107:5555. |
PIN installateur par défaut = 1234.
Comment lancer / tester
- Réel (réseau) :
flutter run -d 192.168.1.107:5555(Android WiFi). Pas Chrome pour le réseau (CORS LAN). Voir mémoireweb-lan-cors. - Linux natif : ❌ bloqué (Flutter snap sans linker
ld.lld). Web headless = page blanche (CanvasKit). Captures fiables =adb exec-out screencap -p. - Sonde RPC sans GUI : script
dart:ioWebSocket.connect('ws://<host>:4444')+ JSON-RPC (ex. utilisé pour valider Modbus/discovery contre.75).
6. Faits techniques à ne pas reperdre
- Signes nymea (power balance agrégé) :
productionpositif,consumptionNÉGATIF,acquisitionnet-signé (négatif = export).currentPowerthing-level = négatif pour un producteur (convention conso). Ne jamais deviner/.abs()le signe. - Cumuls (
totalAcquisition/totalReturn) séparés → autoconso/autonomie dérivables des logs. UUID Hello brace-wrapped{...}(normalisé dans_finishConnect). - Discovery SDM : discoveryParam =
slaveAddressuniquement (Int, déf 1). Le master n'est PAS une entrée — il ressort dansdescriptor.params. Ajout final =AddThing(thingDescriptorId). - CreateMethod = {User, Auto, Discovery} · SetupMethod = {JustAdd, DisplayPin, EnterPin, PushButton, UserAndPassword, OAuth}. Orthogonaux (une classe JustAdd peut exiger Discovery).
7. Reprise — prochaines actions probables
- Brancher un SDM sur
.75→ sonde discovery réelle → lever les 3 points + débloquer le merge du lot C. - Puis lot B (ABB + génériques) ou lot D (pairing).
- Côté energy : ouvrir la session dédiée Phase 4 (contrat
docs/UI_data_contract.md). - Décider du push de
feature/beta-add-config.
Mémoires persistantes liées :
web-lan-cors,energy-chart-redesign,deploy.