Livré AVANT le déploiement, parce que ce refus-là est muet : le motif ne franchit pas la frontière RPC, et le symptôme au doigt aurait été « mon glissement ne s'enregistre pas », sans cause visible à l'écran. Même famille que le §9 du contrat de données. Trois points vérifiés, dans l'ordre demandé : 1. LE RÉAPLATISSAGE REND TOUJOURS DES RANGS DISTINCTS. flattenToPayload numérote 1..n sur l'ordre courant, donc par construction — mais « par construction » ne se vérifie pas tout seul : le test le refait après CHAQUE permutation de domaines et après un déplacement intra-domaine, en partant de la config du banc qui porte déjà deux charges au rang 1. Les états intermédiaires du glissement ne partent pas : seul `save()` écrit. 2. RIEN NE PART SI LE BROUILLON N'EST PAS COHÉRENT. validateSet() — le miroir client de LoadConfigStore::validateSet() — apprend le contrôle des rangs et rend un conflit `tiedPriority` qui NOMME les deux charges et le rang partagé. L'écran distingue désormais les trois refus (thing revendiqué deux fois, identifiant en double, rangs à égalité) là où il annonçait « deux charges revendiquent le même appareil » pour tout. 3. ⚠️ LE CHEMIN DE SUPPRESSION ÉTAIT LE PLUS EXPOSÉ, et rien ne l'aurait montré. Il réécrit les survivantes VERBATIM — c'est sa règle, et elle est bonne — donc il transporte les rangs tels que la box les porte. Or c'est le plugin lui-même qui a créé des égalités avant +etm27 (borne détectée au rang 1 d'une installation déjà réglée). Une suppression légitime aurait donc été refusée en bloc, sans motif. Elle refuse maintenant côté app, avec la raison — plutôt que de renumérer en douce : un geste de nettoyage qui redistribue les rangs devient un geste d'arbitrage. Deux tests de load_priority_test construisaient leurs entrées sans `priority` : les deux tombaient donc à égalité, et le nouveau contrôle masquait la règle qu'ils éprouvaient. Rangs distincts posés, avec la raison en commentaire. Les deux autres nouveautés d'+etm27 sont au TODO, non codées : `o:rankOrigin` (badge « rang par défaut, pas un choix », avec la règle absent = rien d'affirmé) et l'entrée en queue des bornes auto-provisionnées — qui devrait faire taire d'elle-même la note d'ordre entrelacé. 213 tests (+8), flutter analyze inchangé à 27 remarques. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
392 lines
16 KiB
Dart
392 lines
16 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,
|
|
];
|
|
}
|
|
|
|
/// 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;
|
|
}
|