Patrick Schurig 2c193077f7 fix(config): +etm27 refuse deux rangs identiques — l'app le voit avant la box
Livré AVANT le déploiement, parce que ce refus-là est muet : le motif ne franchit pas la
frontière RPC, et le symptôme au doigt aurait été « mon glissement ne s'enregistre pas »,
sans cause visible à l'écran. Même famille que le §9 du contrat de données.

Trois points vérifiés, dans l'ordre demandé :

1. LE RÉAPLATISSAGE REND TOUJOURS DES RANGS DISTINCTS. flattenToPayload numérote 1..n sur
   l'ordre courant, donc par construction — mais « par construction » ne se vérifie pas
   tout seul : le test le refait après CHAQUE permutation de domaines et après un
   déplacement intra-domaine, en partant de la config du banc qui porte déjà deux charges
   au rang 1. Les états intermédiaires du glissement ne partent pas : seul `save()` écrit.

2. RIEN NE PART SI LE BROUILLON N'EST PAS COHÉRENT. validateSet() — le miroir client de
   LoadConfigStore::validateSet() — apprend le contrôle des rangs et rend un conflit
   `tiedPriority` qui NOMME les deux charges et le rang partagé. L'écran distingue
   désormais les trois refus (thing revendiqué deux fois, identifiant en double, rangs à
   égalité) là où il annonçait « deux charges revendiquent le même appareil » pour tout.

