L'arrivée de la V2C sur .75 a révélé deux défauts que le banc ne pouvait pas montrer avant, plus deux pièges de contrat à documenter. 1. ⚠️ isNotGrouped ÉTAIT CALCULÉ ET AFFICHÉ NULLE PART. GroupedOrder détecte depuis le premier jour qu'un ordre n'est pas groupé par domaine, le provider l'expose, et aucune vue ne le lisait — le cas ne s'était jamais produit, chaque domaine du banc ayant ses charges d'un seul tenant. Il se produit maintenant : la V2C, créée d'office au rang 1 que le chauffe-eau occupait déjà, donne l'ordre à plat « ecs · ev · heating · ev · ev ». Le domaine ev est coupé en deux par le chauffage. Ce n'était pas un détail d'affichage. L'écran groupe pour afficher, donc ce qu'on voit n'est plus l'ordre réel de la box — et le premier enregistrement réaplatit EN GROUPANT, ce qui change l'arbitrage sans que personne ne l'ait demandé. Chiffré par test sur la configuration réelle : la PAC passerait du rang 2 au rang 5. C'est exactement ce que le commentaire de GroupedOrder interdit depuis le début ; le garde-fou existait, il n'était pas branché. 2. « L'APPAREIL N'EXISTE PLUS » N'EST PAS UN DÉFAUT À LEVER. La Terra AC a été supprimée de nymea ; son entrée LoadConfig survit, occupe le rang 4, et s'affiche en THING_MISSING avec un bouton « Lever le défaut ». ClearLoadFault y répondrait EnergyErrorNoError sans rien changer, et le défaut reviendrait au cycle suivant — un bouton qui ne peut pas tenir sa promesse fait douter de la box au lieu de désigner la cause. Le bouton disparaît sur ce code, remplacé par ce qu'il faut faire : réinstaller l'appareil, ou retirer la charge. Et le libellé cesse de se lire « votre configuration est cassée » : le cas normal est un appareil remplacé, 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. 3. Deux pièges du Thing V2C, documentés là où quelqu'un serait tenté de les câbler : chargeEnergy est EXACT pendant la charge — sa croissance recoupe currentPower à mieux d'un pour cent — et REMIS À ZÉRO à l'arrêt de la charge, pas au débranchement. Mesuré : 0,3456 kWh → 0 dès que charging passe à faux, 119 secondes avant que le câble ne bouge. Sur du pilotage par surplus il repartirait de zéro à chaque nuage, et après coup une nuit entière vaudrait 0 — soit « rien livré » affiché là où la vérité est « pas mesurable ». Le plugin publiera un sessionEnergy construit ; rien n'est bâti en attendant, et surtout aucune accumulation côté app. powerL1/L2/L3 sont des ampères mal nommés, renommage currentL* en cours côté plugin. Ni affichage, ni conversion : une conversion écrite ici survivrait au correctif et le contredirait. Fixture ajoutée : la configuration réelle à cinq entrées, avec ses deux enseignements de terrain — deux rangs à 1, et une entrée orpheline qui consomme un rang. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
565 lines
23 KiB
Dart
565 lines
23 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;
|
|
|
|
// Depuis `+etm23`, l'arbitre est le SEUL à commander une borne : l'allocation
|
|
// publiée est bien un ordre, et la mise en garde qui la présentait comme une simple
|
|
// intention est retirée.
|
|
//
|
|
// Ce qui reste vrai, et qui ne doit pas se perdre dans la levée de la réserve :
|
|
// commander n'est pas obtenir. Un plafond matériel, un véhicule qui refuse, un câble
|
|
// débranché font diverger l'ordre et la réalité. La borne garde donc sa ligne d'état
|
|
// matériel — pas parce qu'un autre commandeur la contredirait, mais parce que c'est
|
|
// le seul endroit où cet écart-là se lit.
|
|
final borne = mech?.isEvCharger == true;
|
|
|
|
// 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: [
|
|
// ── Ce que la borne FAIT, à côté de ce qui lui a été commandé ───────
|
|
//
|
|
// `chargingEnabled` / `currentA` / `pluggedIn` viennent du Thing. L'allocation
|
|
// vient de l'arbitre, qui est désormais seul à commander — mais l'ordre et son
|
|
// effet restent deux choses, et c'est ici que l'écart se voit.
|
|
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(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)),
|
|
// 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;
|
|
// « L'appareil n'existe plus » n'est pas un défaut de charge comme les autres : aucune
|
|
// action de l'app ne peut y remédier, et la seule sortie passe par nymea.
|
|
final thingAbsent = code == 'THING_MISSING';
|
|
|
|
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),
|
|
// ── L'APPAREIL A DISPARU : il n'y a rien à lever ────────────────────
|
|
//
|
|
// `ClearLoadFault` répondrait `EnergyErrorNoError` — l'ack ne prouve rien — et
|
|
// le défaut reviendrait au cycle suivant, puisque aucun appareil ne répond. Un
|
|
// bouton qui ne peut pas tenir sa promesse est pire que pas de bouton : il fait
|
|
// douter de la box au lieu de désigner la cause.
|
|
//
|
|
// Le cas est banal, pas exotique : une borne remplacée. Le plugin crée d'office
|
|
// une entrée `LoadConfig` par borne détectée (`+etm23`) mais ne la retire pas
|
|
// quand le Thing s'en va — constaté sur `.75` avec une ABB Terra AC remplacée
|
|
// par une V2C, dont l'entrée occupait encore un rang deux jours plus tard.
|
|
if (thingAbsent)
|
|
Text(t.faultThingMissingAction,
|
|
style: const TextStyle(fontSize: 10.5, color: Color(0xFF7F1D1D)))
|
|
else ...[
|
|
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 '';
|
|
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';
|
|
}
|