/// 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'; /// Borne de recharge. **Entrée créée d'office par le plugin** depuis `+etm23`, pour que /// l'invariant `loads[] ⊆ GetLoadConfig` tienne à nouveau : les bornes étaient entrées /// en télémétrie sans figurer nulle part en configuration, et l'app s'appuie sur cet /// invariant pour résoudre libellé, domaine et rang de toute charge publiée. /// /// **Elle ne porte AUCUNE charge utile de mécanisme** — ni `relays`, ni `sgReady`, ni /// `powerLevels`/`maxPowerW`/`minPowerW`, ni `minOnS`/`minOffS`. Ce n'est pas un oubli : /// les limites d'une borne viennent du Thing et **changent avec le véhicule branché**. /// Les déclarer en configuration en ferait une seconde source de vérité, qui divergerait /// dès qu'une autre voiture se branche. /// /// Ce qu'elle porte, c'est ce qui est commun à toutes les charges : `id` (le ThingId de /// la borne), `label`, `domain` (`ev`), `priority`, `enabled`, `mode: "dynamic"`. bool get isEvCharger => adapter == 'evcharger'; /// Vrai pour le mécanisme continu — c'est le **défaut** du schéma. /// /// La borne en est exclue explicitement : elle tombait dans ce repli par construction, /// et l'écran de mécanisme lui proposait alors une puissance nominale et des paliers, /// c'est-à-dire des réglages qui n'existent pas pour elle. bool get isVariableLoad => !isRelayRouter && !isSgReady && !isEvCharger; // ── 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; } /// Compteur dédié à CETTE charge, et sonde de la grandeur rendue. Vides = non rattachés. /// /// Ni l'un ni l'autre n'est du **matériel de commande** : ils n'entrent pas dans /// `sameHardware()`, donc les rattacher ne reconstruit rien et ne réarme aucun verrou. /// Ils n'entrent pas non plus dans le contrôle d'unicité des Things — deux charges /// peuvent légitimement partager une sonde de pièce, et un compteur de tableau /// divisionnaire en couvrir plusieurs. String get meterThingId => raw['meterThingId'] as String? ?? ''; String get sensorThingId => raw['sensorThingId'] as String? ?? ''; /// 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. /// /// **La borne suit la même règle que le modulable** — son `id` EST le ThingId de la /// borne — et sans code dédié, des deux côtés. Ce n'est pas une commodité : c'est ce qui /// interdit de déclarer une même borne à la fois comme `evcharger` et comme charge /// pilotée. Deux commandeurs sur un même organe, exactement ce que LM-201 refuse, et /// [validateSet] le voit avant l'envoi. 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 ET evcharger : 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({ /// 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, String? meterThingId, String? sensorThingId, List>? relays, List? powerLevels, Map? sgReady, Map? needs, }) { return { ...raw, 'adapter': ?adapter, 'priority': ?priority, 'domain': ?domain, 'label': ?label, 'enabled': ?enabled, 'minOnS': ?minOnS, 'minOffS': ?minOffS, 'maxPowerW': ?maxPowerW, 'mode': ?mode, 'meterThingId': ?meterThingId, 'sensorThingId': ?sensorThingId, 'relays': ?relays, 'powerLevels': ?powerLevels, 'sgReady': ?sgReady, 'needs': ?needs, }; } /// Patch générique par clés — pour un champ que ce modèle ne typa pas encore. /// /// Sert aux champs qui **arrivent** au contrat : les typer d'avance figerait un nom que /// la spec plugin n'a pas arrêté. La clé n'est écrite que si l'appelant a vérifié /// qu'elle est au schéma — nymea rejette l'appel entier sur une clé inconnue. Map patchedRaw(Map cles) => {...raw, ...cles}; @override String toString() => 'LoadConfigEntry($id, $adapter, p=$priority, d=$domain)'; }