etm-powersync-app/lib/widgets/ev_favorite_tile.dart
Patrick Schurig 2c9cca042e fix(borne): une borne injoignable ne montre ni le zéro forgé ni la valeur périmée
Mesuré sur `.75` le 2026-08-30 : quand le lien Modbus de la V2C tombe, la box ne
fige pas les états, elle publie des zéros — `pluggedIn` false, `charging` false,
`currentPower` 0 — alors que la Skoda était branchée et en charge à 4 356 W.
Avant ça, la dernière valeur connue reste publiée comme si elle était actuelle
(vérifié : aucun état ne bouge en 45 s pendant une charge supposée).

L'app refusait déjà d'inventer un zéro ; elle en relayait un. Sous
`connected == false`, `EvChargerLive` met `pluggedIn`, `charging` et
`currentPowerW` à `null` À L'ENTRÉE — un seul endroit, pas un test par écran — et
porte `joignable` + `messageEtat`. La tuile dit « Hors ligne » et reprend la
phrase du plugin, qui porte l'heure de la coupure et la piste WiFi.

Deux garde-fous dans les tests : `joignable == null` n'est PAS hors ligne (une
borne qui ne publie pas `connected` ne dit rien de son lien — supposer la panne
serait le symétrique du zéro forgé), et le MODE reste affiché hors ligne, parce
qu'il est stocké dans la box et non lu sur la borne.

Brief moteur : les zéros à la perte du lien, `chargingState` figé à `Idle`
pendant une charge réelle, et `ChargingModeNormal` qui n'est pas un boost — il
restaure les valeurs manuelles mémorisées (ici arrêt, 6 A) et sort la borne de
l'arbitrage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-08-30 13:43:31 +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.boost => 'Boost',
};
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))),
],
),
),
],
);
}
}