etm-powersync-app/lib/services/load_hardware.dart
Patrick Schurig 2a8ad95120 feat(config): lot C — un seul écran de configuration, et le grisage vient du schéma
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
2026-08-25 22:17:16 +02:00

250 lines
9.1 KiB
Dart

/// Miroir client de `EnergyArbitrator::sameHardware()` — pour **annoncer avant
/// d'enregistrer**, jamais pour décider à la place de la box.
///
/// ## Ce que la distinction change vraiment
///
/// `SetLoadConfig` remplace l'ensemble des charges, mais le moteur ne reconstruit pas
/// tout : il compare chaque charge à celle qui a servi à bâtir l'adaptateur en place
/// (`rebuildLoadAdapters()`, ECS-412). Deux issues, très différentes pour l'installation :
///
/// - **mise à jour en place** — `priority`, `needs` : l'adaptateur garde son palier
/// courant et ses verrous. Rien ne bouge sur le matériel.
/// - **reconstruction** — tout ce que [sameHardware] compare : l'ancien adaptateur passe
/// par `applySafeState()` (**contacts ouverts, `force`**), le nouveau relit l'état réel,
/// et ses verrous se **réarment à froid** pour leur durée configurée (ECS-412).
///
/// Sur un ballon à `minOnS` de 60 s c'est anecdotique. Sur une PAC à `minStateHoldS` de
/// 900 s, l'installateur qui corrige une ligne se retrouve avec une machine figée un quart
/// d'heure — et rien ne le lui aura dit. D'où cet écran d'annonce.
///
/// ## Le piège mesuré sur `.75` le 2026-08-25
///
/// La comparaison des relais est **indexée**, pas ensembliste : `relays[i].thingId` et
/// `relays[i].powerW`, un à un. **Réordonner la liste sans rien changer d'autre est donc
/// une reconstruction.** Vérifié sur le banc — `relays[]` inversé (2000/1000/500 au lieu
/// de 500/1000/2000), même ensemble, mêmes Things, mêmes puissances :
///
/// ```text
/// [RelayRouter] chauffe-eau — reprise au démarrage : palier 0 W […]
/// [EnergyArbitrator] relay-router construit depuis config: "chauffe-eau" ( 3 relais)
/// [RelayRouter] "chauffe-eau" → […] (force) | "Charge désactivée — mise en état sûr […]"
/// [EnergyArbitrator] 2 charge(s) active(s) — 1 créée(s), 1 inchangée(s), 1 retirée(s)
/// ```
///
/// Un glisser-déposer dans la liste des contacteurs est donc un geste **matériel**. Il
/// doit s'annoncer comme tel, et c'est le cas le moins intuitif de tous.
library;
import '../models/load_config_entry.dart';
/// Ce qu'un enregistrement va provoquer pour une charge donnée.
enum LoadChangeImpact {
/// Rien ne part pour cette charge.
none,
/// Mise à jour en place : rang, besoins. Verrous et palier courant préservés.
inPlace,
/// Reconstruction de l'adaptateur : état sûr, relecture, verrous réarmés à froid.
rebuild,
/// La charge entre dans l'arbitrage (`enabled` faux → vrai, ou charge nouvelle).
created,
/// La charge en sort (`enabled` vrai → faux, ou charge retirée) : elle passe par
/// `applySafeState()` avant destruction (ECS-413).
removed,
}
/// Verdict d'impact pour une charge, avec de quoi le formuler à l'écran.
class LoadImpact {
final String loadId;
final String label;
final LoadChangeImpact impact;
/// Champs matériels qui ont changé (`relays`, `sgReady`, `minOnS`, …). Vide hors
/// reconstruction. Sert à dire **ce qui** a déclenché, pas seulement qu'il y a eu.
final List<String> changedFields;
/// Durée du verrou qui va se réarmer, en secondes, si elle est connue.
///
/// `minStateHoldS` pour une charge `sg-ready`, `max(minOnS, minOffS)` pour un
/// routeur de relais. `null` quand le mécanisme n'en déclare pas.
final int? rearmSeconds;
const LoadImpact({
required this.loadId,
required this.label,
required this.impact,
this.changedFields = const [],
this.rearmSeconds,
});
/// Vrai si l'installateur doit être averti avant que ça parte.
bool get needsWarning =>
impact == LoadChangeImpact.rebuild || impact == LoadChangeImpact.removed;
}
/// Miroir **exact** de `EnergyArbitrator::sameHardware()` (energyarbitrator.cpp).
///
/// Toute divergence ferait mentir l'annonce — dans un sens comme dans l'autre. Les champs
/// comparés, dans l'ordre du moteur : `adapter`, `mode`, `minOnS`, `minOffS`, `maxPowerW`,
/// `powerLevels`, la charge utile `sgReady` entière, puis `relays[]` **index par index**.
///
/// \return vrai si le moteur gardera l'adaptateur en place.
bool sameHardware(LoadConfigEntry a, LoadConfigEntry b) =>
hardwareDiff(a, b).isEmpty;
/// Les champs matériels qui diffèrent. Vide ⇔ [sameHardware].
///
/// Rendre la liste plutôt qu'un booléen permet de dire *ce qui* impose la reconstruction :
/// « vous avez changé l'ordre des contacteurs » est actionnable, « la charge sera
/// reconstruite » ne l'est pas.
List<String> hardwareDiff(LoadConfigEntry a, LoadConfigEntry b) {
final diff = <String>[];
if (a.adapter != b.adapter) diff.add('adapter');
if (a.mode != b.mode) diff.add('mode');
if (a.minOnS != b.minOnS) diff.add('minOnS');
if (a.minOffS != b.minOffS) diff.add('minOffS');
if (a.maxPowerW != b.maxPowerW) diff.add('maxPowerW');
if (!_sameIntList(a.powerLevelsInt, b.powerLevelsInt)) diff.add('powerLevels');
if (!_sameSgReady(a, b)) diff.add('sgReady');
// Comparaison INDEXÉE, comme le moteur : un réordonnancement compte.
final ra = a.relays, rb = b.relays;
var relaysDiffer = ra.length != rb.length;
if (!relaysDiffer) {
for (var i = 0; i < ra.length; i++) {
if (normalizeThingId(ra[i].thingId) != normalizeThingId(rb[i].thingId) ||
ra[i].powerW != rb[i].powerW) {
relaysDiffer = true;
break;
}
}
}
if (relaysDiffer) diff.add('relays');
return diff;
}
/// Impact d'un enregistrement, charge par charge.
///
/// [avant] est l'état de la box (celui de `GetLoadConfig`), [apres] ce qui va partir.
/// Les charges sont appariées par `id` — c'est la clé du moteur.
List<LoadImpact> impactOfSave({
required List<LoadConfigEntry> avant,
required List<LoadConfigEntry> apres,
}) {
final parAvant = {for (final e in avant) e.id: e};
final parApres = {for (final e in apres) e.id: e};
final out = <LoadImpact>[];
for (final e in apres) {
final old = parAvant[e.id];
if (old == null) {
out.add(LoadImpact(
loadId: e.id, label: e.label, impact: LoadChangeImpact.created));
continue;
}
if (!old.enabled && e.enabled) {
out.add(LoadImpact(
loadId: e.id, label: e.label, impact: LoadChangeImpact.created));
continue;
}
if (old.enabled && !e.enabled) {
out.add(LoadImpact(
loadId: e.id, label: e.label, impact: LoadChangeImpact.removed));
continue;
}
if (!e.enabled) {
// Désactivée avant comme après : aucun adaptateur n'existe, rien à reconstruire.
out.add(LoadImpact(
loadId: e.id, label: e.label, impact: LoadChangeImpact.none));
continue;
}
final diff = hardwareDiff(old, e);
if (diff.isNotEmpty) {
out.add(LoadImpact(
loadId: e.id,
label: e.label,
impact: LoadChangeImpact.rebuild,
changedFields: diff,
rearmSeconds: _rearmSecondsOf(e),
));
continue;
}
// `label` et `domain` ne sont PAS dans la comparaison du moteur : ils ne déclenchent
// ni reconstruction ni mise à jour en place — l'adaptateur est réutilisé tel quel.
// Seuls `priority` et `needs` provoquent un updateSoftConfig().
final soft = old.priority != e.priority ||
old.dailyDeadline != e.dailyDeadline ||
old.minEnergyWhPerDay != e.minEnergyWhPerDay;
out.add(LoadImpact(
loadId: e.id,
label: e.label,
impact: soft ? LoadChangeImpact.inPlace : LoadChangeImpact.none,
));
}
for (final e in avant) {
if (parApres.containsKey(e.id)) continue;
out.add(LoadImpact(
loadId: e.id,
label: e.label,
impact: e.enabled ? LoadChangeImpact.removed : LoadChangeImpact.none,
));
}
return out;
}
/// Durée du verrou qui se réarmera après reconstruction, si le mécanisme en déclare une.
int? _rearmSecondsOf(LoadConfigEntry e) {
if (e.isSgReady) {
final h = e.minStateHoldS;
return h > 0 ? h : null;
}
final m = e.minOnS > e.minOffS ? e.minOnS : e.minOffS;
return m > 0 ? m : null;
}
bool _sameIntList(List<int> a, List<int> b) {
if (a.length != b.length) return false;
for (var i = 0; i < a.length; i++) {
if (a[i] != b[i]) return false;
}
return true;
}
/// Compare la charge utile `sg-ready` comme le moteur : `minStateHoldS`, le NOMBRE
/// d'états, puis chaque état index par index (`state`, `relays`, `estimatedPowerW`).
bool _sameSgReady(LoadConfigEntry a, LoadConfigEntry b) {
if (a.minStateHoldS != b.minStateHoldS) return false;
final sa = a.sgReadyStates, sb = b.sgReadyStates;
if (sa.length != sb.length) return false;
for (var i = 0; i < sa.length; i++) {
if (sa[i].state != sb[i].state) return false;
if (!_sameStringList(
sa[i].relays.map(normalizeThingId).toList(),
sb[i].relays.map(normalizeThingId).toList())) {
return false;
}
// Le moteur compare en qFuzzyCompare : une égalité stricte de doubles suffit ici,
// les deux valeurs venant du même aller-retour JSON.
if (sa[i].estimatedPowerW != sb[i].estimatedPowerW) return false;
}
return true;
}
bool _sameStringList(List<String> a, List<String> b) {
if (a.length != b.length) return false;
for (var i = 0; i < a.length; i++) {
if (a[i] != b[i]) return false;
}
return true;
}