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