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
260 lines
9.9 KiB
Dart
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))),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|