etm-powersync-app/lib/widgets/ev_favorite_tile.dart
Patrick Schurig 5a51582729 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
2026-09-08 12:19:39 +02:00

201 lines
8.0 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';
/// La tuile « Borne EV » des favoris — **rendue muette le 2026-08-28**.
///
/// ## Ce qu'elle affichait, et pourquoi c'était grave
///
/// Elle montrait « 3600 W », « 82 % solaire » et « Mode PV » : trois valeurs par défaut de
/// `EnergyData` que l'app **n'a jamais lues sur la box**. Le mode n'était relu nulle part —
/// il partait de `pv` et n'avançait qu'après une commande envoyée depuis l'app. Un client
/// ouvrant ce favori voyait donc une borne en train de charger à 3,6 kW alors que rien
/// n'était branché.
///
/// La tuile **reste** — la retirer laisserait un trou dans une liste que le client a
/// composée lui-même, sans explication. Elle dit maintenant ce que la box publie, et là où
/// il n'y a rien à lire, elle le dit.
///
/// ## Les trois grandeurs qui restent, et leur source
///
/// | Affiché | Source | Absent = |
/// |---|---|---|
/// | branché / en charge | états `pluggedIn`, `charging` du Thing | « état non publié » |
/// | puissance | état `currentPower` du Thing | « puissance non publiée » |
/// | mode | `NymeaEnergy.GetChargingInfos` + `ChargingInfoChanged` | « mode non lu » |
///
/// Pas de part solaire : la box ne dit pas d'où vient le courant d'une borne. Pas
/// d'avancement de charge : LM-1009 §C1 — `chargeEnergy` est remis à zéro à chaque
/// interruption, et l'égalité `sessionEnergy == chargeEnergy` relevée le 2026-08-28 ne
/// prouve pas encore que le second y survive.
///
/// ## Elle ne règle plus rien — et garde quand même l'avertissement
///
/// Les trois boutons de mode sont partis le 2026-08-28 vers l'écran « Configurer » de la
/// borne : deux endroits qui écrivent `SetChargingInfo` sont deux comportements qui
/// divergent, et la mise en service se fait là-bas. Cette tuile est donc **en lecture
/// seule**.
///
/// **Mais la note « hors arbitrage » reste ici.** C'est le client qui ouvre ses favoris,
/// pas l'installateur : si sa borne charge à pleine puissance hors du pilotage, il doit
/// le voir là où il regarde — même s'il doit aller ailleurs pour le corriger.
class EvFavoriteTile extends StatelessWidget {
/// `null` = aucune borne déclarée sur l'installation.
final EvChargerLive? live;
const EvFavoriteTile({super.key, required this.live});
@override
Widget build(BuildContext context) {
// Copie locale : un champ nullable ne se promeut pas après un test, et la tuile lit
// une douzaine de fois la même borne.
final live = this.live;
if (live == null) {
return _Muet(
icon: Icons.ev_station_outlined,
titre: 'Borne de recharge',
detail: 'Aucune borne déclarée sur cette installation.',
);
}
final couleur = live.horsLigne
? EtmTokens.amber
: (live.charging == true ? EtmTokens.green : EtmTokens.blue);
return Row(
children: [
Container(
width: 52,
height: 52,
decoration: BoxDecoration(
color: couleur.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(14),
),
child: Icon(Icons.ev_station_rounded, color: couleur, size: 28),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// La puissance MESURÉE, ou le fait qu'elle ne soit pas publiée. Jamais 0 W
// par défaut : « 0 W » et « pas de mesure » sont deux choses différentes.
if (live.horsLigne)
Text('Hors ligne',
style: EtmTokens.sans(
size: 20, weight: FontWeight.w700, color: couleur))
else if (live.currentPowerW != null)
Text('${live.currentPowerW!.round()} W',
style: EtmTokens.mono(
size: 26, weight: FontWeight.w700, color: couleur))
else
Text('Puissance non publiée',
style: EtmTokens.sans(
size: 13, color: EtmTokens.mutedOf(context))),
Text(live.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: EtmTokens.sans(size: 12, color: couleur)),
Text(_etat(live),
style: EtmTokens.sans(
size: 11, color: EtmTokens.mutedOf(context))),
Text(_mode(live),
style: EtmTokens.sans(
size: 11, color: EtmTokens.mutedOf(context))),
// Pleine puissance = hors arbitrage, et c'est le mode d'office d'une borne
// détectée. Les trois boutons sont juste à côté : c'est l'endroit où le
// dire coûte le moins à celui qui veut le corriger.
if (live.isUnmanaged) ...[
const SizedBox(height: 3),
Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
Icon(Icons.info_outline_rounded,
size: 13, color: EtmTokens.amber),
const SizedBox(width: 4),
Expanded(
child: Text(kEvUnmanagedNote,
style: EtmTokens.sans(
size: 10.5, color: EtmTokens.mutedOf(context))),
),
]),
],
],
),
),
],
);
}
/// Branché ? En charge ? — et « non publié » quand la borne ne le dit pas.
String _etat(EvChargerLive live) {
// Hors ligne : la box ne sait rien du branchement, et ce qu'elle publierait
// (« aucun véhicule branché », « 0 W ») est forgé. On dit la coupure, avec la phrase
// du plugin quand elle existe — elle porte l'heure et la piste.
if (live.horsLigne) {
return live.messageEtat ??
'Borne injoignable — la box ne sait plus si un véhicule est branché';
}
if (live.pluggedIn == null && live.charging == null) {
return 'Branchement non publié par cette borne';
}
if (live.charging == true) return 'En charge';
if (live.pluggedIn == true) return 'Véhicule branché, à l\'arrêt';
if (live.pluggedIn == false) return 'Aucun véhicule branché';
return 'À l\'arrêt';
}
/// Le mode **que la box porte**, jamais celui que l'app a demandé.
String _mode(EvChargerLive live) {
if (live.apiMode == null) return 'Mode non lu';
final ui = live.uiMode;
if (ui == null) return 'Mode non reconnu (${live.apiMode})';
final base = switch (ui) {
ChargingMode.pv => 'PV',
ChargingMode.minPv => 'Min+PV',
ChargingMode.manuel => 'Manuel',
};
return live.hasDeadlineMode ? 'Mode $base + échéance' : 'Mode $base';
}
}
/// Tuile sans donnée : elle nomme ce qu'elle attend, plutôt que d'afficher un zéro.
class _Muet extends StatelessWidget {
final IconData icon;
final String titre;
final String detail;
const _Muet({required this.icon, required this.titre, required this.detail});
@override
Widget build(BuildContext context) {
return Row(
children: [
Container(
width: 52,
height: 52,
decoration: BoxDecoration(
color: EtmTokens.mutedOf(context).withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(14),
),
child: Icon(icon, color: EtmTokens.mutedOf(context), size: 26),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(titre,
style: EtmTokens.sans(size: 14, weight: FontWeight.w600)),
const SizedBox(height: 2),
Text(detail,
style: EtmTokens.sans(
size: 11.5, color: EtmTokens.mutedOf(context))),
],
),
),
],
);
}
}