etm-powersync-app/CLAUDE.md
Patrick Schurig 5a51582729 fix(ev): « Boost » devient « Manuel » — le nom promettait le contraire du comportement
Décision de Patrick : trois modes suffisent — Manuel, PV, Min+PV. Le boost n'est
pas un quatrième mode, c'est la dérogation (+etm61).

LE FAIT. Le bouton écrivait `ChargingModeNormal`, qui sort la borne de
l'arbitrage et lui restitue ses valeurs manuelles stockées —
manualChargingEnabled, manualMaxChargingCurrent, manualDesiredPhaseCount. Sur
.75 elles valaient false / 6 A / 3 phases : mesuré le 2026-09-08 à 11:09:51,
l'appui a ARRÊTÉ la charge et armé un verrou de 300 s. Pas une régression —
c'est ce que Normal a toujours fait. C'est le nom qui mentait.

Renommé jusque dans le code (`ChargingMode.boost` -> `ChargingMode.manuel`), pas
seulement sur l'étiquette : un identifiant faux reste un piège pour le prochain
lecteur, et c'est précisément ce qui a duré des mois ici. L'ordre met Manuel en
tête, parce que ChargingModeNormal est aussi le mode d'office d'une borne
détectée.

ET LE RENOMMAGE NE SUFFISAIT PAS. « Manuel » dit QUI commande, pas ce que la
borne va faire : tant que l'app n'offre pas de régler ces valeurs stockées, le
mode veut dire « ce qui traînait la dernière fois ». Passer en manuel demande
donc confirmation, et la confirmation dit la conséquence — la charge peut
s'arrêter — AVANT, pas après.

⚠️ La confirmation ne se déclenche pas sur un ré-appui du mode déjà en place :
une confirmation qu'on apprend à cliquer sans lire ne protège plus de rien. Un
test le fige.

Vérifié sur .75 (1.15.2+etm61) : SetLoadDerogation et ClearLoadDerogation sont
dans Introspect, avec derogationEnded / durationS / restoredMode. Le contrat de
la dérogation est écrit dans CLAUDE.md — durée obligatoire bornée [60, 21600],
état lu dans decision.code == "DEROGATION_ACTIVE" et jamais dans le mode, fin au
niveau de la trame avec six causes. Aucun bouton de boost n'est écrit : c'est le
lot suivant.

Et une sonde pour le moteur, faite au passage : la V2C est bien restée en
ChargingModeNormal et elle est ABSENTE de loads[] — hors arbitrage. Leur
hypothèse est confirmée sans redémarrage.

339 tests.

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

41 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

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

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

🔴 Le bouton s'appelait « Boost », et le nom était faux — il ARRÊTAIT la charge. ChargingModeNormal sort la borne de l'arbitrage et lui restitue ses valeurs manuelles (manualChargingEnabled, manualMaxChargingCurrent, manualDesiredPhaseCount). Sur .75 elles valaient false / 6 A / 3 phases : mesuré le 2026-09-08 à 11:09:51, l'appui a stoppé la charge et armé un verrou de 300 s. Ce n'était pas une régression — c'est ce que Normal a toujours fait ; c'est le nom qui promettait le contraire.

Renommé jusque dans le code (ChargingMode.manuel), pas seulement sur l'étiquette : un identifiant faux reste un piège pour le prochain lecteur.

⚠️ Et passer en Manuel se confirme. « Manuel » dit qui commande, pas ce que la borne va faire : tant que l'app n'offre pas de régler ces valeurs stockées, le mode veut dire « ce qui traînait la dernière fois ». La confirmation le dit avant, plutôt que de le laisser découvrir après.

Le BOOST n'est pas un mode — c'est une DÉROGATION (+etm61)

NymeaEnergy.SetLoadDerogation   { loadId, durationS, o:targetWh }
NymeaEnergy.ClearLoadDerogation { loadId }

