etm-powersync-app/CLAUDE.md
Patrick Schurig e362fb7430 chore(telemetry): l'alias measuredW est retiré — +etm26 est sur .75
Vérifié par sonde avant de couper : `measuredW` a disparu de la charge utile,
`measurement` est en ligne. L'alias n'aura vécu que le temps de l'intervalle qu'il devait
couvrir, et `MeasurementSource.legacy` part avec lui.

Ce qui reste, `MeasurementSource.inconnue`, ne le remplace pas : il couvre une box PLUS
RÉCENTE que l'app — la même règle que partout ailleurs dans ce fichier, un code inconnu
tombe sur un repli lisible plutôt que sur une phrase inventée. On affiche la valeur, on ne
prétend rien sur ce qu'un écart prouverait.

── La garantie qui manquait à ce que j'avais écrit ──────────────────────────

`measurement` est présent pour toute charge de `loads[]`, et pour elle seule. C'est elle
qui rend la lecture sûre, et elle est maintenant au contrat : son absence ne peut PAS
vouloir dire « pas mesurable » — ça, c'est `none`, un état publié — elle ne peut vouloir
dire que « charge hors de loads[] », cas où l'on ne conclut rien.

J'avais fait la distinction par prudence sur l'écran ; elle est désormais fondée sur une
garantie, pas sur une intuition.

── Les sources du banc ne sont pas celles annoncées, et c'est instructif ────

  chauffe-eau   meter, 1 500 W   porte ECS-Meter
  pac-terrain   meter,   800 W   porte PAC-Meter — pas `none`
  V2C Trydan    absente de loads[]  (pluggedIn: false)

`pac-terrain` en `meter` ne contredit pas LM-1105-b : cette règle décrit ce qu'une PAC
publie SANS compteur dédié. On lui en a posé un — ce que le brief recommandait — et le
compteur explicite l'emporte toujours. Et la V2C illustre la distinction sur machine :
absente, pas `none`. Elle porte `currentPower` et publiera sa mesure au cycle où une
voiture y sera branchée.

⚠️ Le test sur appareil exigeait `measuredW == null` après détachement du compteur.
C'était vrai avant `+etm26`, c'est faux depuis : le moteur retombe sur l'appareil quand il
publie lui-même sa puissance. Ce qui doit cesser, c'est que la source vaille `meter` sans
compteur désigné — pas que la mesure disparaisse.

CLAUDE.md porte le contrat complet et les deux pièges, avec l'illustration du banc.

205 tests, 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 15:32:02 +02:00

18 KiB
Raw Blame History

Agent App — etm_powersync_app

Lire aussi le CLAUDE.md du dossier parent avant de commencer.


ssh banc .75

Accès banc .75 : ssh etm@192.168.1.75 (clé déjà installée). Ni root ni nymea n'existent. Journal : journalctl -u nymead.

Mon rôle

Interface utilisateur Flutter du HEMS ETM-PowerSync. Je communique exclusivement via JSON-RPC nymea port 4444. Le gating des features par tier est géré visuellement ici, mais la source de vérité du tier est dans powersync-optimizer.


Stack technique

Élément Valeur
Flutter SDK ^3.11.0
State management provider ^6.1.2 — NymeaService (ChangeNotifier)
Navigation go_router ^14.6.3 — ShellRoute + 25+ routes
Transport WebSocket port 4444 / TCP brut port 2222
Graphiques fl_chart ^0.70.2
Persistance shared_preferences ^2.3.2
Sécurité installateur crypto ^3.0.6 (PIN SHA-256)

🔴 Recharge EV — l'appel réel, vérifié contre la box

Ce bloc décrivait un bug qui n'existe plus, avec des champs qui n'ont jamais existé. Energy.SetChargingMode n'est pas une méthode de nymea — il n'y en a que cinq dans Energy.*, et celle-là n'en fait pas partie : l'appel échouerait sur « No such method ». Et le namespace EnergyPlugin.* n'existe pas non plus. Corrigé le 2026-08-25 contre JSONRPC.Introspect sur .75 (plugin 1.15.2+etm15).

