etm-powersync-app/lib/services/load_priority.dart
Patrick Schurig a44d64d6c2 feat(3g-2): les bornes sont des charges configurées — une seule liste, un seul rang
+etm23 rend à chaque borne détectée une entrée GetLoadConfig, et l'invariant
loads[] ⊆ GetLoadConfig est rétabli. Sur .75 : 2 entrées → 4. L'app s'appuyait déjà sur cet
invariant pour résoudre libellé, domaine et rang de toute charge publiée.

Vérifié sur machine avant d'écrire quoi que ce soit :
- l'aller-retour verbatim reste NEUTRE avec les quatre entrées (loadconfig_roundtrip.dart,
  verdict IDENTIQUE) ;
- une entrée evcharger porte id/label/domain/priority/enabled, mode dynamic, et AUCUNE
  charge utile de mécanisme.

Trois défauts que ce contrat révèle côté app :

1. isVariableLoad était défini par défaut (« ni relais ni sg-ready »), donc une borne y
   tombait. L'écran de mécanisme lui proposait une puissance nominale, des paliers et des
   temporisations — des réglages qui n'existent pas pour elle et dont l'écriture partirait
   quand même. Pire, le sélecteur offrait de la basculer en routeur de relais : une bascule
   que basculerMecanisme() sait construire et qu'aucun écran ne sait défaire. Une borne a
   maintenant sa section, en lecture seule, qui dit pourquoi il n'y a rien à régler.

2. BorneSection existait pour rattraper des bornes qui n'apparaissaient nulle part. Elles
   apparaissent maintenant dans leur domaine : la section les afficherait DEUX fois, en
   affirmant qu'elles n'ont « ni rang ni domaine » et qu'elles ne passent pas par
   SetLoadConfig. Trois affirmations devenues fausses. Retirée ; son contenu de runtime
   vit désormais dans l'écran de mécanisme, où il a un sens.

3. Le rang par défaut d'une borne est un ARTEFACT, et l'écran le disait comme un réglage.
   À la création le plugin donne « le plus petit rang existant moins un, borné à 1 » ;
   le chauffe-eau occupant déjà le rang 1, l'intention « en tête » dégénère en égalité —
   trois charges à 1 sur le banc. Le tri reste total (l'id départage), donc l'ordre est
   reproductible, et c'est précisément ce qui le rend crédible : l'installateur lit un
   classement plausible et n'y touche pas, validant un ordre que personne n'a posé. Une
   note le nomme, et disparaît dès le premier vrai réglage.

Le glissement, lui, n'a demandé aucun cas particulier — c'était tout l'intérêt d'avoir une
seule liste. Vérifié par test, y compris l'inversion borne / charge pilotée.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
2026-08-27 14:32:56 +02:00

277 lines
12 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,
];
}
/// 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` 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<LoadConfigEntry> entries) {
final ids = <String>{};
final owner = <String, String>{}; // 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;
}