etm-powersync-app/lib/widgets/load_cards.dart
Patrick Schurig ab2a27bd30 feat(etm19): BELOW_MIN_POWER, compteurs rattachés depuis l'écran, glissement exercé
MOTIF BELOW_MIN_POWER — premier ajout au catalogue depuis qu'il existe.

Le sens porté est précis et facile à perdre : il Y A du surplus, mais pas au bon format —
sous le plus petit cran commandable, la charge ne prend rien et le budget file à la
suivante. À distinguer de SURPLUS_INSUFFICIENT et SG_NORMAL, qui ne sortent plus que pour
un budget ≤ 0, « il n'y a rien ». Les confondre ferait chercher un défaut de production là
où il n'y a qu'une granularité. Deux variantes, départagées par la présence de `state`.

Les deux phrases voisines sont reprises en conséquence : SG_NORMAL peut enfin dire « aucun
surplus » sans risque, ce qu'elle ne pouvait pas avant, et SURPLUS_INSUFFICIENT ne parle
plus de « palier non payé » — elle dit qu'il n'y a rien à distribuer.

LE REPLI AURAIT TENU, et c'est vérifié plutôt qu'affirmé : sur BELOW_MIN_POWER privé de
`minPowerW`, l'app rend le code et ses paramètres triés au lieu de fabriquer une phrase
approximative. C'était exactement le cas prévu, et c'est le premier vrai à se présenter.

GLISSEMENT — le test POSE désormais sa condition au lieu de la subir. Il regroupe les deux
charges dans un même domaine, exerce, puis REND le classement d'origine. Deux fois de suite
il s'était contenté d'annoncer « non exercé » parce que le banc était revenu à une charge
par domaine : un test qui dépend de l'état du terrain déclare forfait au moment où on
aurait besoin de lui. Exercé sur appareil : [chauffe-eau, pac-terrain] →
[pac-terrain, chauffe-eau], classement rétabli {ecs, heating}.

COMPTEURS — rattachés pour de vrai depuis le chemin de l'app, et non par une sonde.
measuredW apparaît par charge, chacune portant SON compteur. L'affichage met 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.

Mon assertion initiale était fautive : j'exigeais des mesures DISTINCTES pour prouver le
rattachement par charge. Deux compteurs à l'arrêt lisent légitimement 0 W tous les deux —
le test échouait donc sur une installation saine. Ce qui prouve le rattachement, c'est que
chaque charge porte un compteur différent. Vérifié aussi, en détachant puis rattachant :
une charge sans compteur ne publie RIEN — null n'est pas 0, et afficher un zéro ferait
passer une absence de mesure pour une absence de consommation.

PLANCHER — préparé AVANT son arrivée au schéma, précisément à cause de ce qui vient de se
passer avec meterThingId et sensorThingId : le grisage dérivé les a activés tout seuls,
mais sur un placeholder mort. Le bloc est donc câblé maintenant, reste grisé tant que le
champ n'existe pas, et fonctionnera le jour où il apparaîtra. La box publie déjà
mechanism.minPowerW en télémétrie : on l'affiche en lecture, ce qui donne la valeur en
vigueur même sans réglage ouvert.

Tests 101/101, analyze 0 erreur.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
2026-08-26 11:31:35 +02:00

479 lines
19 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 mechText = mech == null ? null : mechanismText(t, mech);
final lock = live.lock;
// 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: [
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)),
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)),
),
]);
}
}
/// 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';
}