etm-powersync-app/lib/services/load_mechanism_switch.dart
Patrick Schurig aada8e2ba5 fix(config): glissement, sélecteur de mécanisme, chip de domaine, thème — les 4 points ouverts
1) GLISSEMENT — diagnostic d'abord, et il a donné DEUX réponses.

   Le geste n'est PAS avalé par le ListView parent : les deux montages du test
   (section seule / section imbriquée) glissent identiquement. La cause du symptôme était
   bien que .75 n'a qu'une charge par domaine — rien à réordonner.

   Mais l'enquête a trouvé un vrai défaut au passage : le centre de la poignée tombait dans
   le vide de 3 px séparant l'icône de la pastille de rang, et ReorderableDragStartListener
   défère le test de collision à ses enfants. Une bande morte invisible au milieu d'une
   poignée qui paraît d'un seul tenant. Corrigé par un Container transparent.

   Tentative faite et ANNULÉE : rendre les domaines glissables aussi. Deux
   ReorderableListView imbriqués se disputent le geste et l'extérieur gagne — le
   glissement des cartes cassait. Les domaines gardent leurs flèches, et une charge seule
   dans son domaine n'offre plus de prise qui mènerait nulle part (avec l'infobulle qui
   renvoie au bon geste).

   Vérifié SUR L'APPAREIL, contre .75, avec les deux charges mises dans le même domaine :
   [chauffe-eau, pac-terrain] → [pac-terrain, chauffe-eau], brouillon abandonné sans rien
   écrire. Classement ecs/hvac rétabli ensuite.

2) SÉLECTEUR DE MÉCANISME — actif, et c'était le morceau délicat.

   LoadConfig est une union discriminée : écrire `adapter` sans nettoyer le reste produit
   une configuration que la box refuse EN BLOC, et le refus emporte toutes les charges de
   l'appel. D'où basculerMecanisme(), qui retire les clés du mécanisme quitté et pose
   celles du nouveau, en récupérant ce qui peut l'être — les deux premiers contacts
   deviennent SG1/SG2 dans l'encodage standard, les relais gardent leur puissance.

   Ce qui n'est PAS transposé : l'estimation d'un état SG-Ready n'est pas la puissance
   d'une résistance. La charge produite est alors incomplète, et c'est voulu — le
   formulaire réclame plutôt que d'inventer une valeur de plaque.

   La bascule franchit sameHardware() : confirmation préalable qui annonce la
   reconstruction et la durée de réarmement (900 s par défaut vers SG-Ready — la valeur
   d'une PAC réelle, pas celle du banc).

   Le mode fixed d'etmvariableload reste LISIBLE sans être proposé : bloc « Configuration
   héritée » en lecture seule, et conversion seulement sur geste explicite, avec le
   plafond que la déclaration en paliers ne porte pas. Ouvrir l'écran ne convertit personne.

3) CHIP DE DOMAINE — il ne rendait qu'une icône muette : le domaine d'une charge était
   illisible sur sa carte, alors qu'il décide de son groupe et de son rang. Il porte
   maintenant son libellé, et sa couleur suit le thème.

4) THÈME — les 11 derniers sites (pas 9) basculés. Trois cas : contexte disponible
   (main, ac_screen) ; peintre sans contexte, à qui la couleur est passée résolue
   (energy_flow_widget) ; tables statiques, où la teinte devient nullable et se résout au
   rendu (categoryInfoMap, favoris). Plus AUCUN jeton hors thème dans lib/.

5) ZONES — non entreprises, comme convenu.

Tests 93/93, 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 08:49:20 +02:00

174 lines
7.1 KiB
Dart

