fix(recharge): lire-patcher-réécrire — SetChargingInfo n'est pas une écriture partielle

La box promet le contraire de ce qu'elle fait, et par écrit : sa propre description dans
JSONRPC.Introspect dit « Only given properties will be set, others will be untouched ».
ChargingInfo est en réalité reconstruit entier à chaque appel, et un champ absent retombe
sur son défaut C++ — 80 pour targetPercentage.

Reproduit sur .75 le 2026-08-27 (+etm22), borne 88160e45 : le champ valait 0, un
SetChargingInfo portant le seul chargingMode l'a laissé à 80, réponse EnergyErrorNoError.
Banc restauré.

L'app envoyait targetPercentage uniquement avec une échéance. Changer de mode de charge
reposait donc une cible de 80 % à l'insu de l'utilisateur, sans que rien ne le signale.
Désormais : GetChargingInfos, patch, réécriture de l'objet complet — sans chargingState,
qui est r: au schéma et ferait rejeter l'appel. Sans échéance, on repose explicitement la
valeur que la box portait déjà ; si elle n'en portait aucune, le neutre est 0, pas 80.

Et la cible n'est plus affichée hors échéance : 80 peut être l'empreinte d'une écriture qui
ne parlait pas de cible du tout, la montrer en ferait une intention.

Deux notes de contrat au passage :
- fetchPowerBalanceLogs porte enfin le piège qui a fait croire à un défaut de la box côté
  plugin — les entrées de log ne portent PAS le préfixe currentPower. Réutiliser les clés
  de GetPowerBalance ne produit aucune erreur, tout tombe sur 0. Vérifié ici avec les clés
  de l'app : 7 960 échantillons sur 90 jours en SampleRate15Mins, dont 4 975 de production
  non nulle. Rien ne bloque l'historique 90 jours.
- le défaut d'usine de batteryLevelConsideration est passé de 0,9 à 0,2. La carte reste
  nécessaire : le nouveau défaut ne rattrape pas une box qui a déjà persisté 0,9, et le
  seuil reste réglable jusqu'à 1,0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
This commit is contained in:
Patrick Schurig 2026-08-27 07:39:12 +02:00
parent a1455290c9
commit a92e9b2f62
3 changed files with 111 additions and 14 deletions

View File

