Patrick Schurig b60c74d2e4 docs(recap): la journée du 2026-08-27 — et le mot qui manquait : JOURNALIER
RECAP.md porte les neuf commits du jour et les trois défauts que seul l'appareil a montrés :
deux cibles tactiles hors norme (26 px, 18 px), et une correction naïve laide que le test
validait — c'est la capture qui a tranché, pas l'assertion.

Ce qui manquait dans le suivi, et qui est écrit noir sur blanc :

AUCUN BILAN JOURNALIER N'EXISTE DANS L'APP. Les quatre tuiles du tableau de bord mêlent deux
natures de grandeur, et aucune n'est journalière — Autoconsommation et Autonomie sont des
pourcentages instantanés, Vers réseau et Depuis réseau des kWh cumulés depuis l'origine. Les
deux dernières portaient « aujourd'hui » : totalReturn n'est pas remis à zéro chaque nuit,
d'où le « · cumulé ». Le bilan du jour reste à écrire, et le TODO porte les trois pièges de
sa mise en œuvre — bornes à minuit local, clés de log SANS le préfixe currentPower, et les
cumulés corrompus de .75.

Et la question ouverte au moteur, telle qu'elle s'est posée : « ECS d'abord, batterie
ensuite » n'est pas exprimable aujourd'hui, et batteryLevelConsideration fait l'inverse — il
annule le budget SOUS le seuil, donc la batterie passe avant. La cause est structurelle : la
batterie n'est dans aucun GetLoadConfig et le plugin ne la commande nulle part. Le levier
existe pourtant, la classe SunSpec Storage de .75 déclare enableCharging, chargingRate et
enableDischarging.

Deux entrées du TODO étaient devenues fausses : kCodesReserveBatterie est fait, et « Zones —
à ne pas entreprendre » est revu puisque le lot est cadré.

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

48 KiB
Raw Blame History

RECAP — etm-powersync-app · reprise de session

Session 2026-08-27. Branche feature/beta-add-config, 39 commits d'avance, NON POUSSÉS — la forge est à terre (192.168.1.113 injoignable, front public en 503). Bundle de secours vérifié : ~/bundles/etm-powersync-app_feature-beta-add-config_2026-08-27.bundle. La journée du 27 est en tête ; celle du 26 suit.


Journée 2026-08-27 — deux lots moteur, le premier passage écran, et trois défauts trouvés

La box est passée de +etm22 à +etm24 dans la journée. Fil conducteur : ce que l'app affiche doit avoir été dit par la box — et ce qu'aucun test unitaire ne peut voir, seul l'appareil le montre.

Commit Contenu
a145529 3g-1 — BATTERY_RESERVE branché, funding lu, l'état d'une borne passe devant
a92e9b2 SetChargingInfo n'est PAS une écriture partielle — lire-patcher-réécrire
a44d64d 3g-2 — les bornes sont des charges configurées, une seule liste, un seul rang
24822ce 3g-2 — la mise en garde borne tombe, sans devenir une certitude trop large
f7fd91e Le SOC véhicule inventé disparaît — la grandeur n'existe pas
f33b8c2 Le rapport de bug pré-rempli, repoussé depuis le premier jour
da0c87e Le harnais d'appareil, et le cadrage des zones de clim
01fdad7 Les cibles tactiles étaient à 26 px — trouvé au premier passage sur appareil
2701a9c L'arbitrage descend en bas, et les blocs se replient

Les trois défauts que seul l'appareil a montrés

  1. La poignée de glissement mesurait 26 × 27 px (minimum Material : 48). Le Container transparent posé la veille avait supprimé la bande morte, jamais agrandi la cible : le geste accrochait quand on visait juste et ratait sinon.
  2. Les flèches de rang mesuraient 18 × 18 px — la taille de l'icône, sans marge de touche.
  3. La correction naïve était laide : deux cibles de 44 px empilées font 88 px de haut pour deux icônes de 18, et la colonne de rang devenait une colonne vide. Le test disait « 44 px, c'est bon » ; c'est la capture qui a dit que c'était laid. Passées côte à côte.

Le harnais lui-même a demandé trois corrections avant de dire la vérité, et chacune se reproduira : il regardait le haut de l'écran (dans un ListView, ce qui n'est pas visible n'est pas construit) ; find trouve un widget posé mais hors viewport et getCenter en rend une coordonnée qui n'existe pas (mesuré y = 1129 sur un écran de 825 px) ; la course de glissement était devinée (~150) là où elle vaut 384 px mesurés.

