etm-powersync-app/CLAUDE.md
Patrick Schurig 0c5f0ee30d docs(ev): Min+PV sert bien le plancher — prouvé par deux comptages, et boundBy ne prouve rien
+etm58 posé (au-delà de +etm57 annoncé). Trois vérifications.

1. ANCRAGE DES INSTANTS : livré et observable. Les trois champs ISO de la trame
   portent le marqueur Z, `command.since` compris —
   `{divergent, expectedW, observedW, since: "2026-09-07T14:37:00Z"}`.

2. MIN+PV : livré. La voiture a chargé toute la journée sans jamais quitter
   ChargingModeEcoWithMinCurrent, et deux comptages sans source commune
   concordent à 4 % :
     surplus MAX du jour        536 W — 0 quart d'heure sur 71 au-dessus de 4140
     fenêtre 08:45 -> 15:15     16,2 kWh dont 14,2 au-dessus de la base (539 W)
     sessionEnergy de la V2C    1,06 -> 14,72 kWh, soit +13,7 kWh
   Le surplus n'ayant jamais dépassé 536 W, tout a été acheté au réseau : c'est
   le plancher demandé.

3. `boundBy` : ABSENT, et cette 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 et non d'un minimum matériel ; et la borne est sortie de loads[]
   (pluggedIn: false). Un `grep -c boundBy` valant 0 aurait fait conclure
   « non livré » à l'instant où la mesure prouve le contraire. À revoir voiture
   branchée.

Et une affirmation de CLAUDE.md devenue FAUSSE, corrigée : le plafond de
soutirage du §10 existe désormais —
  "draw": { "authorisedW": 6000.4, "committedW": 103.2, "remainingW": 5897.2,
            "binding": "connection", "bindingPhase": "A", "perPhaseBound": true }
`binding` nomme ce qui contraint et `bindingPhase` la phase qui mord : le
plafond se dit donc avec sa source, la condition qu'on avait posée pour
l'afficher. Aucun écran ne le lit encore, et rien n'est câblé — l'écran du §10
n'est pas validé.

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

652 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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**).
```dart
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 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 :
>
> ```jsonc
> // 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
```dart
// 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
```dart
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** :
```jsonc
"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à :
```jsonc
"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) :
```jsonc
"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 :
```dart
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.
---
## 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`