diff --git a/lib/services/nymea_service.dart b/lib/services/nymea_service.dart index 50429b5..c20b95c 100644 --- a/lib/services/nymea_service.dart +++ b/lib/services/nymea_service.dart @@ -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> 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?> 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.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`), 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 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 = { + ...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 getBatteryLevelConsideration() async { diff --git a/lib/widgets/battery_reserve_card.dart b/lib/widgets/battery_reserve_card.dart index eb2bd0e..61b9136 100644 --- a/lib/widgets/battery_reserve_card.dart +++ b/lib/widgets/battery_reserve_card.dart @@ -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. diff --git a/lib/widgets/ev_charging_card.dart b/lib/widgets/ev_charging_card.dart index ef0b324..5f324e3 100644 --- a/lib/widgets/ev_charging_card.dart +++ b/lib/widgets/ev_charging_card.dart @@ -287,10 +287,20 @@ class _EVChargingCardState extends State { ], ], - 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), + ], ], ), );