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
This commit is contained in:
Patrick Schurig 2026-08-26 11:31:35 +02:00
parent cd76df3a73
commit ab2a27bd30
10 changed files with 485 additions and 49 deletions

View File

@ -199,53 +199,74 @@ void main() {
// ── 5-bis. LE GLISSEMENT, sur l'appareil, contre la vraie box ───────────
//
// Le test décisif. Il n'a de sens que si les deux charges partagent un domaine —
// sinon il n'y a rien à réordonner, et c'est précisément ce qui donnait l'impression
// que « rien ne fonctionne ». Poser le classement se fait hors de ce test
// (`tools/rpc/domain_assign.dart --set chauffe-eau=ecs --set pac-terrain=ecs`) ; ici
// on constate, et on saute proprement si la condition n'est pas réunie.
// Le test POSE lui-même sa condition au lieu de la subir. Le glissement n'a de sens
// que si deux charges partagent un domaine ; le banc, lui, revient régulièrement à une
// charge par domaine — deux fois de suite le test s'est donc contenté d'annoncer
// « non exercé ». Un test qui dépend de l'état du terrain ne teste rien de façon
// fiable : il déclare forfait au moment où on aurait besoin de lui.
//
// Il regroupe donc, exerce, puis REND le classement d'origine.
final domainesOrigine = {
for (final e in config.entries) e.id: e.domain ?? ''
};
debugPrint('[appareil] classement d\'origine : $domainesOrigine');
Future<void> ecrireDomaines(Map<String, String> cible) async {
for (final entry in cible.entries) {
config.assignDomain(entry.key, entry.value);
}
await tester.pump(const Duration(milliseconds: 300));
if (!config.hasPendingChanges) return;
await config.save();
await _attendre(
tester,
() => config.saveState == LoadSaveState.confirmed ||
config.saveState == LoadSaveState.diverged ||
config.saveState == LoadSaveState.rejected,
limite: const Duration(seconds: 20),
quoi: 'la confirmation du classement',
);
expect(config.saveState, LoadSaveState.confirmed);
}
// Regrouper les deux charges dans un même domaine.
const domaineTest = 'ecs';
await ecrireDomaines({for (final id in domainesOrigine.keys) id: domaineTest});
await tester.pump(const Duration(seconds: 1));
final parDomaine = <String, int>{};
for (final e in config.entries) {
parDomaine.update(e.domain ?? '', (v) => v + 1, ifAbsent: () => 1);
}
final memeDomaine = parDomaine.values.any((n) => n > 1);
debugPrint('[appareil] charges par domaine : $parDomaine');
debugPrint('[appareil] regroupées : $parDomaine');
expect(parDomaine[domaineTest], greaterThan(1),
reason: 'le préambule doit avoir regroupé les charges');
if (!memeDomaine) {
debugPrint('[appareil] GLISSEMENT NON EXERCÉ — une seule charge par domaine, '
'il n\'y a rien à réordonner. Poser deux charges dans un même domaine, '
'puis relancer.');
} else {
final ordreAvant = [
for (final g in config.groups) ...g.loads.map((e) => e.id)
];
final premier = ordreAvant.first;
final poignee = find.byKey(ValueKey('poignee-charge-$premier'));
final cible = find.byKey(ValueKey('load-${ordreAvant[1]}'));
expect(poignee, findsOneWidget, reason: 'poignée de $premier absente');
// Exercer le glissement — le domaine porte maintenant plusieurs charges, donc ce sont
// les FLÈCHES de carte qui ordonnent à l'intérieur (le glisser-déposer, lui, déplace
// les domaines entre eux).
final ordreAvant = [for (final g in config.groups) ...g.loads.map((e) => e.id)];
final premier = ordreAvant.first;
final bas = find.byKey(ValueKey('descendre-$premier'));
await tester.ensureVisible(bas);
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(bas);
await tester.pump(const Duration(milliseconds: 400));
final dy = tester.getSize(cible).height * 2;
final g = await tester.startGesture(tester.getCenter(poignee));
await tester.pump(const Duration(milliseconds: 60));
for (var i = 0; i < 12; i++) {
await g.moveBy(Offset(0, dy / 12));
await tester.pump(const Duration(milliseconds: 16));
}
await g.up();
await tester.pump(const Duration(milliseconds: 400));
final ordreApres = [for (final g in config.groups) ...g.loads.map((e) => e.id)];
debugPrint('[appareil] réordonnancement : $ordreAvant → $ordreApres');
expect(ordreApres.first, isNot(premier),
reason: 'descendre la première charge doit changer l\'ordre');
final ordreApres = [
for (final g in config.groups) ...g.loads.map((e) => e.id)
];
debugPrint('[appareil] glissement : $ordreAvant → $ordreApres');
expect(ordreApres.first, isNot(premier),
reason: 'le glissement doit réordonner sur l\'appareil aussi');
// Le rang du banc n'appartient pas au test.
config.discardDraft();
await tester.pump(const Duration(milliseconds: 300));
// Le brouillon ne part PAS : le rang du banc n'appartient pas au test.
config.discardDraft();
await tester.pump(const Duration(milliseconds: 200));
debugPrint('[appareil] brouillon abandonné — rien n\'est écrit sur la box');
}
// RENDRE le classement d'origine.
await ecrireDomaines(domainesOrigine);
await tester.pump(const Duration(seconds: 1));
debugPrint('[appareil] classement rétabli : '
'${{for (final e in config.entries) e.id: e.domain ?? ''}}');
// ── 5-bis-bis. Le compteur racine est RELU, pas redemandé ───────────────
//
@ -310,9 +331,16 @@ void main() {
// `+etm16` publie `mechanism.stagesW`. Une seule fonction moteur sert la construction
// du routeur, la comparaison matérielle et cette publication : l'app n'a plus de
// combinatoire à refaire, donc plus de divergence possible.
final live = Provider.of<LoadTelemetryProvider>(ctx, listen: false)
.entryFor('chauffe-eau');
final stages = live?.mechanism?.stagesW;
// Attendre un cycle : les écritures du préambule viennent de republier la télémétrie,
// et la lire dans la foulée peut tomber entre deux.
final teleP = Provider.of<LoadTelemetryProvider>(ctx, listen: false);
await _attendre(
tester,
() => teleP.entryFor('chauffe-eau')?.mechanism?.stagesW != null,
limite: const Duration(seconds: 150),
quoi: 'la publication de stagesW',
);
final stages = teleP.entryFor('chauffe-eau')?.mechanism?.stagesW;
debugPrint('[appareil] paliers publiés par la box : $stages');
expect(stages, isNotNull,
reason: '.75 est en +etm16 : stagesW doit être publié');
@ -377,6 +405,119 @@ void main() {
Navigator.of(tester.element(find.text('Arbitrage').first)).pop();
await tester.pump(const Duration(seconds: 1));
// ── 5-octies. RATTACHER les compteurs, par le chemin de l'app ───────────
//
// Premier rattachement fait depuis l'écran plutôt que par une sonde RPC. Ce qui se
// vérifie ensuite n'est pas que l'écriture est passée — c'est que la box publie
// `measuredW` PAR CHARGE, chacune la sienne, et qu'une charge sans compteur ne publie
// toujours rien. Ce dernier point compte autant que l'autre : `null` n'est pas 0, et
// afficher un zéro ferait passer une absence de mesure pour une absence de
// consommation.
// (l'écran de mécanisme a déjà été refermé plus haut — dépiler une seconde fois
// viderait la pile de navigation)
// Qui est qui, par NOM — le rattachement se pense en appareils, pas en UUID.
String? thingParNom(String motif) {
for (final t in svc.things) {
if (t.name.toLowerCase().contains(motif.toLowerCase())) return t.id;
}
return null;
}
final compteurEcs = thingParNom('ECS-Meter');
final compteurPac = thingParNom('PAC-Meter');
debugPrint('[appareil] compteurs trouvés — ECS: $compteurEcs · PAC: $compteurPac');
if (compteurEcs != null && compteurPac != null) {
// On écrit par le MÊME chemin que le sélecteur de l'écran : patched() sur la map
// reçue, puis save(). Piloter la feuille modale du picker n'ajouterait qu'une
// fragilité de test, pas une garantie de plus — c'est cette écriture-ci qu'il porte.
for (final e in config.entries) {
final c = e.id.contains('pac') ? compteurPac : compteurEcs;
config.replaceEntry(e.id, e.patched(meterThingId: c));
}
await tester.pump(const Duration(milliseconds: 300));
await config.save();
await _attendre(
tester,
() => config.saveState == LoadSaveState.confirmed ||
config.saveState == LoadSaveState.rejected,
limite: const Duration(seconds: 20),
quoi: 'la confirmation du rattachement',
);
expect(config.saveState, LoadSaveState.confirmed,
reason: 'le rattachement d\'un compteur ne doit pas être refusé');
// Rattacher n'est PAS du matériel : aucune reconstruction, aucun verrou réarmé.
for (final e in config.entries) {
debugPrint('[appareil] ${e.id} → compteur ${e.meterThingId}');
expect(e.meterThingId, isNotEmpty);
}
// Laisser passer deux cycles : la mesure arrive avec le cycle d'arbitrage.
final tele = Provider.of<LoadTelemetryProvider>(ctx, listen: false);
await _attendre(
tester,
() => config.entries
.every((e) => tele.entryFor(e.id)?.measuredW != null),
limite: const Duration(seconds: 150),
quoi: 'la publication de measuredW pour chaque charge',
);
final mesures = {
for (final e in config.entries) e.id: tele.entryFor(e.id)?.measuredW
};
debugPrint('[appareil] measuredW par charge : $mesures');
expect(mesures.values.every((v) => v != null), isTrue);
// Ce qui prouve le rattachement PAR CHARGE, c'est que chacune porte un compteur
// DIFFÉRENT — pas que les valeurs diffèrent. Exiger des mesures distinctes était une
// erreur de ma part : deux compteurs à l'arrêt lisent légitimement 0 W tous les
// deux, et le test échouait alors sur une installation parfaitement saine.
final compteurs = {for (final e in config.entries) e.id: e.meterThingId};
debugPrint('[appareil] compteur par charge : $compteurs');
expect(compteurs.values.toSet().length, compteurs.length,
reason: 'chaque charge doit porter SON propre compteur');
// Et une charge SANS compteur ne publie rien — `null` n'est pas 0. On le vérifie en
// détachant, puis on rattache de nouveau : afficher un zéro à la place ferait passer
// une absence de mesure pour une absence de consommation.
final cible = config.entries.first;
final garde = cible.meterThingId;
config.replaceEntry(cible.id, cible.patched(meterThingId: ''));
await tester.pump(const Duration(milliseconds: 300));
await config.save();
await _attendre(
tester,
() => config.saveState == LoadSaveState.confirmed,
limite: const Duration(seconds: 20),
quoi: 'la confirmation du détachement',
);
await _attendre(
tester,
() => tele.entryFor(cible.id)?.measuredW == null,
limite: const Duration(seconds: 150),
quoi: 'l\'arrêt de publication de measuredW après détachement',
);
debugPrint('[appareil] ${cible.id} sans compteur → measuredW = '
'${tele.entryFor(cible.id)?.measuredW}');
// Rendre le rattachement : il est demandé, il reste.
final remise = config.entries.firstWhere((e) => e.id == cible.id);
config.replaceEntry(cible.id, remise.patched(meterThingId: garde));
await tester.pump(const Duration(milliseconds: 300));
await config.save();
await _attendre(
tester,
() => config.saveState == LoadSaveState.confirmed,
limite: const Duration(seconds: 20),
quoi: 'le rétablissement du compteur',
);
} else {
debugPrint('[appareil] RATTACHEMENT NON EXERCÉ — '
'ECS-Meter ou PAC-Meter absent de la box.');
}
// ── 6. On laisse l'écran affiché ────────────────────────────────────────
//
// `flutter test integration_test` referme l'app dès la dernière assertion. Sans

View File

@ -246,7 +246,7 @@
"budget": { "type": "int", "format": "decimalPattern" }
}
},
"decisionSurplusInsufficient": "Le budget de {budget} W ne payait aucun palier — aucun verrou n'a mordu.",
"decisionSurplusInsufficient": "Aucun surplus à distribuer ({budget} W) — rien n'est commandé.",
"@decisionSurplusInsufficient": {
"placeholders": { "budget": { "type": "int", "format": "decimalPattern" } }
},
@ -268,10 +268,27 @@
"budget": { "type": "int", "format": "decimalPattern" }
}
},
"decisionSgNormal": "Pompe à chaleur en état 2 (normal, sans consigne) — {budget} W de budget.",
"decisionSgNormal": "Aucun surplus ({budget} W) — pompe à chaleur en état 2 (normal, sans consigne).",
"@decisionSgNormal": {
"placeholders": { "budget": { "type": "int", "format": "decimalPattern" } }
},
"decisionBelowMinPower": "Budget de {budget} W sous le premier palier ({min} W) — rien n'est commandé, le budget passe à la charge suivante.",
"@decisionBelowMinPower": {
"description": "Il Y A du surplus, mais pas au bon format : sous le plus petit palier commandable. À ne pas confondre avec SURPLUS_INSUFFICIENT, qui dit qu'il n'y a rien.",
"placeholders": {
"budget": { "type": "int", "format": "decimalPattern" },
"min": { "type": "int", "format": "decimalPattern" }
}
},
"decisionBelowMinPowerState": "Budget de {budget} W sous l'état {state} ({min} W estimés) — maintien en état 2, le budget passe à la charge suivante.",
"@decisionBelowMinPowerState": {
"description": "Même sens, mécanisme à états : le premier état utile coûterait plus que ce qui reste.",
"placeholders": {
"budget": { "type": "int", "format": "decimalPattern" },
"state": { "type": "int" },
"min": { "type": "int", "format": "decimalPattern" }
}
},
"decisionDegradedL2": "Consigne de repli : le compteur est muet et le watchdog a suspendu la planification.",
"decisionSafeStateRelay": "État sûr avant retrait : tous les relais sont ouverts.",
"decisionSafeStateSetpoint": "État sûr avant retrait : consigne ramenée à 0 W.",

