etm-powersync-app/lib/services/load_priority.dart
Patrick Schurig dee20896c5 feat(télémétrie): lot B app — consommation de la télémétrie d'arbitrage
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
2026-08-25 19:20:30 +02:00

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