docs(brief): trois constats du moteur vers l'app, dont un retiré

1. SetChargingInfo remplace un targetPercentage absent par 80, EN SILENCE — réponse
   EnergyErrorNoError, aucun avertissement, et la seule trace au journal imprime déjà 80.
   L'objet est reconstruit entier à chaque écriture : un champ omis retombe sur son défaut,
   il n'est pas « inchangé ». L'app doit lire-patcher-réécrire, et ne pas afficher 80 comme
   une cible que l'utilisateur aurait choisie.

2. L'alerte sur SampleRate15Mins était FAUSSE et je la retire : ma sonde lisait
   currentPowerProduction là où les entrées de log portent production. Vérifié dans les deux
   sens — 51 échantillons non nuls par RPC, 7908 lignes en base. L'historique 90 jours n'est
   bloqué par rien. Le piège réel est le nom des champs, sans préfixe currentPower.

3. loads[] contient enfin les bornes (buildTelemetry les sautait) et porte un champ funding
   surplus/grid, sans lequel sommer allocatedW donne un écart ininterprétable. Avec la mise
   en garde qui va avec : adjustEvChargers() re-décide après le waterfall, donc pour une
   borne l'allocation publiée est une intention, pas un ordre.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
This commit is contained in:
Patrick Schurig 2026-08-26 18:43:54 +02:00
parent c1af47476d
commit e84f59d04a

149
docs/BRIEF_depuis_plugin.md Normal file
View File

