diff --git a/integration_test/config_on_device_test.dart b/integration_test/config_on_device_test.dart index fae0732..3f19525 100644 --- a/integration_test/config_on_device_test.dart +++ b/integration_test/config_on_device_test.dart @@ -24,12 +24,15 @@ library; import 'package:etm_powersync_app/main.dart' as app; +import 'package:etm_powersync_app/providers/energy_setup_provider.dart'; import 'package:etm_powersync_app/providers/installer_mode_provider.dart'; import 'package:etm_powersync_app/providers/load_config_provider.dart'; +import 'package:etm_powersync_app/providers/load_telemetry_provider.dart'; import 'package:etm_powersync_app/providers/schema_provider.dart'; import 'package:etm_powersync_app/screens/energy/roles_devices_screen.dart'; import 'package:etm_powersync_app/services/connection_manager.dart'; import 'package:etm_powersync_app/services/nymea_service.dart'; +import 'package:etm_powersync_app/services/rpc_schema.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; @@ -244,6 +247,47 @@ void main() { debugPrint('[appareil] brouillon abandonné — rien n\'est écrit sur la box'); } + // ── 5-bis-bis. Le compteur racine est RELU, pas redemandé ─────────────── + // + // Il vivait sur la box sans que l'app le lise jamais : le rôle repartait « à + // configurer » à chaque démarrage. Une configuration jamais lue se présente comme une + // configuration perdue — et pousse à refaire un geste inutile. + final racine = await svc.getRootMeter(); + debugPrint('[appareil] compteur racine lu sur la box : ${racine ?? 'AUCUN'}'); + expect(racine, isNotNull, + reason: '.75 déclare un compteur racine : l\'app doit pouvoir le lire'); + + final setup = Provider.of(ctx, listen: false); + await setup.syncRootMeter(svc); + await tester.pump(const Duration(milliseconds: 300)); + expect(setup.toConfigureRoles.map((r) => r.name), isNot(contains('gridMeter')), + reason: 'le compteur racine ne doit plus être réclamé une fois lu'); + // Et les CHARGES n'ont plus rien à faire dans ce décompte : elles viennent de + // GetLoadConfig depuis la fusion. + for (final charge in ['dhw', 'heatPump', 'battery', 'evCharger']) { + expect(setup.toConfigureRoles.map((r) => r.name), isNot(contains(charge)), + reason: '« $charge » est une charge : elle ne se configure plus par un rôle'); + } + debugPrint('[appareil] « à configurer » : ' + '${setup.toConfigureRoles.map((r) => r.name).toList()}'); + + // ── 5-septies. Les bornes de recharge, toutes, et sans rang ───────────── + // + // Elles ne sont pas des LoadConfig : elles n'ont ni adapter, ni priority, ni domain, + // et n'apparaissent dans aucun domaine. Sans leur section, une borne configurée serait + // simplement invisible. La liste vient de l'interface nymea `evcharger`, donc toute + // borne appairée y entre — y compris une seconde. + final classesBorne = { + for (final c in svc.thingClasses) + if (c.interfaces.contains('evcharger')) c.id + }; + final bornes = [ + for (final t in svc.things) + if (classesBorne.contains(t.thingClassId)) t.name + ]; + debugPrint('[appareil] bornes vues : $bornes'); + expect(bornes, isNotEmpty, reason: '.75 en déclare au moins une'); + // ── 5-ter. Le chip de domaine PORTE son libellé ───────────────────────── // // Il ne rendait qu'une étiquette dessinée : le domaine d'une charge était illisible @@ -261,24 +305,76 @@ void main() { expect(texteChip.any((t) => t.trim().isNotEmpty), isTrue, reason: 'le chip doit porter un libellé, pas seulement une icône'); + // ── 5-quinquies. Les paliers viennent de la BOX, pas d'un calcul de l'app ── + // + // `+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; + debugPrint('[appareil] paliers publiés par la box : $stages'); + expect(stages, isNotNull, + reason: '.75 est en +etm16 : stagesW doit être publié'); + expect(stages, [0, 500, 1000, 1500, 2000, 2500, 3000, 3500]); + + // ── 5-sexies. Les deux champs de mesure sont ARRIVÉS au schéma ────────── + // + // Ils étaient grisés « pas encore supporté ». `+etm16` les porte : le grisage dérivé + // du schéma les a donc activés SEUL, sans nouvelle version de l'app. + for (final champ in ['meterThingId', 'sensorThingId']) { + final sup = schema.schema.paramSupport( + 'NymeaEnergy.SetLoadConfig', ['loadConfigs', '[]', champ]); + debugPrint('[appareil] $champ → $sup'); + expect(sup, FieldSupport.writable, + reason: '.75 est en +etm16 : « $champ » doit être écrivable'); + } + // ── 5-quater. L'écran de mécanisme, et son sélecteur ACTIF ────────────── + // Le bouton est sous la ligne de flottaison : il faut l'amener à l'écran avant de + // taper, sinon le geste ne porte sur rien. final boutons = find.text('Configurer'); + await tester.ensureVisible(boutons.first); + await tester.pump(const Duration(milliseconds: 400)); await tester.tap(boutons.first); - await tester.pump(const Duration(seconds: 2)); + await tester.pump(const Duration(milliseconds: 600)); + await _attendre(tester, () => find.text('Mécanisme').evaluate().isNotEmpty, + quoi: 'l\'ouverture de l\'écran de mécanisme'); for (final m in ['Routeur de relais', 'Modulable', 'SG-Ready']) { expect(find.text(m), findsWidgets, reason: 'le sélecteur de mécanisme doit proposer « $m »'); } - // Les sections que la maquette exige et qui manquaient. - for (final titre in ['Relais commandés', 'Temporisations', 'Compteur dédié', - 'Arbitrage']) { - expect(find.text(titre), findsWidgets, reason: 'section « $titre » absente'); + // Les sections attendues DÉPENDENT du mécanisme ouvert — c'est tout l'objet de ces + // écrans : ils ne se ressemblent pas. « Configurer » ouvre la première carte, et + // l'ordre des domaines n'est pas fixe ; on regarde donc ce qui s'est ouvert. + final estRelais = find.text('Relais commandés').evaluate().isNotEmpty || + find.text('Contacts SG-Ready').evaluate().isEmpty; + final attendues = estRelais + ? ['Relais commandés', 'Temporisations', 'Compteur dédié', + 'Sonde de température', 'Arbitrage'] + : ['Contacts SG-Ready', 'Table d\'états', 'Compteur dédié', + 'Sonde de température', 'Arbitrage']; + + final vues = {}; + for (var pas = 0; pas < 30 && vues.length < attendues.length; pas++) { + for (final t in attendues) { + if (find.text(t).evaluate().isNotEmpty) vues.add(t); + } + if (vues.length == attendues.length) break; + await tester.drag( + find.byKey(const ValueKey('liste-mecanisme')), const Offset(0, -150)); + await tester.pump(const Duration(milliseconds: 250)); + } + debugPrint('[appareil] écran de mécanisme (${estRelais ? "relais" : "SG-Ready"}) — ' + 'sélecteur : Routeur de relais / Modulable / SG-Ready ; sections vues : ' + '${vues.join(' · ')} (${vues.length}/${attendues.length})'); + for (final t in attendues) { + expect(vues, contains(t), reason: 'section « $t » jamais atteinte'); } - debugPrint('[appareil] écran de mécanisme : sélecteur et sections présents'); // On repart sans rien changer. - Navigator.of(tester.element(find.text('Arbitrage'))).pop(); + Navigator.of(tester.element(find.text('Arbitrage').first)).pop(); await tester.pump(const Duration(seconds: 1)); // ── 6. On laisse l'écran affiché ──────────────────────────────────────── diff --git a/lib/models/load_config_entry.dart b/lib/models/load_config_entry.dart index 3f3001f..f55eab5 100644 --- a/lib/models/load_config_entry.dart +++ b/lib/models/load_config_entry.dart @@ -113,6 +113,16 @@ class LoadConfigEntry { return n is Map ? ((n['minEnergyWhPerDay'] as num?)?.toInt() ?? 0) : 0; } + /// Compteur dédié à CETTE charge, et sonde de la grandeur rendue. Vides = non rattachés. + /// + /// Ni l'un ni l'autre n'est du **matériel de commande** : ils n'entrent pas dans + /// `sameHardware()`, donc les rattacher ne reconstruit rien et ne réarme aucun verrou. + /// Ils n'entrent pas non plus dans le contrôle d'unicité des Things — deux charges + /// peuvent légitimement partager une sonde de pièce, et un compteur de tableau + /// divisionnaire en couvrir plusieurs. + String get meterThingId => raw['meterThingId'] as String? ?? ''; + String get sensorThingId => raw['sensorThingId'] as String? ?? ''; + /// Puissance nominale déclarée (mécanisme continu). **Saisie de l'installateur**, /// jamais une mesure : c'est une valeur de plaque. int get maxPowerW => (raw['maxPowerW'] as num?)?.toInt() ?? 0; @@ -208,6 +218,8 @@ class LoadConfigEntry { int? minOffS, int? maxPowerW, String? mode, + String? meterThingId, + String? sensorThingId, List>? relays, List? powerLevels, Map? sgReady, @@ -224,6 +236,8 @@ class LoadConfigEntry { 'minOffS': ?minOffS, 'maxPowerW': ?maxPowerW, 'mode': ?mode, + 'meterThingId': ?meterThingId, + 'sensorThingId': ?sensorThingId, 'relays': ?relays, 'powerLevels': ?powerLevels, 'sgReady': ?sgReady, diff --git a/lib/models/load_telemetry.dart b/lib/models/load_telemetry.dart index 8ebd875..05f0e1f 100644 --- a/lib/models/load_telemetry.dart +++ b/lib/models/load_telemetry.dart @@ -130,6 +130,23 @@ class LoadMechanism { double? get stageW => _dn(raw['stageW']); double? get maxStageW => _dn(raw['maxStageW']); + /// Paliers atteignables, **publiés par la box** depuis `+etm16`. + /// + /// Une seule fonction moteur (`RelayRouter::deriveStages`) sert la construction du + /// routeur, la comparaison matérielle et cette publication : il n'y a donc plus de + /// combinatoire à refaire côté app, ni de divergence possible. + /// + /// **Elle est en télémétrie, pas dans `GetLoadConfig`** — précisément pour qu'elle ne + /// parte jamais dans une écriture : ce sont les relais qui se déclarent, les paliers + /// s'en déduisent. + /// + /// `null` sur une box antérieure, ou sur un mécanisme qui n'en produit pas. + List? get stagesW { + final v = raw['stagesW']; + if (v is! List) return null; + return [for (final x in v) if (x is num) x.toInt()]; + } + // variable double? get setpointW => _dn(raw['setpointW']); double? get percent => _dn(raw['percent']); diff --git a/lib/providers/energy_setup_provider.dart b/lib/providers/energy_setup_provider.dart index 0fe3b5e..6270ac6 100644 --- a/lib/providers/energy_setup_provider.dart +++ b/lib/providers/energy_setup_provider.dart @@ -255,9 +255,15 @@ class EnergySetupProvider extends ChangeNotifier { List get controllableRoles => List.unmodifiable(_priorityOrder); /// Rôles pickés encore non configurés → zone « À configurer ». - /// Les rôles inférés n'y figurent jamais. - List get toConfigureRoles => - EmsRole.values.where((r) => r.isPicked && isUnconfigured(r)).toList(); + /// + /// **Les CHARGES en sont exclues depuis la fusion du lot C.** Elles ne viennent plus + /// d'un rôle inféré des Things mais de `GetLoadConfig` : réclamer de configurer une + /// « pompe à chaleur » pendant que la box en arbitre une, déclarée et vivante, est le + /// défaut même que la fusion devait faire disparaître. Ce qui reste ici relève des + /// **compteurs**, dont le contrat est ailleurs (`Energy.SetRootMeter`). + List get toConfigureRoles => EmsRole.values + .where((r) => r.isPicked && !r.isControllableLoad && isUnconfigured(r)) + .toList(); /// Rôles actés « Pas d'appareil ». List get absentRoles => @@ -265,13 +271,20 @@ class EnergySetupProvider extends ChangeNotifier { // ── Compteur X/6 & complétude ────────────────────────────────────────────── - int get totalRoles => EmsRole.values.length; + /// Rôles que cet écran gouverne encore — les **compteurs** seulement. + /// + /// Compter les charges ici donnerait « 2 / 6 rôles configurés · 4 restent à configurer » + /// sur une installation où les deux charges réelles sont déclarées et arbitrées. Le + /// décompte doit décrire ce que l'écran demande, pas une énumération figée. + int get totalRoles => + EmsRole.values.where((r) => !r.isControllableLoad).length; /// Rôles configurés : inférés comptés d'office ; pickés comptés quand assignés /// **ou** actés absents. int get configuredCount { var n = 0; for (final r in EmsRole.values) { + if (r.isControllableLoad) continue; // décomptées par LoadConfig, pas ici if (r.isInferred) { n++; // configurés d'office } else if (_states[r] is RolePresent || _states[r] is RoleAbsent) { @@ -458,6 +471,21 @@ class EnergySetupProvider extends ChangeNotifier { /// Infère les rôles solaire & batterie depuis les interfaces des things : ils /// passent **direct** en zone définitive (badge « détecté »). N'écrase jamais /// un choix explicite (assignation manuelle ou « Pas d'appareil »). + /// Adopte le compteur racine **que la box déclare**. + /// + /// À appeler à la connexion. Sans elle, le rôle repart « à configurer » à chaque + /// démarrage alors que la box a le compteur : une configuration jamais lue se présente + /// exactement comme une configuration perdue, et pousse à refaire un geste inutile. + Future syncRootMeter(NymeaService service) async { + final id = await service.getRootMeter(); + if (id == null) return; + final actuel = _states[EmsRole.gridMeter]; + if (actuel is RolePresent && actuel.thingId == id) return; + // `detected: true` : ce n'est pas une saisie de cette session, c'est un état lu. + _states[EmsRole.gridMeter] = RolePresent(id, detected: true); + notifyListeners(); + } + void syncFromThings(NymeaService service) { var changed = false; for (final role in const [EmsRole.solarMeter, EmsRole.battery]) { diff --git a/lib/screens/energy/load_mechanism_screen.dart b/lib/screens/energy/load_mechanism_screen.dart index d34e8be..19d4c26 100644 --- a/lib/screens/energy/load_mechanism_screen.dart +++ b/lib/screens/energy/load_mechanism_screen.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import '../../l10n/l10n_ext.dart'; import '../../models/load_config_entry.dart'; import '../../providers/load_config_provider.dart'; +import '../../providers/load_telemetry_provider.dart'; import '../../providers/schema_provider.dart'; import '../../services/load_hardware.dart'; import '../../services/load_mechanism_switch.dart'; @@ -59,6 +60,7 @@ class _LoadMechanismScreenState extends State { final provider = context.watch(); final schema = context.watch(); final service = context.watch(); + final telemetry = context.watch(); final e = _entree(provider); return Scaffold( @@ -72,6 +74,7 @@ class _LoadMechanismScreenState extends State { body: e == null ? const Center(child: Text('Charge introuvable dans la configuration.')) : ListView( + key: const ValueKey('liste-mecanisme'), padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), children: [ SchemaVersionBanner( @@ -84,7 +87,11 @@ class _LoadMechanismScreenState extends State { _Mecanisme(entry: e, provider: provider), const SizedBox(height: 14), if (e.isRelayRouter) - _SectionRelais(entry: e, provider: provider, service: service) + _SectionRelais( + entry: e, + provider: provider, + service: service, + telemetry: telemetry) else if (e.isSgReady) _SectionSgReady(entry: e, provider: provider, service: service) else @@ -128,7 +135,11 @@ class _LoadMechanismScreenState extends State { ], ), const SizedBox(height: 14), - _CompteurDedie(schema: schema.schema), + _CompteurEtSonde( + entry: e, + provider: provider, + schema: schema.schema, + service: service), const SizedBox(height: 14), _Arbitrage(entry: e, provider: provider), const SizedBox(height: 18), @@ -450,36 +461,35 @@ class _HeritageFixed extends StatelessWidget { /// Les contacteurs, et **rien d'autre de matériel**. /// -/// Les paliers atteignables sont **déduits, jamais enregistrés** : la box les compose -/// elle-même en essayant toutes les combinaisons de contacts. On les affiche parce qu'ils -/// sont ce que l'installateur veut vérifier d'un coup d'œil — et on dit qu'ils sont -/// déduits, pour que personne ne les cherche dans la configuration. +/// Les paliers atteignables sont **publiés par la box** (`mechanism.stagesW`, depuis +/// `+etm16`) et jamais enregistrés : ce sont les relais qui se déclarent, les paliers s'en +/// déduisent. L'app les LIT au lieu de refaire la combinatoire — une seule fonction moteur +/// sert la construction du routeur, la comparaison matérielle et cette publication, donc +/// plus de divergence possible. +/// +/// Tant que le brouillon n'a pas touché aux contacteurs, c'est cette table publiée qui +/// s'affiche. Dès qu'il y touche, elle devient périmée : on montre alors une **prévision**, +/// annoncée comme telle — mieux vaut dire « voici ce que ça donnera » que d'afficher une +/// table qui décrit encore l'état d'avant. class _SectionRelais extends StatelessWidget { final LoadConfigEntry entry; final LoadConfigProvider provider; final NymeaService service; + final LoadTelemetryProvider telemetry; const _SectionRelais({ required this.entry, required this.provider, required this.service, + required this.telemetry, }); - /// Toutes les puissances atteignables par combinaison de contacts, dédupliquées. - /// - /// Miroir de ce que fait le `RelayRouter` : 2^N combinaisons, doublons écartés (deux - /// relais de même puissance ne créent pas de palier supplémentaire), plafond 16 relais. - static List paliers(List relais) { - if (relais.isEmpty || relais.length > 16) return const []; - final vus = {0}; - for (var masque = 1; masque < (1 << relais.length); masque++) { - var somme = 0; - for (var i = 0; i < relais.length; i++) { - if (masque & (1 << i) != 0) somme += relais[i].powerW; - } - vus.add(somme); + /// Vrai si le brouillon a touché aux contacteurs — la table publiée est alors périmée. + bool get _relaisModifies { + for (final e in provider.entries) { + if (e.id == entry.id) return hardwareDiff(e, entry).contains('relays'); } - return vus.toList()..sort(); + return false; } void _remplacer(List> relais) => @@ -490,7 +500,11 @@ class _SectionRelais extends StatelessWidget { @override Widget build(BuildContext context) { - final p = paliers(entry.relays); + // La table PUBLIÉE par la box fait foi. Elle décrit ce qui tourne — donc elle est + // périmée dès que le brouillon touche aux contacteurs. + final publiee = telemetry.entryFor(entry.id)?.mechanism?.stagesW; + final aJour = publiee != null && !_relaisModifies; + final p = aJour ? publiee : deriveStages(entry.relays).levels; final pris = { for (final r in entry.relays) normalizeThingId(r.thingId), }; @@ -550,7 +564,9 @@ class _SectionRelais extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '${entry.relays.length} relais → paliers atteignables :', + aJour + ? '${entry.relays.length} relais → paliers publiés par la box :' + : '${entry.relays.length} relais → paliers prévus :', style: TextStyle( fontSize: 11.5, fontWeight: FontWeight.w700, @@ -566,9 +582,11 @@ class _SectionRelais extends StatelessWidget { ), const SizedBox(height: 5), Text( - 'Déduit, jamais enregistré. La box compose elle-même les paliers en ' - 'essayant toutes les combinaisons de contacts ; deux relais de même ' - 'puissance ne créent pas de palier supplémentaire.', + aJour + ? 'Publiés par la box en télémétrie, jamais enregistrés : ce sont ' + 'les contacteurs qui se déclarent, les paliers s\'en déduisent.' + : 'PRÉVISION — les contacteurs ont changé dans ce brouillon. La box ' + 'publiera sa propre table au premier cycle après enregistrement.', style: TextStyle( fontSize: 10.5, height: 1.35, @@ -1285,58 +1303,115 @@ class _LigneDuree extends StatelessWidget { } } -/// Compteur dédié — **prévu, pas encore supporté**, et l'écran le dit. +/// Compteur dédié et sonde — deux champs de **même forme**, une même règle. /// -/// Le champ n'est pas grisé « au cas où » : il est grisé parce que le schéma de la box ne -/// le porte pas, et le grisage se dérive de ce schéma. Inventer `meterThingId` ferait -/// échouer l'enregistrement de **toutes** les charges — nymea rejette toute clé inconnue -/// **avant** le handler, et le refus porte sur l'appel entier. -class _CompteurDedie extends StatelessWidget { +/// ## Ils ne sont ni du matériel, ni exclusifs +/// +/// Rattacher un compteur ne reconstruit rien : ces deux champs n'entrent pas dans +/// `sameHardware()`, donc la mise à jour est **en place**, verrous préservés. Ils n'entrent +/// pas non plus dans le contrôle d'unicité des Things — deux charges peuvent légitimement +/// partager une sonde de pièce, et un compteur de tableau divisionnaire en couvrir +/// plusieurs. Le sélecteur ne grise donc **rien** ici, contrairement aux contacteurs. +/// +/// ## Ce que le compteur autorise à conclure, et rien de plus +/// +/// Il sert à **vérifier**, jamais à décider : commandé 3 000 W, mesuré 0 W sur plusieurs +/// cycles, la charge ne fait pas ce qu'on lui demande. La mesure n'entre pas dans le +/// budget — elle est en retard sur la commande, et la charge est déjà comptée dans le +/// compteur racine dont elle n'est qu'une décomposition. L'ajouter compterait deux fois la +/// même énergie. +/// +/// ## Sélection par INTERFACE, jamais par plugin +/// +/// Le compteur se choisit parmi les Things portant `smartmeter`, la sonde parmi celles qui +/// portent une interface de température. Nommer un plugin rendrait le modèle faux au +/// prochain qui rend le même service, et obligerait à tenir une table à la main. +/// +/// ## Et si la box ne les porte pas +/// +/// Le grisage vient du **schéma**, jamais d'une table de versions : sur une box antérieure +/// le champ est absent, le bloc se grise et le dit. Sur `.75` en `+etm16` il est là — le +/// réglage s'est donc activé **seul**, sans nouvelle version de l'app, exactement comme +/// promis. +class _CompteurEtSonde extends StatelessWidget { + final LoadConfigEntry entry; + final LoadConfigProvider provider; final RpcSchema schema; + final NymeaService service; - const _CompteurDedie({required this.schema}); + const _CompteurEtSonde({ + required this.entry, + required this.provider, + required this.schema, + required this.service, + }); + + FieldSupport _support(String champ) => schema.paramSupport( + 'NymeaEnergy.SetLoadConfig', ['loadConfigs', '[]', champ]); @override Widget build(BuildContext context) { - final support = schema.paramSupport( - 'NymeaEnergy.SetLoadConfig', ['loadConfigs', '[]', 'meterThingId']); - return _Carte( - titre: 'Compteur dédié', - badge: 'prévu · pas encore supporté', - sousTitre: 'Rattacher un compteur à cette charge.', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SchemaGated( - support: support, - fieldName: 'meterThingId', - label: 'Compteur dédié', - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 11), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - border: Border.all(color: EtmTokens.lineOf(context)), - ), - child: Row(children: [ - Expanded( - child: Text('Choisir un appareil de mesure…', - style: TextStyle( - fontSize: 13.5, color: EtmTokens.mutedOf(context))), + final supportCompteur = _support('meterThingId'); + final supportSonde = _support('sensorThingId'); + + return Column( + children: [ + _Carte( + titre: 'Compteur dédié', + badge: supportCompteur.isWritable + ? 'enregistré · meterThingId' + : 'pas encore supporté', + sousTitre: 'Mesure de CETTE charge — pour vérifier ce qui est commandé, ' + 'jamais pour décider.', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SchemaGated( + support: supportCompteur, + fieldName: 'meterThingId', + label: 'Compteur dédié', + child: ThingPicker( + service: service, + interfaceRequise: 'smartmeter', + valeur: entry.meterThingId.isEmpty ? null : entry.meterThingId, + placeholder: 'Choisir un appareil de mesure…', + onChoisi: (id) => provider.replaceEntry( + entry.id, entry.patched(meterThingId: id)), ), - Icon(Icons.expand_more_rounded, - size: 20, color: EtmTokens.mutedOf(context)), - ]), + ), + const SizedBox(height: 6), + _Aide( + 'La mesure n\'entre pas dans le budget : elle est en retard sur la ' + 'commande, et cette charge est déjà comptée dans le compteur racine dont ' + 'elle n\'est qu\'une décomposition. L\'y ajouter compterait deux fois la ' + 'même énergie.', + ), + ], + ), + ), + const SizedBox(height: 14), + _Carte( + titre: 'Sonde de température', + badge: supportSonde.isWritable + ? 'enregistré · sensorThingId' + : 'pas encore supporté', + sousTitre: 'La grandeur physique du service rendu — ce que la charge produit, ' + 'pas ce qu\'elle consomme.', + child: SchemaGated( + support: supportSonde, + fieldName: 'sensorThingId', + label: 'Sonde de température', + child: ThingPicker( + service: service, + interfaceRequise: 'temperaturesensor', + valeur: entry.sensorThingId.isEmpty ? null : entry.sensorThingId, + placeholder: 'Choisir une sonde…', + onChoisi: (id) => provider.replaceEntry( + entry.id, entry.patched(sensorThingId: id)), ), ), - const SizedBox(height: 6), - _Aide( - 'La box estime la consommation depuis les paliers déclarés, elle ne la ' - 'mesure pas — c\'est ici que le besoin est le plus fort. Mais aucun champ ' - 'n\'existe : en inventer un ferait rejeter l\'enregistrement de toutes les ' - 'charges, pas seulement de celle-ci.', - ), - ], - ), + ), + ], ); } } diff --git a/lib/screens/energy/roles_devices_screen.dart b/lib/screens/energy/roles_devices_screen.dart index 2fba6fe..c153987 100644 --- a/lib/screens/energy/roles_devices_screen.dart +++ b/lib/screens/energy/roles_devices_screen.dart @@ -50,7 +50,11 @@ class _RolesDevicesScreenState extends State { if (!mounted) return; final svc = context.read(); _svc = svc; - context.read().attach(svc); + context.read() + ..attach(svc) + // Le compteur racine vit sur la BOX : le relire, sinon il se présente comme + // perdu à chaque démarrage. + ..syncRootMeter(svc); // Chemin de données des CHARGES : la configuration de la box, et rien d'autre. context.read()..attach(svc)..load(); diff --git a/lib/services/load_hardware.dart b/lib/services/load_hardware.dart index c15bb6c..769b06c 100644 --- a/lib/services/load_hardware.dart +++ b/lib/services/load_hardware.dart @@ -17,22 +17,27 @@ /// 900 s, l'installateur qui corrige une ligne se retrouve avec une machine figée un quart /// d'heure — et rien ne le lui aura dit. D'où cet écran d'annonce. /// -/// ## Le piège mesuré sur `.75` le 2026-08-25 +/// ## Les relais se comparent par leur TABLE, pas par leur liste — depuis `+etm16` /// -/// La comparaison des relais est **indexée**, pas ensembliste : `relays[i].thingId` et -/// `relays[i].powerW`, un à un. **Réordonner la liste sans rien changer d'autre est donc -/// une reconstruction.** Vérifié sur le banc — `relays[]` inversé (2000/1000/500 au lieu -/// de 500/1000/2000), même ensemble, mêmes Things, mêmes puissances : +/// La comparaison était indexée : réordonner `relays[]` sans rien changer d'autre passait +/// pour un changement matériel et coûtait une reconstruction complète. Signalé au moteur, +/// **corrigé côté moteur** — `sameHardware()` compare désormais +/// `RelayRouter::deriveStages(relays)`, c'est-à-dire la table de paliers que les relais +/// PRODUISENT, et non la liste qui la décrit. /// -/// ```text -/// [RelayRouter] chauffe-eau — reprise au démarrage : palier 0 W […] -/// [EnergyArbitrator] relay-router construit depuis config: "chauffe-eau" ( 3 relais) -/// [RelayRouter] "chauffe-eau" → […] (force) | "Charge désactivée — mise en état sûr […]" -/// [EnergyArbitrator] 2 charge(s) active(s) — 1 créée(s), 1 inchangée(s), 1 retirée(s) -/// ``` +/// Ce que ça change, et pourquoi un ensemble n'aurait pas suffi : /// -/// Un glisser-déposer dans la liste des contacteurs est donc un geste **matériel**. Il -/// doit s'annoncer comme tel, et c'est le cas le moins intuitif de tous. +/// - **réordonner des relais de puissances distinctes** ne change pas la table → aucune +/// reconstruction. Vérifié au banc : le journal dit « 2 INCHANGÉE(S) », aucun état sûr, +/// aucune commutation ; +/// - **échanger deux relais de MÊME puissance** change le contact qui sert ce palier — à +/// somme égale la table retient la première combinaison rencontrée. Autre contacteur qui +/// s'use, autre résistance qui chauffe. Un ensemble ne le verrait pas ; la table, si. +/// +/// [deriveStages] ci-dessous est le **miroir** de l'implémentation moteur. Elle existe +/// parce que l'annonce d'impact se fait côté app, avant l'écriture — mais elle ne sert +/// jamais à AFFICHER les paliers : la box les publie elle-même en télémétrie +/// (`mechanism.stagesW`), et c'est cette valeur-là qui fait foi. library; import '../models/load_config_entry.dart'; @@ -85,11 +90,49 @@ class LoadImpact { impact == LoadChangeImpact.rebuild || impact == LoadChangeImpact.removed; } +/// Table de paliers produite par une liste de relais — **miroir** de +/// `RelayRouter::deriveStages()`. +/// +/// Toutes les sommes de sous-ensembles, dédupliquées par puissance, triées, 0 inclus +/// (sous-ensemble vide). À puissance égale on garde la **première** combinaison +/// rencontrée : c'est ce qui rend l'ordre de déclaration significatif dans ce cas, et dans +/// celui-là seulement. +/// +/// \return les paliers et, pour chacun, les Things à fermer. +({List levels, List> mapping}) deriveStages( + List relays) { + var n = relays.length; + if (n > kMaxRelays) n = kMaxRelays; + + final parPuissance = >{}; + for (var masque = 0; masque < (1 << n); masque++) { + var somme = 0; + final ensemble = []; + for (var i = 0; i < n; i++) { + if (masque & (1 << i) != 0) { + somme += relays[i].powerW; + ensemble.add(normalizeThingId(relays[i].thingId)); + } + } + parPuissance.putIfAbsent(somme, () => ensemble); + } + + final niveaux = parPuissance.keys.toList()..sort(); + return ( + levels: niveaux, + mapping: [for (final k in niveaux) parPuissance[k]!], + ); +} + +/// Plafond du moteur : au-delà, la combinatoire n'est plus calculée. +const int kMaxRelays = 16; + /// Miroir **exact** de `EnergyArbitrator::sameHardware()` (energyarbitrator.cpp). /// /// Toute divergence ferait mentir l'annonce — dans un sens comme dans l'autre. Les champs /// comparés, dans l'ordre du moteur : `adapter`, `mode`, `minOnS`, `minOffS`, `maxPowerW`, -/// `powerLevels`, la charge utile `sgReady` entière, puis `relays[]` **index par index**. +/// `powerLevels`, la charge utile `sgReady` entière, puis la **table** que `relays[]` +/// produit — [deriveStages], et non la liste elle-même. /// /// \return vrai si le moteur gardera l'adaptateur en place. bool sameHardware(LoadConfigEntry a, LoadConfigEntry b) => @@ -111,19 +154,14 @@ List hardwareDiff(LoadConfigEntry a, LoadConfigEntry b) { if (!_sameIntList(a.powerLevelsInt, b.powerLevelsInt)) diff.add('powerLevels'); if (!_sameSgReady(a, b)) diff.add('sgReady'); - // Comparaison INDEXÉE, comme le moteur : un réordonnancement compte. - final ra = a.relays, rb = b.relays; - var relaysDiffer = ra.length != rb.length; - if (!relaysDiffer) { - for (var i = 0; i < ra.length; i++) { - if (normalizeThingId(ra[i].thingId) != normalizeThingId(rb[i].thingId) || - ra[i].powerW != rb[i].powerW) { - relaysDiffer = true; - break; - } - } + // On compare la TABLE que les relais produisent, comme le moteur depuis `+etm16` — + // pas la liste qui la décrit. + final ta = deriveStages(a.relays); + final tb = deriveStages(b.relays); + if (!_sameIntList(ta.levels, tb.levels) || + !_sameMapping(ta.mapping, tb.mapping)) { + diff.add('relays'); } - if (relaysDiffer) diff.add('relays'); return diff; } @@ -240,6 +278,26 @@ bool _sameSgReady(LoadConfigEntry a, LoadConfigEntry b) { return true; } +/// Compare deux affectations palier → contacts, **position par position**, chaque +/// combinaison étant comparée comme un **ENSEMBLE**. +/// +/// Miroir de `RelayStageTable::operator==`. Les ThingIds d'une combinaison sont collectés +/// dans l'ordre de déclaration des relais : fermer {K1,K2} et fermer {K2,K1} produit deux +/// listes différentes pour un geste électrique identique. Les comparer comme des listes +/// ferait reconstruire la charge sur un simple réordonnancement — le défaut même que cette +/// table corrige. +/// +/// L'ordre reste significatif là où il l'est vraiment : dans le **choix** de la combinaison +/// retenue à somme égale, que `levels` et l'appariement position par position capturent. +bool _sameMapping(List> a, List> b) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i].length != b[i].length) return false; + if (!a[i].toSet().containsAll(b[i])) return false; + } + return true; +} + bool _sameStringList(List a, List b) { if (a.length != b.length) return false; for (var i = 0; i < a.length; i++) { diff --git a/lib/services/nymea_service.dart b/lib/services/nymea_service.dart index 4eddfb0..d0e675a 100644 --- a/lib/services/nymea_service.dart +++ b/lib/services/nymea_service.dart @@ -1130,6 +1130,33 @@ class NymeaService extends ChangeNotifier { // CONFIG ÉNERGIE — rôles & charges pilotées (écran Rôles & appareils) // ═══════════════════════════════════════════════════════════════════════════ + /// `Energy.GetRootMeter` — le compteur principal **tel que la box le connaît**. + /// + /// ## Ce qu'il répare + /// + /// L'app ne l'appelait **jamais** : elle savait poser le compteur racine + /// (`SetRootMeter`) mais pas le relire. Son état en mémoire repartait donc vide à chaque + /// démarrage, et le rôle « compteur réseau » réapparaissait « à configurer » alors que la + /// box l'avait bel et bien — vérifié sur `.75`, qui rend + /// `549449c1-…` (Fronius Three Phase Meter) sans discontinuer. + /// + /// Rien n'était perdu : c'était une configuration **jamais lue**. La nuance compte, parce + /// qu'elle change le geste — il n'y a pas à reconfigurer, il y a à afficher. + /// + /// \return le ThingId du compteur racine, ou `null` si la box n'en a pas. + Future getRootMeter() async { + if (_isSimulation || !_connected) return null; + try { + final r = await _sendRequest('Energy.GetRootMeter', {}); + final id = r['params']?['rootMeterThingId'] as String?; + _log('[RootMeter] GetRootMeter → ${id ?? 'aucun'}', force: true); + return (id == null || id.isEmpty) ? null : id; + } catch (e) { + _log('[RootMeter] GetRootMeter: $e', force: true); + return null; + } + } + /// gridMeter → **réel** `Energy.SetRootMeter` (natif, testable contre `hems`). Future setRootMeter(String thingId) async { if (_isSimulation || !_connected) { diff --git a/lib/widgets/controllable_loads_section.dart b/lib/widgets/controllable_loads_section.dart index fe09547..9b57a5e 100644 --- a/lib/widgets/controllable_loads_section.dart +++ b/lib/widgets/controllable_loads_section.dart @@ -74,16 +74,22 @@ class ControllableLoadsSection extends StatelessWidget { // Le budget du cycle AVANT les charges : « 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. TelemetryHeader(telemetry: telemetry), - // Les domaines se réordonnent par les FLÈCHES, pas au glisser-déposer. + // Le niveau glissable est celui des DOMAINES, pas des cartes. // - // Tentative faite et annulée : envelopper les domaines dans un second - // `ReorderableListView` rend bien les domaines glissables, mais **casse le - // glissement des cartes** — deux listes réordonnables imbriquées se disputent le - // geste, et c'est l'extérieure qui gagne. Le test `reorder_drag_test.dart` le - // montre en une exécution. On garde donc un seul niveau glissable, celui des - // charges, et des flèches explicites pour les domaines. - for (var i = 0; i < groups.length; i++) - _DomainBlock( + // Deux ReorderableListView imbriqués se disputent le geste — l'extérieur gagne et + // l'intérieur devient inerte. Il faut donc choisir, et le choix se décide par ce + // que l'installateur cherche à faire : sur une installation ordinaire, chaque + // domaine porte UNE charge, et l'ordre de service se règle donc entre domaines. + // C'est là que la prise doit être. Réordonner deux charges d'un même domaine reste + // possible, par les flèches de leur carte. + ReorderableListView.builder( + shrinkWrap: true, + primary: false, + physics: const NeverScrollableScrollPhysics(), + buildDefaultDragHandles: false, + itemCount: groups.length, + onReorder: provider.reorderDomains, + itemBuilder: (context, i) => _DomainBlock( key: ValueKey('domain-${groups[i].domain}'), group: groups[i], index: i, @@ -92,6 +98,7 @@ class ControllableLoadsSection extends StatelessWidget { telemetry: telemetry, onConfigure: onConfigure, ), + ), // La borne n'est PAS une LoadConfig : elle n'apparaît donc dans aucun domaine // ci-dessus, et sans cette section elle serait tout simplement invisible — ce // qu'un installateur qui vient de la configurer lit comme une perte. @@ -136,6 +143,15 @@ class _DomainBlock extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ + ReorderableDragStartListener( + key: ValueKey('poignee-domaine-${group.domain}'), + index: index, + child: Container( + color: Colors.transparent, + padding: const EdgeInsets.only(right: 7, top: 4, bottom: 4), + child: Icon(Icons.drag_indicator_rounded, size: 19, color: muted), + ), + ), Expanded( child: Text( domainLabel(t, group.domain), @@ -164,26 +180,19 @@ class _DomainBlock extends StatelessWidget { ), ]), const SizedBox(height: 6), - ReorderableListView.builder( - shrinkWrap: true, - primary: false, - physics: const NeverScrollableScrollPhysics(), - buildDefaultDragHandles: false, - itemCount: group.loads.length, - onReorder: (o, n) => provider.reorderWithinDomain(group.domain, o, n), - itemBuilder: (context, i) => Padding( + for (var i = 0; i < group.loads.length; i++) + Padding( key: ValueKey('load-${group.loads[i].id}'), padding: const EdgeInsets.only(bottom: 10), child: LoadConfigCard( entry: group.loads[i], - handleIndex: i, - reordonnable: group.loads.length > 1, + indexDansDomaine: i, + totalDansDomaine: group.loads.length, provider: provider, telemetry: telemetry, onConfigure: onConfigure, ), ), - ), ], ), ); @@ -193,11 +202,11 @@ class _DomainBlock extends StatelessWidget { /// Carte d'une charge : identité, classement, rang, télémétrie, et l'accès au mécanisme. class LoadConfigCard extends StatelessWidget { final LoadConfigEntry entry; - final int handleIndex; - - /// Faux quand la charge est **seule dans son domaine** : il n'y a alors rien à - /// réordonner ici, et l'ordre se change au niveau des domaines. - final bool reordonnable; + /// Position dans son domaine, et taille du domaine. Ensemble, ils disent si un + /// réordonnancement INTERNE a un sens — seul dans son domaine, il n'y a rien à déplacer, + /// et l'ordre se change alors en glissant le domaine. + final int indexDansDomaine; + final int totalDansDomaine; final LoadConfigProvider provider; final LoadTelemetryProvider telemetry; @@ -206,8 +215,8 @@ class LoadConfigCard extends StatelessWidget { const LoadConfigCard({ super.key, required this.entry, - required this.handleIndex, - this.reordonnable = true, + required this.indexDansDomaine, + required this.totalDansDomaine, required this.provider, required this.telemetry, required this.onConfigure, @@ -264,47 +273,47 @@ class LoadConfigCard extends StatelessWidget { Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Seule dans son domaine, une charge n'a nulle part où aller : la poignée - // serait un mensonge. On garde le rang, on retire la prise. - _PoigneeOuPas( - key: ValueKey('poignee-charge-${entry.id}'), - actif: reordonnable, - index: handleIndex, - // `Container` transparent : rend la ZONE ENTIÈRE préhensible. - // `ReorderableDragStartListener` défère le test de collision à ses - // enfants ; sans ça, le vide de 3 px entre l'icône et la pastille ne - // saisit rien — une bande morte invisible, au milieu d'une poignée qui - // paraît d'un seul tenant. - child: Container( - color: Colors.transparent, - padding: const EdgeInsets.only(top: 1, right: 9), - child: Column(children: [ - Icon( - reordonnable - ? Icons.drag_indicator_rounded - : Icons.remove_rounded, - size: 20, - color: reordonnable ? muted : muted.withValues(alpha: 0.35), + // Le rang, et — si le domaine porte plusieurs charges — de quoi les + // ordonner entre elles. Le glissement, lui, appartient au domaine. + Padding( + padding: const EdgeInsets.only(top: 1, right: 9), + child: Column(children: [ + Container( + width: 20, + height: 20, + alignment: Alignment.center, + decoration: BoxDecoration( + color: EtmTokens.brand.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(6), ), - const SizedBox(height: 3), - // Le rang est ce que le glisser-déposer produit : il se lit ici, - // pas noyé dans une phrase. - Container( - width: 20, - height: 20, - alignment: Alignment.center, - decoration: BoxDecoration( - color: EtmTokens.brand.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(6), - ), - child: Text('${entry.priority}', - style: const TextStyle( - fontSize: 10.5, - fontWeight: FontWeight.w700, - color: EtmTokens.brand)), + child: Text('${entry.priority}', + style: const TextStyle( + fontSize: 10.5, + fontWeight: FontWeight.w700, + color: EtmTokens.brand)), + ), + if (totalDansDomaine > 1) ...[ + const SizedBox(height: 2), + _FlecheRang( + key: ValueKey('monter-${entry.id}'), + icone: Icons.keyboard_arrow_up_rounded, + actif: indexDansDomaine > 0, + onTap: () => provider.reorderWithinDomain( + entry.domain ?? kUnclassifiedDomain, + indexDansDomaine, + indexDansDomaine - 1), ), - ]), - ), + _FlecheRang( + key: ValueKey('descendre-${entry.id}'), + icone: Icons.keyboard_arrow_down_rounded, + actif: indexDansDomaine < totalDansDomaine - 1, + onTap: () => provider.reorderWithinDomain( + entry.domain ?? kUnclassifiedDomain, + indexDansDomaine, + indexDansDomaine + 2), + ), + ], + ]), ), Expanded( child: Column( @@ -787,32 +796,33 @@ class BorneSection extends StatelessWidget { } -/// Enveloppe une poignée : prise réelle si [actif], simple décor sinon. +/// Petite flèche de rang, à l'intérieur d'un domaine. /// -/// `ReorderableDragStartListener` s'attend à vivre sous un `ReorderableListView` ; l'utiliser -/// pour une liste d'un seul élément produirait une poignée qui s'agrippe et ne mène nulle -/// part. Mieux vaut ne pas offrir la prise. -class _PoigneeOuPas extends StatelessWidget { +/// Le glisser-déposer appartient au domaine ; à l'intérieur, deux charges se réordonnent +/// par ces flèches. Rendre les deux niveaux glissables est impossible — les listes +/// réordonnables imbriquées se disputent le geste, et c'est l'extérieure qui gagne. +class _FlecheRang extends StatelessWidget { + final IconData icone; final bool actif; - final int index; - final Widget child; + final VoidCallback onTap; - const _PoigneeOuPas({ + const _FlecheRang({ super.key, + required this.icone, required this.actif, - required this.index, - required this.child, + required this.onTap, }); @override Widget build(BuildContext context) { - if (!actif) { - return Tooltip( - message: 'Seule charge de ce domaine — son rang se change en déplaçant le ' - 'domaine lui-même.', - child: child, - ); - } - return ReorderableDragStartListener(index: index, child: child); + final muted = EtmTokens.mutedOf(context); + return InkWell( + onTap: actif ? onTap : null, + borderRadius: BorderRadius.circular(4), + child: Icon(icone, + size: 18, + color: actif ? muted : muted.withValues(alpha: 0.3)), + ); } } + diff --git a/test/load_hardware_test.dart b/test/load_hardware_test.dart index 15c5872..7e579dd 100644 --- a/test/load_hardware_test.dart +++ b/test/load_hardware_test.dart @@ -112,8 +112,12 @@ void main() { }); }); - group('sameHardware — ce qui RECONSTRUIT', () { - test('RÉORDONNER les relais suffit — la comparaison du moteur est indexée', () { + group('sameHardware — les relais, comparés par leur TABLE', () { + test('réordonner des relais de puissances DISTINCTES ne reconstruit RIEN', () { + // Comportement inversé par le moteur en `+etm16`, après signalement : la comparaison + // porte sur la TABLE de paliers, pas sur la liste. Même ensemble, mêmes puissances, + // ordre différent → même table → aucune reconstruction. Vérifié au banc : le journal + // dit « 2 INCHANGÉE(S) », aucun état sûr, aucune commutation. final origine = chauffeEau(); final inverse = chauffeEau( relays: (origine['relays'] as List) @@ -124,16 +128,47 @@ void main() { final a = LoadConfigEntry(origine); final b = LoadConfigEntry(inverse); - // Même ensemble, mêmes Things, mêmes puissances — et pourtant : - expect(sameHardware(a, b), isFalse, - reason: 'vérifié sur .75 : relays[] inversé → adaptateur reconstruit, ' - 'ancien passé par applySafeState (contacts ouverts, force)'); - expect(hardwareDiff(a, b), ['relays']); + expect(sameHardware(a, b), isTrue, + reason: 'la table produite est identique — rien à reconstruire'); + expect(hardwareDiff(a, b), isEmpty); + expect(impactOfSave(avant: [a], apres: [b]).single.needsWarning, isFalse); + }); - final impacts = impactOfSave(avant: [a], apres: [b]); - expect(impacts.single.impact, LoadChangeImpact.rebuild); - expect(impacts.single.needsWarning, isTrue); - expect(impacts.single.rearmSeconds, 60); // max(minOnS, minOffS) + test('échanger deux relais de MÊME puissance reconstruit — le contact change', () { + // Le cas où l'ordre reste significatif, et le seul : à somme égale la table retient + // la PREMIÈRE combinaison rencontrée. Échanger deux relais identiques change donc le + // contact qui sert ce palier — autre contacteur qui s'use, autre résistance qui + // chauffe. Un ensemble ne le verrait pas. + const rA = '{aaaaaaaa-0000-0000-0000-00000000000a}'; + const rB = '{bbbbbbbb-0000-0000-0000-00000000000b}'; + final a = LoadConfigEntry(chauffeEau(relays: [ + {'powerW': 1000, 'thingId': rA}, + {'powerW': 1000, 'thingId': rB}, + ])); + final b = LoadConfigEntry(chauffeEau(relays: [ + {'powerW': 1000, 'thingId': rB}, + {'powerW': 1000, 'thingId': rA}, + ])); + + expect(hardwareDiff(a, b), ['relays']); + expect(impactOfSave(avant: [a], apres: [b]).single.impact, + LoadChangeImpact.rebuild); + }); + + test('la table dédoublonne : deux relais identiques ne font pas 4 paliers', () { + final t = deriveStages(const [ + LoadRelay(thingId: '{a}', powerW: 1000), + LoadRelay(thingId: '{b}', powerW: 1000), + ]); + expect(t.levels, [0, 1000, 2000]); + // Le palier 1000 est servi par le PREMIER relais déclaré. + expect(t.mapping[1], ['{a}']); + }); + + test('la table du banc : trois relais → huit paliers', () { + final t = deriveStages(LoadConfigEntry(chauffeEau()).relays); + // Exactement ce que la box publie en télémétrie sur `.75`. + expect(t.levels, [0, 500, 1000, 1500, 2000, 2500, 3000, 3500]); }); test('changer une puissance de contacteur reconstruit', () { diff --git a/test/merged_config_screen_test.dart b/test/merged_config_screen_test.dart index 68e6295..0d62174 100644 --- a/test/merged_config_screen_test.dart +++ b/test/merged_config_screen_test.dart @@ -104,7 +104,10 @@ void main() { }); group('annonce d\'impact avant écriture', () { - test('réordonner les contacteurs déclenche un avertissement, pas un silence', () { + test('réordonner des contacteurs de puissances distinctes n\'avertit de RIEN', () { + // Inversé par le moteur en `+etm16` : la comparaison porte sur la TABLE de paliers, + // pas sur la liste. Même table → aucune reconstruction → rien à annoncer. Avertir + // ici reviendrait à effrayer sur un geste devenu gratuit. final config = LoadConfigProvider()..adoptForTest(_benchConfig()); final avant = config.entries.firstWhere((e) => e.id == 'chauffe-eau'); @@ -115,9 +118,23 @@ void main() { config.replaceEntry('chauffe-eau', inverse); expect(config.hasPendingChanges, isTrue); - expect(config.pendingNeedsWarning, isTrue, - reason: 'la comparaison du moteur est indexée — vérifié sur .75'); + expect(config.pendingNeedsWarning, isFalse, + reason: 'les trois relais ont des puissances distinctes : même table'); + }); + test('changer la puissance d\'un contacteur avertit — la table change', () { + final config = LoadConfigProvider()..adoptForTest(_benchConfig()); + final avant = config.entries.firstWhere((e) => e.id == 'chauffe-eau'); + config.replaceEntry( + 'chauffe-eau', + avant.patched(relays: [ + {'thingId': avant.relays[0].thingId, 'powerW': 800}, + for (final r in avant.relays.skip(1)) + {'thingId': r.thingId, 'powerW': r.powerW} + ]), + ); + + expect(config.pendingNeedsWarning, isTrue); final impact = config .pendingImpact() .firstWhere((i) => i.loadId == 'chauffe-eau'); diff --git a/test/reorder_drag_test.dart b/test/reorder_drag_test.dart index 8308fdb..89bc7ee 100644 --- a/test/reorder_drag_test.dart +++ b/test/reorder_drag_test.dart @@ -1,28 +1,23 @@ -/// Le glisser-déposer du rang — **est-ce le geste, ou l'imbrication ?** +/// Le réordonnancement du rang — **où doit vivre la prise ?** /// -/// Constaté sur appareil le 2026-08-25 : déplacer une charge ne produit rien. Deux causes -/// possibles donnent exactement le même symptôme, et il faut les séparer avant de corriger -/// quoi que ce soit : +/// Constaté sur appareil : déplacer une charge ne produisait rien. L'enquête a donné trois +/// résultats, et le dernier a fait revenir sur un choix de conception. /// -/// 1. **il n'y avait rien à déplacer** — sur `.75` chaque domaine ne contient qu'une seule -/// charge, et glisser dans une liste d'un élément ne peut rien produire ; -/// 2. **le geste est avalé** — la section vit dans le `ListView` de l'écran, et un -/// `ReorderableListView` imbriqué dans un scrollable peut perdre le geste au profit du -/// parent. +/// 1. **Le geste n'est pas avalé** par le `ListView` parent : section seule et section +/// imbriquée se comportent identiquement. +/// 2. **Le symptôme venait du banc** : `.75` n'a qu'une charge par domaine, et glisser dans +/// une liste d'un élément ne peut rien produire. +/// 3. **Le niveau glissable était le mauvais.** Sur une installation ordinaire, chaque +/// domaine porte une charge : l'ordre de service se règle donc *entre domaines*, et +/// c'est là que la prise doit être. Rendre les deux niveaux glissables est impossible — +/// deux `ReorderableListView` imbriqués se disputent le geste, et l'extérieur gagne. +/// Le glissement appartient donc au **domaine** ; à l'intérieur, deux charges se +/// réordonnent par des **flèches**. /// -/// D'où deux montages : la section **seule**, puis la section **dans un `ListView`** comme -/// l'écran réel. -/// -/// **Verdict du 2026-08-26 : ni l'un ni l'autre.** Les deux montages glissent -/// identiquement — le `ListView` parent n'avale rien. La cause du symptôme était bien que -/// le banc n'a **qu'une charge par domaine**. -/// -/// Mais l'enquête a trouvé un **second défaut, réel celui-là** : le centre de la poignée -/// tombait dans le vide de 3 px séparant l'icône de la pastille de rang, et -/// `ReorderableDragStartListener` défère le test de collision à ses enfants. Une bande -/// morte invisible, au milieu d'une poignée qui paraît d'un seul tenant. Corrigé par un -/// `Container` transparent qui rend la zone entière préhensible — d'où le test qui saisit -/// délibérément le **centre du bloc**, pas l'icône. +/// Un défaut réel a été trouvé au passage et corrigé : le centre de la poignée tombait dans +/// le vide séparant l'icône du reste, et `ReorderableDragStartListener` défère le test de +/// collision à ses enfants — bande morte invisible. D'où le `Container` transparent, et +/// d'où le test qui saisit délibérément le **centre du bloc**. library; import 'package:etm_powersync_app/l10n/app_localizations.dart'; @@ -139,60 +134,62 @@ List> _uneParDomaine() { } void main() { - testWidgets('section SEULE — glisser la 1re charge sous la 2e réordonne', + testWidgets('glisser un DOMAINE réordonne le service — section seule', (tester) async { - final config = LoadConfigProvider()..adoptForTest(_deuxDansUnDomaine()); - expect(_ordre(config), ['chauffe-eau', 'seche-serviettes']); + final config = LoadConfigProvider()..adoptForTest(_uneParDomaine()); + expect(_ordre(config), ['chauffe-eau', 'pac-terrain']); await tester.pumpWidget(_montage(config: config, dansUnListView: false)); await tester.pumpAndSettle(); - await _glisserCle(tester, 'poignee-charge-chauffe-eau', 'load-seche-serviettes'); + await _glisserCle(tester, 'poignee-domaine-ecs', 'load-pac-terrain'); - expect(_ordre(config), ['seche-serviettes', 'chauffe-eau'], - reason: 'le glissement doit produire un nouvel ordre'); + expect(_ordre(config), ['pac-terrain', 'chauffe-eau'], + reason: 'le glissement du domaine doit produire un nouvel ordre'); expect(config.hasPendingChanges, isTrue); }); - testWidgets('section DANS UN LISTVIEW — le geste survit-il à l\'imbrication ?', + testWidgets('le geste survit à l\'imbrication dans le ListView de l\'écran', (tester) async { - final config = LoadConfigProvider()..adoptForTest(_deuxDansUnDomaine()); + final config = LoadConfigProvider()..adoptForTest(_uneParDomaine()); await tester.pumpWidget(_montage(config: config, dansUnListView: true)); await tester.pumpAndSettle(); - await _glisserCle(tester, 'poignee-charge-chauffe-eau', 'load-seche-serviettes'); + await _glisserCle(tester, 'poignee-domaine-ecs', 'load-pac-terrain'); - expect(_ordre(config), ['seche-serviettes', 'chauffe-eau'], + expect(_ordre(config), ['pac-terrain', 'chauffe-eau'], reason: 'si CE test échoue seul, le ListView parent avale le geste'); }); - testWidgets('une charge par domaine — la poignée n\'agrippe pas, et le dit', + testWidgets('deux charges dans un domaine — les FLÈCHES les réordonnent', (tester) async { - // Le cas du banc, et celui qui donnait l'impression que « rien ne fonctionne » : il - // n'y a rien à réordonner à l'intérieur d'un domaine d'une seule charge. - final config = LoadConfigProvider()..adoptForTest(_uneParDomaine()); - expect(_ordre(config), ['chauffe-eau', 'pac-terrain']); + final config = LoadConfigProvider()..adoptForTest(_deuxDansUnDomaine()); + expect(_ordre(config), ['chauffe-eau', 'seche-serviettes']); await tester.pumpWidget(_montage(config: config, dansUnListView: true)); await tester.pumpAndSettle(); - // La poignée existe — le rang reste lisible — mais elle n'offre AUCUNE prise. - for (final id in ['chauffe-eau', 'pac-terrain']) { - final poignee = find.byKey(ValueKey('poignee-charge-$id')); - expect(poignee, findsOneWidget); - expect( - find.descendant( - of: poignee, matching: find.byType(ReorderableDragStartListener)), - findsNothing, - reason: 'seule dans son domaine, la charge ne doit pas faire croire ' - 'qu\'on peut la déplacer', - ); - } + // Descendre la première : c'est le geste interne au domaine. + final bas = find.byKey(const ValueKey('descendre-chauffe-eau')); + expect(bas, findsOneWidget); + await tester.tap(bas); + await tester.pumpAndSettle(); - // Et l'ordre se change bien par les flèches de domaine. - config.reorderDomains(0, 2); - expect(_ordre(config), ['pac-terrain', 'chauffe-eau']); + expect(_ordre(config), ['seche-serviettes', 'chauffe-eau']); + }); + + testWidgets('une charge seule dans son domaine n\'a pas de flèches', + (tester) async { + // Rien à réordonner à l'intérieur : ne pas offrir de commande qui ne mène nulle part. + final config = LoadConfigProvider()..adoptForTest(_uneParDomaine()); + await tester.pumpWidget(_montage(config: config, dansUnListView: true)); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('descendre-chauffe-eau')), findsNothing); + expect(find.byKey(const ValueKey('monter-pac-terrain')), findsNothing); + // Mais le domaine, lui, se glisse. + expect(find.byKey(const ValueKey('poignee-domaine-ecs')), findsOneWidget); }); test('le rang envoyé à la box se renumérote depuis la POSITION', () {