View File

@ -580,7 +580,7 @@ abstract class L10n {
/// No description provided for @decisionSurplusInsufficient.
///
/// In fr, this message translates to:
/// **'Le budget de {budget} W ne payait aucun palier — aucun verrou n\'a mordu.'**
/// **'Aucun surplus à distribuer ({budget} W) — rien n\'est commandé.'**
String decisionSurplusInsufficient(int budget);
/// No description provided for @decisionLockMinStateHold.
@ -604,9 +604,21 @@ abstract class L10n {
/// No description provided for @decisionSgNormal.
///
/// In fr, this message translates to:
/// **'Pompe à chaleur en état 2 (normal, sans consigne) — {budget} W de budget.'**
/// **'Aucun surplus ({budget} W) — pompe à chaleur en état 2 (normal, sans consigne).'**
String decisionSgNormal(int budget);
/// Il Y A du surplus, mais pas au bon format : sous le plus petit palier commandable. À ne pas confondre avec SURPLUS_INSUFFICIENT, qui dit qu'il n'y a rien.
///
/// In fr, this message translates to:
/// **'Budget de {budget} W sous le premier palier ({min} W) — rien n\'est commandé, le budget passe à la charge suivante.'**
String decisionBelowMinPower(int budget, int min);
/// Même sens, mécanisme à états : le premier état utile coûterait plus que ce qui reste.
///
/// In fr, this message translates to:
/// **'Budget de {budget} W sous l\'état {state} ({min} W estimés) — maintien en état 2, le budget passe à la charge suivante.'**
String decisionBelowMinPowerState(int budget, int state, int min);
/// No description provided for @decisionDegradedL2.
///
/// In fr, this message translates to:

View File

@ -410,7 +410,7 @@ class L10nFr extends L10n {
intl.NumberFormat.decimalPattern(localeName);
final String budgetString = budgetNumberFormat.format(budget);
return 'Le budget de $budgetString W ne payait aucun palier — aucun verrou n\'a mordu.';
return 'Aucun surplus à distribuer ($budgetString W) — rien n\'est commandé.';
}
@override
@ -448,7 +448,33 @@ class L10nFr extends L10n {
intl.NumberFormat.decimalPattern(localeName);
final String budgetString = budgetNumberFormat.format(budget);
return 'Pompe à chaleur en état 2 (normal, sans consigne) — $budgetString W de budget.';
return 'Aucun surplus ($budgetString W) — pompe à chaleur en état 2 (normal, sans consigne).';
}
@override
String decisionBelowMinPower(int budget, int min) {
final intl.NumberFormat budgetNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String budgetString = budgetNumberFormat.format(budget);
final intl.NumberFormat minNumberFormat = intl.NumberFormat.decimalPattern(
localeName,
);
final String minString = minNumberFormat.format(min);
return 'Budget de $budgetString W sous le premier palier ($minString W) — rien n\'est commandé, le budget passe à la charge suivante.';
}
@override
String decisionBelowMinPowerState(int budget, int state, int min) {
final intl.NumberFormat budgetNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String budgetString = budgetNumberFormat.format(budget);
final intl.NumberFormat minNumberFormat = intl.NumberFormat.decimalPattern(
localeName,
);
final String minString = minNumberFormat.format(min);
return 'Budget de $budgetString W sous l\'état $state ($minString W estimés) — maintien en état 2, le budget passe à la charge suivante.';
}
@override

View File

@ -245,6 +245,13 @@ class LoadConfigEntry {
};
}
/// Patch générique par clés — pour un champ que ce modèle ne typa pas encore.
///
/// Sert aux champs qui **arrivent** au contrat : les typer d'avance figerait un nom que
/// la spec plugin n'a pas arrêté. La clé n'est écrite que si l'appelant a vérifié
/// qu'elle est au schéma — nymea rejette l'appel entier sur une clé inconnue.
Map<String, dynamic> patchedRaw(Map<String, dynamic> cles) => {...raw, ...cles};
@override
String toString() => 'LoadConfigEntry($id, $adapter, p=$priority, d=$domain)';
}

