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
This commit is contained in:
Patrick Schurig 2026-08-26 12:01:30 +02:00
parent 449c6ac50a
commit 801ba15094
9 changed files with 358 additions and 5 deletions

View File

@ -144,11 +144,18 @@ void main() {
expect(ids, contains('pac-terrain'));
// Les deux charges s'affichent, avec un bouton de détail chacune.
await _attendre(
tester,
() => find.text('Configurer').evaluate().length >= 2,
quoi: 'les cartes de charge à l\'écran',
);
//
// Il faut DESCENDRE pour les atteindre : dans un `ListView`, ce qui n'est pas à
// l'écran n'est pas construit, et l'en-tête d'arbitrage puis la carte de réserve
// batterie occupent le haut. Chercher sans faire défiler ne prouverait que la hauteur
// de l'écran.
for (var pas = 0; pas < 20; pas++) {
if (find.text('Configurer').evaluate().length >= 2) break;
await tester.drag(find.byType(Scrollable).first, const Offset(0, -200));
await tester.pump(const Duration(milliseconds: 200));
}
expect(find.text('Configurer'), findsWidgets,
reason: 'les cartes de charge doivent être atteignables');
expect(find.text('chauffe-eau'), findsWidgets);
// La PAC vient du lot B-bis : avant lui, elle était arbitrée sans être
@ -197,6 +204,21 @@ void main() {
);
debugPrint('[appareil] libellé restauré : ${avant.label}');
// ── 5-zero. La réserve batterie est LUE et affichée ─────────────────────
//
// Un seuil qui peut geler toute l'installation ne doit pas rester un paramètre
// d'usine invisible : le symptôme, chez un client, est un HEMS muet par grand soleil,
// sans erreur ni panne.
final reserve = await svc.getBatteryLevelConsideration();
debugPrint('[appareil] réserve batterie : $reserve '
'(SOC ${svc.energyData.batterySOC.round()} %)');
expect(reserve, isNotNull, reason: '.75 expose le réglage');
await _attendre(
tester,
() => find.text('Réserve batterie').evaluate().isNotEmpty,
quoi: 'la carte de réserve batterie à l\'écran',
);
// ── 5-bis. LE GLISSEMENT, sur l'appareil, contre la vraie box ───────────
//
// Le test POSE lui-même sa condition au lieu de la subir. Le glissement n'a de sens

View File

@ -289,6 +289,18 @@
"min": { "type": "int", "format": "decimalPattern" }
}
},
"decisionBatteryReserve": "Budget annulé par la réserve batterie ({soc} % sous le seuil de {seuil} %) — il y a du surplus, mais une règle interdit de le distribuer.",
"@decisionBatteryReserve": {
"description": "À NE PAS confondre avec « pas de surplus » : le surplus existe, une règle l'annule. Le client voit une installation qui ne pilote rien par grand soleil, sans erreur ni panne — la phrase doit donc nommer la cause, pas décrire l'effet.",
"placeholders": {
"soc": { "type": "int" },
"seuil": { "type": "int" }
}
},
"decisionBatteryReserveShort": "Budget annulé par la réserve batterie — il y a du surplus, mais une règle interdit de le distribuer.",
"@decisionBatteryReserveShort": {
"description": "Variante sans paramètres, si le motif n'en porte pas."
},
"decisionDegradedL2": "Consigne de repli : le compteur est muet et le watchdog a suspendu la planification.",
"decisionSafeStateRelay": "État sûr avant retrait : tous les relais sont ouverts.",
"decisionSafeStateSetpoint": "État sûr avant retrait : consigne ramenée à 0 W.",

View File

