Décision de Patrick : trois modes suffisent — Manuel, PV, Min+PV. Le boost n'est pas un quatrième mode, c'est la dérogation (+etm61). LE FAIT. Le bouton écrivait `ChargingModeNormal`, qui sort la borne de l'arbitrage et lui restitue ses valeurs manuelles stockées — manualChargingEnabled, manualMaxChargingCurrent, manualDesiredPhaseCount. Sur .75 elles valaient false / 6 A / 3 phases : mesuré le 2026-09-08 à 11:09:51, l'appui a ARRÊTÉ la charge et armé un verrou de 300 s. Pas une régression — c'est ce que Normal a toujours fait. C'est le nom qui mentait. Renommé jusque dans le code (`ChargingMode.boost` -> `ChargingMode.manuel`), pas seulement sur l'étiquette : un identifiant faux reste un piège pour le prochain lecteur, et c'est précisément ce qui a duré des mois ici. L'ordre met Manuel en tête, parce que ChargingModeNormal est aussi le mode d'office d'une borne détectée. ET LE RENOMMAGE NE SUFFISAIT PAS. « Manuel » dit QUI commande, pas ce que la borne va faire : tant que l'app n'offre pas de régler ces valeurs stockées, le mode veut dire « ce qui traînait la dernière fois ». Passer en manuel demande donc confirmation, et la confirmation dit la conséquence — la charge peut s'arrêter — AVANT, pas après. ⚠️ La confirmation ne se déclenche pas sur un ré-appui du mode déjà en place : une confirmation qu'on apprend à cliquer sans lire ne protège plus de rien. Un test le fige. Vérifié sur .75 (1.15.2+etm61) : SetLoadDerogation et ClearLoadDerogation sont dans Introspect, avec derogationEnded / durationS / restoredMode. Le contrat de la dérogation est écrit dans CLAUDE.md — durée obligatoire bornée [60, 21600], état lu dans decision.code == "DEROGATION_ACTIVE" et jamais dans le mode, fin au niveau de la trame avec six causes. Aucun bouton de boost n'est écrit : c'est le lot suivant. Et une sonde pour le moteur, faite au passage : la V2C est bien restée en ChargingModeNormal et elle est ABSENTE de loads[] — hors arbitrage. Leur hypothèse est confirmée sans redémarrage. 339 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
160 lines
6.3 KiB
Dart
160 lines
6.3 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
import '../models/energy_data.dart' show ChargingMode;
|
|
import '../models/ev_charger_live.dart';
|
|
import '../theme/etm_tokens.dart';
|
|
|
|
/// Les trois modes de recharge — **le seul endroit de l'app qui écrit `SetChargingInfo`**.
|
|
///
|
|
/// ## Un seul lieu d'écriture, délibérément
|
|
///
|
|
/// Ces boutons ont vécu aux favoris, sur la tuile de la borne. Ils sont ici depuis le
|
|
/// 2026-08-28 : « Configurer » est l'écran de **mise en service**, celui où un installateur
|
|
/// découvre qu'une borne neuve est hors arbitrage — et deux écrans qui écrivent la même
|
|
/// chose sont deux comportements qui divergent. La tuile des favoris garde la LECTURE et
|
|
/// la note « hors arbitrage » : le client doit voir que sa borne n'est pas pilotée, même
|
|
/// là où il ne peut pas la régler.
|
|
///
|
|
/// ## Ce qui est affiché comme sélectionné
|
|
///
|
|
/// Le mode que la **box** porte, jamais celui que l'app vient de demander. Un mode non lu
|
|
/// n'allume rien : trois boutons éteints valent mieux qu'un allumé au hasard. Après une
|
|
/// écriture acceptée, `NymeaService` relit `GetChargingInfos` — la sélection ne bouge que
|
|
/// quand la box l'a dit.
|
|
///
|
|
/// ## Un refus se voit
|
|
///
|
|
/// `setChargingInfo` rend un code d'erreur ; il était avalé. Une commande muette est pire
|
|
/// qu'une lecture seule — l'utilisateur croit avoir réglé la borne.
|
|
class EvModeButtons extends StatefulWidget {
|
|
final EvChargerLive live;
|
|
|
|
/// Écrit le mode et rend `null` si la box a accepté, sinon le code de refus.
|
|
final Future<String?> Function(ChargingMode) onSetMode;
|
|
|
|
/// Disposition : en colonne sur une tuile étroite, en ligne sur un écran.
|
|
final Axis axis;
|
|
|
|
const EvModeButtons({
|
|
super.key,
|
|
required this.live,
|
|
required this.onSetMode,
|
|
this.axis = Axis.horizontal,
|
|
});
|
|
|
|
@override
|
|
State<EvModeButtons> createState() => _EvModeButtonsState();
|
|
}
|
|
|
|
class _EvModeButtonsState extends State<EvModeButtons> {
|
|
/// Mode en cours d'écriture — jamais affiché comme sélectionné : tant que la box n'a pas
|
|
/// répondu, l'app ne sait pas ce que porte la borne.
|
|
ChargingMode? _pending;
|
|
|
|
/// **Passer en Manuel se confirme.** Ce n'est pas de la prudence décorative.
|
|
///
|
|
/// `ChargingModeNormal` sort la borne de l'arbitrage et lui **restitue ses valeurs
|
|
/// manuelles stockées** — `manualChargingEnabled`, `manualMaxChargingCurrent`,
|
|
/// `manualDesiredPhaseCount`. Elles peuvent dire n'importe quoi, y compris « charge
|
|
/// désactivée » : sur `.75` elles valaient `false / 6 A / 3 phases`, et le bouton — qui
|
|
/// s'appelait alors « Boost » — a **arrêté la charge** le 2026-09-08 à 11:09:51, avec un
|
|
/// verrou de 300 s derrière.
|
|
///
|
|
/// Le renommage seul ne suffisait pas : « Manuel » dit bien QUI commande, mais rien de
|
|
/// ce que la borne va faire. Tant que l'app n'offre pas de régler ces valeurs, Manuel
|
|
/// veut dire « ce qui traînait la dernière fois », et l'utilisateur a le droit de le
|
|
/// savoir avant, pas de le découvrir après.
|
|
Future<bool> _confirmerManuel() async {
|
|
final ok = await showDialog<bool>(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
title: const Text('Rendre la main à la borne ?'),
|
|
content: const Text(
|
|
'En mode manuel, le HEMS ne pilote plus cette borne : elle reprend ses propres '
|
|
'réglages enregistrés (charge autorisée ou non, courant, nombre de phases).\n\n'
|
|
'Selon ce qu\'ils contiennent, la charge peut s\'arrêter.',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx, false),
|
|
child: const Text('Annuler')),
|
|
FilledButton(
|
|
onPressed: () => Navigator.pop(ctx, true),
|
|
child: const Text('Passer en manuel')),
|
|
],
|
|
),
|
|
);
|
|
return ok == true;
|
|
}
|
|
|
|
Future<void> _set(ChargingMode m) async {
|
|
if (m == ChargingMode.manuel && widget.live.uiMode != ChargingMode.manuel) {
|
|
if (!await _confirmerManuel()) return;
|
|
if (!mounted) return;
|
|
}
|
|
setState(() => _pending = m);
|
|
final err = await widget.onSetMode(m);
|
|
if (!mounted) return;
|
|
setState(() => _pending = null);
|
|
if (err == null) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
|
content: Text('La box a refusé le mode de recharge ($err). '
|
|
'Détail dans son journal.'),
|
|
));
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final boutons = [for (final m in ChargingMode.values) _bouton(m)];
|
|
return widget.axis == Axis.horizontal
|
|
? Row(children: [
|
|
for (final b in boutons) ...[Expanded(child: b), const SizedBox(width: 6)]
|
|
]..removeLast())
|
|
: Column(mainAxisSize: MainAxisSize.min, children: boutons);
|
|
}
|
|
|
|
/// 44 px de cible tactile — les pastilles d'origine faisaient 7 à 10 px, et écrivaient
|
|
/// pour de vrai.
|
|
Widget _bouton(ChargingMode m) {
|
|
final (label, couleur) = switch (m) {
|
|
ChargingMode.manuel => ('Manuel', EtmTokens.mutedOf(context)),
|
|
ChargingMode.pv => ('PV', EtmTokens.amber),
|
|
ChargingMode.minPv => ('Min+PV', EtmTokens.blue),
|
|
};
|
|
final selected = widget.live.uiMode == m;
|
|
final busy = _pending != null;
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
|
child: SizedBox(
|
|
height: 44,
|
|
width: widget.axis == Axis.vertical ? 76 : null,
|
|
child: OutlinedButton(
|
|
onPressed: busy ? null : () => _set(m),
|
|
style: OutlinedButton.styleFrom(
|
|
padding: EdgeInsets.zero,
|
|
backgroundColor:
|
|
selected ? couleur.withValues(alpha: 0.14) : Colors.transparent,
|
|
side: BorderSide(
|
|
color: selected
|
|
? couleur
|
|
: EtmTokens.lineOf(context).withValues(alpha: 0.6)),
|
|
shape:
|
|
RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
|
),
|
|
child: _pending == m
|
|
? const SizedBox(
|
|
width: 14,
|
|
height: 14,
|
|
child: CircularProgressIndicator(strokeWidth: 2))
|
|
: Text(label,
|
|
style: EtmTokens.sans(
|
|
size: 12,
|
|
weight: selected ? FontWeight.w700 : FontWeight.w400,
|
|
color: selected ? couleur : EtmTokens.mutedOf(context))),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|