nymeaService.call('NymeaEnergy.SetChargingInfo', {
  'chargingInfo': {
    'evChargerId': chargerId,              // Uuid — requis
    'chargingMode': 'ChargingModeEco',     // requis, énumération ci-dessous
    'targetPercentage': 80,                // o: Uint — charge cible
    'endDateTime': 1786250400,             // o: Uint — horodatage, PAS une chaîne
    'repeatDays': [1, 2, 3, 4, 5],         // o: Int[] — 1 = LUNDI … 7 = dimanche, jamais 0
    'assignedCarId': carId,                // o: Uuid
    'spotMarketChargingEnabled': false,    // o: Bool
    'dailySpotMarketPercentage': 30,       // o: Uint
  }
});

🔴 SetChargingInfo n'est PAS une écriture partielle, malgré ce que dit son propre Introspect (« Only given properties will be set, others will be untouched »). ChargingInfo est reconstruit intégralement à chaque appel : un champ absent du JSON retombe sur le défaut C++, et celui de targetPercentage vaut 80. Muet — réponse EnergyErrorNoError, et la ChargingInfoChanged qui suit porte 80 comme si l'utilisateur l'avait choisi. Reproduit sur .75 le 2026-08-27 (+etm22) : le champ valait 0, un appel portant le seul chargingMode l'a laissé à 80.

Donc : lire (GetChargingInfos), patcher, réécrire l'objet complet — sans chargingState, qui est r: et ferait rejeter l'appel. Et ne jamais afficher targetPercentage comme une cible choisie par l'utilisateur tant que l'écran ne l'a pas lui-même écrite : 80 peut être l'empreinte d'une écriture qui ne parlait pas de cible.

chargingState est en lecture seule (r:) dans la charge utile : ChargingStateIdle · ChargingStateSurplusCharging · ChargingStateSpotMarketCharging · ChargingStateTimeRequirement.

Énumération ChargingMode — les vrais noms

ChargingModeNormal · ChargingModeEco · ChargingModeEcoWithTargetTime · ChargingModeEcoWithMinCurrent · ChargingModeEcoMinWithTargetTime

Modes UI EV — 3 boutons + option échéance

[PV]  [Min+PV]  [Boost]
        ↓
  Option échéance ? → charge cible (targetPercentage) + heure (endDateTime)

PV                → ChargingModeEco
Min+PV            → ChargingModeEcoWithMinCurrent
Boost             → ChargingModeNormal
PV + échéance     → ChargingModeEcoWithTargetTime      + targetPercentage + endDateTime
Min+PV + échéance → ChargingModeEcoMinWithTargetTime   + targetPercentage + endDateTime

⚠️ minCurrent n'existe nulle part. Le mode ChargingModeEcoWithMinCurrent est bien dans l'énumération, mais ChargingInfo ne porte aucun champ de courant minimum. Soit il est fixé ailleurs (paramètre du Thing borne), soit il manque côté plugin. Ne pas proposer de réglage de courant minimum tant que ce n'est pas tranché — un champ qui ne part nulle part est pire que pas de champ.


APIs JSON-RPC consommées

Energy.* — nymea-experience-plugin-energy · 5 méthodes

Méthode Usage Tier
GetPowerBalance Dashboard temps réel Community
GetPowerBalanceLogs(sampleRate, from, to) Historique — envoyer les bons from/to pour 90j Community
GetThingPowerLogs(sampleRate, thingIds[], from, to) Historique par device Community
GetRootMeter Lire le compteur racine configuré Community
SetRootMeter(rootMeterThingId) Config installateur Community

Energy.* s'arrête là. SetChargingMode n'existe pas — ni ici, ni ailleurs.

NymeaEnergy.* — powersync-energy-plugin-etm · 20 méthodes / 14 notifications

⚠️ Le namespace est NymeaEnergy, pas EnergyPlugin. Ce dernier n'existe pas. Source d'autorité : energyplugin/nymeaenergyjsonhandler.cpp et JSONRPC.Introspect. Ce n'est pas dans OPTIMIZER_PROTOCOL.md — ce document décrit le protocole plugin ↔ optimiseur (couche A ↔ couche B), pas la frontière JSON-RPC de l'app.

Charges pilotées

