diff --git a/integration_test/config_on_device_test.dart b/integration_test/config_on_device_test.dart index 3f19525..57e5288 100644 --- a/integration_test/config_on_device_test.dart +++ b/integration_test/config_on_device_test.dart @@ -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 ecrireDomaines(Map 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 = {}; 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(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(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(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 diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 8f1fdb6..0acf00e 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -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.", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 6e3e96c..763bb46 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -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: diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 78aac1a..c048308 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -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 diff --git a/lib/models/load_config_entry.dart b/lib/models/load_config_entry.dart index f55eab5..0d2d881 100644 --- a/lib/models/load_config_entry.dart +++ b/lib/models/load_config_entry.dart @@ -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 patchedRaw(Map cles) => {...raw, ...cles}; + @override String toString() => 'LoadConfigEntry($id, $adapter, p=$priority, d=$domain)'; } diff --git a/lib/models/load_telemetry.dart b/lib/models/load_telemetry.dart index 05f0e1f..1632f42 100644 --- a/lib/models/load_telemetry.dart +++ b/lib/models/load_telemetry.dart @@ -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.from(lock)) : null, mechanism: mech is Map ? LoadMechanism.fromJson(Map.from(mech)) diff --git a/lib/screens/energy/load_mechanism_screen.dart b/lib/screens/energy/load_mechanism_screen.dart index 19d4c26..21474b6 100644 --- a/lib/screens/energy/load_mechanism_screen.dart +++ b/lib/screens/energy/load_mechanism_screen.dart @@ -96,7 +96,11 @@ class _LoadMechanismScreenState extends State { _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.'), + ], + ], + ), + ); + } +} diff --git a/lib/services/telemetry_text.dart b/lib/services/telemetry_text.dart index a922231..22fce84 100644 --- a/lib/services/telemetry_text.dart +++ b/lib/services/telemetry_text.dart @@ -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. diff --git a/lib/widgets/load_cards.dart b/lib/widgets/load_cards.dart index 0d4e666..99d05b4 100644 --- a/lib/widgets/load_cards.dart +++ b/lib/widgets/load_cards.dart @@ -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'; +} diff --git a/test/load_telemetry_test.dart b/test/load_telemetry_test.dart index 082854c..bd7952b 100644 --- a/test/load_telemetry_test.dart +++ b/test/load_telemetry_test.dart @@ -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,