# 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"`). 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.committedW`** (`+etm31`) est publié dans la **même trame que `budget`**, et **seul** : `authorisedW`, `remainingW`, `binding`, `perPhaseBound` sont **absents, pas à zéro** — le plafond de soutirage du §10 n'existe pas encore, et un `authorisedW: 0` afficherait un plafond fictif avec sa source. ```jsonc "budget": { … }, "draw": { "committedW": 0 } ``` > **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. --- ## 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 && 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`