Deux manques trouvés en usage réel sur téléphone, contre .75. ── 1. Retirer une charge dont l'appareil n'existe plus ──────────────────────── Le plugin crée d'office une entrée par borne détectée mais ne la retire pas quand le Thing s'en va : la Terra AC supprimée de nymea tenait encore le rang 4 deux jours plus tard, et l'app savait le dire sans savoir y remédier. Le bouton n'apparaît que sur THING_MISSING. Sur une charge vivante le geste n'aurait pas de sens — le plugin recrée l'entrée au cycle suivant, et un bouton qui se défait tout seul ferait douter de la box. Le chemin est celui qui était demandé, et pour la raison qui était donnée : · LIRE (GetLoadConfig, frais — pas l'état en mémoire, la box a pu changer d'avis) · PATCHER (payloadWithoutLoad : les autres charges recopiées VERBATIM, champ par champ) · RÉÉCRIRE (SetLoadConfig, qui remplace l'ensemble) Jamais une reconstruction depuis le modèle typé : un champ qu'une box plus récente porterait et que l'app ne connaît pas serait perdu pour TOUTES les charges d'un coup, en silence. Les rangs ne sont pas renumérotés — le trou laissé (1,2,3,5) ne change l'ordre de personne, alors qu'une renumérotation réécrirait le rang de charges que personne n'a touchées. Rien à moitié écrit : un refus laisse la configuration locale intacte (aucune adoption avant confirmation) et la box refuse l'ensemble ou l'accepte en entier. Le geste est refusé tant qu'un brouillon est en cours — sinon la suppression emporterait des modifications non enregistrées. La confirmation annonce ce que l'utilisateur ne peut pas deviner : retirer une charge réécrit toutes les autres. ── 2. Le mode de recharge, un seul lieu d'écriture ─────────────────────────── Les trois boutons quittent la tuile des favoris pour l'écran « Configurer » de la borne : c'est l'écran de mise en service, celui où on découvre qu'une borne neuve est en ChargingModeNormal. Deux endroits qui écrivent SetChargingInfo seraient deux comportements qui divergent. La tuile des favoris garde la LECTURE et l'avertissement « hors arbitrage » : c'est le client qui ouvre ses favoris, et il doit voir que sa borne n'est pas pilotée même s'il doit aller ailleurs pour le corriger. ⚠️ Bug latent trouvé en déplaçant : setChargingInfo écrivait toujours sur la PREMIÈRE borne configurée (_findEvChargerId). Sur le banc qui en porte trois, régler la V2C depuis son propre écran aurait réglé la wallbox simulée. La borne est maintenant visée explicitement, et evChargerFor() réconcilie les deux graphies d'identifiant (§9 du contrat de données). 181 tests (+12), flutter analyze inchangé à 27 remarques. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
188 lines
7.4 KiB
Dart
188 lines
7.4 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.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))),
|
|
),
|
|
]),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// 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';
|
|
}
|
|
}
|
|
|
|
/// 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))),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|