feat(etm16): s'aligner sur le nouveau contrat — table de paliers, stagesW, compteur et sonde

RELAIS : mon avertissement de reconstruction sur un réordonnancement est devenu FAUX, et
il est corrigé. `+etm16` compare la TABLE que les relais produisent (deriveStages), plus la
liste par index. Le miroir Dart suit, y compris le détail qui compte : les ThingIds d'une
combinaison se comparent comme un ENSEMBLE — collectés dans l'ordre de déclaration, fermer
{K1,K2} ou {K2,K1} est le même geste électrique. Mais l'appariement palier par palier
reste positionnel, ce qui préserve le seul cas où l'ordre signifie encore quelque chose :
deux relais de MÊME puissance, où il décide quel contact sert ce palier.

Conséquence pour l'écran : réordonner des contacteurs de puissances distinctes n'avertit
plus de rien. Avertir sur un geste devenu gratuit serait aussi trompeur que de se taire sur
un geste coûteux.

PALIERS : l'app ne dérive plus la combinatoire pour l'affichage — la box les publie
(mechanism.stagesW). Vérifié sur appareil : [0, 500, 1000, 1500, 2000, 2500, 3000, 3500].
deriveStages reste côté app pour la seule annonce d'impact, qui se fait avant l'écriture.
Tant que le brouillon n'a pas touché aux contacteurs, c'est la table PUBLIÉE qui s'affiche ;
dès qu'il y touche elle devient périmée, et l'écran montre une prévision annoncée comme
telle plutôt qu'une table qui décrit l'état d'avant.

COMPTEUR ET SONDE : ils ne « vont pas arriver », ils SONT au schéma de .75
(o:meterThingId, o:sensorThingId). Le grisage dérivé les a donc activés seul, sans nouvelle
version de l'app — exactement ce qui était promis. Mais un bloc actif sur un placeholder
mort est pire qu'un bloc grisé : les deux sont désormais câblés sur un vrai sélecteur,
filtré par INTERFACE (smartmeter, temperaturesensor) et jamais par nom de plugin.

Ni l'un ni l'autre n'entre dans sameHardware() : les rattacher ne reconstruit rien. Ni dans
le contrôle d'unicité : deux charges peuvent partager une sonde, un compteur divisionnaire
en couvrir plusieurs — le sélecteur ne grise donc rien ici, contrairement aux contacteurs.
Et la mesure sert à VÉRIFIER, jamais à décider : elle n'entre pas dans le budget, la charge
étant déjà comptée dans le racine dont elle n'est qu'une décomposition.

COMPTEURS CUMULÉS : la base purgée, les valeurs sont saines (55 730 / 50 / 6 327 / 62 007
kWh) et la tuile « compteur box invalide » a disparu d'elle-même. Le plafond de
plausibilité reste : un zéro réel s'affiche « 0,0 kWh », jamais « — ».

hasNeverRun était déjà traité comme un état distinct — « l'absence de cycle est dite pour
elle-même, jamais rendue par un âge de zéro ».

Vérifié sur appareil contre .75 : compteur racine relu, « à configurer » vide, deux bornes
listées (Simulated wallbox + Terra AC Charger), stagesW de la box, les deux champs
writable, 5/5 sections de l'écran SG-Ready. Tests 98/98, analyze 0 erreur.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
This commit is contained in:
Patrick Schurig 2026-08-26 10:54:16 +02:00
parent 708914861e
commit cd76df3a73
12 changed files with 634 additions and 256 deletions

View File

@ -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<EnergySetupProvider>(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<LoadTelemetryProvider>(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
? <String>['Relais commandés', 'Temporisations', 'Compteur dédié',
'Sonde de température', 'Arbitrage']
: <String>['Contacts SG-Ready', 'Table d\'états', 'Compteur dédié',
'Sonde de température', 'Arbitrage'];
final vues = <String>{};
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é ────────────────────────────────────────

View File

@ -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<Map<String, dynamic>>? relays,
List<int>? powerLevels,
Map<String, dynamic>? sgReady,
@ -224,6 +236,8 @@ class LoadConfigEntry {
'minOffS': ?minOffS,
'maxPowerW': ?maxPowerW,
'mode': ?mode,
'meterThingId': ?meterThingId,
'sensorThingId': ?sensorThingId,
'relays': ?relays,
'powerLevels': ?powerLevels,
'sgReady': ?sgReady,

View File

@ -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<int>? 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']);

View File

@ -255,9 +255,15 @@ class EnergySetupProvider extends ChangeNotifier {
List<EmsRole> 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<EmsRole> 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<EmsRole> get toConfigureRoles => EmsRole.values
.where((r) => r.isPicked && !r.isControllableLoad && isUnconfigured(r))
.toList();
/// Rôles actés « Pas d'appareil ».
List<EmsRole> 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<void> 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]) {

View File

@ -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<LoadMechanismScreen> {
final provider = context.watch<LoadConfigProvider>();
final schema = context.watch<SchemaProvider>();
final service = context.watch<NymeaService>();
final telemetry = context.watch<LoadTelemetryProvider>();
final e = _entree(provider);
return Scaffold(
@ -72,6 +74,7 @@ class _LoadMechanismScreenState extends State<LoadMechanismScreen> {
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<LoadMechanismScreen> {
_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<LoadMechanismScreen> {
],
),
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<int> paliers(List<LoadRelay> relais) {
if (relais.isEmpty || relais.length > 16) return const [];
final vus = <int>{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<Map<String, dynamic>> 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.',
),
],
),
),
],
);
}
}

View File

@ -50,7 +50,11 @@ class _RolesDevicesScreenState extends State<RolesDevicesScreen> {
if (!mounted) return;
final svc = context.read<NymeaService>();
_svc = svc;
context.read<EnergySetupProvider>().attach(svc);
context.read<EnergySetupProvider>()
..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<LoadConfigProvider>()..attach(svc)..load();

View File

@ -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<int> levels, List<List<String>> mapping}) deriveStages(
List<LoadRelay> relays) {
var n = relays.length;
if (n > kMaxRelays) n = kMaxRelays;
final parPuissance = <int, List<String>>{};
for (var masque = 0; masque < (1 << n); masque++) {
var somme = 0;
final ensemble = <String>[];
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<String> 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<List<String>> a, List<List<String>> 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<String> a, List<String> b) {
if (a.length != b.length) return false;
for (var i = 0; i < a.length; i++) {

View File

@ -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<String?> 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<void> setRootMeter(String thingId) async {
if (_isSimulation || !_connected) {

View File

@ -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)),
);
}
}

View File

@ -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', () {

View File

@ -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');

View File

@ -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<Map<String, dynamic>> _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', () {