@ -619,6 +619,18 @@ abstract class L10n {
/// **'Budget de {budget} W sous l\'état {state} ({min} W estimés) — maintien en état 2, le budget passe à la charge suivante.'**
String decisionBelowMinPowerState(int budget, int state, int min);
/// À NE PAS confondre avec « pas de surplus » : le surplus existe, une règle l'annule. Le client voit une installation qui ne pilote rien par grand soleil, sans erreur ni panne — la phrase doit donc nommer la cause, pas décrire l'effet.
///
/// In fr, this message translates to:
/// **'Budget annulé par la réserve batterie ({soc} % sous le seuil de {seuil} %) — il y a du surplus, mais une règle interdit de le distribuer.'**
String decisionBatteryReserve(int soc, int seuil);
/// Variante sans paramètres, si le motif n'en porte pas.
///
/// In fr, this message translates to:
/// **'Budget annulé par la réserve batterie — il y a du surplus, mais une règle interdit de le distribuer.'**
String get decisionBatteryReserveShort;
/// No description provided for @decisionDegradedL2.
///
/// In fr, this message translates to:

View File

@ -477,6 +477,15 @@ class L10nFr extends L10n {
return 'Budget de $budgetString W sous l\'état $state ($minString W estimés) — maintien en état 2, le budget passe à la charge suivante.';
}
@override
String decisionBatteryReserve(int soc, int seuil) {
return 'Budget annulé par la réserve batterie ($soc % sous le seuil de $seuil %) — il y a du surplus, mais une règle interdit de le distribuer.';
}
@override
String get decisionBatteryReserveShort =>
'Budget annulé par la réserve batterie — il y a du surplus, mais une règle interdit de le distribuer.';
@override
String get decisionDegradedL2 =>
'Consigne de repli : le compteur est muet et le watchdog a suspendu la planification.';

View File

@ -9,6 +9,7 @@ import '../../providers/load_telemetry_provider.dart';
import '../../providers/schema_provider.dart';
import '../../services/nymea_service.dart';
import '../../theme/etm_tokens.dart';
import '../../widgets/battery_reserve_card.dart';
import '../../widgets/controllable_loads_section.dart';
import '../../widgets/schema_gated.dart';
import 'load_mechanism_screen.dart';
@ -136,6 +137,9 @@ class _RolesDevicesScreenState extends State<RolesDevicesScreen> {
schemaLoaded: context.watch<SchemaProvider>().schema.loaded,
),
const SizedBox(height: 12),
// AVANT les charges : un seuil qui peut toutes les geler doit se lire avant
// elles, pas après.
const BatteryReserveCard(),
ControllableLoadsSection(onConfigure: _openMechanism),
const _Legend(),

View File

@ -1130,6 +1130,55 @@ class NymeaService extends ChangeNotifier {
// CONFIG ÉNERGIE — rôles & charges pilotées (écran Rôles & appareils)
// ═══════════════════════════════════════════════════════════════════════════
/// `NymeaEnergy.GetBatteryLevelConsideration` — la **réserve batterie**.
///
/// ## Un seuil dont la portée s'élargit
///
/// Fraction de 0 à 1 : sous ce niveau de charge, la batterie est considérée comme
/// réservée. Aujourd'hui la règle ne gouverne que la recharge du véhicule. Une fois
/// 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.
///
/// ## 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.
///
/// \return la fraction lue, ou `null` si la box ne l'expose pas.
Future<double?> getBatteryLevelConsideration() async {
if (_isSimulation || !_connected) return null;
try {
final r = await _sendRequest('NymeaEnergy.GetBatteryLevelConsideration', {});
final v = (r['params']?['batteryLevelConsideration'] as num?)?.toDouble();
_log('[Batterie] réserve → ${v ?? 'non exposée'}', force: true);
return v;
} catch (e) {
_log('[Batterie] GetBatteryLevelConsideration: $e', force: true);
return null;
}
}
/// `NymeaEnergy.SetBatteryLevelConsideration`.
///
/// \param fraction niveau de réserve, de 0 à 1.
/// \return `null` si la box a accepté, sinon le code d'erreur.
Future<String?> setBatteryLevelConsideration(double fraction) async {
if (_isSimulation || !_connected) return 'Disconnected';
try {
final r = await _sendRequest('NymeaEnergy.SetBatteryLevelConsideration',
{'batteryLevelConsideration': fraction});
final err = r['params']?['energyError'] as String?;
_log('[Batterie] réserve ← $fraction : ${err ?? r['status']}', force: true);
if (err == null || err == 'EnergyErrorNoError') return null;
return err;
} catch (e) {
_log('[Batterie] SetBatteryLevelConsideration: $e', force: true);
return e.toString();
}
}
/// `Energy.GetRootMeter` — le compteur principal **tel que la box le connaît**.
///
/// ## Ce qu'il répare

View File

@ -116,6 +116,27 @@ String decisionText(L10n t, LoadDecision d) {
// Deux variantes, selon que le mécanisme se commande en watts ou en états. La présence
// de `state` les départage — et si elle manque sur un mécanisme à états, le repli
// rendra le code et ses paramètres plutôt qu'une phrase fausse.
// ── Réserve batterie : préparé, pas encore branché ──────────────────────
//
// Le motif viendra quand la règle montera dans le budget. Son CODE n'est pas tranché
// côté plugin, donc le deviner reviendrait à écrire une branche morte qui ne
// s'activerait jamais — ou pire, à en activer une sur un code homonyme.
//
// Ce qui est prêt, c'est la PHRASE, et c'est la partie difficile : elle doit nommer la
// cause (« une règle interdit de distribuer ») et non décrire l'effet (« rien n'est
// piloté »), sans quoi elle se confond avec « pas de surplus ». Le jour où le code est
// publié, il n'y a qu'à l'ajouter à [kCodesReserveBatterie].
//
// En attendant, le repli fait exactement son travail : un code inconnu sort en clair
// avec ses paramètres, ce qui ne peut PAS se lire comme « aucun surplus ». C'est
// vérifié par un test plutôt qu'espéré.
case _ when kCodesReserveBatterie.contains(d.code):
final soc = _i(d, 'batteryLevel') ?? _i(d, 'soc');
final seuil = _i(d, 'threshold') ?? _i(d, 'batteryLevelConsideration');
return (soc == null || seuil == null)
? t.decisionBatteryReserveShort
: t.decisionBatteryReserve(soc, seuil);
case 'BELOW_MIN_POWER':
final budget = _w(d, 'budgetW');
final min = _w(d, 'minPowerW');
@ -130,6 +151,14 @@ String decisionText(L10n t, LoadDecision d) {
return t.decisionUnknown(d.code.isEmpty ? '—' : d.code, formatParams(p));
}
/// Codes que le plugin publiera pour « budget annulé par la réserve batterie ».
///
/// **Volontairement vide.** Le nom n'est pas arrêté côté plugin ; y mettre une supposition
/// créerait soit une branche morte, soit — pire — une branche qui s'activerait sur un
/// homonyme. Le jour où le code est publié, une seule ligne suffit ici, et la phrase est
/// déjà écrite et relue.
const Set<String> kCodesReserveBatterie = <String>{};
/// Rendu déterministe des paramètres bruts, pour le repli.
///
/// Trié par clé : deux affichages du même motif doivent être identiques, sinon l'écran

View File

@ -0,0 +1,183 @@
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),
),
],
),
);
}
}