Vérifiées présentes dans Introspect sur .75 en 1.15.2+etm61. Elle se superpose au mode courant, sert la charge à son maximum en tête de passe éco — devant toute autre charge, quel que soit son rang — financée au réseau, et s'efface d'elle-même.

  • durationS est obligatoire, bornée [60, 21600], refusée hors de là (LM-1500). Donc pas de « boost jusqu'à annulation » : l'écran doit faire saisir une durée, le moteur refuserait le reste. targetWh est optionnel, en énergie, jamais en SoC — pas de communication véhicule (cf. LM-1009).
  • 🔴 Ne pas lire le MODE pour savoir si un boost tourne. Si la borne était en manuel, le moteur force Eco le temps de la dérogation, et l'écran verrait un mode que l'utilisateur n'a pas choisi. La vérité est sur la charge : decision.code == "DEROGATION_ACTIVE", avec endsAt (ISO 8601 UTC) pour le compte à rebours, maxW / gridW, et boundBy ∈ drawCap (l'abonnement bride) | loadCeiling (la charge est à son plafond de cycle).
  • La fin arrive au niveau de la TRAME, pas dans loads[] — au débranchement la charge n'y est déjà plus. Champ derogationEnded[], présent le seul cycle de la fin :
"derogationEnded": [ { "loadId": "…", "cause": "durationElapsed", "restoredMode": 0 } ]