/// Changer le **mécanisme** d'une charge — la bascule de charge utile, et rien d'autre.
///
/// ## Pourquoi ça ne peut pas être un simple `adapter: …`
///
/// `LoadConfig` est une **union discriminée** (LM-300) : chaque mécanisme a sa charge
/// utile, et celles des autres doivent être **absentes**. La box ne se contente pas de les
/// ignorer, elle **refuse en bloc** :
///
/// ```
/// sg-ready : relays[] interdit (charge utile d'un autre mécanisme)
/// etmvariableload : relays[] interdit (réservé à relay-router)
/// <mécanisme> : sgReady interdit (charge utile du mécanisme sg-ready)
/// ```
///
/// Écrire `adapter` sans nettoyer le reste produit donc une configuration que la box
/// rejette — et le refus porte sur **toutes** les charges de l'appel, sans motif.
///
/// ## Ce que la bascule coûte
///
/// `adapter` et `mode` sont comparés par `sameHardware()` : changer de mécanisme
/// **reconstruit l'adaptateur**. Ses contacts passent par l'état sûr, ses verrous se
/// réarment à froid. Sur une PAC à 900 s, c'est un quart d'heure de machine figée. L'écran
/// doit l'annoncer **avant** de valider — jamais après.
///
/// ## Le mode `fixed` d'`etmvariableload` n'est PAS proposé
///
/// Le multipalier natif existe encore côté box, qualifié de *legacy* par le plugin.
/// [basculerMecanisme] ne le produit jamais : une bascule vers `etmvariableload` donne
/// toujours `dynamic`. Mais une configuration déployée qui l'utilise se **lit** sans être
/// réécrite — [estHeritageFixed] la reconnaît, et seule une conversion explicitement
/// demandée la change. Ouvrir un écran ne doit convertir personne.
library;
import '../models/load_config_entry.dart';
/// Les trois mécanismes qu'une charge peut porter.
const List<String> kMecanismes = ['relay-router', 'etmvariableload', 'sg-ready'];
/// Libellés d'interface. Des **codes** côté contrat, des mots côté écran.
const Map<String, String> kMecanismeLabels = {
'relay-router': 'Routeur de relais',
'etmvariableload': 'Modulable',
'sg-ready': 'SG-Ready',
};
/// Vrai si cette charge est un `etmvariableload` en **multipalier natif** (`fixed`) —
/// la forme *legacy* que l'app lit sans la proposer ni la réécrire.
bool estHeritageFixed(LoadConfigEntry e) =>
e.isVariableLoad && e.mode == 'fixed' && e.powerLevelsInt.isNotEmpty;
/// Charge utile d'une charge basculée vers [vers].
///
/// Repart de la map d'origine — donc en préserve tout ce qui n'appartient à aucun
/// mécanisme (`id`, `label`, `domain`, `priority`, `enabled`, `needs`) — puis **retire les
/// clés du mécanisme quitté** et pose celles du nouveau.
///
/// Ce qui est **récupéré** quand c'est possible, parce que le jeter ferait perdre à
/// l'installateur un travail qu'il vient de faire :
///
/// - vers `sg-ready` depuis un routeur : les **deux premiers relais** deviennent SG1 et
/// SG2, dans l'encodage standard ;
/// - vers `relay-router` depuis une PAC : les contacts SG-Ready redeviennent des lignes de
/// relais, **puissance à saisir** (une estimation d'état n'est pas la puissance d'une
/// résistance) ;
/// - `minOnS`/`minOffS` survivent partout où ils ont un sens.
///
/// La charge produite peut être **incomplète** — un `etmvariableload` sans `maxPowerW`,
/// par exemple. C'est voulu : le formulaire la refusera et dira quoi remplir, ce qui vaut
/// mieux que d'inventer une valeur de plaque.
Map<String, dynamic> basculerMecanisme(LoadConfigEntry e, String vers) {
assert(kMecanismes.contains(vers));
// Les contacts que l'on peut récupérer, tous mécanismes confondus.
final contacts = <String>[];
void ajouter(String id) {
if (id.trim().isEmpty) return;
if (contacts.any((x) => normalizeThingId(x) == normalizeThingId(id))) return;
contacts.add(id);
}
for (final r in e.relays) {
ajouter(r.thingId);
}
for (final s in e.sgReadyStates) {
for (final r in s.relays) {
ajouter(r);
}
}
switch (vers) {
case 'relay-router':
// Les puissances d'origine si elles existent ; sinon 0, que le formulaire refusera
// en le disant — une estimation d'état SG-Ready n'est pas la puissance d'une
// résistance, et la transposer serait une invention.
final parThing = {
for (final r in e.relays) normalizeThingId(r.thingId): r.powerW
};
return e.patched(
adapter: 'relay-router',
mode: 'fixed',
relays: [
for (final c in contacts)
{'thingId': c, 'powerW': parThing[normalizeThingId(c)] ?? 0}
],
sgReady: const <String, dynamic>{},
powerLevels: const <int>[],
maxPowerW: 0,
);
case 'etmvariableload':
// TOUJOURS `dynamic` : le multipalier natif est *legacy* et ne se propose pas.
return e.patched(
adapter: 'etmvariableload',
mode: 'dynamic',
relays: const <Map<String, dynamic>>[],
sgReady: const <String, dynamic>{},
powerLevels: const <int>[],
maxPowerW: e.maxPowerW > 0 ? e.maxPowerW : 0,
);
case 'sg-ready':
default:
final sg1 = contacts.isNotEmpty ? contacts[0] : '';
final sg2 = contacts.length > 1 ? contacts[1] : '';
// Encodage SG-Ready standard, celui que l'écran sait étiqueter :
// SG1 fermé en 1 (blocage), SG2 fermé en 3 (recommandation), les deux en 4.
final estimations = {
for (final s in e.sgReadyStates) s.state: s.estimatedPowerW
};
return e.patched(
adapter: 'sg-ready',
mode: 'fixed',
relays: const <Map<String, dynamic>>[],
powerLevels: const <int>[],
maxPowerW: 0,
sgReady: {
// 900 s par défaut : la valeur d'une PAC réelle, pas celle du banc. Mieux vaut
// une protection trop longue qu'un compresseur en court-cycle.
'minStateHoldS':
e.minStateHoldS > 0 ? e.minStateHoldS : 900,
'states': [
{'state': 1, 'relays': [if (sg1.isNotEmpty) sg1], 'estimatedPowerW': estimations[1] ?? 0.0},
{'state': 2, 'relays': <String>[], 'estimatedPowerW': estimations[2] ?? 0.0},
{'state': 3, 'relays': [if (sg2.isNotEmpty) sg2], 'estimatedPowerW': estimations[3] ?? 0.0},
{
'state': 4,
'relays': [if (sg1.isNotEmpty) sg1, if (sg2.isNotEmpty) sg2],
'estimatedPowerW': estimations[4] ?? 0.0,
},
],
},
);
}
}
/// Convertit un `etmvariableload` **legacy** (`fixed` + paliers) en modulation continue.
///
/// Geste **explicite**, jamais automatique : basculer `fixed` → `dynamic` change
/// l'arbitrage réel de la charge. La box refuse par ailleurs `dynamic` sans `maxPowerW` et
/// refuse `powerLevels` avec — d'où le plafond, que la déclaration en paliers ne porte pas
/// et qu'il faut donc fournir.
///
/// \param maxPowerW plafond de modulation, strictement positif.
Map<String, dynamic> convertirEnModulation(LoadConfigEntry e, int maxPowerW) {
return e.patched(
adapter: 'etmvariableload',
mode: 'dynamic',
powerLevels: const <int>[],
relays: const <Map<String, dynamic>>[],
sgReady: const <String, dynamic>{},
maxPowerW: maxPowerW,
);
}