Patrick Schurig 17b2f03ce0 tools(devlog): garder le pont VM Service au dépôt
flutter run n'imprime pas les traces dart:developer log() — elles partent au VM
Service, ni sur stdout ni dans logcat. Ce pont est ce qui a rendu le passage sur
appareil du 2026-08-25 exploitable ; il ne vivait que dans un scratchpad de session,
alors que le RECAP le donnait « à récupérer au prochain passage ».

Mode d'emploi en tête, avec le piège déjà payé une fois : le VM Service livre
l'historique bufférisé d'un coup au streamListen, donc horodater à la réception
écrase toute la chronologie sous une seule seconde. On lit event.timestamp.

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

32 KiB
Raw Blame History

RECAP — etm-powersync-app · reprise de session

Session 2026-08-25. Branche feature/beta-add-config. Rien n'est poussé. La section « État au 2026-06-30 » plus bas reste valable pour tout ce qu'elle décrit.


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:locale dans Hello, m_clientLocales). Pas de forçage LANG/LC_ALL dans l'unité systemd. Mais sur 784 chaînes affichables de .75, l'allemand en change 1, le français 0 : aucun catalogue .qm fr n'est livré. Les displayName = identifiants anglais.
  • Frontière RPC / runtime de l'arbitre : quasi rien ne sort. Seuls degradedMode (notification ChargingSchedulesChanged uniquement — pas relisable par Get), les deux ratios d'énergie, chargingState de l'EV et le plan de recharge franchissent. Allocation, budget, verrous, paliers, available, motifs : journal seulement. SurplusContext / LoadContextTelemetry n'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 par patched() depuis la map d'origine, jamais de re-sérialisation.
  • services/load_priority.dart — groupByDomain / flattenToPayload / applyDefaultDomainOrder / miroir client de validateSet().
  • providers/load_config_provider.dart — séquence pending → 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. Extension context.l10n. Français seul.
  • NymeaEnergy.GetLoadConfig / SetLoadConfig deviennent 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

  1. La fraîcheur ne repose pas sur le seul timestamp. Trois signaux concourent : l'âge du timestamp (le seul qui vaille à l'instant du premier instantané — un Get rend 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 le timestamp a 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.
  2. 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) ».
  3. 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)

  1. Aucune reconnexion automatique. _onDone() pose _connected = false et 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.
  2. /energy/setup dé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 .75 n'a aucun SolarEdge (11 things : Fronius ×3, Terra AC, 6 relais). La trace disait SetRootMeter 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 bien Fronius Three Phase Meter. L'écran affirme dans les deux cas. Cinq [LoadConfig] N descripteur(s) construits mais NON émis pendant l'exercice.
  3. Cumuls aberrants venus de la box. totalReturn = 9.10e+33 kWh, totalAcquisition = 7.53e+33, totalConsumption = -1.57e+33 — seul totalProduction est 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 de EnergyRatiosInterim ont tenu : ratios à 0 % plutôt qu'une absurdité.
  4. Thème ignoré sur l'écran des charges. Couleurs claires en dur (Scaffold 0xFFF0F2F5, AppBar blanche, textes 0xFF1A1A2E) 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.md dit pourtant « utiliser app_theme.dart systématiquement ».
  5. 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.
  6. Titre d'écran dupliqué : la clé ARB installerLoadsTitle vaut « Rôles & appareils », soit le titre de l'AUTRE écran. Deux écrans, un seul titre.
  7. load() hors connexion efface l'instantané. getLoadTelemetry() renvoie null pour !_connected et 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.
  8. Bruit de transport. Les notifications nymea portent un id : _processMessage les journalise d'abord en « réponse non attendue (déjà timeout?) » avant de les traiter correctement. Et le heartbeat interroge Energy.GetPowerBalance toutes les 5 s alors que Energy.PowerBalanceChanged arrive 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 des LoadDescriptor reconstruits depuis des EmsRole. C'est la cause racine du défaut 2 ; sans ça, un glisser-déposer n'y écrira jamais un priority ré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 mode fixed d'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 :

  1. le namespace LoadConfig cherché dans OPTIMIZER_PROTOCOL.md, où il n'est pas (note de périmètre ajoutée côté plugin, commit afaa8c2) ;
  2. le « bug critique » Energy.SetChargingMode — cette méthode n'existe pas, l'appel échouerait sur « No such method » ;
  3. les champs de ChargingInfo (mode/minCurrent/targetSoc/endTime au lieu de chargingMode/targetPercentage/endDateTime), le namespace EnergyPlugin.* inexistant, et AirConditioning donné 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

  1. Faire tourner l'app sur appareil (adb refusé jusqu'ici). Tant que ça n'est pas fait, §7-6 et §7-9 restent des vérifications de protocole, pas d'écran.
  2. Capturer une fixture brute de défaut au prochain passage de fault_probe.dart (--dump).
  3. Écran de réglage par charge — l'agent plugin le demande explicitement : pac-terrain est 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 de master). 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 via flutter_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, .75 ouvert, switch, déverrouillage installateur.

Rôles & appareils

  • EnergySetupProvider tri-état (present/absent/non-configuré), écran roles_devices_screen, LoadDescriptor (rév.2 : PAC exclue → SG-Ready), Energy.SetRootMeter réel, Get/SetLoadConfig en stub loggé.

Energy — Phase 1 (interim)

  • services/energy_ratios.dart = SEAM UNIQUE EnergyRatiosInterim : ratios autoconso/autonomie par Δ-de-cumuls (reseed jour/non-monotone, gardes n/a, clamp). selfConsumptionPower net-signé sans .abs(). Test : test/energy_ratios_test.dart.
  • Calcul ratios app-side retiré de _parsePowerBalance + getter autoconsommationW supprimé.

Installateur — Modbus RTU (validé live)

  • models/modbus_models.dart, 5 RPC ModbusRtu.* dans nymea_service, écran protocols_screen (CRUD), route /settings/system/protocols.

Installateur — Catalogue things lot A (validé live)

  • getVendors() + getAllThingClasses(), écran thing_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/ttyUSB0 est connected:false). 3 points marqués À REVALIDER SUR SDM RÉEL dans thing_discovery_screen.dart / nymea_service.dart :
    1. résolution master/slave par heuristique de nom de paramTypes ('master'/'modbus'/'slave') ;
    2. timeout scan (_sendRequest = 15 s, à allonger si bus réel plus lent) ;
    3. title/description du descriptor (peuvent être vides/génériques).
  • 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 (modbusMasterUuid vs rtuMaster). 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émoire web-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:io WebSocket.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é) : production positif, consumption NÉGATIF, acquisition net-signé (négatif = export). currentPower thing-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 = slaveAddress uniquement (Int, déf 1). Le master n'est PAS une entrée — il ressort dans descriptor.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

  1. Brancher un SDM sur .75 → sonde discovery réelle → lever les 3 points + débloquer le merge du lot C.
  2. Puis lot B (ABB + génériques) ou lot D (pairing).
  3. Côté energy : ouvrir la session dédiée Phase 4 (contrat docs/UI_data_contract.md).
  4. Décider du push de feature/beta-add-config.

Mémoires persistantes liées : web-lan-cors, energy-chart-redesign, deploy.