Ce que le moteur a changé, et ce que l'app en a fait

  • loads[] ⊆ GetLoadConfig rétabli : toute borne détectée reçoit une entrée LoadConfig (.75 passe de 2 à 4). Aller-retour verbatim vérifié neutre sur machine.
  • La mise en garde borne tombe — mais personne d'autre ne commande n'est pas la borne obéit. allocationIsCommand est supprimé plutôt que mis à true partout : le garder inviterait à lire « commande » comme « réalité ».
  • EV_GRID_START aurait cassé la réconciliation en silence. C'est le seul motif dont l'allocation se PARTAGE entre les deux compteurs du budget ; filtrer sur funding == "surplus" le comptait zéro. Trouvé en lisant le brief plutôt que son résumé.
  • Le rang par défaut d'une borne est un artefact (trois charges à priority = 1 sur le banc). L'écran le nomme au lieu de le présenter comme un classement.

Ce qui a été RETIRÉ parce que c'était faux

  • Le SOC véhicule à 62 % en dur. Vérifié sur les 58 classes de .75 : aucune classe evcharger ne déclare d'état de charge. Le brief moteur a tranché ensuite, et c'est pire qu'une mesure manquante — carBatteryLevel est une valeur que le moteur écrit lui-même, publiée sous un nom de mesure. Décision LM-1009 : l'avancement s'affichera en énergie livrée, jamais en pourcentage.
  • BorneSection : les bornes ont désormais un rang et un domaine, la section les affichait une seconde fois en affirmant le contraire.
  • Les deux flèches de domaine : doublon de la poignée, désormais vérifiée au doigt.

🔴 Aucun bilan JOURNALIER n'existe dans l'app

Constaté en répondant à une question sur le tableau de bord. Les quatre tuiles sous le flux énergétique (features/dashboard/widgets/kpi_row.dart) mêlent deux natures de grandeur, et aucune n'est journalière :

Tuile Ce qu'elle montre RÉELLEMENT
Autoconsommation % instantané
Autonomie % instantané
Vers réseau · cumulé kWh cumulés depuis l'origine
Depuis réseau · cumulé kWh cumulés depuis l'origine

Les deux dernières portaient « aujourd'hui », et c'était faux : totalReturn n'est pas remis à zéro chaque nuit. Le libellé dit « · cumulé » depuis. Le bilan du jour reste à faire — il passe par GetPowerBalanceLogs avec des bornes à minuit local, jamais par les totaux de GetPowerBalance. Voir TODO.md.

❓ Question ouverte au moteur — « ECS d'abord, batterie ensuite »

Demandé par Patrick : que le surplus aille d'abord à l'eau chaude, et à la batterie seulement ensuite. Ce n'est pas exprimable aujourd'hui, et le réglage existant fait l'inverse : batteryLevelConsideration annule le budget sous le seuil, donc la batterie est prioritaire jusque-là. Le mettre à 0 ne donne pas la priorité à l'ECS — il retire seulement le blocage, et c'est l'onduleur qui arbitre.

La cause est structurelle : la batterie n'est pas dans le waterfall. Elle n'a aucune entrée GetLoadConfig, et enableCharging / chargingRate / enableDischarging n'apparaissent nulle part dans le plugin ETM. Le levier existe pourtant — la classe SunSpec Storage de .75 déclare ces cinq actions. Question à porter au moteur : le stockage est-il une charge arbitrable, ou reste-t-il hors du budget ?


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 ReorderableListView dans 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 : ReorderableDragStartListener délègue le test de toucher à ses enfants, un Container transparent 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/setup est gardé par le mode installateur : le routeur renvoyait ailleurs, et le symptôme affiché était « aucune charge lue ». Le harnais déverrouille au PIN 1234 et 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 measuredW distincts pour prouver le rattachement — deux compteurs au repos lisent légitimement 0,0 W tous les deux ; l'assertion porte maintenant sur des meterThingId distincts plus un détachement montrant null ≠ 0. Et une assertion cherchait les cartes sans faire défiler : dans un ListView, ce qui n'est pas à l'écran n'est pas construit — elle ne prouvait que la hauteur de l'écran.

Reste ouvert

  • minPowerW == null sans véhicule branché : pas exercé sur appareil, la borne n'entre pas dans loads[] avant 3g.
  • kCodesReserveBatterie à câbler quand le moteur publiera le code du motif.
  • Les zones (ac_screen.dart est encore de la donnée fictive, .75 n'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 de GetLoadConfig, plus de LoadDescriptor reconstruits depuis des EmsRole — LM-201 interdit de toute façon de déclarer dhw et heatPump pour 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 : namespace EnergyPlugin.* inexistant, champs mode/targetSoc/endTime/minCurrent au lieu de chargingMode/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

  1. Le glissement (drag & drop) ne semble rien produire. À départager : le geste est-il avalé par le ListView parent, ou n'y a-t-il simplement rien à déplacer ? Sur .75 il n'y a qu'une charge par domaine — le test décisif est de mettre les deux dans le même domaine.
  2. 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.
  3. Le chip de domaine s'affiche sans libellé sur la carte de charge.
  4. Les 9 sites de couleur hors thème (tables statiques, peintres).
  5. Zones de climatisation : ac_screen.dart n'appelle aucune méthode AirConditioning, ses 4 pièces sont des constantes de maquette, et .75 dé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: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.