Deux manques trouvés en usage réel sur téléphone, contre .75. ── 1. Retirer une charge dont l'appareil n'existe plus ──────────────────────── Le plugin crée d'office une entrée par borne détectée mais ne la retire pas quand le Thing s'en va : la Terra AC supprimée de nymea tenait encore le rang 4 deux jours plus tard, et l'app savait le dire sans savoir y remédier. Le bouton n'apparaît que sur THING_MISSING. Sur une charge vivante le geste n'aurait pas de sens — le plugin recrée l'entrée au cycle suivant, et un bouton qui se défait tout seul ferait douter de la box. Le chemin est celui qui était demandé, et pour la raison qui était donnée : · LIRE (GetLoadConfig, frais — pas l'état en mémoire, la box a pu changer d'avis) · PATCHER (payloadWithoutLoad : les autres charges recopiées VERBATIM, champ par champ) · RÉÉCRIRE (SetLoadConfig, qui remplace l'ensemble) Jamais une reconstruction 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, alors qu'une renumérotation réécrirait le rang de charges que personne n'a touchées. Rien à moitié écrit : un refus laisse la configuration locale intacte (aucune adoption avant confirmation) et la box refuse l'ensemble ou l'accepte en entier. Le geste est refusé tant qu'un brouillon est en cours — sinon la suppression emporterait des modifications non enregistrées. La confirmation annonce ce que l'utilisateur ne peut pas deviner : retirer une charge réécrit toutes les autres. ── 2. Le mode de recharge, un seul lieu d'écriture ─────────────────────────── Les trois boutons quittent la tuile des favoris pour l'écran « Configurer » de la borne : c'est l'écran de mise en service, celui où on découvre qu'une borne neuve est en ChargingModeNormal. Deux endroits qui écrivent SetChargingInfo seraient deux comportements qui divergent. La tuile des favoris garde la LECTURE et l'avertissement « hors arbitrage » : c'est le client qui ouvre ses favoris, et il doit voir que sa borne n'est pas pilotée même s'il doit aller ailleurs pour le corriger. ⚠️ Bug latent trouvé en déplaçant : setChargingInfo écrivait toujours sur la PREMIÈRE borne configurée (_findEvChargerId). Sur le banc qui en porte trois, régler la V2C depuis son propre écran aurait réglé la wallbox simulée. La borne est maintenant visée explicitement, et evChargerFor() réconcilie les deux graphies d'identifiant (§9 du contrat de données). 181 tests (+12), flutter analyze inchangé à 27 remarques. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
299 lines
13 KiB
Dart
299 lines
13 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;
|
|
}
|
|
|
|
/// 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;
|
|
}
|