@ -909,6 +909,19 @@ class NymeaService extends ChangeNotifier {
/// [sampleRate] : "SampleRate15Mins" | "SampleRate1Hour" |
/// "SampleRate3Hours" | "SampleRate1Day" | "SampleRate1Week"
/// Timestamps envoyés en **secondes** (convention nymea Energy API).
///
/// ## ⚠️ Les entrées de log ne portent PAS le préfixe `currentPower`
///
/// `GetPowerBalance` rend `currentPowerProduction` / `currentPowerConsumption` ; les
/// entrées de log, elles, s'appellent **`production`, `consumption`, `acquisition`,
/// `storage`**. Réutiliser les clés de l'un pour l'autre ne produit **aucune erreur** :
/// tout tombe sur le défaut `0`, et l'historique paraît vide alors que la base est
/// pleine. C'est exactement ce qui a fait croire à un défaut de la box côté plugin — et
/// c'est pour ça que ce piège est écrit ici plutôt que retenu.
///
/// Vérifié sur `.75` le 2026-08-27, avec les clés ci-dessous : **7 960 échantillons sur
/// 90 jours** en `SampleRate15Mins`, dont 4 975 avec une production non nulle. Rien ne
/// bloque l'historique 90 jours de ce côté.
Future<List<PowerBalanceEntry>> fetchPowerBalanceLogs({
required DateTime from,
required DateTime to,
@ -1034,6 +1047,32 @@ class NymeaService extends ChangeNotifier {
return null;
}
/// `NymeaEnergy.GetChargingInfos` — l'objet de recharge **tel que la box le porte**.
///
/// Lecture indispensable avant toute écriture : `SetChargingInfo` reconstruit l'objet
/// entier et un champ absent retombe sur son défaut C++ (cf. [setChargingInfo]). On ne
/// peut donc pas « écrire un champ » sans avoir lu les autres.
///
/// \return l'objet de cette borne, ou `null` si la box n'en porte pas — auquel cas il
/// n'y a rien à préserver, et c'est une information, pas un échec.
Future<Map<String, dynamic>?> getChargingInfo(String evChargerId) async {
if (_isSimulation || !_connected) return null;
try {
final r = await _sendRequest('NymeaEnergy.GetChargingInfos', {});
final list = r['params']?['chargingInfos'];
if (list is! List) return null;
for (final e in list) {
if (e is Map && e['evChargerId'] == evChargerId) {
return Map<String, dynamic>.from(e);
}
}
return null;
} catch (e) {
_log('[Recharge] GetChargingInfos: $e', force: true);
return null;
}
}
/// `NymeaEnergy.SetChargingInfo` — mode de recharge du véhicule.
///
/// ## Ce bloc envoyait une requête que la box REJETTE
@ -1059,6 +1098,30 @@ class NymeaService extends ChangeNotifier {
/// valeur du courant minimal ne se règle pas ici. Le mode se choisit, son paramètre
/// non — et l'écran doit le dire au lieu d'offrir un réglage sans effet.
///
/// ## Écriture PARTIELLE : la box promet le contraire de ce qu'elle fait
///
/// `JSONRPC.Introspect` décrit la méthode ainsi : *« Only given properties will be set,
/// others will be untouched. »* **C'est faux.** `ChargingInfo` est reconstruit
/// intégralement à chaque écriture (`unpack<ChargingInfo>`), et un champ absent du JSON
/// ne reste pas en place : il retombe sur le **défaut du membre C++**. Pour
/// `targetPercentage`, ce défaut vaut **80**.
///
/// Et la substitution est **muette** : réponse `EnergyErrorNoError`, aucun
/// avertissement (le seul du chemin d'écriture ne se déclenche qu'au-delà de 100), et
/// la notification `ChargingInfoChanged` qui suit porte 80 comme si l'utilisateur
/// l'avait choisi.
///
/// Reproduit sur `.75` le 2026-08-27, plugin `+etm22` : `targetPercentage` valait `0`,
/// un `SetChargingInfo` portant le seul `chargingMode` l'a laissé à **80**.
///
/// D'où **lire, patcher, réécrire l'objet complet** — le même chemin que `priority`
/// dans `LoadConfig`. Le seul champ qu'on ne renvoie PAS est `chargingState`, marqué
/// `r:` au schéma : le réémettre ferait rejeter l'appel entier.
///
/// Conséquence pour l'écran : `targetPercentage` **n'est une cible choisie par
/// l'utilisateur que s'il l'a lui-même écrite**. 80 peut être l'empreinte d'une
/// écriture qui ne parlait pas de cible du tout.
///
/// \return `null` si la box a accepté, sinon de quoi l'annoncer. **Ne rien inventer**
/// sur la cause : le détail reste au journal de la box.
Future<String?> setChargingInfo({
@ -1089,17 +1152,33 @@ class NymeaService extends ChangeNotifier {
error = 'NoEvCharger';
} else {
try {
// LIRE d'abord — une écriture partielle n'existe pas ici, tout champ omis
// retombe sur son défaut C++. Une lecture qui échoue laisse `courant` vide :
// on écrit alors le strict nécessaire, ce qui n'est pas pire que l'ancien
// comportement et reste honnête sur ce qu'on sait.
final courant = await getChargingInfo(evChargerId) ?? const {};
final info = <String, dynamic>{
...courant,
'evChargerId': evChargerId,
'chargingMode': apiMode,
};
// Rien qui ne soit au schéma ne part : `minCurrent` n'existe pas, et une clé
// inconnue ferait rejeter TOUT l'appel (nymea valide avant le handler).
// `chargingState` est en lecture seule (`r:`) au schéma : le renvoyer ferait
// rejeter TOUT l'appel. Même règle pour toute clé hors schéma — nymea valide
// avant d'atteindre le handler.
info.remove('chargingState');
if (deadline && mode != ChargingMode.boost) {
info['targetPercentage'] = targetSoc;
if (endTime != null) {
info['endDateTime'] = endTime.millisecondsSinceEpoch ~/ 1000;
}
} else if (!info.containsKey('targetPercentage')) {
// Sans échéance, l'app ne CHOISIT pas de cible — mais l'omettre en
// choisirait une (80) à la place de l'utilisateur, sans rien lui dire. On
// repose donc explicitement la valeur que la box portait déjà ; si elle n'en
// portait aucune, `0` est le neutre, pas `80`.
info['targetPercentage'] = 0;
}
final r = await _sendRequest(
'NymeaEnergy.SetChargingInfo', {'chargingInfo': info});
@ -1141,10 +1220,12 @@ class NymeaService extends ChangeNotifier {
///
/// ## Pourquoi il ne peut pas rester invisible
///
/// Le défaut d'usine est à **0,9**. Sur une installation dont la batterie plafonne à
/// 50 %, cela gèle tout — et le symptôme, chez un client, est un HEMS qui ne pilote plus
/// rien par grand soleil, **sans erreur ni panne**. Un seuil qui peut produire ça n'est
/// pas un paramètre d'usine : c'est un réglage d'installation, et il doit se voir.
/// Le défaut d'usine **était** à 0,9 ; il est passé à **0,2**. Sur une installation dont
/// la batterie plafonne à 50 %, 0,9 gèle tout — et le symptôme, chez un client, est un
/// HEMS qui ne pilote plus rien par grand soleil, **sans erreur ni panne**. Le nouveau
/// défaut ne rattrape pas les box déjà déployées, qui gardent la valeur persistée, et le
/// seuil reste réglable jusqu'à 1,0. Un seuil qui peut produire ça n'est pas un
/// paramètre d'usine : c'est un réglage d'installation, et il doit se voir.
///
/// \return la fraction lue, ou `null` si la box ne l'expose pas.
Future<double?> getBatteryLevelConsideration() async {

View File

@ -13,10 +13,16 @@ import '../theme/etm_tokens.dart';
/// seuil, le budget de surplus est **annulé** — pas réduit — donc ni ECS, ni PAC, ni
/// bornes.
///
/// Le défaut d'usine est à **0,9**. Sur une installation dont la batterie plafonne à 50 %,
/// cela gèle tout. Et le symptôme, chez un client, est le pire qui soit : un HEMS qui ne
/// pilote plus rien **par grand soleil, sans erreur ni panne**. Rien à lire dans un
/// journal, rien à diagnostiquer — juste une installation muette.
/// Le défaut d'usine **était** à 0,9. Sur une installation dont la batterie plafonne à
/// 50 %, cela gèle tout. Et le symptôme, chez un client, est le pire qui soit : un HEMS
/// qui ne pilote plus rien **par grand soleil, sans erreur ni panne**. Rien à lire dans
/// un journal, rien à diagnostiquer — juste une installation muette.
///
/// Il est passé à **0,2** côté plugin (`smartchargingmanager.cpp`, défaut de lecture des
/// réglages). **Cela ne rend pas cette carte inutile, pour deux raisons :** le nouveau
/// défaut ne s'applique qu'à une installation qui n'a jamais écrit le réglage — une box
/// déjà déployée garde le 0,9 qu'elle a persisté — et le seuil reste réglable jusqu'à 1,0,
/// donc reproductible à la main.
///
/// Un seuil qui peut produire ça n'est pas un paramètre d'usine. C'est un réglage
/// d'installation, et il doit se voir avant de mordre, pas après.

View File

@ -287,10 +287,20 @@ class _EVChargingCardState extends State<EVChargingCard> {
],
],
const SizedBox(height: 14),
// SOC progress
_SocProgress(targetSoc: _targetSoc, currentSoc: 62),
// Cible de charge — affichée SEULEMENT quand l'écran l'a lui-même écrite.
//
// `targetPercentage` n'est pas un réglage que l'app peut lire et présenter
// comme un choix de l'utilisateur : `SetChargingInfo` reconstruit l'objet
// entier, et le défaut C++ du champ vaut **80**. Une écriture qui ne parlait
// que du mode de charge laisse donc 80 derrière elle, sans que rien ne le
// signale — reproduit sur `.75` le 2026-08-27. Montrer « cible 80 % » hors
// échéance afficherait cette empreinte comme une intention.
//
// Hors échéance, l'app n'a pas de cible : elle n'en montre pas.
if (_deadlineEnabled) ...[
const SizedBox(height: 14),
_SocProgress(targetSoc: _targetSoc, currentSoc: 62),
],
],
),
);