/// 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}); } /// 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'; 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. Map patched({int? priority, String? domain}) { return { ...raw, 'priority': ?priority, 'domain': ?domain, }; } @override String toString() => 'LoadConfigEntry($id, $adapter, p=$priority, d=$domain)'; }