Méthode Usage Tier
GetLoadConfig Lire les charges déclarées Community
SetLoadConfig(loadConfigs[]) Écrire — remplace TOUT l'ensemble, refus en bloc Community
GetLoadTelemetry État runtime de l'arbitrage (budget, allocation, verrous, motifs) Community
— Depuis +etm22, loads[] contient aussi les bornes (mechanism.kind = "evcharger") et chaque entrée porte o:funding ("surplus" / "grid") —
ClearLoadFault(loadId) Lever le verrou de défaut — l'ack ne prouve rien Community

Recharge EV

Méthode Usage Tier
GetChargingInfos(evChargerId) Lire config EV Community
SetChargingInfo(chargingInfo) Écrire config EV — voir le bloc plus haut Community
GetChargingSchedules(evChargerId) Planning EV Community
GetLockOnUnplug / SetLockOnUnplug(Bool) Verrouillage au débranchement Community

Réglages globaux d'installation

Méthode Usage Tier
GetPhasePowerLimit / SetPhasePowerLimit(Uint) AMPÈRES par phase, pas des watts. 0 = désactivé, et coupe toute la recharge intelligente Community
GetAcquisitionTolerance / SetAcquisitionTolerance(Double) Seuil de surplus au démarrage — unité non documentée par la box Community
GetBatteryLevelConsideration / SetBatteryLevelConsideration(Double) Part du stockage prise en compte Community

Tarifs dynamiques

Méthode Usage Tier
GetAvailableSpotMarketProviders Liste providers Predict AI
GetSpotMarketConfiguration / SetSpotMarketConfiguration(enabled, providerId) Config tarif dynamique Predict AI
GetSpotMarketScoreEntries(date) Cotations aWATTar Predict AI
GetEnergyRatios Autoconsommation / autonomie Community

Notifications — LoadConfigChanged · LoadTelemetryChanged · ChargingInfoAdded/Changed/Removed · ChargingSchedulesChanged · EnergyRatiosChanged · PhasePowerLimitChanged · AcquisitionToleranceChanged · BatteryLevelConsiderationChanged · LockOnUnplugChanged · SpotMarketConfigurationChanged · SpotMarketStatusChanged · SpotMarketScoreEntriesChanged

AirConditioning.* — nymea-experience-plugin-airconditioning · 8 méthodes

Méthode Usage Tier
GetZones Afficher zones PAC/thermostat Auto
AddZone / RemoveZone CRUD de zones Auto
SetZoneName Renommer Auto
SetZoneThings(zoneId, thermostats[], valves[], indoorSensors[], outdoorSensors[], windowSensors[], notifications[]) Rattacher les appareils Auto
SetZoneSetpointOverride(zoneId, mode, setpointOverride, minutes) Dérogation manuelle Auto
SetZoneStandbySetpoint Consigne de réduit Auto
SetZoneWeekSchedule Planning 7 jours Auto

ZoneInfo est presque entièrement en lecture (r:) : température, humidité, PM2.5, COV, consigne courante, dérogation et sa fin, listes d'appareils. On écrit par les six Set*, jamais par un objet complet.

Rules.* — nymea core

Méthode Usage Tier
GetRules Lecture automatisations Community
AddRule Créer règle HP/HC, surplus → relais Community
RemoveRule / EditRule Gérer règles Community

État des écrans

Écran État Action requise
Dashboard (Sankey + EV card) ✅ Corriger API EV + brancher notifications
EnergyScreen (4 onglets) ✅ Ajouter sélecteur plage 90j
ThingsScreen + ThingDetail ✅ —
FavoritesScreen ✅ Persister dans SharedPreferences
InstallerMode (PIN SHA-256) ✅ —
RoleConfigFlow wizard ⚠️ Stub Brancher sur vrais RPC
TariffScreen ⚠️ Stub Brancher SetSpotMarketConfiguration
SchedulerScreen ⚠️ Stub Brancher GetChargingSchedules
TimelineScreen ⚠️ Stub Brancher scheduler réel
AirConditioning zones ❌ Absent Créer (Auto)
Rules UI (automatisations) ❌ Absent Créer (Community)
DeveloperScreen ❌ Vide Créer
AboutScreen ❌ Vide Créer

Persistance — tout doit survivre au redémarrage

Donnée État Action
Adresse serveur, PIN, préférences UI ✅ SharedPreferences —
RoleAssignments ❌ Mémoire Persister SharedPreferences
FavoriteWidgets ❌ Mémoire Persister SharedPreferences
TariffConfig / HcHpConfig ❌ Mémoire Persister SharedPreferences
SchedulerConfig ❌ Mémoire Persister SharedPreferences

