L'agent plugin a mesuré l'inverse sur la même box : écho verbatim, "auto" compris, EnergyErrorNoError. L'exception existe déjà côté moteur — reconcileRankOrigin() accepte "auto" quand le persisté le porte déjà, écrite exprès pour ne pas rompre la neutralité Get → Set. Ce que j'avais mesuré est autre chose : entre ma lecture et mon écriture, la marque avait été consommée par une manipulation concurrente. Mon écho portait donc un "auto" que le persisté ne portait plus — un écho périmé, correctement refusé. Deux cas distincts, un seul symptôme, et c'est précisément pourquoi une écriture qui ne relit pas juste avant ne prouve rien. Le contrôle de la suppression, lui, relit avant de conclure ; ma sonde jetable, non. §10 corrigé : la ligne du tableau dit maintenant ce qu'elle mesure vraiment, et ce qui reste à mesurer (un écho inchangé sur une marque FRAÎCHE). sansRankOriginAuto() est documentée comme un contournement EN SURSIS — elle part au résultat de cette mesure, et la marque survivra alors aux gestes qui ne la concernent pas. Rien n'est retiré d'ici là. Ce qui reste vrai de mon relevé : "user" s'écrit, ne déplace pas le rang, survit aux gestes voisins. Et l'effacement de "auto" que j'ai observé est le fait de l'APP — de son nettoyage — pas d'un refus de la box. ── Deux pièges de sonde, consignés dans tools/rpc/README.md ───────────────── 1. Un refus arrive en `status: "success"`, verdict dans params.energyError. Une sonde qui ne teste que `status` annonce « accepté » sur une écriture rejetée. 2. Les identifiants ne s'écrivent pas pareil d'un appel à l'autre — le §9, documenté la VEILLE, et la sonde y est quand même tombée : elle rendait des verdicts sur une charge qu'elle ne regardait pas. La leçon n'est pas « écrire des sondes plus soigneusement » : c'est que la sonde ne partage pas les garde-fous de l'app. Quand la question porte sur ce que l'app fera, importer son modèle vaut mieux que relire le JSON ; quand elle porte sur la box, croiser la réponse RPC avec journalctl avant de conclure. Et la raison lisible d'un refus n'existe QUE dans ce journal — côté RPC il ne reste qu'un code. Silence de refus ouvert depuis le premier jour, rencontré une fois de plus, et raison d'être des miroirs clients comme validateSet. Rien de touché côté entrelacement : ça attend le moteur. 226 tests inchangés, analyze inchangé à 27. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
435 lines
19 KiB
Dart
435 lines
19 KiB
Dart
/// 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<String> 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<String> 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<LoadConfigEntry> loads;
|
|
|
|
const DomainGroup({required this.domain, required this.loads});
|
|
|
|
bool get isUnclassified => domain == kUnclassifiedDomain;
|
|
|
|
DomainGroup copyWith({List<LoadConfigEntry>? 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<DomainGroup> 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<LoadConfigEntry> 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<String> 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<LoadConfigEntry> tiedByPriority(List<LoadConfigEntry> entries) {
|
|
final counts = <int, int>{};
|
|
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,
|
|
];
|
|
}
|
|
|
|
/// Retire `rankOrigin: "auto"` de tout ce qui part vers la box.
|
|
///
|
|
/// ## Pourquoi l'app ne peut PAS renvoyer ce qu'elle vient de lire
|
|
///
|
|
/// `"auto"` est la marque que le moteur pose lui-même sur une entrée qu'il a créée d'office.
|
|
/// **Un client qui l'écrit est refusé** — `EnergyErrorInvalidParameter`, l'ensemble entier
|
|
/// rejeté, rien de modifié (vérifié sur `.75` en `1.15.2+etm27`). Seuls l'effacement et
|
|
/// `"user"` sont permis.
|
|
///
|
|
/// Or les deux chemins d'écriture de l'app recopient les charges **verbatim** — c'est leur
|
|
/// règle, et elle est bonne : réécrire depuis le modèle typé perdrait un champ qu'une box
|
|
/// plus récente porterait. Mais recopier verbatim une entrée marquée `"auto"`, c'est
|
|
/// écrire `"auto"`. Un simple renommage, ou la suppression d'une charge orpheline, serait
|
|
/// donc **refusé en bloc dès qu'une borne auto-provisionnée figure dans l'installation** —
|
|
/// et le motif ne franchit pas la frontière RPC.
|
|
///
|
|
/// ## Ce que ça coûte, et pourquoi c'est dit
|
|
///
|
|
/// Le champ part vide, donc la marque du moteur est **effacée** sur les charges concernées
|
|
/// — y compris celles que le geste ne visait pas. Ce n'est pas neutre : le badge « rang
|
|
/// posé par la box » disparaîtra d'une borne que personne n'a pourtant classée.
|
|
///
|
|
/// C'est le moindre mal entre trois options, aucune bonne : refuser l'écriture (une
|
|
/// installation avec une borne neuve deviendrait non modifiable), renvoyer `"auto"` (refus
|
|
/// systématique), ou effacer une marque informative.
|
|
///
|
|
/// **Contournement EN SURSIS.** Le moteur porte déjà l'exception demandée —
|
|
/// `reconcileRankOrigin()` accepte `"auto"` quand le persisté le porte déjà, pour ne pas
|
|
/// rompre la neutralité `Get` → `Set`. Le refus mesuré le 2026-08-29 côté app portait un
|
|
/// écho **périmé** : la marque avait été consommée entre la lecture et l'écriture, et
|
|
/// refuser un écho périmé est le bon comportement.
|
|
///
|
|
/// Dès que l'écho *inchangé* sur une marque *fraîche* sera vérifié, **cette fonction
|
|
/// disparaît** — et la marque survivra alors aux gestes qui ne la concernent pas, ce qui
|
|
/// est le comportement voulu. Elle ne reste que le temps de cette vérification.
|
|
///
|
|
/// Le motif général — *un interdit d'écriture doit se prononcer sur l'écho inchangé* — est
|
|
/// écrit en `docs/UI_data_contract.md` §10, avec le contrôle à faire une fois par règle.
|
|
List<Map<String, dynamic>> sansRankOriginAuto(List<Map<String, dynamic>> payload) => [
|
|
for (final m in payload)
|
|
if (m['rankOrigin'] == 'auto') {...m, 'rankOrigin': ''} else m,
|
|
];
|
|
|
|
/// 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<Map<String, dynamic>>? payloadWithoutLoad(
|
|
List<Map<String, dynamic>> frais, String loadId) {
|
|
final reste = <Map<String, dynamic>>[
|
|
for (final m in frais)
|
|
if (m['id'] != loadId) Map<String, dynamic>.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<String> diffPayloads(
|
|
List<Map<String, dynamic>> envoye, List<Map<String, dynamic>> relu) {
|
|
final ecarts = <String>[];
|
|
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<LoadConfigEntry> 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 = <String>[];
|
|
final buckets = <String, List<LoadConfigEntry>>{};
|
|
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 = <String>[];
|
|
for (final key in order) {
|
|
final idx = <int>[];
|
|
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<Map<String, dynamic>> flattenToPayload(List<DomainGroup> groups) {
|
|
final out = <Map<String, dynamic>>[];
|
|
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<DomainGroup> applyDefaultDomainOrder(List<DomainGroup> 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<LoadConfigEntry> entries) {
|
|
final ids = <String>{};
|
|
final owner = <String, String>{}; // thingId normalisé → libellé de la charge
|
|
final rangs = <int, String>{}; // 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;
|
|
}
|