Trouvé en relisant .75 après le déploiement d'+etm27, pas en écrivant le badge. `rankOrigin: "auto"` écrit par un client est refusé : EnergyErrorInvalidParameter, ensemble entier rejeté (vérifié sur machine). Or les deux chemins d'écriture de l'app recopient les charges VERBATIM — c'est leur règle, et elle est bonne. Recopier verbatim une entrée marquée "auto", c'est écrire "auto". Conséquence, qu'aucun test ne montrait et que le banc ne peut pas produire aujourd'hui (aucune borne auto-provisionnée depuis +etm27) : dès qu'une installation cliente portera une borne détectée par le moteur, un SIMPLE RENOMMAGE aurait été refusé en bloc — et le motif ne franchit pas la frontière RPC. Même famille que le refus sur rangs doublés d'hier, même symptôme muet. Le nettoyage est posé au point de sortie UNIQUE (NymeaService.setLoadConfig) plutôt que sur chaque chemin : un chemin ajouté demain hériterait sinon du refus sans que rien ne l'annonce. `"user"` n'est pas touché — il est permis — et une clé absente le reste : on n'en fabrique pas. ⚠️ Ce que ça coûte est dit dans le code et remonté au brief : la marque du moteur est EFFACÉE sur les charges concernées, y compris celles que le geste ne visait pas. Le badge « rang posé par la box » disparaîtra donc d'une borne que personne n'a classée, au premier renommage d'une autre charge. Question posée au moteur : un écho inchangé de "auto" peut-il être toléré ? Trois options écartées, avec leurs raisons. Et le relevé du jour précise la lecture : depuis +etm27 la clé sort TOUJOURS, à `""` quand rien n'est déclaré — même convention que `domain` et `meterThingId`. Les quatre charges du banc portent `rankOrigin: ""`. La traiter comme une valeur afficherait un badge sur toute l'installation ; le getter la lit comme un silence, et quatre tests le figent. 222 tests (+4), flutter analyze inchangé à 27 remarques. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
312 lines
14 KiB
Dart
312 lines
14 KiB
Dart
/// 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();
|
||
}
|
||
|
||
/// **Qui a posé ce rang** — `"auto"`, `"user"`, ou **rien** (`+etm27`).
|
||
///
|
||
/// Le moteur écrit `"auto"` quand il crée une entrée d'office — une borne détectée — et
|
||
/// l'**efface dès qu'un rang différent arrive** pour cet identifiant. Le champ se répare
|
||
/// donc tout seul au premier vrai réglage, sans que l'app ait à y penser.
|
||
///
|
||
/// ## Absent n'affirme RIEN
|
||
///
|
||
/// Ni « choisi par un humain », ni « posé d'office ». C'est la même règle que
|
||
/// `measurement.source` : l'absence est un silence, pas une affirmation — et c'est
|
||
/// pourquoi le badge ne s'affiche que sur `"auto"`, jamais par défaut.
|
||
///
|
||
/// **Deux formes d'absence, un seul sens.** Une box antérieure à `+etm27` ne publie pas
|
||
/// la clé ; depuis `+etm27` elle sort **toujours**, à `""` quand rien n'est déclaré —
|
||
/// même convention que `domain` et `meterThingId`. Relevé sur `.75` le 2026-08-29 : les
|
||
/// quatre charges portent `rankOrigin: ""`. Traiter la chaîne vide comme une valeur
|
||
/// afficherait un badge sur toute l'installation.
|
||
///
|
||
/// `"user"` est ce qu'un client écrit pour dire « j'ai vu ce rang par défaut et il me
|
||
/// va », sans déplacer la charge. **L'app ne peut jamais écrire `"auto"`** — le moteur le
|
||
/// refuse, et c'est ce qui empêche un écran de fabriquer une provenance.
|
||
String? get rankOrigin {
|
||
final v = raw['rankOrigin'];
|
||
if (v is! String || v.trim().isEmpty) return null;
|
||
return v.trim();
|
||
}
|
||
|
||
/// Vrai quand le rang vient du moteur et que **personne ne l'a confirmé**.
|
||
bool get rangPoseDOffice => rankOrigin == 'auto';
|
||
|
||
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<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.
|
||
///
|
||
/// **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<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 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<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,
|
||
String? meterThingId,
|
||
String? sensorThingId,
|
||
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,
|
||
'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<String, dynamic> patchedRaw(Map<String, dynamic> cles) => {...raw, ...cles};
|
||
|
||
@override
|
||
String toString() => 'LoadConfigEntry($id, $adapter, p=$priority, d=$domain)';
|
||
}
|