diff --git a/integration_test/config_on_device_test.dart b/integration_test/config_on_device_test.dart index 57e5288..4e90a7b 100644 --- a/integration_test/config_on_device_test.dart +++ b/integration_test/config_on_device_test.dart @@ -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 diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 0acf00e..9efbc11 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -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.", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 763bb46..2728992 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -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: diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index c048308..087414f 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -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.'; diff --git a/lib/screens/energy/roles_devices_screen.dart b/lib/screens/energy/roles_devices_screen.dart index c153987..93f1a5d 100644 --- a/lib/screens/energy/roles_devices_screen.dart +++ b/lib/screens/energy/roles_devices_screen.dart @@ -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 { schemaLoaded: context.watch().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(), diff --git a/lib/services/nymea_service.dart b/lib/services/nymea_service.dart index d0e675a..50429b5 100644 --- a/lib/services/nymea_service.dart +++ b/lib/services/nymea_service.dart @@ -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 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 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 diff --git a/lib/services/telemetry_text.dart b/lib/services/telemetry_text.dart index 22fce84..0484f95 100644 --- a/lib/services/telemetry_text.dart +++ b/lib/services/telemetry_text.dart @@ -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 kCodesReserveBatterie = {}; + /// 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 diff --git a/lib/widgets/battery_reserve_card.dart b/lib/widgets/battery_reserve_card.dart new file mode 100644 index 0000000..eb2bd0e --- /dev/null +++ b/lib/widgets/battery_reserve_card.dart @@ -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 createState() => _BatteryReserveCardState(); +} + +class _BatteryReserveCardState extends State { + double? _valeur; + double? _brouillon; + bool _charge = true; + String? _erreur; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _lire()); + } + + Future _lire() async { + final v = await context.read().getBatteryLevelConsideration(); + if (!mounted) return; + setState(() { + _valeur = v; + _brouillon = v; + _charge = false; + }); + } + + Future _ecrire() async { + final v = _brouillon; + if (v == null) return; + final err = await context.read().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(); + 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), + ), + ], + ), + ); + } +} diff --git a/test/load_telemetry_test.dart b/test/load_telemetry_test.dart index bd7952b..5fc1598 100644 --- a/test/load_telemetry_test.dart +++ b/test/load_telemetry_test.dart @@ -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,