etm-powersync-app/lib/models/load_config_entry.dart
Patrick Schurig 7ca42973f2 feat(installateur): ordre de service des charges + locale + ossature i18n
Lot B côté app, partie NON bloquée par le lot plugin (voir §Limites).

§1 Locale (indépendant) — JSONRPC.Hello portait {} : l'app était en en_US de
fait alors que nymea gère la locale PAR CONNEXION (o:locale). Envoi de la
locale à chaque Hello, donc aussi après reconnexion subie (m_clientLocales
repart au défaut de la box à chaque socket). Locale demandée ET retenue
journalisées : un .qm introuvable rend exactement comme une absence de
traduction, sans cette trace un catalogue mal packagé passe inaperçu.

§2 i18n — flutter_localizations + intl + ARB (français seul). Aucune chaîne
en dur dans le nouvel écran. Les codes reçus de la box (domaine, mécanisme)
sont des clés ARB avec repli explicite sur code brut si l'app ne les connaît
pas : une box en avance sur l'app est un cas normal en parc déployé.

§3 Modèle — LoadConfigEntry est une VUE en lecture au-dessus de la map brute ;
l'écriture repart de la map d'origine (patched()). Pas de couche de filtrage
préventive : l'aller-retour neutre passe contre .75, donc l'écho verbatim est
sûr par construction (§7-1).

§4 Priorité à deux niveaux — groupByDomain / flattenToPayload. Le rang de
domaine se déduit de l'ordre d'apparition à plat ; la box ne sait rien des
groupes. Config non contiguë : signalée, ordre réel affiché, normalisation
seulement sur confirmation explicite. Miroir client de validateSet().

§5 Écritures asynchrones — LoadConfigProvider ne conclut jamais sur
l'acquittement du RPC : pending → LoadConfigChanged → confirmed|diverged.

NymeaEnergy.GetLoadConfig / SetLoadConfig deviennent RÉELS (ils étaient des
stubs journalisés). Conséquence traitée : EnergySetupProvider.persist()
n'émet plus setLoadConfig(buildLoadDescriptors()) — SetLoadConfig remplace
TOUT l'ensemble, et _commit() l'appelait à chaque changement d'UI : une
reconstruction depuis le modèle typé aurait écrasé la config réelle de la
box sans geste explicite.

Écran /energy/loads, gaté installateur (drawer + garde de routeur).

Limites — le lot plugin n'est PAS déployé sur .75 : GetLoadTelemetry,
LoadTelemetryChanged et o:domain sont absents (19 méthodes / 13 notifications
inchangées). Toute la moitié télémétrie du brief est donc hors d'atteinte, et
o:domain n'étant pas au schéma, la box rejetterait la clé (jsonvalidator.cpp:143).
Fixtures multi-domaines synthétiques : .75 n'a qu'une charge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 11:34:53 +02:00

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