View File

@ -247,6 +247,39 @@ void main() {
expect(phrase, contains('budgetW = 320'));
});
test('réserve batterie — le repli ne peut PAS se lire « aucun surplus »', () {
// Le motif viendra ; son code n'est pas tranché, donc rien ne l'attrape encore. Ce
// qui compte d'ici là est que le repli ne fabrique aucune confusion : le cas est
// « il y a du surplus, une règle l'interdit », et le lire comme « il n'y en a pas »
// enverrait chercher un défaut de production.
final repli = decisionText(
t,
const LoadDecision(
code: 'BATTERY_RESERVE_HOLD',
params: {'batteryLevel': 50, 'threshold': 90}),
);
expect(repli, contains('BATTERY_RESERVE_HOLD'));
expect(repli.toLowerCase(), isNot(contains('aucun surplus')));
expect(repli, contains('batteryLevel = 50'));
});
test('la phrase de la réserve nomme la CAUSE, pas l\'effet', () {
// Elle est écrite et relue d'avance : c'est la partie difficile, et la brancher le
// jour venu ne doit pas être l'occasion de la rédiger dans l'urgence.
final avec = t.decisionBatteryReserve(50, 90);
expect(avec, contains('réserve batterie'));
expect(avec, contains('surplus'));
expect(avec.toLowerCase(), isNot(startsWith('rien')),
reason: 'décrire l\'effet ferait chercher une panne');
expect(t.decisionBatteryReserveShort, contains('réserve batterie'));
});
test('le crochet du motif est VIDE tant que le code n\'est pas publié', () {
// Y mettre une supposition créerait une branche morte, ou pire une branche qui
// s'activerait sur un homonyme.
expect(kCodesReserveBatterie, isEmpty);
});
test('un code absent de l\'ARB s\'affiche en repli lisible, sans plantage', () {
final phrase = decisionText(
t,