3. ⚠️ LE CHEMIN DE SUPPRESSION ÉTAIT LE PLUS EXPOSÉ, et rien ne l'aurait montré. Il
   réécrit les survivantes VERBATIM — c'est sa règle, et elle est bonne — donc il
   transporte les rangs tels que la box les porte. Or c'est le plugin lui-même qui a créé
   des égalités avant +etm27 (borne détectée au rang 1 d'une installation déjà réglée).
   Une suppression légitime aurait donc été refusée en bloc, sans motif. Elle refuse
   maintenant côté app, avec la raison — plutôt que de renumérer en douce : un geste de
   nettoyage qui redistribue les rangs devient un geste d'arbitrage.

Deux tests de load_priority_test construisaient leurs entrées sans `priority` : les deux
tombaient donc à égalité, et le nouveau contrôle masquait la règle qu'ils éprouvaient.
Rangs distincts posés, avec la raison en commentaire.

Les deux autres nouveautés d'+etm27 sont au TODO, non codées : `o:rankOrigin` (badge « rang
par défaut, pas un choix », avec la règle absent = rien d'affirmé) et l'entrée en queue des
bornes auto-provisionnées — qui devrait faire taire d'elle-même la note d'ordre entrelacé.

213 tests (+8), flutter analyze inchangé à 27 remarques.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
2026-08-28 16:06:11 +02:00

17 KiB
Raw Blame History

TODO — ETM PowerSync App

Ouvert au 2026-08-27

  • 🔜 1.15.2+etm27 — trois nouveautés, dont une déjà couverte. Déploiement annoncé pour demain matin.

    • Refus en bloc sur deux rangs identiques. Couvert le 2026-08-28, avant le déploiement : validateSet() porte le contrôle (tiedPriority), l'écran nomme les deux charges et leur rang, et le chemin de suppression — qui réécrit verbatim, donc transporte les rangs de la box — refuse plutôt que d'envoyer une égalité héritée. Huit tests, dont le réaplatissage après chaque permutation. Le refus de la box est muet (le motif ne franchit pas la frontière RPC) : sans ce miroir, le symptôme aurait été « mon glissement ne s'enregistre pas ».
    • o:rankOrigin — badge « rang par défaut, pas un choix ». "auto" écrit par le moteur quand il crée une entrée d'office, effacé dès qu'un rang différent arrive pour cet id. Absent = rien d'affirmé, jamais « choisi par un humain » — même règle que measurement.source. "user" est ce qu'un client écrit pour dire « j'ai vu ce défaut et il me va » sans déplacer la charge ; l'app ne peut jamais écrire "auto", le moteur le refuse. C'est l'équivalent, pour une charge arrivée seule, de la note de rangs ex æquo — et il faudra décider si le badge remplace cette note ou s'y ajoute.
    • Les bornes auto-provisionnées entrent en QUEUE (1 + max), plus en tête. Une borne détectée après la mise en service ne passe donc plus devant l'ECS classé exprès ; sur une installation neuve, la liste étant vide, le VE garde le rang 1. Vérifier que la note d'ordre entrelacé et celle de rangs ex æquo se taisent bien dans ce nouveau cas — elles décrivaient un défaut qui disparaît.
  • ⏳ Retirer l'alias measuredW — fait le 2026-08-28, 1.15.2+etm26 déployé sur .75 et vérifié par sonde : measuredW a disparu de la charge utile, measurement est en ligne. MeasurementSource.legacy est parti avec lui ; ce qui reste, MeasurementSource.inconnue, couvre une box plus récente que l'app, pas plus ancienne. Sources relevées sur le banc : meter 1 500 W (chauffe-eau, ECS-Meter), meter 800 W (pac-terrain, PAC-Meter), et la V2C absente de loads[] — pas none.

  • 🔴 Bilan JOURNALIER — fait le 2026-08-28. Les quatre tuiles montrent la journée, sous un en-tête « Aujourd'hui » qui le dit une fois. Méthode : différence des compteurs entre minuit local et maintenant, jamais l'intégration des puissances — mesurée contre estimée, 0,8 % d'écart sur .75. Le contrôle qui la valide est l'identité production + soutiré == consommation + injecté, vérifiée exactement sur le banc.

    • Deux bugs d'unité trouvés en chemin : les compteurs total* sont en kWh et le modèle les nommait Wh. L'écran Énergie les divisait donc encore par mille — une journée de 78 kWh s'y lisait « 78 Wh » — et la simulation accumulait des Wh.
    • Vérifié sur appareil contre .75 en lecture seule. Le passage a trouvé quatre libellés tronqués qu'aucun des 138 tests unitaires ne pouvait voir.
  • ❓ « ECS d'abord, batterie ensuite » — à porter au moteur. Demandé le 2026-08-27, et pas exprimable aujourd'hui. batteryLevelConsideration fait l'inverse : il annule le budget sous le seuil, donc la batterie passe avant. La batterie n'est pas dans le waterfall — 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 : le stockage est-il une charge arbitrable, ou reste-t-il hors du budget ? Une règle Rules.* serait un contournement — deux commandeurs sur la batterie, exactement le défaut que 3g-2 vient de supprimer sur les bornes.

  • Zones de climatisation — cadrage écrit, banc à préparer. Voir docs/CADRAGE_zones_clim.md. Le blocage n'est pas « aucune zone déclarée » : sur les 58 classes de .75, aucune ne porte thermostat, closablesensor, temperaturesensor ni notifications — aucun plugin installé ne sait en créer une. Deux paquets du dépôt apt du banc suffisent (nymea-plugin-generic-heatingcooling, nymea-plugin-generic-sensors). Six décisions à prendre avant de coder.

  • EV_GRID_START / PHASE_LIMIT — éprouvés par test seulement. Les clés ARB sont prêtes, les motifs n'ont jamais été vus sur machine. Signaler la première occurrence réelle. Ne rien bâtir autour d'ici là.

  • ⏳ sessionEnergy est CONSTRUIT par le plugin — mais la trace n'existe pas, et n'existera pas sur cette borne. Le plugin V2C 1.15.2+etm13 (déployé le 2026-08-28 à midi) accumule les incréments de chargeEnergy, détecte les remises à zéro, et ne repart qu'à l'ouverture d'une session : ce n'est pas un alias du registre 0x0BC4. La campagne V2C est arrêtée le 2026-08-28 — la Trydan non-Pro est en WiFi ESP32 sans repli filaire, lien inexploitable à deux mètres du point d'accès (70 % de scrutations avortées, 12 bascules en injoignable en vingt minutes) ; le modèle sort du catalogue ETM, seule la Trydan Pro (port Ethernet) y reste. Le cas qui lève la réserve — chargeEnergy à zéro, sessionEnergy qui continue — se verra sur elle. Aucun avancement affiché d'ici là (LM-1009 §C1), et la note reste en place telle quelle.

  • Le piège des accolades — écrit comme MOTIF le 2026-08-28, pas comme correctif local : docs/UI_data_contract.md §9, « Deux réponses de la même box, deux conventions ». Deux occurrences en une semaine (clés de log sans préfixe currentPower, identifiants de borne avec/sans accolades), la règle, la façon de le chercher, et un renvoi depuis les deux sites de code plus tools/rpc/README.md — là où quelqu'un croise deux réponses.

  • Lot TOTALISATEUR — smartmeterconsumer sur la Trydan, et ce qu'il débloque. Ce n'est pas qu'une déclaration d'interface : un cumul d'énergie ouvre le logging d'énergie natif de nymea sur la borne, et avec lui les rapports mensuels. C'est ce qui manque aussi à l'historique de recharge (entrée plus bas). Détail de l'écart : Relevé du 2026-08-28 : la classe wallbox (borne simulée) déclare connectable, evcharger, power, smartmeter, smartmeterconsumer ; la classe trydan n'a que connectable, evcharger, power. La V2C n'est donc pas proposée comme compteur — l'app a élargi son sélecteur aux bornes publiant currentPower (correctif du jour), mais c'est un contournement de l'écart, pas sa disparition. Le manque est de fond, et c'est le même que celui qui a bloqué LM-1009 : l'interface smartmeter attend un cumul d'énergie ; la wallbox simulée publie totalEnergyConsumed, la Trydan n'a que sessionEnergy / chargeEnergy — une session, pas un total. À chercher dans la carte Modbus V2C, ou à construire comme sessionEnergy l'a été (1.15.2+etm13). C'est un lot plugin, pas un correctif d'app.

  • ⏳ Journal de sessions de recharge — côté moteur, rien à câbler ici. Attendu comme la source propre d'un historique par session, là où l'app ne peut aujourd'hui montrer qu'un instantané. Ne rien bâtir avant qu'il existe : un journal reconstruit côté app divergerait du sien à la première interruption.

  • Historique des états d'une borne — sessionEnergy n'est pas enregistré. suggestLogging est écrasé par les préférences persistées de things.conf sur un Thing déjà appairé, et la V2C n'y a que connected, charging, power. La valeur courante est juste — « la nuit relue à 7 h » fonctionne — mais il n'y aura pas de courbe. Le réglage est côté app : choisir les états enregistrés d'un Thing. À décider si un historique de recharge est voulu ; sans lui, l'écran ne peut montrer qu'un instantané.

  • ⏳ sessionEnergy existe — mais rien ne prouve qu'il survive à une interruption — répondu le 2026-08-28 par l'origine du champ (entrée ci-dessus). Le relevé qui a posé la question est conservé : Integrations.GetThings sur .75, cet après-midi — la V2C publie sessionEnergy et chargeEnergy, tous deux à 4,229 kWh — égaux, ce qui est exactement ce qu'on attend d'une session sans interruption. Le test qui tranche est une remise à zéro observée de chargeEnergy avec un sessionEnergy qui continue. Tant qu'elle n'est pas vue, LM-1009 §C1 reste entière : pas d'avancement affiché. À vérifier sur la campagne de mesures en cours.

    • Deux attentes levées au même relevé : le renommage currentL1/L2/L3 (unité Ampère) est déployé — plus de powerL1/L2/L3 — et phaseCount vaut 3 sur la Trydan, le réglage ayant été posé. PREPA_BANC_20260828.md (dépôt plugin) est corrigé en conséquence : il décrivait l'état de la veille au soir.
  • EVChargingCard n'est montée par AUCUN écran — tranché le 2026-08-28 : elle part. La monter aurait publié de la fiction. Trois des grandeurs qu'elle affiche ne sont jamais lues sur la box : chargingMode, chargingPower (3,6 kW) et solarSourcePercent (82 %) sont des valeurs par défaut de EnergyData, et le mode n'est écrit qu'en optimiste après une commande — jamais relu par GetChargingInfos. Sa feuille « Paramètres de charge » proposait en plus un courant minimum, qui n'existe dans aucun contrat, et son bouton « Enregistrer » ne faisait que Navigator.pop : rien n'était écrit.

  • La tuile EV des favoris affiche des valeurs par défaut comme des mesures — fait le 2026-08-28 : rendue muette, pas retirée. La retirer aurait laissé un trou dans une liste que le client a composée lui-même. EvFavoriteTile (widgets/ev_favorite_tile.dart) lit la borne — pluggedIn, charging, currentPower du Thing, mode par GetChargingInfos + ChargingInfoChanged — et dit ce qui n'est pas publié plutôt que d'afficher zéro. L'écriture du mode restitue le refus et les cibles tactiles passent de 7–10 px à 44 px. Les trois champs fictifs de EnergyData (chargingMode, chargingPower, solarSourcePercent) sont supprimés du modèle : la fiction n'a plus de porte d'entrée.

  • La vue « consommation » du Sankey reste une ESTIMATION — elle le dit désormais. features/dashboard/widgets/energy_flow_card.dart : PAC = 38 % de la maison, Eau chaude = 18 %, Autres = 22 % sont trois ratios en dur posés à la maquette. Depuis le 2026-08-28 les trois portent le cercle pointillé (estimated) — un seul l'avait — et une légende sous le schéma nomme les branches concernées : « seule la voiture est mesurée à la borne ». La voiture, elle, lit la mesure réelle (currentPower, « non lu » sans borne) au lieu des 3,6 kW en dur qu'elle affichait en permanence. Reste le vrai lot : brancher les trois branches sur measurement par charge (GetLoadTelemetry), pas sur des ratios. Relevé du 2026-08-28 : chauffe-eau 1 500 W et pac-terrain 800 W, tous deux en source: "meter". Deux règles à respecter dans la refonte, elles ne sont pas décoratives : une charge en none n'a pas de branche — pas une branche à zéro — et une charge absente de loads[] ne dit rien, donc ne se dessine pas non plus. C'est une refonte de l'écran d'accueil, pas un correctif. Ce qu'une carte de recharge dédiée pourrait dire, si elle revient un jour :

    • le mode courant, lu par NymeaEnergy.GetChargingInfos et tenu à jour par ChargingInfoChanged — l'écriture existe déjà et est vérifiée (nymea_service.setChargingInfo, lecture → patch → réécriture complète) ;
    • la puissance mesurée de la borne, prise sur le Thing (currentPower) ;
    • l'échéance (targetPercentage + endDateTime) — mais uniquement comme intention saisie, jamais comme un avancement : LM-1009 §C1. Pas de SOC véhicule (il n'existe pas), pas de chargeEnergy (remis à zéro à chaque interruption), pas de courant minimum (aucun champ), pas de phaseCount (la V2C ne le publie pas).
  • Le passage sur appareil doit devenir une habitude, pas un rattrapage. Il a glissé deux fois, et au premier passage il a trouvé deux cibles tactiles hors norme (26 px, 18 px) qu'aucun des 129 tests unitaires ne pouvait voir. integration_test/ui_3g2_on_device_test.dart est le harnais. Rappel MIUI : la confirmation d'installation est redemandée à chaque fois — faire un adb install -r -t préalable, puis lancer le test.

  • 🔴 Journal de recharges — à porter au MOTEUR, lot à part. Une ligne par recharge : début, fin, énergie livrée ; plus tard le véhicule et la part surplus / réseau, que funding distingue déjà. La raison est un manque, pas un confort : il n'existe aucun rapport, nulle part — ni par session, ni par charge, ni par mois. Le bilan journalier s'arrête à la journée.

    • Pourquoi le moteur et pas l'app : c'est lui qui voit le début et la fin d'une session, et qui connaît déjà la ventilation surplus / réseau. L'app ne peut que la reconstituer.
    • Pourquoi pas en intégrant une courbe : ça reviendrait à ré-estimer une somme que le plugin connaît exactement — sessionEnergy de la V2C est accumulé au watt-heure près (validé le 2026-08-28 : 9,04023 + 0,182573 = 9,222803, somme exacte, pas un ordre de grandeur). Intégrer une puissance pour retrouver ce chiffre serait perdre de la précision qu'on a déjà.
    • Ne remplace pas l'enregistrement de currentPower, qui reste nécessaire : il couvre la consommation sur une période indépendamment des sessions, donc y compris une recharge lancée à la main sur la borne, hors pilotage — cas qu'un journal de sessions raterait.

