/// 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 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 relays; final double estimatedPowerW; const SgReadyState({ required this.state, required this.relays, required this.estimatedPowerW, }); Map 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 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 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 get _sgReadyMap { final sg = raw['sgReady']; return sg is Map ? Map.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 get sgReadyStates { final st = _sgReadyMap['states']; if (st is! List) return const []; return [ for (final m in st.whereType()) SgReadyState( state: (m['state'] as num?)?.toInt() ?? 0, relays: (m['relays'] as List?)?.whereType().toList() ?? const [], estimatedPowerW: (m['estimatedPowerW'] as num?)?.toDouble() ?? 0, ), ]; } List get relays { final list = raw['relays']; if (list is! List) return const []; return list.whereType().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 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()) if (s['relays'] is List) ...(s['relays'] as List).whereType().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 patched({ int? priority, String? domain, String? label, bool? enabled, int? minOnS, int? minOffS, int? maxPowerW, String? mode, List>? relays, List? powerLevels, Map? sgReady, Map? needs, }) { return { ...raw, '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)'; }