/// Priorité à deux niveaux — regroupement par domaine au-dessus d'un `priority` plat. /// /// ## Le modèle /// /// L'installateur manipule deux niveaux : l'ordre des **domaines** entre eux, puis /// l'ordre des **appareils à l'intérieur** d'un domaine. L'ordre effectif est /// lexicographique strict : `(rang de domaine, rang dans le domaine)`. /// /// ## Pourquoi aucun champ nouveau n'est nécessaire /// /// Un ordre lexicographique implique que les charges d'un même domaine sont **contiguës** /// dans l'ordre à plat. Le rang de domaine est donc **déductible** de la liste à plat : /// c'est l'ordre d'apparition des domaines. /// /// **La box ne sait rien des groupes.** `priority` reste un entier plat, l'arbitre est /// inchangé, et aucune clé `group` / `groupPriority` n'est ajoutée à `LoadConfig`. library; import '../models/load_config_entry.dart'; /// Ordre de domaines proposé à la mise en service, **avant** toute intervention. /// /// C'est un **défaut modifiable porté par l'app**, pas une constante du plugin : le figer /// côté box le rendrait impossible à faire évoluer sans redéployer une machine. /// /// - `ev` en premier : échéance dure — la voiture doit être chargée pour un départ. /// - `ecs` ensuite : échéance souple, thermostat mécanique en filet. /// - `hvac` : granularité fine, et chauffe la pièce → fait retomber la demande du chauffage. /// - `heating` : palier grossier, sert le reliquat. /// /// La **batterie** n'y figure pas : sa place s'exprime en cible de SOC, pas en enveloppe /// de watts. /// /// Le rang de `ev` **n'est plus décoratif** : depuis le lot plugin 3g-2 (`+etm23`), les /// bornes sont arbitrées par le waterfall comme les autres charges et portent un /// `LoadConfig.priority` ordinaire. Il n'existe **pas** de second système de priorité pour /// les bornes, et il n'y en aura pas : un classement propre aux bornes ne saurait pas /// exprimer « VE1 > ECS > VE2 », et deux classements qui ne peuvent pas s'interclasser /// sont un défaut, pas deux fonctionnalités. /// /// Une limite reste, à ne pas présenter comme du pilotage : le couplage clim → baisse de /// demande du chauffage passe par l'inertie du bâti ; il n'est ni mesuré ni vérifiable /// avec le modèle actuel. C'est une stratégie **posée par l'ordre de priorité**, pas un /// asservissement. const List kDefaultDomainOrder = ['ev', 'ecs', 'hvac', 'heating']; /// Domaines que l'app a le droit de **proposer à l'affectation**. /// /// L'énumération est **fermée côté plugin** (`ecs` · `ev` · `heating` · `hvac` · /// `battery`) et `SetLoadConfig` rejette en bloc toute valeur hors liste /// (`EnergyErrorInvalidParameter`) : proposer un domaine inventé ferait échouer /// l'enregistrement de **toutes** les charges, pas seulement de celle qu'on classe. /// /// Distinct de [kDefaultDomainOrder], qui n'est qu'un ordre de mise en service : la /// batterie est classable, mais sa place dans l'ordre de service ne s'exprime pas en /// watts. Un domaine lu depuis une box plus récente et absent d'ici s'affiche quand même /// (repli `domainUnknown`) — il n'est simplement pas proposé au choix. const List kAssignableDomains = ['ev', 'ecs', 'hvac', 'heating', 'battery']; /// Clé du bucket des charges sans domaine. `null` dans les données ; cette sentinelle /// n'existe que pour l'affichage et le tri. const String kUnclassifiedDomain = ''; /// Un domaine et ses charges, dans l'ordre de service intra-domaine. class DomainGroup { /// Code de domaine, ou [kUnclassifiedDomain] pour le bucket « non classé ». final String domain; final List loads; const DomainGroup({required this.domain, required this.loads}); bool get isUnclassified => domain == kUnclassifiedDomain; DomainGroup copyWith({List? loads}) => DomainGroup(domain: domain, loads: loads ?? this.loads); } /// Résultat de la lecture : les deux niveaux reconstitués, plus le verdict de contiguïté. class GroupedOrder { /// Domaines dans leur ordre d'apparition à plat. final List groups; /// Ordre à plat réellement en place sur la box, trié par `priority` croissant. /// C'est **cet** ordre qu'il faut afficher quand [isContiguous] est faux. final List flatOrder; /// Faux si au moins un domaine est entrelacé avec un autre dans l'ordre à plat. /// /// Une config lue peut ne pas être groupée : fichier édité à la main, config /// antérieure, charge ajoutée par un autre chemin. Dans ce cas il est **interdit** de /// regrouper silencieusement et de réaplatir — l'app changerait l'arbitrage réel au /// premier enregistrement sans que personne ne l'ait demandé. final bool isContiguous; /// Domaines effectivement entrelacés — pour nommer le problème à l'installateur. final List interleavedDomains; const GroupedOrder({ required this.groups, required this.flatOrder, required this.isContiguous, required this.interleavedDomains, }); } /// Charges dont le rang est **à égalité** avec au moins une autre. /// /// Le tri reste **total** — l'identifiant départage, donc l'ordre affiché ne bouge pas /// d'une lecture à l'autre. Mais reproductible n'est pas choisi, et la distinction compte /// à la mise en service. /// /// À la création automatique d'une borne (`+etm23`), le plugin lui donne « le plus petit /// rang existant moins un, borné à 1 ». Quand une charge occupe déjà le rang 1 — c'est le /// cas dès qu'une installation a été réglée — l'intention « en tête » **dégénère en /// égalité** : sur le banc, les deux bornes et le chauffe-eau sont tous à 1. /// /// Conséquence pour l'écran : **l'ordre affiché avant tout réglage de l'installateur est /// un artefact, pas un choix.** Le présenter comme un réglage ferait valider par /// inadvertance un classement que personne n'a posé. Le point n'est pas tranché côté /// moteur ; les vrais rangs se posent à la mise en service, ici. List tiedByPriority(List entries) { final counts = {}; for (final e in entries) { counts[e.priority] = (counts[e.priority] ?? 0) + 1; } return [ for (final e in entries) if ((counts[e.priority] ?? 0) > 1) e, ]; } /// La charge utile à réécrire pour **supprimer** une charge — `null` si elle n'y est pas. /// /// `SetLoadConfig` remplace l'ensemble : supprimer une charge, c'est réécrire toutes les /// autres. Elles sont donc recopiées **verbatim**, telles que la box vient de les rendre, /// jamais reconstruites depuis le modèle typé — un champ qu'une box plus récente porterait /// et que l'app ne connaît pas serait perdu pour toutes les charges d'un coup, en silence. /// /// Les rangs ne sont pas renumérotés : le trou laissé (1, 2, 3, 5) ne change l'ordre de /// personne — le tri est sur `priority` — alors qu'une renumérotation réécrirait le rang de /// charges que l'utilisateur n'a pas touchées. /// /// `null` plutôt qu'une liste inchangée : « rien à supprimer » et « supprimé » ne doivent /// pas se ressembler côté appelant. List>? payloadWithoutLoad( List> frais, String loadId) { final reste = >[ for (final m in frais) if (m['id'] != loadId) Map.from(m), ]; return reste.length == frais.length ? null : reste; } /// Compare ce qu'on a **envoyé** à ce que la box **porte ensuite**, clé par clé. /// /// C'est le contrôle de l'aller-retour neutre (`tools/rpc/loadconfig_roundtrip.dart`) /// appliqué à une écriture qui **retire** : une suppression ne doit changer que la /// présence d'une charge, jamais un champ d'une autre — pas même un rang. /// /// Il a fallu le faire faire à l'app plutôt qu'à l'opérateur : le 2026-08-28, la /// vérification a été faite à la main, après un autre geste, et il est devenu impossible /// de dire laquelle des deux écritures avait bougé les rangs. Une vérification qui arrive /// trop tard ne prouve rien. /// /// Rend la liste des écarts, vide quand tout concorde. Les libellés sont destinés à être /// LUS par quelqu'un qui vient de voir sa configuration se réécrire. List diffPayloads( List> envoye, List> relu) { final ecarts = []; final aId = {for (final m in envoye) m['id'] as String? ?? '': m}; final bId = {for (final m in relu) m['id'] as String? ?? '': m}; for (final entry in aId.entries) { final autre = bId[entry.key]; if (autre == null) { ecarts.add('${entry.key} a disparu'); continue; } final champs = {...entry.value.keys, ...autre.keys}; for (final k in champs) { final av = entry.value[k]; final ap = autre[k]; if (!_memeValeur(av, ap)) { ecarts.add('${entry.key} · $k : $av → $ap'); } } } for (final id in bId.keys) { if (!aId.containsKey(id)) ecarts.add('$id est apparue'); } return ecarts; } /// Égalité structurelle — les charges utiles portent des listes et des objets imbriqués /// (`relays`, `sgReady`, `powerLevels`), que `==` compare par référence. bool _memeValeur(Object? a, Object? b) { if (a is Map && b is Map) { if (a.length != b.length) return false; for (final k in a.keys) { if (!b.containsKey(k) || !_memeValeur(a[k], b[k])) return false; } return true; } if (a is List && b is List) { if (a.length != b.length) return false; for (var i = 0; i < a.length; i++) { if (!_memeValeur(a[i], b[i])) return false; } return true; } return a == b; } /// Reconstitue les deux niveaux depuis la liste brute de `GetLoadConfig`. /// /// Le rang de domaine est l'**ordre d'apparition** dans l'ordre à plat trié par /// `priority` — jamais [kDefaultDomainOrder], qui ne sert qu'à la mise en service d'une /// installation encore non ordonnée. GroupedOrder groupByDomain(List entries) { final flat = [...entries]..sort((a, b) { final c = a.priority.compareTo(b.priority); // Départage stable sur l'id : deux charges de même priority ne doivent pas // permuter d'une lecture à l'autre, sinon l'écran « bouge » sans raison. return c != 0 ? c : a.id.compareTo(b.id); }); // ── Un groupe est une PLAGE CONTIGUË, pas un seau par domaine ───────────── // // Le regroupement par seau rassemblait les charges d'un même domaine où qu'elles // soient dans l'ordre. L'affichage ne pouvait donc pas représenter un ordre entrelacé — // et comme l'enregistrement réaplatit ce qui est affiché, **tout enregistrement // regroupait**, déplaçant des charges que personne n'avait touchées. // // Le moteur a tranché le 2026-08-29, et l'argument vaut d'être gardé ici : **grouper à // l'affichage oui, à l'écriture non**. Un regroupement appliqué à l'enregistrement est // une machine qui réordonne ce qu'un humain a décidé — exactement ce que LM-1209 // interdit au moteur. Et la contiguïté n'a jamais été un invariant : le waterfall trie // sur le **rang seul**, `domain` n'est pas lu par l'arbitre. // // En découpant par plages, l'affichage épouse l'ordre réel : deux plages `ev` séparées // par `heating` s'affichent comme deux blocs, et réaplatir rend exactement ce qu'on a // lu. Le regroupement devient un **geste explicite** (`groupContiguous`), jamais un // effet de bord. final groups = []; for (final e in flat) { final key = e.domain ?? kUnclassifiedDomain; if (groups.isNotEmpty && groups.last.domain == key) { groups.last.loads.add(e); } else { groups.add(DomainGroup(domain: key, loads: [e])); } } // Un domaine présent dans plusieurs plages : ce n'est **pas un défaut**, seulement un // ordre non groupé. L'information reste publiée — un écran peut vouloir proposer le // regroupement — mais elle ne s'annonce plus comme une anomalie à réparer. final vus = {}; for (final g in groups) { vus[g.domain] = (vus[g.domain] ?? 0) + 1; } final interleaved = [ for (final e in vus.entries) if (e.value > 1) e.key ]; return GroupedOrder( groups: groups, flatOrder: flat, isContiguous: interleaved.isEmpty, interleavedDomains: interleaved, ); } /// **Regrouper par domaine — un geste, jamais un effet de bord.** /// /// Rassemble les plages d'un même domaine à la position de leur **première** apparition, /// et rend le nouvel ordre à plat. C'est ce que l'enregistrement faisait en douce jusqu'au /// 2026-08-29 ; c'est désormais quelque chose que l'installateur demande, voit, et peut /// annuler avant d'écrire. List groupContiguous(List groups) { final ordre = []; final parDomaine = >{}; for (final g in groups) { if (!parDomaine.containsKey(g.domain)) { parDomaine[g.domain] = []; ordre.add(g.domain); } parDomaine[g.domain]!.addAll(g.loads); } return [ for (final d in ordre) DomainGroup(domain: d, loads: parDomaine[d]!), ]; } /// Réaplatit les deux niveaux en une suite d'entiers `1..N`, et renvoie les charges /// utiles **patchées** prêtes pour `SetLoadConfig`. /// /// Chaque entrée repart de sa map d'origine ([LoadConfigEntry.patched]) : seul `priority` /// est réécrit. Les charges utiles des mécanismes non modélisés sont transportées telles /// quelles. List> flattenToPayload(List groups) { final out = >[]; var rank = 1; for (final g in groups) { for (final load in g.loads) { out.add(load.patched(priority: rank)); rank++; } } return out; } /// Ordonne des groupes selon [kDefaultDomainOrder], en conservant en queue les domaines /// inconnus et le bucket « non classé ». /// /// Utilisé **uniquement** à la mise en service ou lors d'une normalisation explicitement /// confirmée par l'installateur — jamais automatiquement à la lecture. List applyDefaultDomainOrder(List groups) { int rankOf(DomainGroup g) { final i = kDefaultDomainOrder.indexOf(g.domain); if (i >= 0) return i; // Domaine inconnu de cette version d'app, ou bucket non classé : en queue, mais // dans un ordre stable et prévisible. return kDefaultDomainOrder.length + (g.isUnclassified ? 1 : 0); } final sorted = [...groups]; sorted.sort((a, b) { final c = rankOf(a).compareTo(rankOf(b)); return c != 0 ? c : a.domain.compareTo(b.domain); }); return sorted; } // ───────────────────────────────────────────────────────────────────────────── // Miroir client de LoadConfigStore::validateSet() // ───────────────────────────────────────────────────────────────────────────── /// Conflit détecté par [validateSet], suffisamment décrit pour être rendu en français. class LoadSetConflict { /// `duplicateId`, `thingClaimedTwice` ou `tiedPriority`. final String kind; final String? id; final String? thingId; final String? firstLabel; final String? secondLabel; /// Rang partagé, pour `tiedPriority`. final int? rank; const LoadSetConflict.duplicateId(this.id) : kind = 'duplicateId', thingId = null, firstLabel = null, secondLabel = null, rank = null; const LoadSetConflict.thingClaimedTwice({ required this.thingId, required this.firstLabel, required this.secondLabel, }) : kind = 'thingClaimedTwice', id = null, rank = null; /// **Deux charges au même rang** — refusé en bloc depuis `1.15.2+etm27`. /// /// Le motif du refus ne franchit pas la frontière RPC : sans ce miroir, le symptôme est /// « mon glissement ne s'enregistre pas », sans cause visible. C'est exactement la /// famille de panne muette que ce dépôt traque. const LoadSetConflict.tiedPriority({ required this.rank, required this.firstLabel, required this.secondLabel, }) : kind = 'tiedPriority', id = null, thingId = null; } /// Rejoue la validation d'ensemble du plugin **avant** l'envoi. /// /// Miroir de `LoadConfigStore::validateSet()` (`loadconfigstore.cpp:83-112`) : /// unicité des identifiants sur **toutes** les charges, unicité des Things revendiqués /// **parmi les `enabled` seulement** — une charge désactivée ne construit aucun /// adaptateur, donc ne revendique rien — et, depuis `1.15.2+etm27`, **unicité des rangs**. /// /// Ce troisième contrôle vaut plus que les deux autres, parce que le refus qu'il anticipe /// est **muet** : le motif ne franchit pas la frontière RPC, et le symptôme au doigt est /// « mon glissement ne s'enregistre pas ». /// /// C'est un **confort d'affichage**. La box reste l'autorité : si elle refuse, c'est son /// refus qu'il faut montrer. LoadSetConflict? validateSet(List entries) { final ids = {}; final owner = {}; // thingId normalisé → libellé de la charge final rangs = {}; // priority → libellé de la première charge vue for (final c in entries) { if (!ids.add(c.id)) return LoadSetConflict.duplicateId(c.id); // ⚠️ `1.15.2+etm27` : deux charges au même rang font refuser TOUT l'ensemble. Le // contrôle porte sur ce qui part, y compris une charge désactivée — le moteur ne les // exclut pas de cette règle-ci, contrairement à la revendication de Thing. final vu = rangs[c.priority]; if (vu != null) { return LoadSetConflict.tiedPriority( rank: c.priority, firstLabel: vu, secondLabel: c.label); } rangs[c.priority] = c.label; if (!c.enabled) continue; // ne construit aucun adaptateur : ne revendique rien for (final thing in c.claimedThingIds) { final already = owner[thing]; if (already != null) { return LoadSetConflict.thingClaimedTwice( thingId: thing, firstLabel: already, secondLabel: c.label, ); } owner[thing] = c.label; } } return null; }