/// 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, ]; } /// 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); }); final order = []; final buckets = >{}; for (final e in flat) { final key = e.domain ?? kUnclassifiedDomain; if (!buckets.containsKey(key)) { buckets[key] = []; order.add(key); } buckets[key]!.add(e); } // Contiguïté : les indices d'un même domaine doivent former une plage continue. final interleaved = []; for (final key in order) { final idx = []; for (var i = 0; i < flat.length; i++) { if ((flat[i].domain ?? kUnclassifiedDomain) == key) idx.add(i); } if (idx.isNotEmpty && (idx.last - idx.first + 1) != idx.length) { interleaved.add(key); } } return GroupedOrder( groups: [ for (final key in order) DomainGroup(domain: key, loads: buckets[key]!), ], flatOrder: flat, isContiguous: interleaved.isEmpty, interleavedDomains: interleaved, ); } /// 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` ou `thingClaimedTwice`. final String kind; final String? id; final String? thingId; final String? firstLabel; final String? secondLabel; const LoadSetConflict.duplicateId(this.id) : kind = 'duplicateId', thingId = null, firstLabel = null, secondLabel = null; const LoadSetConflict.thingClaimedTwice({ required this.thingId, required this.firstLabel, required this.secondLabel, }) : kind = 'thingClaimedTwice', id = 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. /// /// 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 for (final c in entries) { if (!ids.add(c.id)) return LoadSetConflict.duplicateId(c.id); 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; }