View File

@ -130,6 +130,14 @@ class LoadMechanism {
double? get stageW => _dn(raw['stageW']);
double? get maxStageW => _dn(raw['maxStageW']);
/// Plancher de modulation — la consigne non nulle la plus basse que la charge accepte.
///
/// **Publié en télémétrie avant d'être configurable** : le champ de configuration
/// correspondant n'est pas encore au schéma de `SetLoadConfig`. On le LIT donc, sans
/// prétendre le régler — cf. le bloc « Plancher » de l'écran de mécanisme, grisé tant
/// que le champ d'écriture n'existe pas.
double? get minPowerW => _dn(raw['minPowerW']);
/// Paliers atteignables, **publiés par la box** depuis `+etm16`.
///
/// Une seule fonction moteur (`RelayRouter::deriveStages`) sert la construction du
@ -201,6 +209,18 @@ class LoadTelemetryEntry {
/// Absent quand la charge est saine.
final String? faultCode;
/// Puissance **MESURÉE** par le compteur rattaché à cette charge. `null` quand aucun
/// compteur ne l'est.
///
/// Elle ne remplace pas [allocatedW] et ne s'y substitue jamais : l'une est ce que le
/// waterfall a **décidé**, l'autre ce qui **passe réellement**. Les afficher côte à côte
/// est tout l'intérêt du rattachement — 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.
///
/// **`null` n'est pas 0.** Une charge sans compteur ne publie rien, et afficher un zéro
/// à la place ferait passer une absence de mesure pour une absence de consommation.
final double? measuredW;
final LoadLock? lock;
final LoadMechanism? mechanism;
final LoadDecision decision;
@ -211,6 +231,7 @@ class LoadTelemetryEntry {
required this.allocatedW,
required this.available,
required this.faultCode,
this.measuredW,
required this.lock,
required this.mechanism,
required this.decision,
@ -228,6 +249,7 @@ class LoadTelemetryEntry {
// « disponible » est le repli qui n'invente pas un défaut.
available: m['available'] as bool? ?? true,
faultCode: (fault is String && fault.isNotEmpty) ? fault : null,
measuredW: _dn(m['measuredW']),
lock: lock is Map ? LoadLock.fromJson(Map<String, dynamic>.from(lock)) : null,
mechanism: mech is Map
? LoadMechanism.fromJson(Map<String, dynamic>.from(mech))

View File

@ -96,7 +96,11 @@ class _LoadMechanismScreenState extends State<LoadMechanismScreen> {
_SectionSgReady(entry: e, provider: provider, service: service)
else
_SectionModulable(
entry: e, provider: provider, service: service),
entry: e,
provider: provider,
service: service,
schema: schema,
telemetry: telemetry),
const SizedBox(height: 14),
if (e.isSgReady)
_Temporisation(
@ -698,11 +702,15 @@ class _SectionModulable extends StatelessWidget {
final LoadConfigEntry entry;
final LoadConfigProvider provider;
final NymeaService service;
final SchemaProvider schema;
final LoadTelemetryProvider telemetry;
const _SectionModulable({
required this.entry,
required this.provider,
required this.service,
required this.schema,
required this.telemetry,
});
@override
@ -766,6 +774,12 @@ class _SectionModulable extends StatelessWidget {
'estimation — et elle sert au recrédit du budget rendu au reste de la '
'cascade.',
),
const SizedBox(height: 14),
_Plancher(
entry: entry,
provider: provider,
schema: schema.schema,
telemetry: telemetry),
],
),
),
@ -1737,3 +1751,88 @@ class _Aide extends StatelessWidget {
);
}
}
/// Plancher de modulation — **préparé avant son arrivée au schéma**, délibérément.
///
/// ## Pourquoi l'écrire maintenant
///
/// Le grisage de cet écran se dérive de `JSONRPC.Introspect` : le jour où le plugin ajoute
/// le champ, le bloc s'active **tout seul**. C'est la promesse, et elle a déjà été tenue —
/// `meterThingId` et `sensorThingId` se sont activés sans nouvelle version de l'app.
///
/// Mais ce jour-là, ils se sont activés sur un **placeholder mort** : un contrôle d'allure
/// utilisable qui n'écrivait nulle part. Pire qu'un bloc grisé, parce qu'un bloc grisé dit
/// ce qu'il est. La leçon vaut d'être appliquée en avance : le plancher est câblé
/// maintenant, il restera grisé tant que le champ n'existe pas, et il fonctionnera le jour
/// où il apparaîtra — sans que personne n'ait à y revenir.
///
/// ## Ce que la box publie déjà
///
/// `mechanism.minPowerW` est **en télémétrie** avant d'être configurable. On l'affiche
/// donc en lecture, ce qui donne la valeur réellement en vigueur même quand le réglage,
/// lui, n'est pas encore ouvert.
class _Plancher extends StatelessWidget {
final LoadConfigEntry entry;
final LoadConfigProvider provider;
final RpcSchema schema;
final LoadTelemetryProvider telemetry;
const _Plancher({
required this.entry,
required this.provider,
required this.schema,
required this.telemetry,
});
/// Nom pressenti du champ. S'il diffère à l'arrivée, le bloc restera simplement grisé —
/// une erreur visible et sans conséquence, là où une écriture inventée ferait rejeter
/// l'enregistrement de TOUTES les charges.
static const String champ = 'minPowerW';
@override
Widget build(BuildContext context) {
final support = schema
.paramSupport('NymeaEnergy.SetLoadConfig', ['loadConfigs', '[]', champ]);
final publie = telemetry.entryFor(entry.id)?.mechanism?.minPowerW;
return _Carte(
titre: 'Plancher de modulation',
badge: support.isWritable ? 'enregistré · $champ' : 'pas encore supporté',
sousTitre: 'La consigne non nulle la plus basse que l\'appareil accepte — en '
'dessous, il vaut mieux ne rien commander du tout.',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SchemaGated(
support: support,
fieldName: champ,
label: 'Plancher de modulation',
child: TextFormField(
key: ValueKey('plancher-${entry.id}'),
initialValue: (publie ?? 0).round().toString(),
enabled: support.isWritable,
keyboardType: TextInputType.number,
decoration:
const InputDecoration(isDense: true, suffixText: 'W'),
onChanged: (v) {
final n = int.tryParse(v.trim());
// Rien ne part tant que le champ n'est pas au schéma : nymea rejette
// l'appel ENTIER sur une clé inconnue, et le refus emporterait toutes
// les charges.
if (n != null && n >= 0 && support.isWritable) {
provider.replaceEntry(
entry.id, entry.patchedRaw({champ: n}));
}
},
),
),
if (publie != null) ...[
const SizedBox(height: 6),
_Aide('Valeur en vigueur, publiée par la box : ${publie.round()} W.'),
],
],
),
);
}
}

