etm-powersync-app/lib/widgets/load_cards.dart
Patrick Schurig a1455290c9 feat(3g): BATTERY_RESERVE branché, funding lu, et l'état d'une borne passe devant
Deux constats du brief moteur, qui touchent le même fichier de lecture.

1. kCodesReserveBatterie était vide, et les clés cherchées n'ont JAMAIS existé côté
   plugin : batteryLevel/soc et threshold/batteryLevelConsideration contre socPercent,
   reservePercent, withheldW. Le motif sortait donc en repli — c'est-à-dire en anglais
   brut — sur le seul cas que le client ne peut déduire d'aucune mesure : « il y a du
   surplus, une règle l'interdit ». Vérifié contre decisionreason.h.

   Les trois paramètres sont des entiers DÉJÀ en pourcentage : reservePercent vaut 40, pas
   0,4 — à ne pas confondre avec batteryLevelConsideration, qui est la fraction du réglage.
   Et withheldW mène la phrase, comme côté plugin : le moteur en fait la condition
   d'émission du motif, parce qu'un motif à 0 W dirait « une règle vous bloque » là où la
   vérité est « il n'y a pas de surplus ».

   Le motif n'a PAS pu être observé en vrai sur .75 : la bascule du seuil à 0,9 a produit
   surplusW à −3 551 W, donc rien à confisquer, donc garde du plugin active et motif tu —
   ce qui est le comportement juste. Il faut du surplus réel sous le seuil. Seuil remis
   à 0,4.

2. funding (« surplus » / « grid ») est lu, et avec lui l'identité que le plugin déclare :
   somme des allocatedW financés au surplus == budget.allocatedW. Sommer sans filtrer
   compte une borne servie au réseau et creuse un écart qu'on ne peut pas interpréter.
   Absent = surplus, pas zéro : c'est le seul financement qui existait avant ce champ.

3. Les bornes sont entrées dans loads[] (+etm22), et adjustEvChargers() re-décide après le
   waterfall : pour une borne, allocatedW et son motif sont la DÉCISION DE L'ARBITRE, pas
   l'état de la borne. L'écran affiche donc d'abord ce que le matériel fait
   (mechanism.chargingEnabled / currentA, qui viennent du Thing), ensuite ce que le moteur
   a voulu, étiqueté comme tel. L'écart mesuré/commandé se tait sur une borne — « sous la
   consigne » y supposerait une consigne. La réserve ne touche PAS l'ECS ni la PAC, où
   l'allocation publiée est bien la commande : elle est portée par allocationIsCommand, et
   tombe avec le lot plugin 3g-2.

EV_SURPLUS, EV_ECO_MIN et EV_IDLE ne sont plus émis. Les branches restent : les retirer
rendrait illisible un journal antérieur, pas l'app plus juste.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
2026-08-27 07:38:48 +02:00

559 lines
22 KiB
Dart

