etm-powersync-app/lib/services/load_priority.dart
Patrick Schurig a00a9209e9 feat(config): un groupe devient une PLAGE — l'enregistrement ne regroupe plus
Le moteur a tranché l'entrelacement, et son argument va plus loin que le mien : 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.

Il ne suffisait donc pas d'adoucir la note : c'est le modèle d'affichage qui la produisait.
Un groupe était un SEAU par domaine, rassemblant les charges où qu'elles soient dans
l'ordre — donc l'écran ne pouvait pas représenter un ordre entrelacé, et comme
l'enregistrement réaplatit ce qui est affiché, TOUT enregistrement regroupait. La PAC
reculait du rang 2 au rang 5 sans que personne l'ait demandé.

Un groupe est maintenant une PLAGE CONTIGUË. 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
qui a été lu. Test à l'appui : la PAC garde sa position, et l'ordre à plat écrit est
identique à celui lu sur la box.

Le regroupement reste possible, mais devient un GESTE : bouton « Regrouper par domaine »,
qui pose un brouillon — visible, annulable, rien ne part avant save(). Ce que
l'enregistrement faisait en douce, quelqu'un le demande désormais.

Et la note cesse d'alerter : l'ordre non groupé est une information, pas un défaut.
L'annoncer comme une anomalie poussait à réparer un ordre que personne n'avait cassé —
d'autant que depuis +etm27 une borne détectée entre en queue, ce qui produit un ordre non
groupé sur une installation parfaitement saine.

Trois conséquences trouvées en chemin, toutes dues au fait qu'un domaine peut désormais
occuper plusieurs plages :
· la clé de la liste réordonnable portait le DOMAINE → deux plages d'un même domaine
  partageaient une clé, et la liste cassait sur une assertion de sémantiques, loin de la
  cause. Elle porte l'id de la première charge de la plage.
· le repli suivait le domaine → deux plages se repliaient ensemble.
· reorderWithinDomain(domain) désignait la première plage venue → reorderWithinGroup(index).
· assignDomain rejoint la DERNIÈRE plage du domaine, pas la première : remonter la charge
  dans la première la ferait sauter des rangs que personne n'a touchés.

Retiré au passage : sansRankOriginAuto(). L'écho verbatim inchangé passe — mesuré côté
moteur sur marque fraîche — donc la marque survit désormais aux gestes qui ne la concernent
pas, et le badge ne s'éteint plus sur une borne que personne n'a classée.

Noté au TODO : supprimer l'entrée d'une borne dont le Thing existe encore ne la recrée pas
(défaut moteur en cours de correction). Le bouton réservé aux THING_MISSING protège déjà de
ce cas — ne pas l'élargir.

226 tests, 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-29 11:06:56 +02:00

427 lines
18 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);
});
// ── 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 = <DomainGroup>[];
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 = <String, int>{};
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<DomainGroup> groupContiguous(List<DomainGroup> groups) {
final ordre = <String>[];
final parDomaine = <String, List<LoadConfigEntry>>{};
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<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;
}