diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index d61ddfd..12296ee 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -351,7 +351,14 @@ "decisionSafeStateSgReady": "État sûr avant retrait : état 2 (normal), jamais le blocage.", "faultWriteFailed": "Échec d'écriture : l'échelle de commande est épuisée, plus aucune commande n'est émise tant que le défaut n'est pas levé.", - "faultThingMissing": "L'appareil commandé est absent de la configuration nymea.", + "faultThingMissing": "L'appareil commandé n'existe plus dans nymea — retiré, ou jamais réinstallé après un remplacement. La charge, elle, est toujours déclarée : c'est pour ça qu'elle apparaît ici.", + "@faultThingMissing": { + "description": "À NE PAS lire comme « votre configuration est cassée ». Le cas normal est un appareil physiquement remplacé : le Thing disparaît, l'entrée LoadConfig reste — 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. Constaté sur .75 le 2026-08-28 avec une ABB Terra AC remplacée par une V2C." + }, + "faultThingMissingAction": "Rien à lever ici : le défaut ne partira pas tant qu'aucun appareil ne répond. Réinstallez-le dans nymea, ou retirez cette charge de la configuration.", + "@faultThingMissingAction": { + "description": "Remplace le bouton « Lever le défaut » sur THING_MISSING. 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 est pire que pas de bouton." + }, "faultUnusableEncoding": "Encodage SG-Ready inexploitable : l'état 2 ne peut pas être exprimé, cette pompe à chaleur n'est pas pilotable.", "faultClearPending": "Levée demandée — en attente de la télémétrie…", "faultClearStillFaulty": "La charge est toujours en défaut après la levée.", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 0c5f9fc..bfd45e5 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -709,12 +709,18 @@ abstract class L10n { /// **'Échec d\'écriture : l\'échelle de commande est épuisée, plus aucune commande n\'est émise tant que le défaut n\'est pas levé.'** String get faultWriteFailed; - /// No description provided for @faultThingMissing. + /// À NE PAS lire comme « votre configuration est cassée ». Le cas normal est un appareil physiquement remplacé : le Thing disparaît, l'entrée LoadConfig reste — 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. Constaté sur .75 le 2026-08-28 avec une ABB Terra AC remplacée par une V2C. /// /// In fr, this message translates to: - /// **'L\'appareil commandé est absent de la configuration nymea.'** + /// **'L\'appareil commandé n\'existe plus dans nymea — retiré, ou jamais réinstallé après un remplacement. La charge, elle, est toujours déclarée : c\'est pour ça qu\'elle apparaît ici.'** String get faultThingMissing; + /// Remplace le bouton « Lever le défaut » sur THING_MISSING. 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 est pire que pas de bouton. + /// + /// In fr, this message translates to: + /// **'Rien à lever ici : le défaut ne partira pas tant qu\'aucun appareil ne répond. Réinstallez-le dans nymea, ou retirez cette charge de la configuration.'** + String get faultThingMissingAction; + /// No description provided for @faultUnusableEncoding. /// /// In fr, this message translates to: diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index f10c073..36baf16 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -567,7 +567,11 @@ class L10nFr extends L10n { @override String get faultThingMissing => - 'L\'appareil commandé est absent de la configuration nymea.'; + 'L\'appareil commandé n\'existe plus dans nymea — retiré, ou jamais réinstallé après un remplacement. La charge, elle, est toujours déclarée : c\'est pour ça qu\'elle apparaît ici.'; + + @override + String get faultThingMissingAction => + 'Rien à lever ici : le défaut ne partira pas tant qu\'aucun appareil ne répond. Réinstallez-le dans nymea, ou retirez cette charge de la configuration.'; @override String get faultUnusableEncoding => diff --git a/lib/models/load_telemetry.dart b/lib/models/load_telemetry.dart index 7e81119..a02ed85 100644 --- a/lib/models/load_telemetry.dart +++ b/lib/models/load_telemetry.dart @@ -163,6 +163,28 @@ class LoadMechanism { int? get state => (raw['state'] as num?)?.toInt(); double? get estimatedPowerW => _dn(raw['estimatedPowerW']); + /// ⚠️ **Ne PAS lire `chargeEnergy` comme une énergie de session.** + /// + /// Ce champ n'est pas au contrat de télémétrie — il vit sur le Thing — et cette note est + /// ici pour qu'on ne soit pas tenté de l'y faire entrer. Mesuré sur la V2C Trydan de + /// `.75` le 2026-08-28, cycle complet : la valeur est **exacte pendant la charge** (sa + /// croissance recoupe `currentPower` à mieux d'un pour cent, deux mesures de suite), + /// mais elle est **remise à zéro à l'ARRÊT de la charge** — pas au débranchement. + /// + /// Relevé : 0,3456 kWh → 0 dès que `charging` passe à faux, **119 secondes avant** que le + /// câble ne bouge. Sur du pilotage par surplus, la charge s'interrompt à chaque nuage : + /// le compteur repartirait de zéro plusieurs fois par jour, et après coup une nuit + /// entière vaudrait 0. + /// + /// Afficher ce zéro dirait « rien livré » là où la vérité est « pas mesurable » — les + /// deux états que la condition C1 de LM-1009 exige de distinguer. **Le plugin V2C + /// publiera un `sessionEnergy` construit ; attendre celui-là**, et n'accumuler rien + /// côté app en attendant. + /// + /// Même prudence pour `powerL1/L2/L3` de cette borne : ce sont des **ampères mal nommés** + /// (renommage `currentL1/L2/L3` en cours côté plugin). Ne pas les afficher, et surtout + /// n'écrire aucune conversion ici — elle survivrait au correctif et le contredirait. + // ── evcharger (3g) — l'ÉTAT RÉEL de la borne ──────────────────────────────── // // Ces quatre champs viennent du Thing ; `allocatedW` vient de l'arbitre. Depuis `+etm23` diff --git a/lib/widgets/controllable_loads_section.dart b/lib/widgets/controllable_loads_section.dart index 15f4467..b2a4590 100644 --- a/lib/widgets/controllable_loads_section.dart +++ b/lib/widgets/controllable_loads_section.dart @@ -88,6 +88,7 @@ class _ControllableLoadsSectionState extends State { children: [ // La note de rang reste EN TÊTE : elle prévient que l'ordre affiché juste en // dessous n'en est pas un. La lire après les cartes arriverait trop tard. + _NoteOrdreEntrelace(provider: provider), _NoteRangsExAequo(entries: provider.entries), // Le niveau glissable est celui des DOMAINES, pas des cartes. // @@ -143,6 +144,66 @@ class _ControllableLoadsSectionState extends State { } } +/// **L'ordre en place n'est PAS groupé par domaine — et l'enregistrer le changerait.** +/// +/// `GroupedOrder` calcule cette contiguïté depuis le premier jour, et le provider +/// l'expose (`isNotGrouped`). Rien ne l'affichait : le cas ne s'était jamais produit sur +/// le banc, chaque domaine y ayant ses charges d'un seul tenant. +/// +/// Il s'est produit le 2026-08-28, à l'arrivée de la V2C. Créée d'office au rang 1 — que +/// le chauffe-eau occupait déjà — elle a donné l'ordre à plat `ecs · ev · heating · ev · +/// ev` : le domaine `ev` est coupé en deux par le chauffage. +/// +/// **Pourquoi il faut le dire, et pas seulement le savoir.** L'écran groupe les charges +/// par domaine pour les afficher. Sur un ordre entrelacé, ce regroupement ne correspond +/// plus à l'ordre réel de la box, et le premier enregistrement réaplatit en groupant — +/// donc **change l'arbitrage** sans que personne ne l'ait demandé. Mesuré sur la +/// configuration du banc : la PAC passerait du rang 2 au rang 5. +/// +/// La note ne bloque rien : elle nomme l'écart et annonce ce que l'enregistrement fera. +/// Décider reste à l'installateur — mais en connaissance de cause. +class _NoteOrdreEntrelace extends StatelessWidget { + final LoadConfigProvider provider; + + const _NoteOrdreEntrelace({required this.provider}); + + @override + Widget build(BuildContext context) { + if (!provider.isNotGrouped) return const SizedBox.shrink(); + final doms = provider.interleavedDomains + .map((d) => domainLabel(context.l10n, d)) + .join(', '); + + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(11), + decoration: BoxDecoration( + color: EtmTokens.orange.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl), + border: Border.all(color: EtmTokens.orange.withValues(alpha: 0.45)), + ), + child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Icon(Icons.shuffle_rounded, size: 15, color: EtmTokens.orange), + const SizedBox(width: 7), + Expanded( + child: Text( + 'L\'ordre en place sur la box n\'est pas groupé par domaine : $doms ' + 'est coupé en deux par une autre famille de charges. L\'écran les regroupe ' + 'pour les afficher, donc ce que vous voyez n\'est pas l\'ordre réel.\n\n' + 'Le premier enregistrement REGROUPERA pour de bon, et changera l\'ordre de ' + 'service du surplus. Vérifiez les rangs avant d\'enregistrer.', + style: TextStyle( + fontSize: 11.5, height: 1.35, color: EtmTokens.inkOf(context)), + ), + ), + ]), + ), + ); + } +} + /// **L'ordre affiché n'est pas un choix quand des rangs sont à égalité.** /// /// À la détection d'une borne, le plugin lui attribue « le plus petit rang existant moins diff --git a/lib/widgets/load_cards.dart b/lib/widgets/load_cards.dart index 4ebb78a..d5da140 100644 --- a/lib/widgets/load_cards.dart +++ b/lib/widgets/load_cards.dart @@ -284,6 +284,9 @@ class LoadFaultBox extends StatelessWidget { 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)), @@ -319,28 +322,44 @@ class LoadFaultBox extends StatelessWidget { 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), + // ── 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), + ), ), - ), + ], ], ), ); diff --git a/test/evcharger_config_test.dart b/test/evcharger_config_test.dart index 5c83e31..7b71ea3 100644 --- a/test/evcharger_config_test.dart +++ b/test/evcharger_config_test.dart @@ -158,4 +158,75 @@ void main() { expect(tiedByPriority(regle), isEmpty); }); }); + + // ─────────────────────────────────────────────────────────────────────────── + group('terrain 2026-08-28 — la V2C remplace la Terra AC', () { + List banc() { + final raw = + File('test/fixtures/loadconfig_hems75_v2c.json').readAsStringSync(); + final list = (jsonDecode(raw) as Map)['loadConfigs'] as List; + return [ + for (final m in list.whereType()) + LoadConfigEntry(Map.from(m)), + ]; + } + + test('l\'entrée d\'une borne SURVIT à la suppression de son appareil', () { + // Le plugin crée d'office une entrée par borne détectée, et ne la retire pas quand + // le Thing s'en va. Deux jours après le remplacement physique, la Terra AC occupe + // encore un rang dans la liste de l'installateur. + final e = banc(); + expect(e.length, 5); + final terra = e.firstWhere((x) => x.label.contains('Terra')); + expect(terra.isEvCharger, isTrue); + expect(terra.enabled, isTrue, reason: 'elle n\'est pas désactivée — elle est ORPHELINE'); + expect(terra.priority, 4, reason: 'et elle consomme un rang'); + }); + + test('la note de rangs ex æquo DOIT s\'afficher sur ce banc', () { + // C'est le cas réel qui justifie la note : la V2C, créée d'office, arrive au rang 1 + // que le chauffe-eau occupe déjà. Personne n'a choisi l'ordre entre les deux. + final exAequo = tiedByPriority(banc()); + expect(exAequo, isNotEmpty); + expect(exAequo.map((e) => e.label), + containsAll(['chauffe-eau', 'V2C Trydan'])); + expect(exAequo.length, 2, + reason: 'deux charges au rang 1 — la Terra est seule au rang 4'); + }); + + test('⚠️ le rang par défaut de la V2C CASSE la contiguïté des domaines', () { + // Constat de terrain, et il va plus loin que « l'ordre n'est pas choisi ». + // + // La V2C est créée au rang 1, que le chauffe-eau (ecs) occupe déjà. L'ordre à plat + // devient : ecs, ev, heating, ev, ev — le domaine `ev` est ENTRELACÉ avec heating. + // + // L'app sait le détecter, et c'est vital : regrouper silencieusement puis réaplatir + // changerait l'arbitrage réel au premier enregistrement, sans que personne ne l'ait + // demandé. Ce test fige le fait que le cas se produit VRAIMENT sur une installation + // ordinaire — il n'était jamais apparu sur le banc avant l'arrivée de la V2C. + final groupes = groupByDomain(banc()); + expect(groupes.isContiguous, isFalse); + expect(groupes.interleavedDomains, contains('ev')); + + final aPlat = groupes.flatOrder.map((e) => e.domain).toList(); + expect(aPlat, ['ecs', 'ev', 'heating', 'ev', 'ev']); + + final ev = groupes.groups.firstWhere((g) => g.domain == 'ev'); + expect(ev.loads.length, 3, reason: 'V2C + wallbox simulée + Terra orpheline'); + }); + + test('réaplatir un ordre entrelacé le CHANGE — d\'où l\'avertissement', () { + // La preuve chiffrée de ce que l'écran doit annoncer avant d'enregistrer : la PAC + // passe du rang 2 au rang 5. Ce n'est pas un détail d'affichage, c'est l'ordre de + // service du surplus qui bascule. + final groupes = groupByDomain(banc()); + final avant = {for (final e in banc()) e.id: e.priority}; + final apres = { + for (final m in flattenToPayload(groupes.groups)) m['id']: m['priority'] + }; + expect(avant['pac-terrain'], 2); + expect(apres['pac-terrain'], 5, + reason: 'la PAC recule de trois rangs par le seul fait de regrouper'); + }); + }); } diff --git a/test/fixtures/loadconfig_hems75_v2c.json b/test/fixtures/loadconfig_hems75_v2c.json new file mode 100644 index 0000000..940f588 --- /dev/null +++ b/test/fixtures/loadconfig_hems75_v2c.json @@ -0,0 +1,160 @@ +{ + "_source": "NymeaEnergy.GetLoadConfig sur 192.168.1.75, 2026-08-28 — après remplacement de la ABB Terra AC par une V2C Trydan", + "_note": "CINQ entrées. Deux enseignements du terrain : (1) DEUX charges partagent le rang 1 — chauffe-eau et la V2C créée d'office, dont le rang par défaut « le plus petit moins un, borné à 1 » a dégénéré en égalité ; (2) l'entrée de la Terra AC SURVIT à la suppression de son Thing et occupe encore le rang 4 — le plugin crée d'office une entrée par borne détectée mais ne la retire pas.", + "loadConfigs": [ + { + "adapter": "relay-router", + "domain": "ecs", + "enabled": true, + "id": "chauffe-eau", + "label": "chauffe-eau", + "maxPowerW": 0, + "meterThingId": "4938b26a-d862-437f-b681-aaff7bce23ff", + "minOffS": 60, + "minOnS": 60, + "minPowerW": 0, + "mode": "fixed", + "needs": { + "dailyDeadline": "", + "minEnergyWhPerDay": 0 + }, + "powerLevels": [], + "priority": 1, + "relays": [ + { + "powerW": 500, + "thingId": "{8538782f-2c8c-4a30-bfce-c8140f791c9b}" + }, + { + "powerW": 1000, + "thingId": "{2ebe6bef-829b-4695-9d11-ddb4c16c5448}" + }, + { + "powerW": 2000, + "thingId": "b033b212-1adb-4df0-ba2b-8fa477de52a2" + } + ], + "sensorThingId": "", + "sgReady": {} + }, + { + "adapter": "sg-ready", + "domain": "heating", + "enabled": true, + "id": "pac-terrain", + "label": "PAC banc", + "maxPowerW": 0, + "meterThingId": "e37be924-c5c5-4858-ad0c-c82650b96db0", + "minOffS": 0, + "minOnS": 0, + "minPowerW": 0, + "mode": "fixed", + "needs": { + "dailyDeadline": "", + "minEnergyWhPerDay": 0 + }, + "powerLevels": [], + "priority": 2, + "relays": [], + "sensorThingId": "", + "sgReady": { + "minStateHoldS": 300, + "states": [ + { + "estimatedPowerW": 0, + "relays": [ + "{beaf92e1-aedc-4b84-9ce4-e423648638cc}" + ], + "state": 1 + }, + { + "estimatedPowerW": 1200, + "relays": [], + "state": 2 + }, + { + "estimatedPowerW": 1500, + "relays": [ + "{bf236e64-5ae7-4bf1-82a6-4ede03de75a6}" + ], + "state": 3 + }, + { + "estimatedPowerW": 3000, + "relays": [ + "{beaf92e1-aedc-4b84-9ce4-e423648638cc}", + "{bf236e64-5ae7-4bf1-82a6-4ede03de75a6}" + ], + "state": 4 + } + ] + } + }, + { + "adapter": "evcharger", + "domain": "ev", + "enabled": true, + "id": "{09e520fd-d472-47c4-8887-6e7759372889}", + "label": "Simulated wallbox", + "maxPowerW": 0, + "meterThingId": "", + "minOffS": 0, + "minOnS": 0, + "minPowerW": 0, + "mode": "dynamic", + "needs": { + "dailyDeadline": "", + "minEnergyWhPerDay": 0 + }, + "powerLevels": [], + "priority": 3, + "relays": [], + "sensorThingId": "", + "sgReady": {} + }, + { + "adapter": "evcharger", + "domain": "ev", + "enabled": true, + "id": "{88160e45-7df2-48cc-9639-f0d15ae6e382}", + "label": "Terra AC Charger (TCP)", + "maxPowerW": 0, + "meterThingId": "", + "minOffS": 0, + "minOnS": 0, + "minPowerW": 0, + "mode": "dynamic", + "needs": { + "dailyDeadline": "", + "minEnergyWhPerDay": 0 + }, + "powerLevels": [], + "priority": 4, + "relays": [], + "sensorThingId": "", + "sgReady": {} + }, + { + "adapter": "evcharger", + "domain": "ev", + "enabled": true, + "id": "{4d1adafd-cafc-4813-93b1-ca8933ce71ab}", + "label": "V2C Trydan", + "maxPowerW": 0, + "meterThingId": "", + "minOffS": 0, + "minOnS": 0, + "minPowerW": 0, + "mode": "dynamic", + "needs": { + "dailyDeadline": "", + "minEnergyWhPerDay": 0 + }, + "powerLevels": [], + "priority": 1, + "relays": [], + "sensorThingId": "", + "sgReady": {} + } + ] +}