etm-powersync-app/lib/models/load_config_entry.dart
Patrick Schurig aada8e2ba5 fix(config): glissement, sélecteur de mécanisme, chip de domaine, thème — les 4 points ouverts
1) GLISSEMENT — diagnostic d'abord, et il a donné DEUX réponses.

   Le geste n'est PAS avalé par le ListView parent : les deux montages du test
   (section seule / section imbriquée) glissent identiquement. La cause du symptôme était
   bien que .75 n'a qu'une charge par domaine — rien à réordonner.

   Mais l'enquête a trouvé un vrai défaut au passage : le centre de la poignée tombait dans
   le vide de 3 px séparant l'icône de la pastille de rang, et ReorderableDragStartListener
   défère le test de collision à ses enfants. Une bande morte invisible au milieu d'une
   poignée qui paraît d'un seul tenant. Corrigé par un Container transparent.

   Tentative faite et ANNULÉE : rendre les domaines glissables aussi. Deux
   ReorderableListView imbriqués se disputent le geste et l'extérieur gagne — le
   glissement des cartes cassait. Les domaines gardent leurs flèches, et une charge seule
   dans son domaine n'offre plus de prise qui mènerait nulle part (avec l'infobulle qui
   renvoie au bon geste).

   Vérifié SUR L'APPAREIL, contre .75, avec les deux charges mises dans le même domaine :
   [chauffe-eau, pac-terrain] → [pac-terrain, chauffe-eau], brouillon abandonné sans rien
   écrire. Classement ecs/hvac rétabli ensuite.

2) SÉLECTEUR DE MÉCANISME — actif, et c'était le morceau délicat.

   LoadConfig est une union discriminée : écrire `adapter` sans nettoyer le reste produit
   une configuration que la box refuse EN BLOC, et le refus emporte toutes les charges de
   l'appel. D'où basculerMecanisme(), qui retire les clés du mécanisme quitté et pose
   celles du nouveau, en récupérant ce qui peut l'être — les deux premiers contacts
   deviennent SG1/SG2 dans l'encodage standard, les relais gardent leur puissance.

   Ce qui n'est PAS transposé : l'estimation d'un état SG-Ready n'est pas la puissance
   d'une résistance. La charge produite est alors incomplète, et c'est voulu — le
   formulaire réclame plutôt que d'inventer une valeur de plaque.

   La bascule franchit sameHardware() : confirmation préalable qui annonce la
   reconstruction et la durée de réarmement (900 s par défaut vers SG-Ready — la valeur
   d'une PAC réelle, pas celle du banc).

   Le mode fixed d'etmvariableload reste LISIBLE sans être proposé : bloc « Configuration
   héritée » en lecture seule, et conversion seulement sur geste explicite, avec le
   plafond que la déclaration en paliers ne porte pas. Ouvrir l'écran ne convertit personne.

3) CHIP DE DOMAINE — il ne rendait qu'une icône muette : le domaine d'une charge était
   illisible sur sa carte, alors qu'il décide de son groupe et de son rang. Il porte
   maintenant son libellé, et sa couleur suit le thème.

4) THÈME — les 11 derniers sites (pas 9) basculés. Trois cas : contexte disponible
   (main, ac_screen) ; peintre sans contexte, à qui la couleur est passée résolue
   (energy_flow_widget) ; tables statiques, où la teinte devient nullable et se résout au
   rendu (categoryInfoMap, favoris). Plus AUCUN jeton hors thème dans lib/.

5) ZONES — non entreprises, comme convenu.

Tests 93/93, analyze 0 erreur.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
2026-08-26 08:49:20 +02:00