Feature gating par tier

// TierProvider — à créer, lit le tier depuis le plugin via RPC
// En attendant : valeur par défaut 'community'

if (tierProvider.tier >= Tier.auto) {
  // afficher feature Auto
}

// Utiliser pro_lock_badge.dart (déjà présent) pour verrouiller visuellement

Features par tier

Feature Community Auto Predict AI
Dashboard temps réel ✅ ✅ ✅
Config EV (SetChargingInfo) ✅ ✅ ✅
Tarif HP/HC statique ✅ ✅ ✅
UI automatisations (Rules.*) ✅ ✅ ✅
Historique 90 jours 🔒 ✅ ✅
Wizard onboarding 🔒 ✅ ✅
Zones PAC/ECS (AirConditioning.*) 🔒 ✅ ✅
Prévision solaire Open-Meteo 🔒 ✅ ✅
Notifications d'anomalies 🔒 ✅ ✅
Accès distant sécurisé 🔒 ✅ ✅
Tarifs dynamiques aWATTar 🔒 🔒 ✅
Accès fonctionnalités beta 🔒 🔒 ✅

Thème — utiliser app_theme.dart systématiquement

primaryGreen  solarYellow  gridGray   homeBlue
batteryGreen  boostRed     pvGreen    minPvBlue  accentTeal

Télémétrie d'arbitrage — les pièges de lecture

1. funding — ne jamais sommer allocatedW sans filtrer. Depuis +etm22 une borne peut figurer dans loads[] au titre du surplus ou du réseau (échéance, tarif dynamique). L'identité vérifiable est : somme des allocatedW financés au surplus == budget.allocatedW ; une borne financée au réseau vit dans budget.evReservedW. Sommer sans filtrer donne un écart que rien ne permet d'interpréter. Omis en mode dégradé — sans plan, pas de financement. Cf. LoadTelemetry.surplusAllocatedW.