View File

@ -104,6 +104,26 @@ String decisionText(L10n t, LoadDecision d) {
final budget = _w(d, 'budgetW');
if (budget == null) break;
return t.decisionSgNormal(budget);
// « Il y a du surplus, mais pas au bon format. »
//
// Le sens est à distinguer soigneusement de SURPLUS_INSUFFICIENT et SG_NORMAL, qui ne
// sortent désormais que pour un budget ≤ 0 — « il n'y a rien ». Ici il y a de quoi,
// simplement pas assez pour le plus petit cran commandable : la charge ne prend rien
// et le budget file à la suivante. Les confondre ferait chercher un défaut de
// production là où il n'y a qu'une granularité.
//
// Deux variantes, selon que le mécanisme se commande en watts ou en états. La présence
// de `state` les départage — et si elle manque sur un mécanisme à états, le repli
// rendra le code et ses paramètres plutôt qu'une phrase fausse.
case 'BELOW_MIN_POWER':
final budget = _w(d, 'budgetW');
final min = _w(d, 'minPowerW');
if (budget == null || min == null) break;
final state = _i(d, 'state');
return state == null
? t.decisionBelowMinPower(budget, min)
: t.decisionBelowMinPowerState(budget, state, min);
}
// Code inconnu, ou code connu dont un paramètre attendu manque.

