etm-powersync-app/CLAUDE.md
Patrick Schurig f33b8c243a feat(support): le rapport de bug pré-rempli, repoussé depuis le premier jour
/bug-report était un stub. Un champ libre seul produit « ça ne marche pas », et l'aller-retour
pour obtenir versions, charges et état d'arbitrage coûte des jours. Tout ce que l'app SAIT
est donc joint d'office : version du paquet, hôte et état de connexion, ligne de versions du
schéma, les charges déclarées avec adaptateur / rang / domaine, et le dernier cycle publié.

Deux choix qui ne sont pas cosmétiques :

- LES DEUX IDENTITÉS DU BUDGET sont dans le rapport, avec leur verdict. Elles valent souvent
  plus que la description du symptôme : portées fausses, elles désignent le moteur ; portées
  vraies, elles désignent l'écran. Sans elles, le premier échange consiste à les demander.

- Aucun secret n'y entre : ni PIN installateur même haché, ni jeton, ni mot de passe. Un
  rapport voyage — courriel, capture, fil de discussion — et ce qui y entre en sort. Les
  UUID de Things restent : ce sont eux qui permettent de recouper avec le journal de la box,
  et ils n'ont aucune valeur hors de l'installation.

Les absences gardent leur sens, comme partout ailleurs : « aucune télémétrie reçue » n'est
pas un arbitrage à zéro, « budget ABSENT » n'est pas un budget de zéro watt, et un
financement omis se dit omis.

Pas d'envoi automatique : il n'existe aucun service de collecte, et un bouton « Envoyer »
sans destinataire donnerait le sentiment que quelqu'un l'a reçu. Le rapport se copie, ce qui
est vérifiable.

package_info_plus est ajouté plutôt qu'une constante de version recopiée à la main : une
constante dérive de pubspec.yaml en silence, et un rapport qui annonce la mauvaise version
envoie chercher un défaut dans un code qui n'est pas celui-là.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
2026-08-27 14:54:27 +02:00

