FUSION. L'écran « Ordre de service » est supprimé ; sa route redirige. C'est son CHEMIN DE DONNÉES (LoadConfigProvider) qui a migré dans la présentation de « Rôles & appareils », jamais l'inverse. EmsRole fige six rôles dont dhw ET heatPump séparés — or LM-201 dit qu'une PAC sans ballon séparé est UNE charge : le modèle des rôles aurait exigé deux charges sur un canal unique, ce que validateSet() refuse à raison. Les charges viennent donc de GetLoadConfig. Les compteurs restent sur EnergySetupProvider (autre contrat, SetRootMeter). La note « le pilote peut bousculer l'ordre les jours Tempo rouge ou à l'approche d'une échéance » est retirée : aucun de ces comportements n'existe dans le moteur. ANNONCE AVANT ÉCRITURE. Miroir exact de EnergyArbitrator::sameHardware(). Le piège, vérifié sur .75 : la comparaison des relais est INDEXÉE, donc réordonner relays[] sans rien changer d'autre reconstruit l'adaptateur — contacts ouverts (applySafeState, force) et verrous réarmés à froid. Un glisser-déposer dans la liste des contacteurs est un geste matériel. L'écran le nomme, avec la durée de réarmement. GRISAGE PAR INTROSPECTION. RpcSchema lit JSONRPC.Introspect à la connexion ; trois états et non deux — écrivable, r: (mesure, pas fonction manquante), absent — plus « inconnu » quand le schéma n'a pas pu être lu. Chaque champ grisé porte sa raison et le nom du champ manquant. La version affichée est celle de l'API d'expérience (NymeaEnergy 0.8) : le paquet +etm15 n'est exposé par aucune méthode. CORRIGÉ AU PASSAGE — les modes de recharge étaient INOPÉRANTS. setChargingInfo() appelait EnergyPlugin.SetChargingInfo (namespace inexistant) avec mode/targetSoc/endTime/minCurrent au lieu de chargingMode/targetPercentage/endDateTime, et minCurrent n'existe dans aucun champ. Vérifié par appel réel : « Missing required key: chargingMode ». Le refus était de plus avalé et l'état local mis à jour quand même. Désormais l'état ne suit que si la box accepte, et le refus remonte sans en inventer la cause. ZONES — constaté, pas corrigé. L'hypothèse « champs grisés » est fausse : AirConditioning publie 8 méthodes toutes en écriture. ac_screen.dart n'en appelle aucune, ses 4 pièces sont des constantes de maquette, et .75 déclare zéro zone. getZones() ajouté + bandeau qui lit le vrai compte et annonce la maquette. Le câblage est un lot en soi, non exerçable sans zone. Tests 80/80, analyze 0 erreur. Relevé : docs/RELEVE_LOTC.md — dont §6, ce qui n'a PAS pu être vérifié : l'IU n'a pas pu être pilotée sur l'appareil, MIUI refusant INJECT_EVENTS. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
232 lines
9.2 KiB
Dart
232 lines
9.2 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();
|
||
}
|
||
|
||
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<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({
|
||
int? priority,
|
||
String? domain,
|
||
String? label,
|
||
bool? enabled,
|
||
int? minOnS,
|
||
int? minOffS,
|
||
int? maxPowerW,
|
||
String? mode,
|
||
List<Map<String, dynamic>>? relays,
|
||
List<int>? powerLevels,
|
||
Map<String, dynamic>? sgReady,
|
||
Map<String, dynamic>? 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)';
|
||
}
|