2. allocatedW est ce qui a été COMMANDÉ — jamais ce que la charge tire. La mise en garde « pour une borne, ce n'est même pas une commande » est levée : depuis +etm23, adjustEvChargers() ne commande plus, l'arbitre est seul (mesuré : 9 commandes à la borne, 9 par l'arbitre, 0 par le proxy). Mais ce qui devient vrai, c'est que personne d'autre ne commande — pas que la charge obéit. Un plafond matériel, un véhicule qui refuse, un câble débranché font toujours diverger l'ordre et la réalité. L'écart se lit dans measurement et dans la charge utile mechanism (chargingEnabled, currentA, phaseCount, pluggedIn), jamais dans allocatedW.

2-bis. measuredW n'existe plus — measurement porte le RÉGIME, pas seulement la valeur (+etm26, déployé sur .75 le 2026-08-28) :

"measurement": { "source": "meter" | "device" | "none", "powerW": 3980.0 }

meter = un compteur a été désigné exprès · device = l'appareil publie lui-même · none = pas mesurable, et powerW est alors absent.

Trois règles d'affichage, chacune payée une fois :

  • none est un état positif, pas une absence : il autorise à masquer le bloc de mesure, et sépare « pas mesurable » de « compteur en panne ». Ne jamais combler avec une puissance nominale de mechanism.* — l'écart serait identiquement nul à tous les cycles, soit un « tout concorde » qui ne signale plus rien.
  • {source: "device", powerW: 0} est une MESURE. Une borne branchée qui ne charge pas mesure zéro watt. La source ne bascule pas vers none quand la charge s'éteint.
  • Ce qu'un écart prouve dépend de la source : sous device, il montre la désobéissance mais une concordance ne prouve rien — plusieurs wallbox renvoient leur consigne ; sous meter, la mesure est indépendante et la concordance devient une preuve.

Et deux pièges de lecture, tous deux au contrat :

  1. Décider sur measurement.source, jamais sur adapter. Un SG-Ready est none par construction (LM-1105-b : son contact commande un signal, la PAC est alimentée ailleurs) — mais un routeur de relais à topologie mixte l'est aussi, une somme partielle n'étant pas une mesure. « Masqué dès que la charge mesure, sauf SG-Ready » manque ce second cas.
  2. measurement est présent pour toute charge de loads[], et pour elle seule. Donc une charge absente de loads[] n'est pas none : elle ne dit rien. Sur .75 le 2026-08-28, la V2C était absente (pluggedIn: false) alors qu'elle publie currentPower — la déclarer « pas mesurable » aurait été faux.

3. EV_GRID_START partage une allocation entre les DEUX compteurs du budget. C'est le seul motif qui le fasse : budgetW vient du surplus et entre dans budget.allocatedW, gridW est acheté au réseau et entre dans budget.evReservedW, avec budgetW + gridW == allocatedW. Toute réconciliation doit traiter cette ligne à part — cf. LoadTelemetryEntry.surplusShareW. Motif jamais vu sur machine au 2026-08-27 : la clé est prête, rien n'est bâti autour.

Bornes en configuration (+etm23) — toute borne détectée reçoit d'office une entrée GetLoadConfig : adapter: "evcharger", mode: "dynamic", domain: "ev", plus label / priority / enabled. Aucune charge utile de mécanisme — ni relays, ni sgReady, ni powerLevels/maxPowerW/minPowerW, ni minOnS/minOffS : les limites d'une borne viennent du Thing et changent avec le véhicule branché. L'aller-retour verbatim reste neutre (vérifié sur .75 le 2026-08-27, quatre entrées). Son rang est un priority ordinaire — il n'y a pas de second système de priorité pour les bornes, et le glisser-déposer marche sur une seule liste.

⚠️ Le rang par défaut d'une borne n'est pas tranché. À la création, elle reçoit « le plus petit rang existant moins un, borné à 1 » ; quand une charge occupe déjà le rang 1, l'intention dégénère en égalité (sur le banc : trois charges à 1). Le tri reste total — l'identifiant départage — donc l'ordre est reproductible, mais reproductible n'est pas choisi. Ne pas présenter l'ordre affiché avant réglage comme un choix.

Une borne configurée absente de loads[] = hors arbitrage en ce moment — aucun véhicule branché (depuis +etm24), pas de voiture assignée, ou mode manuel. Jamais « perdue », jamais « désactivée ».

Aucun SOC de véhicule mesuré n'existe. Vérifié sur .75 : sur 58 classes, aucune classe evcharger ne déclare d'état de charge. Le seul batteryLevel d'une installation énergie est celui de la batterie de la maison — autre grandeur, à ne pas afficher en face d'une cible de recharge.

Et il y a pire qu'une mesure manquante : le moteur compare targetPercentage à carBatteryLevel, une valeur qu'il écrit lui-même (intégration de la puissance, × facteur de pertes, ÷ capacité déclarée). Elle sort par la frontière RPC sous le nom batteryLevel, indistinguable d'une mesure. Décision LM-1009 : l'avancement s'affiche en énergie livrée (sessionEnergy, par session), jamais en pourcentage — le pourcentage reste bon pour SAISIR l'intention, pas pour rendre compte. Certaines bornes ne publient pas sessionEnergy : l'écran doit alors dire « pas mesurable », et surtout pas zéro.

Motif BATTERY_RESERVE — params socPercent, reservePercent, withheldW, tous entiers déjà en pourcentage (ne pas confondre reservePercent = 40 avec batteryLevelConsideration = 0,4). Le défaut d'usine de ce réglage est passé de 0,9 à 0,2, mais une box déjà déployée garde la valeur qu'elle a persistée.


Règles de modification

  • Tout nouvel écran → valider maquette avec Patrick avant de coder
  • Tout nouvel appel RPC → vérifier dans INTERFACE.md que la méthode existe
  • Piloter un EV = NymeaEnergy.SetChargingInfo (Energy.SetChargingMode n'existe pas)
  • Vérifier ce fichier contre JSONRPC.Introspect avant chaque lot. Trois fois le 2026-08-25, la doc locale était en retard sur la machine et a envoyé un agent dans le vide. La sonde tient en une ligne : dart tools/rpc/probe.dart 192.168.1.75 JSONRPC.Introspect
  • Toujours tester la persistance : killer l'app et vérifier que les données survivent
  • Flavors à configurer : com.etm-powersync.community / .auto / .predictai