etm-powersync-app/lib/services/load_priority.dart
Patrick Schurig 2c193077f7 fix(config): +etm27 refuse deux rangs identiques — l'app le voit avant la box
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
2026-08-28 16:06:11 +02:00

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;
}