/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
324 lines
16 KiB
Markdown
324 lines
16 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.** 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`
|