/// Miroir client de `EnergyArbitrator::sameHardware()` — pour **annoncer avant /// d'enregistrer**, jamais pour décider à la place de la box. /// /// ## Ce que la distinction change vraiment /// /// `SetLoadConfig` remplace l'ensemble des charges, mais le moteur ne reconstruit pas /// tout : il compare chaque charge à celle qui a servi à bâtir l'adaptateur en place /// (`rebuildLoadAdapters()`, ECS-412). Deux issues, très différentes pour l'installation : /// /// - **mise à jour en place** — `priority`, `needs` : l'adaptateur garde son palier /// courant et ses verrous. Rien ne bouge sur le matériel. /// - **reconstruction** — tout ce que [sameHardware] compare : l'ancien adaptateur passe /// par `applySafeState()` (**contacts ouverts, `force`**), le nouveau relit l'état réel, /// et ses verrous se **réarment à froid** pour leur durée configurée (ECS-412). /// /// Sur un ballon à `minOnS` de 60 s c'est anecdotique. Sur une PAC à `minStateHoldS` de /// 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. /// /// ## Les relais se comparent par leur TABLE, pas par leur liste — depuis `+etm16` /// /// 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. /// /// Ce que ça change, et pourquoi un ensemble n'aurait pas suffi : /// /// - **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'; /// Ce qu'un enregistrement va provoquer pour une charge donnée. enum LoadChangeImpact { /// Rien ne part pour cette charge. none, /// Mise à jour en place : rang, besoins. Verrous et palier courant préservés. inPlace, /// Reconstruction de l'adaptateur : état sûr, relecture, verrous réarmés à froid. rebuild, /// La charge entre dans l'arbitrage (`enabled` faux → vrai, ou charge nouvelle). created, /// La charge en sort (`enabled` vrai → faux, ou charge retirée) : elle passe par /// `applySafeState()` avant destruction (ECS-413). removed, } /// Verdict d'impact pour une charge, avec de quoi le formuler à l'écran. class LoadImpact { final String loadId; final String label; final LoadChangeImpact impact; /// Champs matériels qui ont changé (`relays`, `sgReady`, `minOnS`, …). Vide hors /// reconstruction. Sert à dire **ce qui** a déclenché, pas seulement qu'il y a eu. final List changedFields; /// Durée du verrou qui va se réarmer, en secondes, si elle est connue. /// /// `minStateHoldS` pour une charge `sg-ready`, `max(minOnS, minOffS)` pour un /// routeur de relais. `null` quand le mécanisme n'en déclare pas. final int? rearmSeconds; const LoadImpact({ required this.loadId, required this.label, required this.impact, this.changedFields = const [], this.rearmSeconds, }); /// Vrai si l'installateur doit être averti avant que ça parte. bool get needsWarning => 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 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) => hardwareDiff(a, b).isEmpty; /// Les champs matériels qui diffèrent. Vide ⇔ [sameHardware]. /// /// Rendre la liste plutôt qu'un booléen permet de dire *ce qui* impose la reconstruction : /// « vous avez changé l'ordre des contacteurs » est actionnable, « la charge sera /// reconstruite » ne l'est pas. List hardwareDiff(LoadConfigEntry a, LoadConfigEntry b) { final diff = []; if (a.adapter != b.adapter) diff.add('adapter'); if (a.mode != b.mode) diff.add('mode'); if (a.minOnS != b.minOnS) diff.add('minOnS'); if (a.minOffS != b.minOffS) diff.add('minOffS'); if (a.maxPowerW != b.maxPowerW) diff.add('maxPowerW'); if (!_sameIntList(a.powerLevelsInt, b.powerLevelsInt)) diff.add('powerLevels'); if (!_sameSgReady(a, b)) diff.add('sgReady'); // 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'); } return diff; } /// Impact d'un enregistrement, charge par charge. /// /// [avant] est l'état de la box (celui de `GetLoadConfig`), [apres] ce qui va partir. /// Les charges sont appariées par `id` — c'est la clé du moteur. List impactOfSave({ required List avant, required List apres, }) { final parAvant = {for (final e in avant) e.id: e}; final parApres = {for (final e in apres) e.id: e}; final out = []; for (final e in apres) { final old = parAvant[e.id]; if (old == null) { out.add(LoadImpact( loadId: e.id, label: e.label, impact: LoadChangeImpact.created)); continue; } if (!old.enabled && e.enabled) { out.add(LoadImpact( loadId: e.id, label: e.label, impact: LoadChangeImpact.created)); continue; } if (old.enabled && !e.enabled) { out.add(LoadImpact( loadId: e.id, label: e.label, impact: LoadChangeImpact.removed)); continue; } if (!e.enabled) { // Désactivée avant comme après : aucun adaptateur n'existe, rien à reconstruire. out.add(LoadImpact( loadId: e.id, label: e.label, impact: LoadChangeImpact.none)); continue; } final diff = hardwareDiff(old, e); if (diff.isNotEmpty) { out.add(LoadImpact( loadId: e.id, label: e.label, impact: LoadChangeImpact.rebuild, changedFields: diff, rearmSeconds: _rearmSecondsOf(e), )); continue; } // `label` et `domain` ne sont PAS dans la comparaison du moteur : ils ne déclenchent // ni reconstruction ni mise à jour en place — l'adaptateur est réutilisé tel quel. // Seuls `priority` et `needs` provoquent un updateSoftConfig(). final soft = old.priority != e.priority || old.dailyDeadline != e.dailyDeadline || old.minEnergyWhPerDay != e.minEnergyWhPerDay; out.add(LoadImpact( loadId: e.id, label: e.label, impact: soft ? LoadChangeImpact.inPlace : LoadChangeImpact.none, )); } for (final e in avant) { if (parApres.containsKey(e.id)) continue; out.add(LoadImpact( loadId: e.id, label: e.label, impact: e.enabled ? LoadChangeImpact.removed : LoadChangeImpact.none, )); } return out; } /// Durée du verrou qui se réarmera après reconstruction, si le mécanisme en déclare une. int? _rearmSecondsOf(LoadConfigEntry e) { if (e.isSgReady) { final h = e.minStateHoldS; return h > 0 ? h : null; } final m = e.minOnS > e.minOffS ? e.minOnS : e.minOffS; return m > 0 ? m : null; } bool _sameIntList(List a, List b) { if (a.length != b.length) return false; for (var i = 0; i < a.length; i++) { if (a[i] != b[i]) return false; } return true; } /// Compare la charge utile `sg-ready` comme le moteur : `minStateHoldS`, le NOMBRE /// d'états, puis chaque état index par index (`state`, `relays`, `estimatedPowerW`). bool _sameSgReady(LoadConfigEntry a, LoadConfigEntry b) { if (a.minStateHoldS != b.minStateHoldS) return false; final sa = a.sgReadyStates, sb = b.sgReadyStates; if (sa.length != sb.length) return false; for (var i = 0; i < sa.length; i++) { if (sa[i].state != sb[i].state) return false; if (!_sameStringList( sa[i].relays.map(normalizeThingId).toList(), sb[i].relays.map(normalizeThingId).toList())) { return false; } // Le moteur compare en qFuzzyCompare : une égalité stricte de doubles suffit ici, // les deux valeurs venant du même aller-retour JSON. if (sa[i].estimatedPowerW != sb[i].estimatedPowerW) return false; } 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++) { if (a[i] != b[i]) return false; } return true; }