etm-powersync-app/lib/widgets/ev_favorite_tile.dart
Patrick Schurig e0264b5ed6 feat(ev): une borne à pleine puissance n'est pas arbitrée — les deux écrans le nomment
Geste de mise en service, signalé par le moteur : toute borne détectée reçoit d'office
ChargingModeNormal, c'est-à-dire pleine puissance HORS arbitrage. Une borne neuve n'est
donc jamais pilotée tant que personne n'a posé un mode éco, et rien ne le disait —
l'installateur pose une borne, elle charge à fond, et il cherche du côté du HEMS un défaut
qui n'existe pas.

La note apparaît aux deux endroits qui comptent, mot pour mot (kEvUnmanagedNote) :
  · la carte de configuration, celle qu'on ouvre à la mise en service ;
  · la tuile des favoris, où les trois boutons de mode sont juste à côté.

⚠️ Elle nomme l'ÉTAT, pas une négligence. La box publie le même chargingMode pour un boost
délibéré et pour une borne jamais réglée : l'app ne peut pas les distinguer, donc elle dit
« pleine puissance, hors arbitrage » et rappelle que c'est aussi le mode d'office. Même
règle que la note de rangs ex æquo — reproductible n'est pas choisi, et on ne fait pas dire
à un écran ce que la donnée ne porte pas. Un mode NON LU ne déclenche rien non plus : on ne
conclut pas d'une absence.

Le piège qui aurait rendu la note invisible est couvert par un test : GetLoadConfig rend
l'id de charge AVEC accolades ({4d1adafd-…}), GetChargingInfos rend l'evChargerId SANS.
Comparer les deux tels quels ne rend jamais rien — en silence : l'écran aurait conclu
« mode non lu » sur une borne parfaitement lue. chargingModeOf() réconcilie les deux.

Six tests montés (trois par écran), dont les deux contrôles négatifs.

Et la réserve sessionEnergy est documentée à sa juste place : le champ est CONSTRUIT par
le plugin V2C 1.15.2+etm13 — il accumule les incréments de chargeEnergy, détecte les
remises à zéro, ne repart qu'à l'ouverture d'une session — donc l'égalité observée hier
s'explique d'elle-même. La réserve tient jusqu'à la trace que la campagne produira
(chargeEnergy à zéro, sessionEnergy qui continue) : aucun avancement affiché d'ici là.

Noté aussi, pour la suite : sessionEnergy n'est PAS historisé (suggestLogging écrasé par
things.conf sur un Thing déjà appairé ; la V2C n'y a que connected, charging, power). La
valeur courante est juste, il n'y aura pas de courbe — le choix des états enregistrés est
côté app, entrée au TODO.

164 tests, flutter analyze inchangé à 27 remarques.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
2026-08-28 13:08:01 +02:00

260 lines
9.9 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.
///
/// ## L'écriture restitue le refus, ou n'existe pas
///
/// Les trois modes s'écrivaient sur des pastilles de 7 à 10 px qui **avalaient les
/// refus** : `setChargingInfo` rend un code d'erreur, personne ne le lisait. Une commande
/// muette est pire qu'une lecture seule — l'utilisateur croit avoir réglé la borne. Les
/// boutons font désormais 44 px, montrent l'attente, et affichent le refus tel que la box
/// le nomme.
class EvFavoriteTile extends StatefulWidget {
/// `null` = aucune borne déclarée sur l'installation.
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;
const EvFavoriteTile({super.key, required this.live, required this.onSetMode});
@override
State<EvFavoriteTile> createState() => _EvFavoriteTileState();
}
class _EvFavoriteTileState extends State<EvFavoriteTile> {
/// 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;
Future<void> _set(ChargingMode m) async {
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 live = widget.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.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.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))),
),
]),
],
],
),
),
const SizedBox(width: 8),
Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final m in ChargingMode.values) _bouton(live, m),
],
),
],
);
}
/// Branché ? En charge ? — et « non publié » quand la borne ne le dit pas.
String _etat(EvChargerLive live) {
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.boost => 'Boost',
};
return live.hasDeadlineMode ? 'Mode $base + échéance' : 'Mode $base';
}
/// 44 px de cible tactile — les pastilles précédentes faisaient 7 à 10 px, et le premier
/// passage sur appareil avait déjà trouvé deux cibles hors norme sur d'autres écrans.
Widget _bouton(EvChargerLive live, ChargingMode m) {
final (label, couleur) = switch (m) {
ChargingMode.pv => ('PV', EtmTokens.amber),
ChargingMode.minPv => ('Min+PV', EtmTokens.blue),
ChargingMode.boost => ('Boost', EtmTokens.green),
};
// Sélection = ce que la BOX porte. Un mode non lu ne sélectionne rien : mieux vaut
// trois boutons éteints qu'un bouton allumé au hasard.
final selected = live.uiMode == m;
final busy = _pending != null;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: SizedBox(
height: 44,
width: 76,
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: 11,
weight: selected ? FontWeight.w700 : FontWeight.w400,
color: selected ? couleur : EtmTokens.mutedOf(context))),
),
),
);
}
}
/// 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))),
],
),
),
],
);
}
}