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
559 lines
22 KiB
Dart
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';
|
|
}
|