etm-powersync-app/lib/widgets/battery_reserve_card.dart
Patrick Schurig a92e9b2f62 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
2026-08-27 07:39:12 +02:00

190 lines
7.4 KiB
Dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/nymea_service.dart';
import '../theme/etm_tokens.dart';
/// **Réserve batterie** — le seuil sous lequel plus rien n'est piloté.
///
/// ## Pourquoi ce réglage sort de l'usine
///
/// `batteryLevelConsideration` ne gouverne aujourd'hui que la recharge du véhicule. Une
/// fois la règle montée dans le budget, elle gouvernera **toute l'installation** : sous le
/// seuil, le budget de surplus est **annulé** — pas réduit — donc ni ECS, ni PAC, ni
/// bornes.
///
/// 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.
///
/// ## Ce que la carte ne fait pas
///
/// Elle n'invente pas de valeur par défaut et n'en propose pas : la box a la sienne, la
/// carte la lit. Si la box ne l'expose pas, la carte se tait — un réglage absent affiché
/// comme réglable serait pire que rien.
class BatteryReserveCard extends StatefulWidget {
const BatteryReserveCard({super.key});
@override
State<BatteryReserveCard> createState() => _BatteryReserveCardState();
}
class _BatteryReserveCardState extends State<BatteryReserveCard> {
double? _valeur;
double? _brouillon;
bool _charge = true;
String? _erreur;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _lire());
}
Future<void> _lire() async {
final v = await context.read<NymeaService>().getBatteryLevelConsideration();
if (!mounted) return;
setState(() {
_valeur = v;
_brouillon = v;
_charge = false;
});
}
Future<void> _ecrire() async {
final v = _brouillon;
if (v == null) return;
final err = await context.read<NymeaService>().setBatteryLevelConsideration(v);
if (!mounted) return;
setState(() {
_erreur = err;
if (err == null) _valeur = v;
});
}
/// Vrai si le seuil est **au-dessus** de ce que la batterie atteint réellement — le cas
/// qui gèle tout sans rien dire.
bool _seuilInatteignable(double soc) =>
_valeur != null && soc > 0 && _valeur! * 100 > soc;
@override
Widget build(BuildContext context) {
final svc = context.watch<NymeaService>();
if (_charge) return const SizedBox.shrink();
// Box qui n'expose pas le réglage : se taire plutôt que de montrer un curseur mort.
if (_valeur == null) return const SizedBox.shrink();
final muted = EtmTokens.mutedOf(context);
final soc = svc.energyData.batterySOC;
final alerte = _seuilInatteignable(soc);
final modifie = _brouillon != null && _brouillon != _valeur;
return Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: EtmTokens.surfaceOf(context),
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
border: Border.all(
color: alerte
? EtmTokens.orange.withValues(alpha: 0.6)
: EtmTokens.lineOf(context)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Icon(Icons.battery_saver_rounded,
size: 18, color: alerte ? EtmTokens.orange : muted),
const SizedBox(width: 8),
Expanded(
child: Text('Réserve batterie',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: EtmTokens.inkOf(context))),
),
Text('${((_brouillon ?? _valeur)! * 100).round()} %',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
fontFamily: 'monospace',
color: EtmTokens.inkOf(context))),
]),
const SizedBox(height: 2),
Text(
'Sous ce niveau de charge, le budget de surplus est ANNULÉ — pas réduit. '
'Aucune charge n\'est pilotée : ni eau chaude, ni pompe à chaleur, ni borne.',
style: TextStyle(fontSize: 11.5, height: 1.35, color: muted),
),
Slider(
value: (_brouillon ?? _valeur)!.clamp(0.0, 1.0),
divisions: 20,
label: '${((_brouillon ?? _valeur)! * 100).round()} %',
onChanged: (v) => setState(() => _brouillon = v),
),
if (alerte)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.warning_amber_rounded,
size: 16, color: EtmTokens.orange),
const SizedBox(width: 7),
Expanded(
child: Text(
'Le seuil est au-dessus de la charge actuelle '
'(${soc.round()} %) : rien ne sera piloté tant que la batterie ne '
'l\'aura pas dépassé — par grand soleil comme la nuit, et sans '
'qu\'aucune erreur ne le signale.',
style: TextStyle(
fontSize: 11.5,
height: 1.35,
color: EtmTokens.inkOf(context)),
),
),
],
),
),
if (_erreur != null)
Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Text('La box a refusé le réglage ($_erreur).',
style: const TextStyle(fontSize: 11.5, color: EtmTokens.danger)),
),
if (modifie)
Row(children: [
TextButton(
onPressed: () => setState(() => _brouillon = _valeur),
child: const Text('Annuler'),
),
const Spacer(),
FilledButton(onPressed: _ecrire, child: const Text('Enregistrer')),
]),
const SizedBox(height: 2),
Text(
// La portée VA changer : le dire évite qu'un installateur règle aujourd'hui un
// seuil « pour la voiture » qui coupera tout demain.
'Aujourd\'hui ce seuil ne gouverne que la recharge du véhicule. Il gouvernera '
'toute l\'installation quand l\'arbitrage prendra la recharge en charge — le '
'régler « pour la voiture » couperait alors le reste.',
style: TextStyle(fontSize: 10.5, height: 1.35, color: muted),
),
],
),
);
}
}