etm-powersync-app/lib/widgets/battery_reserve_card.dart
Patrick Schurig 801ba15094 feat(3g): la réserve batterie sort de l'usine — un seuil qui peut tout geler doit se voir
`batteryLevelConsideration` ne gouverne aujourd'hui que la recharge du véhicule. Une
fois la règle montée dans le budget, il 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 est 0,9. Le banc `.75` est exactement dans ce cas : seuil 0,9,
batterie à 50 %. Le symptôme chez un client serait le pire qui soit — un HEMS qui ne
pilote plus rien par grand soleil, sans erreur, sans panne, rien à lire dans un
journal. Un paramètre qui peut produire ça n'est pas un paramètre d'usine.

- `BatteryReserveCard` lit et écrit le réglage, et ALERTE quand le seuil dépasse le SOC
  réellement atteint — le cas silencieux. Elle n'invente aucune valeur par défaut : si
  la box ne l'expose pas, la carte se tait plutôt que d'afficher un curseur mort.
- La portée future est dite sur la carte : régler ce seuil « pour la voiture »
  couperait le reste demain.
- Clé ARB `decisionBatteryReserve` posée, et `kCodesReserveBatterie` volontairement
  VIDE côté `telemetry_text` : le crochet attend le code que publiera le moteur, on ne
  devine pas son nom.

Vérifié sur appareil contre `.75` : réglage lu (0.9 / SOC 50 %), carte affichée,
campagne verte. L'assertion des cartes de charge fait désormais DÉFILER avant de
compter — dans un `ListView` ce qui n'est pas à l'écran n'est pas construit, et
chercher sans défiler ne prouvait que la hauteur de l'écran.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
2026-08-26 12:01:30 +02:00

184 lines
7.0 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 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.
///
/// 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),
),
],
),
);
}
}