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
190 lines
7.4 KiB
Dart
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),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|