Le lot plugin B-bis (1.15.2+etm15) est sur .75 et GetLoadConfig/GetLoadTelemetry concordent : les critères 4 à 8 du brief deviennent atteignables. - models/load_telemetry.dart : vue en lecture. timestamp/budget/mechanism/lock/ faultCode nullables — omis porte un sens que zéro ne porterait pas. - services/telemetry_text.dart : rendu français des CODES (19 motifs, 3 défauts, 3 verrous, 4 mécanismes). Repli lisible sur un code inconnu ET sur un code connu privé d'un paramètre : une phrase trouée a l'air d'une donnée, le code brut a l'air de ce qu'il est. - providers/load_telemetry_provider.dart : instantané + LoadTelemetryChanged, fraîcheur à trois signaux (âge du timestamp, silence des trames, timestamp qui cesse d'avancer), machine à états de ClearLoadFault qui ne conclut JAMAIS sur l'acquittement du RPC. - load_config_provider : assignDomain() en brouillon, énumération fermée. - load_order_screen : budget, fraîcheur, mode dégradé, bloc par charge, encart défaut, menu de domaine. - nymea_service : getLoadTelemetry(), clearLoadFault(), aiguillage de la notification. Une box sans la méthode s'annonce comme telle, pas comme en panne. Seuil de fraîcheur à 180 s : le battement de cœur du plugin est de 60 s et le timestamp avance d'un cycle par minute. Un seuil plus court crierait « données anciennes » sur une installation vivante. Fixtures v3 prises sur .75 après déploiement, dont un cycle porteur d'un verrou — « lock » étant optionnel, un dump pris au mauvais moment ne l'éprouverait pas. 57 tests (30 avant), flutter analyze inchangé à 25 issues. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
246 lines
9.9 KiB
Dart
246 lines
9.9 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.
|
|
///
|
|
/// Deux limites à ne pas présenter comme du pilotage :
|
|
/// - le rang de `ev` est **décoratif** tant que l'EV est décidé en proxy AVANT le
|
|
/// waterfall (transplantation 3g non faite) ;
|
|
/// - 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,
|
|
});
|
|
}
|
|
|
|
/// 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;
|
|
}
|