324 lines
16 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.** Le mode `ChargingModeEcoWithMinCurrent` est bien
> dans l'énumération, mais `ChargingInfo` ne porte **aucun champ** de courant minimum. Soit
> il est fixé ailleurs (paramètre du Thing borne), soit il manque côté plugin. **Ne pas
> proposer de réglage de courant minimum tant que ce n'est pas tranché** — un champ qui ne
> part nulle part est pire que pas de champ.
---
## APIs JSON-RPC consommées
### `Energy.*` — nymea-experience-plugin-energy · **5 méthodes**
| Méthode | Usage | Tier |
|---|---|---|
| `GetPowerBalance` | Dashboard temps réel | Community |
| `GetPowerBalanceLogs(sampleRate, from, to)` | Historique — envoyer les bons `from/to` pour 90j | Community |
| `GetThingPowerLogs(sampleRate, thingIds[], from, to)` | Historique par device | Community |
| `GetRootMeter` | Lire le compteur racine configuré | Community |
| `SetRootMeter(rootMeterThingId)` | Config installateur | Community |
> `Energy.*` s'arrête là. **`SetChargingMode` n'existe pas** — ni ici, ni ailleurs.
### `NymeaEnergy.*` — powersync-energy-plugin-etm · **20 méthodes / 14 notifications**
> ⚠️ Le namespace est **`NymeaEnergy`**, pas `EnergyPlugin`. Ce dernier n'existe pas.
> Source d'autorité : `energyplugin/nymeaenergyjsonhandler.cpp` et `JSONRPC.Introspect`.
> **Ce n'est pas dans `OPTIMIZER_PROTOCOL.md`** — ce document décrit le protocole
> plugin ↔ optimiseur (couche A ↔ couche B), pas la frontière JSON-RPC de l'app.
**Charges pilotées**
| Méthode | Usage | Tier |
|---|---|---|
| `GetLoadConfig` | Lire les charges déclarées | Community |
| `SetLoadConfig(loadConfigs[])` | Écrire — **remplace TOUT l'ensemble**, refus en bloc | Community |
| `GetLoadTelemetry` | État runtime de l'arbitrage (budget, allocation, verrous, motifs) | Community |
| — | Depuis `+etm22`, `loads[]` contient **aussi les bornes** (`mechanism.kind = "evcharger"`) et chaque entrée porte `o:funding` (`"surplus"` / `"grid"`) | — |
| `ClearLoadFault(loadId)` | Lever le verrou de défaut — **l'ack ne prouve rien** | Community |
**Recharge EV**
| Méthode | Usage | Tier |
|---|---|---|
| `GetChargingInfos(evChargerId)` | Lire config EV | Community |
| `SetChargingInfo(chargingInfo)` | Écrire config EV — voir le bloc plus haut | Community |
| `GetChargingSchedules(evChargerId)` | Planning EV | Community |
| `GetLockOnUnplug` / `SetLockOnUnplug(Bool)` | Verrouillage au débranchement | Community |
**Réglages globaux d'installation**
| Méthode | Usage | Tier |
|---|---|---|
| `GetPhasePowerLimit` / `SetPhasePowerLimit(Uint)` | **AMPÈRES par phase**, pas des watts. `0` = désactivé, et coupe toute la recharge intelligente | Community |
| `GetAcquisitionTolerance` / `SetAcquisitionTolerance(Double)` | Seuil de surplus au démarrage — **unité non documentée par la box** | Community |
| `GetBatteryLevelConsideration` / `SetBatteryLevelConsideration(Double)` | Part du stockage prise en compte | Community |
**Tarifs dynamiques**
| Méthode | Usage | Tier |
|---|---|---|
| `GetAvailableSpotMarketProviders` | Liste providers | Predict AI |
| `GetSpotMarketConfiguration` / `SetSpotMarketConfiguration(enabled, providerId)` | Config tarif dynamique | Predict AI |
| `GetSpotMarketScoreEntries(date)` | Cotations aWATTar | Predict AI |
| `GetEnergyRatios` | Autoconsommation / autonomie | Community |
**Notifications** — `LoadConfigChanged` · `LoadTelemetryChanged` · `ChargingInfoAdded/Changed/Removed` ·
`ChargingSchedulesChanged` · `EnergyRatiosChanged` · `PhasePowerLimitChanged` ·
`AcquisitionToleranceChanged` · `BatteryLevelConsiderationChanged` · `LockOnUnplugChanged` ·
`SpotMarketConfigurationChanged` · `SpotMarketStatusChanged` · `SpotMarketScoreEntriesChanged`
### `AirConditioning.*` — nymea-experience-plugin-airconditioning · **8 méthodes**
| Méthode | Usage | Tier |
|---|---|---|
| `GetZones` | Afficher zones PAC/thermostat | Auto |
| `AddZone` / `RemoveZone` | CRUD de zones | Auto |
| `SetZoneName` | Renommer | Auto |
| `SetZoneThings(zoneId, thermostats[], valves[], indoorSensors[], outdoorSensors[], windowSensors[], notifications[])` | Rattacher les appareils | Auto |
| `SetZoneSetpointOverride(zoneId, mode, setpointOverride, minutes)` | Dérogation manuelle | Auto |
| `SetZoneStandbySetpoint` | Consigne de réduit | Auto |
| `SetZoneWeekSchedule` | Planning 7 jours | Auto |
> `ZoneInfo` est **presque entièrement en lecture** (`r:`) : température, humidité, PM2.5, COV,
> consigne courante, dérogation et sa fin, listes d'appareils. On écrit par les six `Set*`,
> jamais par un objet complet.
### `Rules.*` — nymea core
| Méthode | Usage | Tier |
|---|---|---|
| `GetRules` | Lecture automatisations | Community |
| `AddRule` | Créer règle HP/HC, surplus → relais | Community |
| `RemoveRule / EditRule` | Gérer règles | Community |
---
## État des écrans
| Écran | État | Action requise |
|---|---|---|
| Dashboard (Sankey + EV card) | ✅ | Corriger API EV + brancher notifications |
| EnergyScreen (4 onglets) | ✅ | Ajouter sélecteur plage 90j |
| ThingsScreen + ThingDetail | ✅ | — |
| FavoritesScreen | ✅ | Persister dans SharedPreferences |
| InstallerMode (PIN SHA-256) | ✅ | — |
| RoleConfigFlow wizard | ⚠️ Stub | Brancher sur vrais RPC |
| TariffScreen | ⚠️ Stub | Brancher `SetSpotMarketConfiguration` |
| SchedulerScreen | ⚠️ Stub | Brancher `GetChargingSchedules` |
| TimelineScreen | ⚠️ Stub | Brancher scheduler réel |
| AirConditioning zones | ❌ Absent | Créer (Auto) |
| Rules UI (automatisations) | ❌ Absent | Créer (Community) |
| DeveloperScreen | ❌ Vide | Créer |
| AboutScreen | ❌ Vide | Créer |
---
## Persistance — tout doit survivre au redémarrage
| Donnée | État | Action |
|---|---|---|
| Adresse serveur, PIN, préférences UI | ✅ SharedPreferences | — |
| `RoleAssignments` | ❌ Mémoire | Persister SharedPreferences |
| `FavoriteWidgets` | ❌ Mémoire | Persister SharedPreferences |
| `TariffConfig` / `HcHpConfig` | ❌ Mémoire | Persister SharedPreferences |
| `SchedulerConfig` | ❌ Mémoire | Persister SharedPreferences |
---
## Feature gating par tier
```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` — ne jamais sommer `allocatedW` sans filtrer.** Depuis `+etm22` une borne
peut figurer dans `loads[]` au titre du surplus **ou** du réseau (échéance, tarif
dynamique). L'identité vérifiable est : *somme des `allocatedW` financés au surplus ==
`budget.allocatedW`* ; une borne financée au réseau vit dans `budget.evReservedW`. Sommer
sans filtrer donne un écart que rien ne permet d'interpréter. Omis en mode dégradé — sans
plan, pas de financement. Cf. `LoadTelemetry.surplusAllocatedW`.
**2. `allocatedW` est ce qui a été COMMANDÉ — jamais ce que la charge tire.**
La mise en garde « pour une borne, ce n'est même pas une commande » est **levée** :
depuis `+etm23`, `adjustEvChargers()` ne commande plus, l'arbitre est seul (mesuré : 9
commandes à la borne, 9 par l'arbitre, 0 par le proxy). Mais ce qui devient vrai, c'est
que **personne d'autre ne commande** — pas que la charge obéit. Un plafond matériel, un
véhicule qui refuse, un câble débranché font toujours diverger l'ordre et la réalité.
L'écart se lit dans `measuredW` et dans la charge utile `mechanism` (`chargingEnabled`,
`currentA`, `phaseCount`, `pluggedIn`), **jamais dans `allocatedW`**.
**3. `EV_GRID_START` partage une allocation entre les DEUX compteurs du budget.** C'est le
seul motif qui le fasse : `budgetW` vient du surplus et entre dans `budget.allocatedW`,
`gridW` est acheté au réseau et entre dans `budget.evReservedW`, avec
`budgetW + gridW == allocatedW`. Toute réconciliation doit traiter cette ligne à part —
cf. `LoadTelemetryEntry.surplusShareW`. Motif **jamais vu sur machine** au 2026-08-27 :
la clé est prête, rien n'est bâti autour.
**Bornes en configuration (`+etm23`)** — toute borne détectée reçoit d'office une entrée
`GetLoadConfig` : `adapter: "evcharger"`, `mode: "dynamic"`, `domain: "ev"`, plus
`label` / `priority` / `enabled`. **Aucune charge utile de mécanisme** — ni `relays`, ni
`sgReady`, ni `powerLevels`/`maxPowerW`/`minPowerW`, ni `minOnS`/`minOffS` : les limites
d'une borne viennent du Thing et changent avec le véhicule branché. L'aller-retour verbatim
reste neutre (vérifié sur `.75` le 2026-08-27, quatre entrées). Son rang est un
`priority` ordinaire — **il n'y a pas de second système de priorité pour les bornes**, et
le glisser-déposer marche sur une seule liste.
> ⚠️ **Le rang par défaut d'une borne n'est pas tranché.** À la création, elle reçoit « le
> plus petit rang existant moins un, borné à 1 » ; quand une charge occupe déjà le rang 1,
> l'intention dégénère en **égalité** (sur le banc : trois charges à 1). Le tri reste total
> — l'identifiant départage — donc l'ordre est reproductible, mais **reproductible n'est
> pas choisi**. Ne pas présenter l'ordre affiché avant réglage comme un choix.
**Une borne configurée absente de `loads[]`** = *hors arbitrage en ce moment* — aucun
véhicule branché (depuis `+etm24`), pas de voiture assignée, ou mode manuel. Jamais
« perdue », jamais « désactivée ».
**Aucun SOC de véhicule mesuré n'existe.** Vérifié sur `.75` : sur 58 classes, aucune
classe `evcharger` ne déclare d'état de charge. Le seul `batteryLevel` d'une installation
énergie est celui de la **batterie de la maison** — autre grandeur, à ne pas afficher en
face d'une cible de recharge.
> Et il y a pire qu'une mesure manquante : le moteur compare `targetPercentage` à
> `carBatteryLevel`, une valeur **qu'il écrit lui-même** (intégration de la puissance, ×
> facteur de pertes, ÷ capacité déclarée). Elle sort par la frontière RPC sous le nom
> `batteryLevel`, **indistinguable d'une mesure**. Décision LM-1009 : l'avancement
> s'affiche en **énergie livrée** (`sessionEnergy`, par session), jamais en pourcentage —
> le pourcentage reste bon pour SAISIR l'intention, pas pour rendre compte. Certaines
> bornes ne publient pas `sessionEnergy` : l'écran doit alors dire « pas mesurable », et
> surtout **pas zéro**.
**Motif `BATTERY_RESERVE`** — params `socPercent`, `reservePercent`, `withheldW`, tous
entiers **déjà en pourcentage** (ne pas confondre `reservePercent` = 40 avec
`batteryLevelConsideration` = 0,4). Le défaut d'usine de ce réglage est passé de 0,9 à
**0,2**, mais une box déjà déployée garde la valeur qu'elle a persistée.
---
## Règles de modification
- Tout nouvel écran → valider maquette avec Patrick avant de coder
- Tout nouvel appel RPC → vérifier dans `INTERFACE.md` que la méthode existe
- Piloter un EV = `NymeaEnergy.SetChargingInfo` (`Energy.SetChargingMode` n'existe pas)
- **Vérifier ce fichier contre `JSONRPC.Introspect` avant chaque lot.** Trois fois le
2026-08-25, la doc locale était en retard sur la machine et a envoyé un agent dans le vide.
La sonde tient en une ligne : `dart tools/rpc/probe.dart 192.168.1.75 JSONRPC.Introspect`
- Toujours tester la persistance : killer l'app et vérifier que les données survivent
- Flavors à configurer : `com.etm-powersync.community` / `.auto` / `.predictai`