/// Briques d'affichage d'une charge pilotable, **partagées** par les écrans de
/// configuration.
///
/// Extraites de l'ancien écran « Ordre de service » au moment de sa fusion dans
/// « Rôles & appareils ». C'est la moitié de l'écran clair qu'il fallait sauver : le
/// chemin de données correct et l'affichage fidèle de la télémétrie. Les dupliquer
/// aurait laissé deux rendus du même contrat diverger.
///
/// Règle qui traverse tout ce fichier : **des codes, jamais des libellés en dur.**
/// `domain`, `adapter`, `decision.code`, `faultCode` sont des clés ARB ; un domaine
/// inconnu de cette version d'app (box plus récente) tombe sur un repli lisible qui
/// montre le code brut, jamais une phrase vide.
library;
import 'package:flutter/material.dart';
import '../l10n/app_localizations.dart';
import '../l10n/l10n_ext.dart';
import '../models/load_config_entry.dart';
import '../models/load_telemetry.dart';
import '../providers/load_config_provider.dart';
import '../providers/load_telemetry_provider.dart';
import '../services/load_priority.dart';
import '../services/telemetry_text.dart';
import '../theme/etm_tokens.dart';
/// Libellé d'un domaine. Un domaine inconnu de cette version d'app (box plus récente)
/// a un repli lisible : le code brut, jamais une phrase vide.
String domainLabel(L10n t, String domain) => switch (domain) {
'ev' => t.domainEv,
'ecs' => t.domainEcs,
'hvac' => t.domainHvac,
'heating' => t.domainHeating,
'battery' => t.domainBattery,
kUnclassifiedDomain => t.sectionUnclassified,
_ => t.domainUnknown(domain),
};
// ── Choix du domaine ─────────────────────────────────────────────────────────
/// Choix du domaine — énumération fermée côté plugin, donc liste fermée ici.
class DomainMenu extends StatelessWidget {
final LoadConfigEntry entry;
final LoadConfigProvider provider;
const DomainMenu({super.key, required this.entry, required this.provider});
@override
Widget build(BuildContext context) {
final t = context.l10n;
final current = entry.domain ?? kUnclassifiedDomain;
final classe = entry.domain == null;
// Un chip qui PORTE son libellé, pas une icône muette.
//
// Il n'affichait qu'une étiquette dessinée : le domaine d'une charge était donc
// illisible sur sa carte, alors que c'est lui qui décide de son groupe et de son
// rang. Et la couleur était figée en clair — le chip restait pâle en thème sombre.
return PopupMenuButton<String>(
tooltip: t.domainAssignAction,
onSelected: (d) => provider.assignDomain(entry.id, d),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: classe
? Colors.transparent
: EtmTokens.brand.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: classe
? EtmTokens.lineOf(context)
: EtmTokens.brand.withValues(alpha: 0.35),
),
),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.label_outline_rounded,
size: 14,
color: classe ? EtmTokens.mutedOf(context) : EtmTokens.brand),
const SizedBox(width: 5),
Text(
domainLabel(t, current),
style: TextStyle(
fontSize: 11.5,
fontWeight: classe ? FontWeight.w400 : FontWeight.w600,
color: classe ? EtmTokens.mutedOf(context) : EtmTokens.brand,
),
),
const SizedBox(width: 2),
Icon(Icons.arrow_drop_down_rounded,
size: 16,
color: classe ? EtmTokens.mutedOf(context) : EtmTokens.brand),
]),
),
itemBuilder: (c) => [
PopupMenuItem<String>(
enabled: false,
child: Text(t.domainAssignTitle(entry.label),
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700)),
),
const PopupMenuDivider(),
for (final d in kAssignableDomains)
CheckedPopupMenuItem<String>(
value: d,
checked: current == d,
child: Text(domainLabel(t, d)),
),
const PopupMenuDivider(),
PopupMenuItem<String>(
value: kUnclassifiedDomain,
child: Text(t.domainAssignNone),
),
],
);
}
}
// ── Bloc télémétrie d'une charge ─────────────────────────────────────────────
// ── Bloc télémétrie d'une charge ─────────────────────────────────────────────
class LoadLiveBlock extends StatelessWidget {
final LoadTelemetryEntry live;
final bool stale;
final LoadTelemetryProvider telemetry;
const LoadLiveBlock({
super.key,
required this.live,
required this.stale,
required this.telemetry,
});
@override
Widget build(BuildContext context) {
final t = context.l10n;
final mech = live.mechanism;
final lock = live.lock;
// Une BORNE ne se lit pas comme les autres charges tant que 3g-2 n'est pas livré :
// `adjustEvChargers()` re-décide après le waterfall et peut commander l'inverse de ce
// que l'arbitre a publié. L'allocation et son motif restent affichés — ils disent ce
// que le moteur a VOULU, ce qui est une information — mais l'état réel du matériel
// passe DEVANT, et rien ne présente le motif comme la cause de ce que fait la borne.
final borne = !live.allocationIsCommand;
// La ligne de mécanisme redirait le courant déjà donné par l'état réel d'une borne.
final mechText =
(mech == null || borne) ? null : mechanismText(t, mech);
// Les données anciennes restent affichées — les masquer priverait du seul état
// connu — mais grisées : elles décrivent un cycle, pas l'instant présent.
final tone = stale ? const Color(0xFF9CA3AF) : const Color(0xFF374151);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── L'ÉTAT RÉEL de la borne, AVANT la décision de l'arbitre ─────────
//
// `chargingEnabled` / `currentA` viennent du Thing ; l'allocation et son motif
// viennent de l'arbitre. Les deux peuvent se contredire aujourd'hui, et l'ordre
// d'affichage est ce qui empêche l'écran d'avoir l'air faux : on montre d'abord
// ce que le matériel fait, ensuite ce que le moteur a décidé.
if (borne) ...[
Row(children: [
Icon(Icons.ev_station_rounded, size: 14, color: tone),
const SizedBox(width: 4),
Flexible(
child: Text(_etatBorne(t, mech),
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12, fontWeight: FontWeight.w600, color: tone)),
),
]),
const SizedBox(height: 2),
],
Row(children: [
Icon(Icons.bolt_rounded,
size: 14,
color: stale ? const Color(0xFFB0B7BF) : const Color(0xFF2E7D32)),
const SizedBox(width: 4),
Text(
borne
? t.telemetryEvArbiterIntent(live.allocatedW.round())
: t.telemetryAllocated(live.allocatedW.round()),
style: TextStyle(
fontSize: 12, fontWeight: FontWeight.w600, color: tone)),
if (mechText != null) ...[
const SizedBox(width: 6),
Flexible(
child: Text('· $mechText',
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 12, color: tone)),
),
],
]),
// La MESURE, à côté de l'allocation — jamais à sa place.
//
// L'une est ce que le waterfall a décidé, l'autre ce qui passe réellement.
// Commandé 3 000 W, mesuré 0 W : la charge ne fait pas ce qu'on lui demande, et
// c'est la seule chose que la mesure autorise à conclure. Une charge sans compteur
// n'affiche RIEN — un zéro ferait passer l'absence de mesure pour une absence de
// consommation.
if (live.measuredW != null) ...[
const SizedBox(height: 2),
Row(children: [
Icon(Icons.speed_rounded, size: 13, color: tone),
const SizedBox(width: 4),
Text('Mesuré ${live.measuredW!.round()} W',
style: TextStyle(
fontSize: 12, fontWeight: FontWeight.w600, color: tone)),
const SizedBox(width: 6),
Flexible(
child: Text(
_ecartMesure(live),
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 11, color: tone),
),
),
]),
],
const SizedBox(height: 2),
Text(decisionText(t, live.decision),
style: TextStyle(fontSize: 11, color: tone)),
// Le motif d'une borne est la décision de l'arbitre, pas la cause de ce que la
// borne fait. Le dire explicitement coûte une ligne ; le taire fait passer une
// contradiction du moteur pour un défaut de l'écran.
if (borne) ...[
const SizedBox(height: 2),
Text(t.telemetryEvIntentDisclaimer,
style: TextStyle(
fontSize: 11, fontStyle: FontStyle.italic, color: tone)),
],
// Financement — une charge servie au RÉSEAU n'est pas comptée au budget de
// surplus. Sans cette mention, l'utilisateur voit une charge servie alors que le
// surplus est nul, et cherche un défaut.
if (live.funding == 'grid') ...[
const SizedBox(height: 2),
Row(children: [
Icon(Icons.power_rounded, size: 13, color: tone),
const SizedBox(width: 4),
Flexible(
child: Text(t.telemetryFundedByGrid,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 11, color: tone)),
),
]),
],
if (lock != null) ...[
const SizedBox(height: 2),
Row(children: [
Icon(Icons.lock_clock_rounded, size: 13, color: tone),
const SizedBox(width: 4),
Flexible(
child: Text(
// Les secondes affichées datent du cycle publié : l'app ne les décompte
// pas, le plugin exclut volontairement ce champ de sa détection de
// changement.
'${lockText(t, lock)} (${t.lockAsOfCycle})',
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 11, color: tone),
),
),
]),
],
if (live.inFault) ...[
const SizedBox(height: 6),
LoadFaultBox(live: live, telemetry: telemetry),
],
],
);
}
}
/// Charge en défaut, et levée du verrou.
///
/// Le bouton n'existe **que** sur une charge dont `available` est faux, et son résultat
/// n'est jamais annoncé sur le retour du RPC : `ClearLoadFault` répond
/// `EnergyErrorNoError` même sur une charge saine (vérifié sur `.75`). Le verdict vient
/// de la télémétrie relue.
/// Charge en défaut, et levée du verrou.
///
/// Le bouton n'existe **que** sur une charge dont `available` est faux, et son résultat
/// n'est jamais annoncé sur le retour du RPC : `ClearLoadFault` répond
/// `EnergyErrorNoError` même sur une charge saine (vérifié sur `.75`). Le verdict vient
/// de la télémétrie relue.
class LoadFaultBox extends StatelessWidget {
final LoadTelemetryEntry live;
final LoadTelemetryProvider telemetry;
const LoadFaultBox({super.key,required this.live, required this.telemetry});
@override
Widget build(BuildContext context) {
final t = context.l10n;
final state = telemetry.clearStateOf(live.loadId);
final code = live.faultCode;
final (String? message, Color messageColor) = switch (state) {
FaultClearState.pending => (t.faultClearPending, const Color(0xFF6B7280)),
FaultClearState.cleared => (t.faultCleared, const Color(0xFF2E7D32)),
FaultClearState.stillFaulty => (t.faultClearStillFaulty, const Color(0xFFC62828)),
FaultClearState.rejected => (
t.faultClearRejected(telemetry.clearErrorOf(live.loadId) ?? '?'),
const Color(0xFFC62828)
),
FaultClearState.idle => (null, Colors.transparent),
};
return Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: const Color(0xFFFDECEA),
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
const Icon(Icons.error_outline_rounded, size: 15, color: Color(0xFFC62828)),
const SizedBox(width: 6),
Text(t.faultTitle,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: Color(0xFFC62828))),
]),
const SizedBox(height: 4),
Text(code == null ? t.faultUnknown('—') : faultText(t, code),
style: const TextStyle(fontSize: 11, color: Color(0xFF7F1D1D))),
const SizedBox(height: 4),
Text(t.faultClearHint,
style: const TextStyle(fontSize: 10, color: Color(0xFF9A5B5B))),
if (message != null) ...[
const SizedBox(height: 6),
Text(message, style: TextStyle(fontSize: 11, color: messageColor)),
],
const SizedBox(height: 4),
Align(
alignment: Alignment.centerRight,
child: OutlinedButton.icon(
icon: state == FaultClearState.pending
? const SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.lock_open_rounded, size: 16),
label: Text(t.faultClearAction),
onPressed: state == FaultClearState.pending
? null
: () => telemetry.clearFault(live.loadId),
),
),
],
),
);
}
}
// ══════════════════════════════════════════════════════════════════════════════
// En-tête d'arbitrage — repris de l'écran « Ordre de service » à sa disparition.
//
// Il avait été perdu à la fusion, et c'est un manque qui se voit : sans le budget du
// cycle, les allocations affichées sur chaque carte ne se rattachent à rien. « Alloué
// 3 000 W » ne veut rien dire tant qu'on ne sait pas s'il y avait 3 100 ou 12 000 W à
// distribuer.
// ══════════════════════════════════════════════════════════════════════════════
class TelemetryHeader extends StatelessWidget {
final LoadTelemetryProvider telemetry;
const TelemetryHeader({super.key, required this.telemetry});
@override
Widget build(BuildContext context) {
final t = context.l10n;
final snapshot = telemetry.telemetry;
if (snapshot == null) {
return telemetry.loading
? const Padding(
padding: EdgeInsets.only(bottom: 12),
child: LinearProgressIndicator(minHeight: 2))
: const SizedBox.shrink();
}
final age = telemetry.displayedAge;
final stale = telemetry.stale;
return Card(
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 12, 14, 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Icon(
stale ? Icons.history_toggle_off_rounded : Icons.sensors_rounded,
size: 20,
color: stale ? const Color(0xFFB26A00) : const Color(0xFF2E7D32),
),
const SizedBox(width: 8),
Expanded(
child: Text(t.telemetryTitle,
style:
const TextStyle(fontWeight: FontWeight.w700, fontSize: 14)),
),
]),
const SizedBox(height: 8),
// Fraîcheur — un arbitre figé ne doit pas ressembler à une installation
// stable. L'absence de cycle est dite pour elle-même, jamais rendue par
// un âge de zéro.
if (snapshot.hasNeverRun)
_Note(
icon: Icons.hourglass_empty_rounded,
color: const Color(0xFFB26A00),
text: t.telemetryNeverRan)
else if (stale)
_Note(
icon: Icons.warning_amber_rounded,
color: const Color(0xFFB26A00),
text: t.telemetryStale(age?.inSeconds ?? 0))
else
_Note(
icon: Icons.schedule_rounded,
color: const Color(0xFF6B7280),
text: t.telemetryLastCycle(age?.inSeconds ?? 0)),
if (snapshot.degradedMode) ...[
const SizedBox(height: 6),
_Note(
icon: Icons.report_gmailerrorred_rounded,
color: const Color(0xFFC62828),
text: t.telemetryDegradedMode),
],
const SizedBox(height: 12),
Text(t.telemetryBudgetTitle,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 12,
color: Color(0xFF6B7280))),
const SizedBox(height: 6),
// `budget` absent = repli L2 : il n'existe AUCUN budget pour ce cycle.
// Afficher des zéros se lirait « arbitrage exécuté, rien à allouer ».
if (snapshot.budget == null)
Text(t.telemetryBudgetAbsent,
style: const TextStyle(fontSize: 12, color: Color(0xFFC62828)))
else
_BudgetTable(budget: snapshot.budget!),
],
),
),
);
}
}
class _BudgetTable extends StatelessWidget {
final TelemetryBudget budget;
const _BudgetTable({required this.budget});
@override
Widget build(BuildContext context) {
final t = context.l10n;
final rows = <(String, double)>[
(t.budgetSurplus, budget.surplusW),
(t.budgetEvReserved, budget.evReservedW),
(t.budgetRecredited, budget.recreditedW),
(t.budgetAllocated, budget.allocatedW),
(t.budgetRemaining, budget.remainingW),
];
return Column(
children: [
for (final (label, value) in rows)
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(children: [
Expanded(
child: Text(label,
style: const TextStyle(fontSize: 12, color: Color(0xFF6B7280))),
),
Text(t.watts(value.round()),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
fontFeatures: [FontFeature.tabularFigures()])),
]),
),
],
);
}
}
// ── Un domaine et ses charges ────────────────────────────────────────────────
/// Ligne d'information de l'en-tête d'arbitrage.
class _Note extends StatelessWidget {
final IconData icon;
final Color color;
final String text;
const _Note({required this.icon, required this.color, required this.text});
@override
Widget build(BuildContext context) {
return Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
Icon(icon, size: 15, color: color),
const SizedBox(width: 6),
Expanded(
child: Text(text, style: TextStyle(fontSize: 12, color: color)),
),
]);
}
}
/// État RÉEL d'une borne, lu dans le Thing — jamais déduit du motif d'arbitrage.
///
/// L'ordre des tests n'est pas indifférent. « Débranchée » prime sur « ne charge pas » :
/// les deux sont vrais, mais le premier dit ce qu'il faut faire. Et `chargingEnabled`
/// absent ne devient PAS « ne charge pas » — le motif d'arbitrage pourrait le contredire,
/// et une absence présentée comme un état est exactement ce que ce bloc existe pour
/// éviter.
String _etatBorne(L10n t, LoadMechanism? mech) {
if (mech == null) return t.telemetryEvStateUnknown;
if (mech.pluggedIn == false) return t.telemetryEvUnplugged;
final on = mech.chargingEnabled;
if (on == null) return t.telemetryEvStateUnknown;
if (!on) return t.telemetryEvIdle;
final a = mech.currentA;
final ph = mech.phaseCount;
// Charge en cours sans courant publié : on dit qu'elle charge, sans inventer combien.
if (a == null || ph == null) return t.telemetryEvIdle;
return t.telemetryEvCharging(a, ph);
}
/// Commentaire d'écart entre commandé et mesuré, quand il y a de quoi le dire.
///
/// Volontairement prudent : un écart de mesure **n'est pas** un défaut d'écriture.
/// `WRITE_FAILED` dit « l'échelle de commande est épuisée » ; un écart dit « la commande
/// est partie, l'effet n'est pas là ». Deux causes, deux gestes chez le client — l'une
/// envoie au bus, l'autre au disjoncteur divisionnaire ou à la résistance. L'app constate,
/// elle ne diagnostique pas.
String _ecartMesure(LoadTelemetryEntry live) {
final m = live.measuredW;
if (m == null) return '';
// Sur une borne, l'allocation n'est pas une consigne : « sous la consigne » y
// affirmerait un écart entre une commande et son effet là où il n'y a peut-être
// aucune commande. Muet tant que 3g-2 n'a pas rendu l'arbitre seul commandeur.
if (!live.allocationIsCommand) return '';
final a = live.allocatedW;
if (a <= 0) return m > 20 ? '· alloué 0 W' : '';
final ecart = (m - a).abs();
// 15 % ou 100 W : en dessous, c'est la latence du compteur, pas un écart.
if (ecart < 100 || ecart / a < 0.15) return '· conforme';
return m < a ? '· sous la consigne' : '· au-dessus de la consigne';
}