@ -0,0 +1,149 @@
# Brief — du moteur vers l'app
Ce que l'agent plugin (`etm-powersync-energy-plugin-etm`) constate et qui **change quelque
chose côté app**. Une section datée par lot. Le plus récent en haut.
---
## 2026-08-26 — passage machine 3g-1 sur le banc `.75`
**Contexte** : `powersync-energy-plugin-nymea 1.15.2+etm22` déployé sur `.75`. Relevé complet
dans le dépôt du plugin, `docs/RELEVE_3g1.md`.
---
### 1. `SetChargingInfo` substitue `targetPercentage = 80` à un champ absent — **en silence**
**Envoyer un `chargingInfo` sans `targetPercentage` ne laisse pas la valeur en place : elle est
remplacée par 80.** Constaté en restaurant le banc — le champ valait `0`, j'ai réécrit l'objet
sans lui, il valait `80` à la relecture.
**Cause** : `ChargingInfo` est reconstruit intégralement à chaque écriture (`unpack<ChargingInfo>`),
et son membre C++ porte `m_targetPercentage = 80` comme valeur par défaut. Un champ absent du
JSON n'est donc pas « inchangé » : il **retombe sur le défaut**. Cela vaut pour tout l'objet,
`targetPercentage` n'est que le cas où le défaut n'est pas neutre.
**Et la substitution est muette.** Vérifié sur machine, journal du banc à 17:35:56 :
- la réponse RPC est `EnergyErrorNoError` — aucune erreur, aucun avertissement ;
- le seul avertissement du chemin d'écriture ne se déclenche que pour `targetPercentage > 100` ;
- la seule trace au journal est une ligne de **debug** qui imprime `Target percentage: 80`,
c'est-à-dire le **résultat** — jamais le fait qu'un champ était absent ;
- la notification `ChargingInfoChanged` qui suit porte 80, comme si l'utilisateur l'avait choisi.
> **Ce que l'app doit en faire.** Toujours envoyer `targetPercentage`, même inchangé — donc
> lire l'objet, patcher le champ voulu, réécrire l'objet complet (le même chemin que pour
> `priority` dans `LoadConfig`). Et **ne jamais afficher `targetPercentage` comme une cible
> choisie par l'utilisateur** tant que l'écran n'a pas lui-même écrit cette valeur : 80 peut
> être une valeur héritée d'une écriture qui ne parlait pas de cible du tout.
Le risque concret : un écran qui change le seul mode de charge repose une cible de 80 % à
l'insu de l'utilisateur, sans que rien ne le signale.
---
### 2. `GetPowerBalanceLogs` en `SampleRate15Mins` — **RIEN À SIGNALER, l'alerte était fausse**
J'avais signalé que ce taux rendait des échantillons tous à zéro. **C'était mon erreur de
lecture, pas un défaut de la box.** Je la consigne quand même pour qu'elle ne circule pas.
Ce qui s'est passé : ma sonde lisait les clés `currentPowerProduction` / `currentPowerConsumption`
alors que les entrées de log portent **`production`, `consumption`, `acquisition`, `storage`** —
sans le préfixe `currentPower`, contrairement à `GetPowerBalance`. Tout tombait donc sur le
défaut `0`.
**Vérifié après coup, dans les deux sens :**
- par RPC, mêmes bornes de temps, avec les bonnes clés : **51 échantillons, 51 non nuls** ;
- dans la base du banc (`/var/lib/nymea/energylogs.sqlite`), la table `powerBalance` porte
**7 908 lignes à `sampleRate = 15`**, du 2026-06-05 à aujourd'hui 16:30, valeurs réelles.
Les autres taux (1, 60, 180, 1440, 10080, 43200) sont peuplés aussi.
> **Conséquence pour l'app : l'historique 90 jours n'est bloqué par rien de ce côté.** Le seul
> piège est le nom des champs — **les entrées de log ne portent pas le préfixe `currentPower`**,
> à la différence de `GetPowerBalance`. Un modèle qui réutilise les mêmes clés pour les deux
> appels lira des zéros sans la moindre erreur. C'est exactement ce qui m'est arrivé.
---
### 3. Contrat neuf — `loads[].funding`, et les bornes entrent enfin dans `loads[]`
#### 3.1 Les bornes franchissent la frontière RPC (`+etm22`)
Jusqu'ici `buildTelemetry()` **sautait les bornes** : elles étaient arbitrées, leurs décisions
apparaissaient au journal du plugin, mais `GetLoadTelemetry` n'en publiait aucune. L'écran ne
pouvait donc pas les voir. C'est corrigé.
`loads[]` contient désormais les bornes de recharge, avec leur `decision`, leur `mechanism`
(`kind: "evcharger"`, `chargingEnabled`, `currentA`, `phaseCount`, `pluggedIn`) et leur
`faultCode` le cas échéant.
Les motifs EV **`EV_SURPLUS`, `EV_ECO_MIN`, `EV_IDLE` ne sont plus émis** : une borne reçoit
maintenant les motifs communs à tous les mécanismes — `SURPLUS_SETPOINT`, `BELOW_MIN_POWER`,
`SURPLUS_INSUFFICIENT`, `LOAD_UNAVAILABLE`. Les trois codes restent rendus côté plugin pour
qu'un journal ancien reste lisible, mais plus rien ne les produit. `EV_SPOT_MARKET` et
`EV_DEADLINE` subsistent.
#### 3.2 Le champ `funding` — `"surplus"` ou `"grid"`
**Nouveau, optionnel (`o:`), présent dès qu'il y a un plan.** Il devient nécessaire du seul fait
que les bornes sont dans `loads[]`, car deux d'entre elles peuvent y être pour des raisons
différentes :
| `funding` | d'où vient l'allocation | où elle est comptée |
|---|---|---|
| `"surplus"` | la cascade (waterfall PV) | dans `budget.allocatedW` |
| `"grid"` | le proxy — échéance de départ, tarif dynamique — qui **soutire au réseau** | dans `budget.evReservedW` |
> **Sommer `loads[].allocatedW` sans filtrer sur `funding == "surplus"` donne un écart avec
> `budget.allocatedW` que rien ne permet d'interpréter** : défaut du moteur, ou borne servie au
> réseau ? L'identité à vérifier est donc : **somme des `allocatedW` des charges financées au
> surplus == `budget.allocatedW`**.
**Omis en mode dégradé** : sans plan, il n'y a pas de financement. Règle maison habituelle —
omis, jamais nul.
#### 3.3 ⚠️ Mise en garde — pour une borne, l'allocation publiée est une INTENTION, pas un ordre
**`adjustEvChargers()` re-décide le surplus après le waterfall et passe outre le refus de
l'arbitre.** Observé sur le banc, deux lignes consécutives du même cycle (17:34:00) :
```
[Arbitre] "{09e520fd…}" → "Budget 1096 W sous le premier palier … — 0 W"
---------------- Adjusting chargers ----------------
Executing action Simulated wallbox to power: ON, Charging current: 6A, Issuer: Surplus
```
L'arbitre refuse et publie `BELOW_MIN_POWER` ; le chemin d'exécution du proxy commande `ON` à
6 A une milliseconde plus tard. **Tant que ce n'est pas traité (lot 3g-2, côté plugin), ce que
la télémétrie publie pour une borne n'est pas ce qui est commandé.**
> **Ce que l'app doit en faire.** Pour les bornes — et seulement pour elles —, ne pas présenter
> `allocatedW` et son motif comme l'état de la borne. Ce sont **la décision de l'arbitre**, ce
> qui n'est pas la même chose que ce que la borne fait. L'état réel se lit dans
> `mechanism.chargingEnabled` / `mechanism.currentA`, qui viennent du Thing.
>
> Concrètement : ne pas écrire « la borne ne charge pas parce que le budget est sous le
> plancher » à partir du seul motif. Les deux peuvent se contredire aujourd'hui, et c'est
> l'écran qui aurait l'air faux.
>
> Cette réserve **ne vaut pas** pour l'ECS, la PAC et les autres charges pilotées : là,
> l'allocation publiée **est** la commande.
---
### 4. Rappel — clés ARB à prévoir côté app
Deux motifs sont émis par le banc et n'ont pas encore de rendu français dans `telemetry_text.dart` :
- **`BELOW_MIN_POWER`** — params `budgetW`, `minPowerW`, et `state` pour les mécanismes à états
(SG-Ready). Déjà traité dans `telemetry_text.dart`, à vérifier côté ARB.
- **`BATTERY_RESERVE`** — params **`socPercent`, `reservePercent`, `withheldW`**. Le
`kCodesReserveBatterie` de `telemetry_text.dart` est encore **vide**, et les clés qu'il
cherche (`batteryLevel`/`soc`, `threshold`/`batteryLevelConsideration`) **ne sont pas celles
publiées**. À aligner sur les trois noms ci-dessus.
Et un réglage à exposer dans l'écran installateur : **`batteryLevelConsideration`** — son défaut
d'usine est passé de 0,9 à **0,2** (à 0,9 avec une batterie à 50 %, le HEMS ne pilote plus rien,
sans erreur ni panne). En revanche **`minimalChargingCurrent` ne doit jamais être présenté comme
configurable** : il n'existe pas comme réglage, il se déduit de la borne et de la voiture.