cause ∈ targetReached | unplugged | measurementLost | durationElapsed | modeChanged | cancelled — six causes, six messages : cible atteinte (rien à faire) · mesure perdue (regarder le lien ou le compteur) · débranchement · durée écoulée · mode changé (quelqu'un a repris le volant) · annulée à la main.

Depuis +etm61, appuyer sur un bouton de mode pendant une dérogation la termine proprement (cause: "modeChanged", restoredMode absent — on ne restitue pas, ce serait écraser le mode que l'utilisateur vient de choisir).

⚠️ minCurrent n'existe nulle part dans ChargingInfo — et c'est normal : ce n'est pas un réglage. Tranché le 2026-09-07. Le plancher est une limite MATÉRIELLE, et elle est déjà sur la box, dans la classe du Thing borne :

// Integrations.GetThingClasses, classe "trydan"
{ "name": "maxChargingCurrent", "minValue": 6, "maxValue": 32, "unit": "UnitAmpere" }
{ "name": "phaseCount", "minValue": 1, "maxValue": 3 }   // état courant sur .75 : 3

6 A × 3 phases × 230 V = 4 140 W. Donc ne jamais proposer de saisir un courant minimum — la règle tient, mais pour une meilleure raison qu'avant : une limite matérielle se lit sur le matériel, elle ne se demande pas à l'utilisateur.

✅ LIVRÉ ET MESURÉ le 2026-09-07 (+etm58). Le mode fait ce qu'il annonce. Preuve par deux comptages indépendants, la voiture ayant chargé toute la journée en Min+PV sans jamais passer en Boost (chargingMode resté ChargingModeEcoWithMinCurrent) :

surplus maximum du jour 536 W — 0 quart d'heure sur 71 au-dessus de 4 140 W
fenêtre de forte consommation 08:45 → 15:15, 16,2 kWh dont 14,2 kWh au-dessus de la base (539 W)
sessionEnergy de la V2C 1,06 kWh à 08:30 → 14,72 kWh à 16:40, soit +13,7 kWh

14,2 kWh vus au compteur contre 13,7 kWh vus par la borne : deux mesures qui ne partagent aucune source, à 4 % près. Et comme le surplus n'a jamais dépassé 536 W, tout cela a été acheté au réseau — c'est exactement le plancher demandé.

⚠️ boundBy reste invérifié, et son absence ne prouve rien : le seul ECO_FLOOR_GRID de la trame est celui du chauffe-eau, dont le plancher vient d'une obligation quotidienne, pas d'un minimum matériel. La borne, elle, est sortie de loads[] (pluggedIn: false — voiture débranchée), donc son niveau eco n'est pas observable. À revoir voiture branchée : c'est le seul endroit où le marqueur est attendu.

🔴 Ce que le mode faisait AVANT +etm57 — gardé parce que le symptôme reviendra sur toute box non mise à jour. Sémantique voulue (Patrick, 2026-09-07) : Min+PV charge toujours au minimum de la borne, acheté au réseau s'il le faut, et ajoute le surplus par-dessus — 400 W d'injection sur un plancher de 4 140 W donnent 4 540 W. Mesuré sur .75 : la borne ne reçoit qu'un niveau comfort, aucun niveau eco, et la décision est SURPLUS_INSUFFICIENT. ChargingModeEcoWithMinCurrent se comporte donc exactement comme ChargingModeEco.

Le mécanisme existe pourtant déjà : c'est ECO_FLOOR_GRID, ce que le chauffe-eau fait dans la même trame. Demande portée au moteur (BRIEF_plugin_depuis_maquettes.md, 2026-09-07).

⚠️ Les deux modes sont indiscernables tant que le surplus reste sous le plancher — à l'œil comme à la sonde. Sur .75 le 2026-09-06, zéro quart d'heure sur 97 n'atteint 4 140 W (meilleur : 2 214 W). C'est ce qui a permis de croire le mode implémenté : deux modes qui rendent le même rien ne se distinguent par aucune observation.

Le marqueur de présence est boundBy, dans ECO_FLOOR_GRID.params (moteur, 2026-09-07) — il n'apparaît que sur un moteur qui sert effectivement le minimum, à partir de +etm57. C'est donc lui, et pas le mode ni la version annoncée, qui répond à « le plancher est-il servi ici ? ».

🔴 Absent ne veut pas dire « pas de plancher » — il veut dire « moteur antérieur à +etm57 ». Même règle que levels[] vide ou counts absent : l'absence date la box, elle ne décrit pas le cycle. Un écran qui lirait l'absence comme « aucun minimum requis » affirmerait une décision d'arbitrage à partir d'un silence de déploiement.

Mesure « avant », prise le 2026-09-07 pour pouvoir dater la livraison : version posée powersync-energy-plugin-nymea 1.15.2+etm56, boundBy absent (0 occurrence dans toute la trame), et ECO_FLOOR_GRID.params du chauffe-eau limité à ses six clés connues — atMaximum, gridW, regime, remainingS, remainingWh, targetWh. Sans cette mesure, un boundBy toujours absent après livraison serait indiscernable d'un champ jamais ajouté.

Le bouton Min+PV reste tel quel (Patrick, 2026-09-07) : il fera ce qu'il annonce dès +etm57 posé. Rien à conditionner avant que le champ soit observable — sonder avant de câbler vaut aussi pour un marqueur de présence.


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"). Le financement, lui, a déménagé en +etm30 : plus de funding sur la charge, un levels[] avec un funding par passe —
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 dit l'ORIGINE, counts dit le REGISTRE — et aucun ne se dérive de l'autre. Depuis +etm30 le financement a quitté la charge pour le niveau ; depuis +etm32 chaque niveau porte en plus où ses watts sont comptés :

"levels": [{ "level": "comfort", "targetW": 1380, "funding": "grid",
             "counts": { "budget.allocatedW": 900, "draw.committedW": 480 } }]
  • funding — « ces watts sont achetés » : une intention, voisine du motif. Sur un niveau partagé il vaut "grid", sous la règle « grid l'emporte dès qu'un watt est acheté » — déterministe, indépendante des proportions. Il dit s'il faut en parler.
  • counts — « ils sont comptés ici » : une comptabilité, avec Σ counts == targetW toujours. Il dit combien, et où.

🔴 Un écran qui lit entry.funding ne trouve plus rien, et sans erreur. Passer par LoadTelemetryEntry.resolvedFunding / isGridFunded. levels[] vide = box antérieure à +etm30, pas « aucune passe » ; counts absent = box antérieure à +etm32.

Les clés de counts sont des identifiants OPAQUES, jamais des chemins à parcourir. Une clé inconnue de l'app ne se compte pas zéro : Σ counts == targetW reste vérifiable sans connaître son sens, et destinationsInconnues la nomme pour que l'écran dise « destination inconnue de cette version ».

Deux identités, et la seconde n'existait pas avant +etm32 (les deux vérifiées à la sonde sur .75 le 2026-08-30 en 1.15.2+etm32) :

Identité Terme sommé Contre
surplus Σ counts["budget.allocatedW"] budget.allocatedW
soutirage Σ counts["draw.committedW"] draw.committedW

EV_GRID_START n'est plus une exception. Le motif partage toujours une allocation — c'est un fait — mais ses deux moitiés tombent dans des compteurs de même nature et se lisent en clair : params.budgetW 900 → counts["budget.allocatedW"] 900 → budget.allocatedW 900 ; params.gridW 480 → counts["draw.committedW"] 480 → draw.committedW 480. L'entorse à connaître a disparu. Elle survit dans le code uniquement là où la box ne sait pas dire le partage autrement (formes sans counts) : la retirer là casserait la réconciliation des installations déjà déployées.

🔴 budget.evReservedW n'est PAS un registre d'allocation. C'est un terme de correction du budget — « commandé, pas encore mesuré », la latence entre l'ordre et sa lecture au compteur, et c'est le libellé d'écran (2026-08-30). « Réservé au véhicule » affirmait deux faussetés : que c'est réservé, et que ça regarde le véhicule plutôt que la latence de sa mesure. Depuis +etm32 il ne reçoit plus aucune tranche d'allocation (evReservedW: 0 pendant qu'un EV_GRID_START partage entre les deux vrais compteurs). Ne réconcilier aucun total dessus, jamais — la nature du champ l'interdit.

draw porte désormais le PLAFOND du §10 — corrigé le 2026-09-07 contre .75 en +etm58. La phrase précédente (« authorisedW, remainingW, binding, perPhaseBound sont absents, le plafond du §10 n'existe pas encore ») est périmée et enverrait un agent chercher un champ qui est là :

"draw": { "authorisedW": 6000.4, "committedW": 103.2, "remainingW": 5897.2,
          "binding": "connection", "bindingPhase": "A", "perPhaseBound": true }

binding nomme ce qui contraint ("connection" = le raccordement) et bindingPhase la phase qui mord — donc le plafond se dit avec sa source, ce qui était la condition posée pour l'afficher. Aucun écran ne le lit encore : LoadTelemetry ne modélise que [drawCommittedW]. À câbler quand l'écran du §10 sera validé, pas avant.

draw.committedW seul (+etm31) reste la forme des box entre +etm31 et +etm57.

Publié même quand rien n'est acheté, et counts publié même à zéro avec sa destination ({"budget.allocatedW": 0} sur une charge non servie — le cas le plus fréquent). Donc l'absence vaut « box antérieure », jamais « rien ce cycle » : zéro est une valeur quand la grandeur existe, l'absence en est une quand elle n'existe pas.

2. allocatedW est ce qui a été DÉCIDÉ — ni le commandé, ni ce que la charge tire. Corrigé le 2026-08-29 par le moteur : buildTelemetry() publie le plan, applyActionsToAdapters() reçoit le slot en const, et rien n'est réécrit après le dispatch — un test attend allocatedW == 3000 sur une borne dont le courant commandé donne 2 990 W. Trois nombres, jamais le même sens :

Nombre Champ Ce qu'il dit
Décidé allocatedW (et Σ levels[].targetW, identiques) ce que l'arbitre a planifié — ce que les compteurs de budget comptent
Appliqué mechanism.* (stageW, setpointW, currentA) ce que l'adaptateur a su produire
Mesuré measurement.powerW ce que la charge tire, quand c'est mesurable

décidé ↔ appliqué = l'arrondi du mécanisme, normal et permanent. appliqué ↔ mesuré = la charge n'obéit pas, et ce que l'écart prouve dépend de measurement.source. décidé ↔ mesuré = la somme des deux, à ne jamais lire seule.

⚠️ Ne pas chercher l'arrondi entre Σ targetW et allocatedW : ils sont égaux par construction. Un écran bâti là-dessus ne montrerait jamais rien, et sans erreur.

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 n'entre dans aucun registre (cf. §1 : evReservedW est une correction, pas une destination), 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.


Échéances — l'heure saisie n'est pas l'heure du client

needs.dailyDeadline est une chaîne "HH:MM" sans fuseau, et la box l'interprète dans le sien. Mesuré le 2026-09-04 : .75 est en Europe/London, donc une échéance « 12:00 » vaut 13:00 sur une horloge française. Le moteur a failli imputer l'écart à un décalage d'heure d'été qui n'existe pas.

Le fuseau franchit la frontière RPC, et l'app peut donc être honnête sans rien supposer :

System.GetTime  ->  {"automaticTime": true, "time": 1788500953, "timeZone": "Europe/London"}

Règle d'écran : ne jamais présenter une échéance comme locale sans dire quelle horloge la box a lue. Afficher le fuseau de la box à côté du champ, et signaler la divergence avec celui de l'appareil quand il y en a une. remainingS de la télémétrie permet de le vérifier après coup — 18 719 s à 06:48 heure box donnent bien 12:00 heure box, pas 12:00 heure client.

Vaut aussi pour toute fenêtre tarifaire à venir. Un lot moteur est ouvert pour trancher d'où vient le fuseau et ce qu'il fait quand il diverge ; d'ici là, l'app dit l'horloge au lieu de la choisir.

Deux natures d'heure, deux traitements — et un piège entre les deux

Un réglage (dailyDeadline) est une chaîne sans fuseau que la BOX interprète : on l'affiche telle quelle et on nomme l'horloge qui la lit. Un instant (measuredSince, periodStartedAt, timestamp) est un point du temps absolu : il se rend dans le fuseau du lecteur, et n'a besoin d'aucune mention.

🔴 Le piège est entre les deux. Les instants arrivent en UTC — measuredSince vaut "2026-09-06T14:38:00Z". DateTime.tryParse d'une chaîne en Z rend un DateTime dont isUtc est vrai, et .hour donne alors l'heure UTC. Sans .toLocal(), l'écran affiche une heure qui n'est celle de personne : vu sur appareil le 2026-09-06 avec .75 en Europe/London, « mesuré depuis 14:38 » quand il était 15:38 à la box et 16:38 au téléphone.

🔴 Correction du 2026-09-07 : la cause que j'avais donnée était fausse. J'avais écrit que le défaut avait survécu « parce que box et téléphone partageaient l'heure ». Non. L'écart est entre UTC et l'appareil, pas entre la box et l'appareil : il est visible sur n'importe quel téléphone hors UTC+0, quel que soit le fuseau de la box. Mesuré le 2026-09-07 avec les deux en Europe/Paris — la box publiait 04:33:28Z, l'écran affichait toujours « 04:33 » là où il fallait lire 06:33.

Ce que la bascule en Europe/London a apporté, ce n'est pas la visibilité : c'est le motif de regarder. Personne n'avait comparé cette heure à une vérité connue. La distinction n'est pas de la nuance — la version fausse fait conclure « les fuseaux coïncident, donc nous sommes tranquilles », et c'est exactement ce qui laisse le défaut en place.

Reste vrai : il est invisible à offset nul, celui du lecteur. Corollaire pour les tests : un attendu d'heure se dérive de .toLocal(), il ne se recopie jamais depuis la charge utile — un « 03:42 » écrit en dur gèle l'heure UTC et valide le défaut. Et sous TZ=UTC, ce contrôle-là ne mord pas : le dire dans le test.

Trois formes d'horodatage, et la nue est celle qui coûte. Le contrat porte désormais la règle — tout horodatage publié est en UTC avec son marqueur, ou en epoch — parce que trois champs coexistaient sous deux formats sans que rien ne l'exige :

DateTime.parse('2026-09-06T20:19:04')   -> isUtc = false, toLocal() SANS EFFET
DateTime.parse('2026-09-06T20:19:04Z')  -> isUtc = true,  toLocal() convertit

Une chaîne sans marqueur est tenue pour locale par Dart : toLocal() n'y fait rien, et une valeur qui était de l'UTC se retrouve décalée sans erreur ni trace. C'est le défaut frère trouvé côté moteur le même jour sur command.since, corrigé en ISODateWithMs. Côté app, tout instant venu de la box passe par instantDeLaBox (lib/models/box_instant.dart), qui requalifie la forme nue en UTC — politique valable pour ce que la box publie, et pas pour les dates que l'app écrit elle-même (Installation.lastSeen, sérialisée en local sans marqueur et relue pareil). Deux héritages opposés sous une même forme : il n'y a donc pas de « parser une date » unique dans ce dépôt.


Un mode d'érosion à part : l'étiquette d'honnêteté qui blanchit une fabrication

Les silences qu'on traque se taisent — un champ absent, un zéro forgé, un motif qui ne sort pas. Celui-ci fait l'inverse : il se déclare honnêtement, et c'est ce qui le protège.

Le Sankey affichait « PAC 1,5 kW » calculé comme 38 % de la consommation du foyer, avec la mention « estimé » et un cercle pointillé. Le nombre était faux ; la mention était vraie ; et la mention a rendu le nombre acceptable pendant des mois. Elle a survécu au passage à de vrais compteurs : le 2026-09-06, ECS et PAC publiaient {powerW: 0, source: "meter"} et l'écran montrait toujours ses pourcentages.

Le remède est le problème : marquer une invention la rend tolérable, donc pérenne. Une valeur qu'on ne mesure pas ne se marque pas — elle ne s'affiche pas.

Deux conséquences pratiques, tirées du même lot :

  • « non publié » plutôt qu'une part. Une branche sans mesure n'a pas de valeur à montrer, et le dire coûte une place qu'un chiffre occupait à tort.
  • Pas de RESTE sans mesure exhaustive. « Autres = 22 % » était un reste calculé sur des parts inventées. Un reste suppose de connaître la somme des parts ; sans elle, il n'existe pas — il se fabrique.

⚠️ Et une invention peut en cacher une autre. ECS-Meter et PAC-Meter sont des Things generic-energy appairés à la main, dont la puissance est saisie et non mesurée : le total dont l'écran tirait ses parts agrégeait déjà des valeurs tapées. Deux couches superposées, dont la seconde ne se voit pas depuis l'app — measurement.source dit meter. Ce que l'écran relaie reste donc ce que la box déclare, et c'est la limite de ce qu'un client peut vérifier.


Deux contrôles qui ne mordent pas — et la question qui les démasque

1. La recherche AUTO-CONFIRMANTE. Après avoir corrigé le signe du solaire, la vérification a cherché pvPower.abs — donc elle a trouvé la ligne qu'on venait d'écrire, et elle a caché la seconde, non corrigée. Il fallait chercher pvPower. Le défaut a survécu un jour de plus et c'est l'écran qui l'a montré.

Chercher la trace de sa propre correction ne dit rien de ce qu'on a manqué. On cherche la GRANDEUR, jamais le correctif.

2. Le contrôle vrai par construction. L'identité deliveredWh + remainingWh == targetWh ne peut pas échouer sous unmeasurable : deliveredWh y vaut 0 et remainingWh la cible. Elle ne mord que sous measured — et l'afficher comme « identité OK » ferait lire un feu vert sur le régime qu'elle ne contrôle pas.

La parade est la même pour les deux, et elle tient en une question :

qu'est-ce que ce contrôle aurait attrapé s'il avait échoué ?

Si la réponse est « rien » ou « je ne sais pas », le contrôle décore. Un contrôle qui ne peut pas échouer n'est pas un contrôle : c'est une phrase rassurante, et elle coûte davantage qu'aucun contrôle — parce qu'elle occupe la place de celui qui aurait mordu.

Corollaire de terrain, rencontré deux fois le même jour : une absence côté APP présentée comme une absence côté BOX. Un provider non chargé, une carte sans domaine — et l'écran annonce « non publié » sur une donnée que la box publie. Avant d'écrire qu'une chose n'est pas publiée, vérifier qu'on est allé la chercher.

La prémisse fausse n'est pas dans le code testé — elle est dans ce qui l'entoure

Trois formes rencontrées, et la constante vaut mieux que les trois exemples :

Ce qui était faux Le défaut resté invisible
un jeu d'essai trop homogène ce que seule l'hétérogénéité révèle
deux horloges au même décalage un instant rendu en UTC
un contexte de montage trop lâche un widget qui ne se contraint pas lui-même

Le dernier est du 2026-09-08. Tous les tests montaient ImpactSaveBar dans une hauteur infinie (un ListView), où le MainAxisSize.max d'un Column se comporte exactement comme min. Déplacée en bottomNavigationBar — contrainte lâche mais bornée — la barre a pris tout l'écran et écrasé le corps à zéro. 320 tests verts, et c'est l'appareil en release qui l'a trouvé.

Un widget qui ne se contraint pas lui-même hérite du contexte. Le déplacer change ses contraintes, donc sa taille, sans qu'une ligne de son code ait bougé. Un test qui ne reproduit pas les contraintes du vrai montage ne teste pas le widget : il teste un cas particulier commode.

Chercher à VOIR est plus productif que chercher à casser

À poser à côté de « qu'est-ce que ce contrôle aurait attrapé s'il avait échoué ? ».

Le chemin des flèches — deux taps qui produisent un brouillon sans effet, là où le glisser-déposer n'est pas reproductible à la sonde — n'a pas été trouvé en cherchant le défaut. Il a été trouvé en cherchant comment observer un correctif qu'on venait d'écrire. Et il a élargi le défaut au passage : le glissement n'était pas la porte d'entrée, seulement celle qu'on avait regardée.

Se demander « par quel chemin est-ce que je peux voir ça ? » fait apparaître des chemins que « comment est-ce que je casse ça ? » ne fait pas apparaître.


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
  • Et vérifier la VERSION POSÉE avant de croire une livraison annoncée, en une ligne aussi : ssh etm@192.168.1.75 'dpkg -l | grep powersync-energy'. Le 2026-08-30, +etm31 était annoncé sur la box : le code était écrit, le cross-build jamais fait, la box portait +etm30 et la clé attendue n'existait pas. Annoncé ≠ construit ≠ posé — et un champ absent parce qu'il n'est pas déployé se lit exactement comme un champ absent parce qu'il est omis.
  • ⚠️ Vérifier le paquet qui possède la GRANDEUR mesurée, pas seulement celui qui porte la fonction. Le 2026-09-04, +etm53 était bien posé et l'étalement adaptatif restait inobservable : le compteur qui devait avancer appartient à nymea-plugin-generic-energy (classe smartMeterConsumer), jamais reconstruit — .so daté du 2026-06-19. La règle ci-dessus aurait répondu « posé, tout va bien » et fait accuser le moteur d'un paquet qu'on ne lui avait pas livré. Le propriétaire se trouve en une ligne : for so in /usr/lib/*/nymea/plugins/*.so; do strings -a $so | grep -q <classe> && dpkg -S $so; done
  • Toujours tester la persistance : killer l'app et vérifier que les données survivent
  • Flavors à configurer : com.etm-powersync.community / .auto / .predictai