+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
652 lines
36 KiB
Markdown
652 lines
36 KiB
Markdown
# 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`
|