etm-powersync-app/lib/models/load_config_entry.dart
Patrick Schurig ab2a27bd30 feat(etm19): BELOW_MIN_POWER, compteurs rattachés depuis l'écran, glissement exercé
MOTIF BELOW_MIN_POWER — premier ajout au catalogue depuis qu'il existe.

Le sens porté est précis et facile à perdre : il Y A du surplus, mais pas au bon format —
sous le plus petit cran commandable, la charge ne prend rien et le budget file à la
suivante. À distinguer de SURPLUS_INSUFFICIENT et SG_NORMAL, qui ne sortent plus que pour
un budget ≤ 0, « il n'y a rien ». Les confondre ferait chercher un défaut de production là
où il n'y a qu'une granularité. Deux variantes, départagées par la présence de `state`.

Les deux phrases voisines sont reprises en conséquence : SG_NORMAL peut enfin dire « aucun
surplus » sans risque, ce qu'elle ne pouvait pas avant, et SURPLUS_INSUFFICIENT ne parle
plus de « palier non payé » — elle dit qu'il n'y a rien à distribuer.

LE REPLI AURAIT TENU, et c'est vérifié plutôt qu'affirmé : sur BELOW_MIN_POWER privé de
`minPowerW`, l'app rend le code et ses paramètres triés au lieu de fabriquer une phrase
approximative. C'était exactement le cas prévu, et c'est le premier vrai à se présenter.

GLISSEMENT — le test POSE désormais sa condition au lieu de la subir. Il regroupe les deux
charges dans un même domaine, exerce, puis REND le classement d'origine. Deux fois de suite
il s'était contenté d'annoncer « non exercé » parce que le banc était revenu à une charge
par domaine : un test qui dépend de l'état du terrain déclare forfait au moment où on
aurait besoin de lui. Exercé sur appareil : [chauffe-eau, pac-terrain] →
[pac-terrain, chauffe-eau], classement rétabli {ecs, heating}.

COMPTEURS — rattachés pour de vrai depuis le chemin de l'app, et non par une sonde.
measuredW apparaît par charge, chacune portant SON compteur. L'affichage met la mesure à
côté de l'allocation, jamais à sa place : l'une est ce que le waterfall a décidé, l'autre
ce qui passe réellement.

Mon assertion initiale était fautive : j'exigeais des mesures DISTINCTES pour prouver le
rattachement par charge. Deux compteurs à l'arrêt lisent légitimement 0 W tous les deux —
le test échouait donc sur une installation saine. Ce qui prouve le rattachement, c'est que
chaque charge porte un compteur différent. Vérifié aussi, en détachant puis rattachant :
une charge sans compteur ne publie RIEN — null n'est pas 0, et afficher un zéro ferait
passer une absence de mesure pour une absence de consommation.

PLANCHER — préparé AVANT son arrivée au schéma, précisément à cause de ce qui vient de se
passer avec meterThingId et sensorThingId : le grisage dérivé les a activés tout seuls,
mais sur un placeholder mort. Le bloc est donc câblé maintenant, reste grisé tant que le
champ n'existe pas, et fonctionnera le jour où il apparaîtra. La box publie déjà
mechanism.minPowerW en télémétrie : on l'affiche en lecture, ce qui donne la valeur en
vigueur même sans réglage ouvert.

Tests 101/101, 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 11:31:35 +02:00

258 lines
11 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;
}
/// 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.
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,
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)';
}