`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
184 lines
7.0 KiB
Dart
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),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|