View File

@ -159,6 +159,31 @@ class LoadLiveBlock extends StatelessWidget {
),
],
]),
// 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)),
@ -432,3 +457,22 @@ class _Note extends StatelessWidget {
]);
}
}
/// 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';
}

View File

@ -199,6 +199,54 @@ void main() {
expect(absent, plain);
});
test('BELOW_MIN_POWER — deux variantes, départagées par `state`', () {
// Premier motif ajouté depuis que le catalogue existe. Le sens porté est précis :
// il Y A du surplus, mais pas au bon format.
final watts = decisionText(
t,
const LoadDecision(
code: 'BELOW_MIN_POWER',
params: {'budgetW': 320, 'minPowerW': 500}),
);
final etats = decisionText(
t,
const LoadDecision(
code: 'BELOW_MIN_POWER',
params: {'budgetW': 900, 'minPowerW': 1500, 'state': 3}),
);
expect(watts, contains('palier'));
expect(watts, isNot(contains('BELOW_MIN_POWER')));
expect(etats, contains('état 3'));
expect(etats, isNot(watts));
});
test('BELOW_MIN_POWER ne se confond pas avec « il n\'y a rien »', () {
// SURPLUS_INSUFFICIENT et SG_NORMAL ne sortent plus que pour un budget ≤ 0. Les
// confondre ferait chercher un défaut de production là où il n'y a qu'une
// granularité de commande.
final rien = decisionText(
t, const LoadDecision(code: 'SURPLUS_INSUFFICIENT', params: {'budgetW': 0}));
final malForme = decisionText(
t,
const LoadDecision(
code: 'BELOW_MIN_POWER', params: {'budgetW': 320, 'minPowerW': 500}));
expect(rien, isNot(malForme));
expect(rien.toLowerCase(), contains('aucun surplus'));
});
test('le repli AURAIT tenu sur ce motif — code connu, paramètre manquant', () {
// C'est exactement le cas que le repli couvre, et le premier vrai à se présenter :
// une box en avance sur l'app. Sans `minPowerW`, on ne fabrique pas une phrase
// approximative — on rend le code et ses paramètres.
final phrase = decisionText(
t,
const LoadDecision(code: 'BELOW_MIN_POWER', params: {'budgetW': 320}),
);
expect(phrase, contains('BELOW_MIN_POWER'));
expect(phrase, contains('budgetW = 320'));
});
test('un code absent de l\'ARB s\'affiche en repli lisible, sans plantage', () {
final phrase = decisionText(
t,