237 lines
9.4 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/// Vue **en lecture** au-dessus de la charge utile brute de `NymeaEnergy.GetLoadConfig`.
///
/// ## Pourquoi une vue et non un modèle
///
/// La `Map` reçue est la **source de vérité en mémoire**. Ce type ne la remplace pas :
/// il l'interprète. L'écriture repart toujours de [raw] avec les seuls champs modifiés
/// patchés ([patched]), jamais d'une re-sérialisation.
///
/// La raison est concrète : le plugin sérialise `LoadConfig` **via le méta-objet**, donc
/// `GetLoadConfig` émet TOUTES les propriétés déclarées — y compris les charges utiles
/// vides des mécanismes inutilisés (`sgReady: {}`, `powerLevels: []`, `needs` à défaut).
/// Un modèle typé qui reconstruirait l'objet amputerait la config des mécanismes qu'il
/// ne modélise pas, en silence, au premier `SetLoadConfig`.
///
/// ## Clés inconnues : pas de filtrage préventif
///
/// nymea **rejette** toute clé hors schéma avant d'atteindre le handler
/// (`jsonvalidator.cpp:143`, `"Invalid key"`), donc transporter une clé inconnue ferait
/// rejeter tout l'appel plutôt que de préserver la config. Mais renvoyer verbatim ce que
/// *cette box* vient de donner ne peut échouer que si les schémas de `Get` et de `Set`
/// divergent. Cet aller-retour a été vérifié neutre contre `.75` le 2026-08-25
/// (`test/fixtures/loadconfig_hems75.json`) : l'écho verbatim est donc sûr par
/// construction, et aucune couche de filtrage n'est justifiée ici. Une telle couche aurait
/// son propre risque — jeter en silence une clé que la box aurait acceptée.
library;
/// Un relais d'une charge `relay-router` : `{thingId, powerW}`.
class LoadRelay {
final String thingId;
final int powerW;
const LoadRelay({required this.thingId, required this.powerW});
Map<String, dynamic> toMap() => {'thingId': thingId, 'powerW': powerW};
}
/// Un état SG-Ready déclaré : `{state, relays[], estimatedPowerW}`.
///
/// [estimatedPowerW] est une **estimation**, jamais un engagement — une PAC ne consomme
/// pas la même chose à −5 °C et à +12 °C. Elle sert à ordonner et à budgéter ; la mesure
/// reste la source de vérité.
class SgReadyState {
final int state;
final List<String> relays;
final double estimatedPowerW;
const SgReadyState({
required this.state,
required this.relays,
required this.estimatedPowerW,
});
Map<String, dynamic> toMap() => {
'state': state,
'relays': relays,
'estimatedPowerW': estimatedPowerW,
};
}
/// Normalise un ThingId à la forme `{uuid}` en minuscules.
///
/// La configuration mélange les formes avec et sans accolades — le plugin normalise via
/// `QUuid` (`loadconfig.cpp:150-166`). Une comparaison textuelle brute laisserait passer
/// un conflit de revendication entre deux charges.
String normalizeThingId(String raw) {
final bare = raw.replaceAll(RegExp(r'[{}]'), '').toLowerCase().trim();
return bare.isEmpty ? '' : '{$bare}';
}
class LoadConfigEntry {
/// Charge utile brute, **intacte**. Source de vérité.
final Map<String, dynamic> raw;
const LoadConfigEntry(this.raw);
// ── Champs communs (requis par le schéma SET) ──────────────────────────────
String get id => raw['id'] as String? ?? '';
String get label => raw['label'] as String? ?? id;
String get adapter => raw['adapter'] as String? ?? 'etmvariableload';
String get mode => raw['mode'] as String? ?? '';
bool get enabled => raw['enabled'] as bool? ?? true;
int get priority => (raw['priority'] as num?)?.toInt() ?? 0;
// ── Temporisations (lecture seule dans ce lot) ─────────────────────────────
int get minOnS => (raw['minOnS'] as num?)?.toInt() ?? 0;
int get minOffS => (raw['minOffS'] as num?)?.toInt() ?? 0;
/// Domaine d'intention saisi par l'installateur (`ev`, `ecs`, `hvac`, `heating`).
///
/// `null` tant que la box n'expose pas la clé — la charge tombe alors dans le bucket
/// « non classé ». **Ne jamais déduire le domaine du mécanisme** : un relais peut
/// piloter un chauffe-eau comme une prise commandée de recharge.
String? get domain {
final d = raw['domain'];
if (d is! String || d.trim().isEmpty) return null;
return d.trim();
}
bool get isRelayRouter => adapter == 'relay-router';
bool get isSgReady => adapter == 'sg-ready';
/// Vrai pour le mécanisme continu — c'est le **défaut** du schéma, donc tout ce qui
/// n'est ni relais ni sg-ready.
bool get isVariableLoad => !isRelayRouter && !isSgReady;
// ── Besoins (mise à jour EN PLACE côté moteur, jamais une reconstruction) ───
String get dailyDeadline {
final n = raw['needs'];
return n is Map ? (n['dailyDeadline'] as String? ?? '') : '';
}
int get minEnergyWhPerDay {
final n = raw['needs'];
return n is Map ? ((n['minEnergyWhPerDay'] as num?)?.toInt() ?? 0) : 0;
}
/// Puissance nominale déclarée (mécanisme continu). **Saisie de l'installateur**,
/// jamais une mesure : c'est une valeur de plaque.
int get maxPowerW => (raw['maxPowerW'] as num?)?.toInt() ?? 0;
/// Paliers déclarés, entiers. Vides pour un routeur de relais — la box les **dérive**
/// de `relays[]` et ne les republie pas ici (vérifié sur `.75` : `powerLevels: []`
/// sur une charge à trois contacteurs).
List<int> get powerLevelsInt {
final l = raw['powerLevels'];
if (l is! List) return const [];
return [for (final v in l) if (v is num) v.toInt()];
}
// ── Charge utile sg-ready ──────────────────────────────────────────────────
Map<String, dynamic> get _sgReadyMap {
final sg = raw['sgReady'];
return sg is Map ? Map<String, dynamic>.from(sg) : const {};
}
/// Durée de maintien d'état de la PAC. Une **reconstruction la réarme à froid**
/// (ECS-412) : c'est la valeur à annoncer avant d'enregistrer.
int get minStateHoldS =>
(_sgReadyMap['minStateHoldS'] as num?)?.toInt() ?? 0;
/// États SG-Ready déclarés, **dans l'ordre de la charge utile** — le moteur les
/// compare index par index, donc cet ordre est significatif.
List<SgReadyState> get sgReadyStates {
final st = _sgReadyMap['states'];
if (st is! List) return const [];
return [
for (final m in st.whereType<Map>())
SgReadyState(
state: (m['state'] as num?)?.toInt() ?? 0,
relays: (m['relays'] as List?)?.whereType<String>().toList() ?? const [],
estimatedPowerW: (m['estimatedPowerW'] as num?)?.toDouble() ?? 0,
),
];
}
List<LoadRelay> get relays {
final list = raw['relays'];
if (list is! List) return const [];
return list.whereType<Map>().map((m) => LoadRelay(
thingId: m['thingId'] as String? ?? '',
powerW: (m['powerW'] as num?)?.toInt() ?? 0,
)).toList();
}
/// Things revendiqués par cette charge, forme normalisée `{uuid}`.
///
/// Miroir exact de `LoadConfig::claimedThingIds()` (`loadconfig.cpp:150-166`) : la liste
/// dépend du mécanisme. `etmvariableload` pilote le Thing qui porte son propre `id` ;
/// les deux autres nomment leurs relais.
Set<String> get claimedThingIds {
if (isSgReady) {
final sg = raw['sgReady'];
if (sg is! Map) return const {};
final states = sg['states'];
if (states is! List) return const {};
return {
for (final s in states.whereType<Map>())
if (s['relays'] is List)
...(s['relays'] as List).whereType<String>().map(normalizeThingId),
}..removeWhere((e) => e.isEmpty);
}
if (isRelayRouter) {
return relays.map((r) => normalizeThingId(r.thingId)).toSet()
..removeWhere((e) => e.isEmpty);
}
// etmvariableload : l'id de la charge EST le ThingId piloté.
final n = normalizeThingId(id);
return n.isEmpty ? const {} : {n};
}
/// Copie patchée : repart de [raw] et ne remplace **que** les clés fournies.
///
/// Toute clé absente des paramètres est transportée telle quelle — c'est ce qui
/// préserve les charges utiles des mécanismes non modélisés par l'app.
///
/// Les charges utiles de mécanisme ([relays], [sgReady], [powerLevels]) se patchent
/// **entières** : ce sont des unions discriminées côté plugin (LM-300), et y remplacer
/// une sous-clé isolée produirait un état que `isValid()` refuse en bloc.
Map<String, dynamic> patched({
/// Le **discriminant** de l'union. Le changer sans nettoyer les charges utiles des
/// autres mécanismes produit une configuration que la box refuse en bloc — passer
/// par `basculerMecanisme()`, jamais par ce paramètre seul.
String? adapter,
int? priority,
String? domain,
String? label,
bool? enabled,
int? minOnS,
int? minOffS,
int? maxPowerW,
String? mode,
List<Map<String, dynamic>>? relays,
List<int>? powerLevels,
Map<String, dynamic>? sgReady,
Map<String, dynamic>? needs,
}) {
return {
...raw,
'adapter': ?adapter,
'priority': ?priority,
'domain': ?domain,
'label': ?label,
'enabled': ?enabled,
'minOnS': ?minOnS,
'minOffS': ?minOffS,
'maxPowerW': ?maxPowerW,
'mode': ?mode,
'relays': ?relays,
'powerLevels': ?powerLevels,
'sgReady': ?sgReady,
'needs': ?needs,
};
}
@override
String toString() => 'LoadConfigEntry($id, $adapter, p=$priority, d=$domain)';
}