Charges pilotables — ce qui attend la box (2026-08-26)

  • kCodesReserveBatterie — fait le 2026-08-27. Le motif est publié : BATTERY_RESERVE, params socPercent / reservePercent / withheldW. Les clés que le fichier cherchait (batteryLevel, threshold) n'avaient jamais existé côté plugin — le motif sortait donc en repli anglais sur le seul cas qu'aucune mesure ne révèle.
  • minPowerW == null sans véhicule branché — couvert en test unitaire (test/ev_runtime_test.dart), pas exercé sur appareil : la borne n'entre pas dans loads[] avant 3g. À reprendre quand l'arbitrage prendra la recharge en charge.
  • Réserve batterie — portée élargie. Le jour où le seuil gouverne tout le budget, reprendre le texte de BatteryReserveCard : il annonce aujourd'hui un futur, il devra décrire un présent.
  • Zones — à ne pas entreprendre — décision revue le 2026-08-27 : le lot est cadré (docs/CADRAGE_zones_clim.md), voir l'entrée en tête de ce fichier. ac_screen.dart reste de la donnée fictive — quatre pièces, huit températures crédibles affichées comme des mesures.

En cours / priorité haute

  • TariffProvider — connexion RPC réelle

    • load() appelle encore un Future.delayed fictif
    • Brancher sur nymea JSON-RPC pour récupérer les prix live (ENTSO-E, Tibber, etc.)
  • EnergySetupProvider — persistance

    • Les assignations de rôles (EV, PAC, batterie…) ne survivent pas au redémarrage
    • Sérialiser en SharedPreferences ou JSON local
  • SchedulerProvider — exécution réelle

    • forceRecalc() ne fait rien pour l'instant
    • Brancher sur l'API nymea Energy Scheduler (si disponible)

Screens à implémenter

  • DeveloperScreen — logs, introspection JSON-RPC, test ping nymea
  • AboutScreen — version app, version nymea, licences
  • SystemSettingsScreen — adresse serveur nymea, port, transport (TCP/WS)

Amélioration UX

  • Timeline — remplacer la simulation bell-curve par les vraies créneaux du scheduler nymea
  • RoleConfigFlow Step 3 — test de connexion sur RPC réel au lieu du délai simulé
  • InstallerModeProvider — persister le hash PIN dans SharedPreferences (actuellement en mémoire)
  • AppSettingsProvider — persister les préférences (thème, densité, écrans actifs) dans SharedPreferences

Intégration nymea live

  • Mapping EmsRole → nymea thingClass pour les installations réelles
  • Vérifier les interfaces nymea sur serveur réel (comparer avec docs/nymea_integrations.md)
  • Energy.GetSchedule / Energy.SetSchedule si l'API le supporte

Technique / dette

  • Ajouter des tests unitaires pour TariffProvider et EnergySetupProvider
  • Gérer le cas où NymeaService.thingClasses est vide en mode live (classes non chargées)
  • Revoir _Step1ThingList : passer en context.watch pour réactivité si les things changent