fix(ev): « Boost » devient « Manuel » — le nom promettait le contraire du comportement

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
This commit is contained in:
Patrick Schurig 2026-09-08 12:19:39 +02:00
parent 4d3f35d6d0
commit 5a51582729
10 changed files with 221 additions and 22 deletions

View File

@ -76,17 +76,68 @@ nymeaService.call('NymeaEnergy.SetChargingInfo', {
### Modes UI EV — 3 boutons + option échéance
```
[PV] [Min+PV] [Boost]
↓
[Manuel] [PV] [Min+PV]
↓
Option échéance ? → charge cible (targetPercentage) + heure (endDateTime)
Manuel → ChargingModeNormal
PV → ChargingModeEco
Min+PV → ChargingModeEcoWithMinCurrent
Boost → ChargingModeNormal
PV + échéance → ChargingModeEcoWithTargetTime + targetPercentage + endDateTime
Min+PV + échéance → ChargingModeEcoMinWithTargetTime + targetPercentage + endDateTime
```
> 🔴 **Le bouton s'appelait « Boost », et le nom était faux — il ARRÊTAIT la charge.**
> `ChargingModeNormal` sort la borne de l'arbitrage et lui restitue ses valeurs manuelles
> (`manualChargingEnabled`, `manualMaxChargingCurrent`, `manualDesiredPhaseCount`). Sur
> `.75` elles valaient `false / 6 A / 3 phases` : mesuré le 2026-09-08 à 11:09:51, l'appui
> a stoppé la charge et armé un verrou de 300 s. **Ce n'était pas une régression** — c'est
> ce que `Normal` a toujours fait ; c'est le nom qui promettait le contraire.
>
> Renommé **jusque dans le code** (`ChargingMode.manuel`), pas seulement sur l'étiquette :
> un identifiant faux reste un piège pour le prochain lecteur.
>
> ⚠️ **Et passer en Manuel se confirme.** « 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 ». La confirmation le dit avant, plutôt que de le
> laisser découvrir après.
### Le BOOST n'est pas un mode — c'est une DÉROGATION (`+etm61`)
```
NymeaEnergy.SetLoadDerogation { loadId, durationS, o:targetWh }
NymeaEnergy.ClearLoadDerogation { loadId }
```
Vérifiées présentes dans `Introspect` sur `.75` en `1.15.2+etm61`. Elle se **superpose** au
mode courant, sert la charge à son maximum **en tête de passe éco** — devant toute autre
charge, quel que soit son rang — financée au réseau, et **s'efface d'elle-même**.
- **`durationS` est obligatoire**, bornée `[60, 21600]`, refusée hors de là (LM-1500). Donc
**pas de « boost jusqu'à annulation »** : l'écran doit faire saisir une durée, le moteur
refuserait le reste. `targetWh` est optionnel, en **énergie**, jamais en SoC — pas de
communication véhicule (cf. LM-1009).
- 🔴 **Ne pas lire le MODE pour savoir si un boost tourne.** Si la borne était en manuel, le
moteur force Eco le temps de la dérogation, et l'écran verrait un mode que l'utilisateur
n'a pas choisi. La vérité est sur la charge : `decision.code == "DEROGATION_ACTIVE"`, avec
`endsAt` (ISO 8601 UTC) pour le compte à rebours, `maxW` / `gridW`, et `boundBy ∈ drawCap`
(l'abonnement bride) `| loadCeiling` (la charge est à son plafond de cycle).
- **La fin arrive au niveau de la TRAME, pas dans `loads[]`** — au débranchement la charge
n'y est déjà plus. Champ `derogationEnded[]`, présent le seul cycle de la fin :
```jsonc
"derogationEnded": [ { "loadId": "…", "cause": "durationElapsed", "restoredMode": 0 } ]
```
`cause ∈ targetReached | unplugged | measurementLost | durationElapsed | modeChanged |
cancelled` — **six causes, six messages** : cible atteinte (rien à faire) · mesure perdue
(regarder le lien ou le compteur) · débranchement · durée écoulée · mode changé (quelqu'un a
repris le volant) · annulée à la main.
Depuis `+etm61`, appuyer sur un bouton de mode pendant une dérogation la **termine
proprement** (`cause: "modeChanged"`, `restoredMode` absent — on ne restitue pas, ce serait
écraser le mode que l'utilisateur vient de choisir).
> ⚠️ **`minCurrent` n'existe nulle part dans `ChargingInfo`** — et c'est normal : ce n'est
> pas un réglage. **Tranché le 2026-09-07.** Le plancher est une limite MATÉRIELLE, et elle
> est déjà sur la box, dans la classe du Thing borne :

View File

@ -139,7 +139,27 @@ class EnergyData {
isPlausibleKwh(gridImportKwh) ? gridImportKwh : null;
}
enum ChargingMode { pv, minPv, boost }
/// Les trois modes de recharge offerts par l'écran.
///
/// 🔴 **`manuel` s'appelait `boost`, et le nom était FAUX.** Il pose
/// `ChargingModeNormal`, qui **sort la borne de l'arbitrage** et restitue ses valeurs
/// manuelles — `manualChargingEnabled`, `manualMaxChargingCurrent`,
/// `manualDesiredPhaseCount`. Sur `.75` ces valeurs valaient `false / 6 A / 3 phases` :
/// mesuré le 2026-09-08 à 11:09:51, le bouton « Boost » a donc **arrêté la charge** et armé
/// un verrou de 300 s.
///
/// Ce n'était pas une régression : c'est ce que `Normal` a toujours fait. Le nom promettait
/// le contraire du comportement, et c'est le nom qui a menti pendant des mois — d'où le
/// renommage jusque dans le code, et pas seulement sur l'étiquette.
///
/// Le vrai boost existe depuis `+etm61`, et **ce n'est pas un mode** : c'est une
/// dérogation (`NymeaEnergy.SetLoadDerogation`) qui se superpose au mode courant, sert la
/// charge à son maximum en tête de passe éco, et s'efface d'elle-même. Elle aura son propre
/// bouton d'action, avec une durée obligatoire — pas une entrée de ce sélecteur.
///
/// L'ordre compte : `manuel` en tête, parce que `ChargingModeNormal` est aussi le mode
/// **d'office** d'une borne détectée (cf. `EvChargerLive.isUnmanaged`).
enum ChargingMode { manuel, pv, minPv }
class HistoryPoint {
final DateTime time;

View File

@ -133,7 +133,7 @@ class EvChargerLive {
/// une valeur inconnue — une borne peut porter un mode qu'aucune version d'app ne
/// connaît, et deviner serait pire que dire « mode non reconnu ».
ChargingMode? get uiMode => switch (apiMode) {
'ChargingModeNormal' => ChargingMode.boost,
'ChargingModeNormal' => ChargingMode.manuel,
'ChargingModeEco' || 'ChargingModeEcoWithTargetTime' => ChargingMode.pv,
'ChargingModeEcoWithMinCurrent' ||
'ChargingModeEcoMinWithTargetTime' =>

View File

@ -336,7 +336,7 @@ class _Mecanisme extends StatelessWidget {
'branchée, et changent avec elle — les figer ici en ferait une seconde source '
'de vérité, fausse dès le prochain véhicule.\n\n'
'Ce qui se règle depuis cet écran : le libellé, le domaine, le rang. Le mode de '
'recharge (PV, Min+PV, Boost) se règle depuis la tuile « Borne EV » des '
'recharge (Manuel, PV, Min+PV) se règle depuis la tuile « Borne EV » des '
'favoris — c\'est le seul endroit de l\'app qui écrit SetChargingInfo.',
style: TextStyle(
fontSize: 12, height: 1.4, color: EtmTokens.mutedOf(context)),
@ -538,7 +538,7 @@ class _SectionBorne extends StatelessWidget {
final base = switch (ui) {
ChargingMode.pv => 'PV',
ChargingMode.minPv => 'Min+PV',
ChargingMode.boost => 'Boost',
ChargingMode.manuel => 'Manuel',
};
return b.hasDeadlineMode ? 'lu : $base + échéance' : 'lu : $base';
}

View File

@ -1432,7 +1432,7 @@ class NymeaService extends ChangeNotifier {
}) async {
// (mode d'UI, échéance) → énumération publiée par la box.
final String apiMode;
if (mode == ChargingMode.boost) {
if (mode == ChargingMode.manuel) {
apiMode = 'ChargingModeNormal';
} else if (mode == ChargingMode.pv && !deadline) {
apiMode = 'ChargingModeEco';
@ -1468,7 +1468,7 @@ class NymeaService extends ChangeNotifier {
// avant d'atteindre le handler.
info.remove('chargingState');
if (deadline && mode != ChargingMode.boost) {
if (deadline && mode != ChargingMode.manuel) {
info['targetPercentage'] = targetSoc;
if (endTime != null) {
info['endDateTime'] = endTime.millisecondsSinceEpoch ~/ 1000;

View File

@ -153,7 +153,7 @@ class EvFavoriteTile extends StatelessWidget {
final base = switch (ui) {
ChargingMode.pv => 'PV',
ChargingMode.minPv => 'Min+PV',
ChargingMode.boost => 'Boost',
ChargingMode.manuel => 'Manuel',
};
return live.hasDeadlineMode ? 'Mode $base + échéance' : 'Mode $base';
}

View File

@ -51,7 +51,47 @@ class _EvModeButtonsState extends State<EvModeButtons> {
/// 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;
@ -77,9 +117,9 @@ class _EvModeButtonsState extends State<EvModeButtons> {
/// 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),
ChargingMode.boost => ('Boost', EtmTokens.green),
};
final selected = widget.live.uiMode == m;
final busy = _pending != null;

View File

@ -71,7 +71,7 @@ void main() {
// Un lien coupé n'invalide pas un réglage stocké dans la box : le cacher
// priverait le client de la seule chose encore vraie à l'écran.
await tester.pumpWidget(_harness(live: horsLigne()));
expect(find.textContaining('Mode Boost'), findsOneWidget);
expect(find.textContaining('Mode Manuel'), findsOneWidget);
});
testWidgets('`joignable == null` n\'est PAS hors ligne', (tester) async {
@ -183,7 +183,7 @@ void main() {
(tester) async {
// Deux écrans qui écrivent SetChargingInfo sont deux comportements qui divergent.
await tester.pumpWidget(_harness(live: _v2c()));
for (final label in ['PV', 'Min+PV', 'Boost']) {
for (final label in ['Manuel', 'PV', 'Min+PV']) {
expect(find.widgetWithText(OutlinedButton, label), findsNothing,
reason: '$label ne doit plus être écrivable depuis les favoris');
}

View File

@ -69,19 +69,19 @@ void main() {
});
testWidgets('la sélection suit la BOX, pas la dernière commande', (tester) async {
// `_v2c()` est en ChargingModeNormal : c'est Boost qui doit être allumé, et lui seul,
// `_v2c()` est en ChargingModeNormal : c'est Manuel qui doit être allumé, et lui seul,
// quoi qu'on ait tapé juste avant.
await tester.pumpWidget(_harness(live: _v2c()));
await tester.tap(find.text('PV'));
await tester.pumpAndSettle();
final boost = tester.widget<OutlinedButton>(
find.widgetWithText(OutlinedButton, 'Boost'));
final manuel = tester.widget<OutlinedButton>(
find.widgetWithText(OutlinedButton, 'Manuel'));
final pv =
tester.widget<OutlinedButton>(find.widgetWithText(OutlinedButton, 'PV'));
expect(boost.style?.backgroundColor?.resolve({}),
expect(manuel.style?.backgroundColor?.resolve({}),
isNot(Colors.transparent),
reason: 'Boost est le mode que la box porte');
reason: 'Manuel est le mode que la box porte');
expect(pv.style?.backgroundColor?.resolve({}), Colors.transparent,
reason: 'PV vient d\'être demandé, mais la box ne l\'a pas encore dit');
});
@ -89,7 +89,7 @@ void main() {
testWidgets('un mode non lu n\'allume RIEN', (tester) async {
// Trois boutons éteints valent mieux qu'un allumé au hasard.
await tester.pumpWidget(_harness(live: _v2c(apiMode: null)));
for (final label in ['PV', 'Min+PV', 'Boost']) {
for (final label in ['Manuel', 'PV', 'Min+PV']) {
final b = tester
.widget<OutlinedButton>(find.widgetWithText(OutlinedButton, label));
expect(b.style?.backgroundColor?.resolve({}), Colors.transparent);
@ -98,7 +98,7 @@ void main() {
testWidgets('les cibles tactiles font au moins 44 px', (tester) async {
await tester.pumpWidget(_harness(live: _v2c()));
for (final label in ['PV', 'Min+PV', 'Boost']) {
for (final label in ['Manuel', 'PV', 'Min+PV']) {
final taille = tester.getSize(find.ancestor(
of: find.text(label),
matching: find.byType(OutlinedButton),
@ -107,4 +107,92 @@ void main() {
reason: '$label : cible tactile trop petite');
}
});
group('« Manuel » se confirme — il peut ARRÊTER la charge', () {
// Le bouton s'appelait « Boost ». `ChargingModeNormal` sort la borne de l'arbitrage et
// lui restitue ses valeurs manuelles stockées : sur `.75` elles valaient
// `manualChargingEnabled: false`, et l'appui a stoppé la charge le 2026-09-08 à
// 11:09:51, avec un verrou de 300 s derrière.
//
// Renommer 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, le mode veut dire « ce qui
// traînait la dernière fois ».
testWidgets('l\'appui n\'écrit RIEN tant qu\'on n\'a pas confirmé', (tester) async {
var appels = 0;
await tester.pumpWidget(_harness(
live: _v2c(apiMode: 'ChargingModeEco'),
onSetMode: (_) async {
appels++;
return null;
},
));
await tester.tap(find.text('Manuel'));
await tester.pumpAndSettle();
expect(find.textContaining('Rendre la main à la borne'), findsOneWidget);
expect(appels, 0, reason: 'rien ne part avant que l\'utilisateur ait dit oui');
expect(find.textContaining('la charge peut s\'arrêter'), findsOneWidget,
reason: 'la conséquence se dit AVANT, pas après');
await tester.tap(find.text('Annuler'));
await tester.pumpAndSettle();
expect(appels, 0);
});
testWidgets('confirmé, il écrit une fois', (tester) async {
var appels = 0;
await tester.pumpWidget(_harness(
live: _v2c(apiMode: 'ChargingModeEco'),
onSetMode: (_) async {
appels++;
return null;
},
));
await tester.tap(find.text('Manuel'));
await tester.pumpAndSettle();
await tester.tap(find.text('Passer en manuel'));
await tester.pumpAndSettle();
expect(appels, 1);
});
testWidgets('déjà en manuel, aucune question — rien ne change', (tester) async {
// Le contrôle qui MORD : la confirmation ne doit pas se déclencher sur un ré-appui
// du mode déjà en place, sinon elle devient un péage qu'on apprend à cliquer sans
// lire — et une confirmation qu'on ne lit plus ne protège plus de rien.
var appels = 0;
await tester.pumpWidget(_harness(
live: _v2c(), // déjà ChargingModeNormal
onSetMode: (_) async {
appels++;
return null;
},
));
await tester.tap(find.text('Manuel'));
await tester.pumpAndSettle();
expect(find.textContaining('Rendre la main'), findsNothing);
expect(appels, 1);
});
testWidgets('les autres modes ne demandent rien', (tester) async {
var appels = 0;
await tester.pumpWidget(_harness(
live: _v2c(),
onSetMode: (_) async {
appels++;
return null;
},
));
await tester.tap(find.text('Min+PV'));
await tester.pumpAndSettle();
expect(find.textContaining('Rendre la main'), findsNothing);
expect(appels, 1);
});
});
}

View File

@ -203,9 +203,9 @@ void main() {
expect(find.widgetWithText(OutlinedButton, 'PV'), findsOneWidget);
expect(find.widgetWithText(OutlinedButton, 'Min+PV'), findsOneWidget);
expect(find.widgetWithText(OutlinedButton, 'Boost'), findsOneWidget);
expect(find.widgetWithText(OutlinedButton, 'Manuel'), findsOneWidget);
// Le mode LU est affiché à côté, et c'est celui de la box.
expect(find.textContaining('lu : Boost'), findsOneWidget);
expect(find.textContaining('lu : Manuel'), findsOneWidget);
// Et l'avertissement d'une borne neuve, là où on la met en service.
expect(find.textContaining('hors arbitrage'), findsOneWidget);
});