etm-powersync-app/lib/screens/energy/load_mechanism_screen.dart
Patrick Schurig a44d64d6c2 feat(3g-2): les bornes sont des charges configurées — une seule liste, un seul rang
+etm23 rend à chaque borne détectée une entrée GetLoadConfig, et l'invariant
loads[] ⊆ GetLoadConfig est rétabli. Sur .75 : 2 entrées → 4. L'app s'appuyait déjà sur cet
invariant pour résoudre libellé, domaine et rang de toute charge publiée.

Vérifié sur machine avant d'écrire quoi que ce soit :
- l'aller-retour verbatim reste NEUTRE avec les quatre entrées (loadconfig_roundtrip.dart,
  verdict IDENTIQUE) ;
- une entrée evcharger porte id/label/domain/priority/enabled, mode dynamic, et AUCUNE
  charge utile de mécanisme.

Trois défauts que ce contrat révèle côté app :

1. isVariableLoad était défini par défaut (« ni relais ni sg-ready »), donc une borne y
   tombait. L'écran de mécanisme lui proposait une puissance nominale, des paliers et des
   temporisations — des réglages qui n'existent pas pour elle et dont l'écriture partirait
   quand même. Pire, le sélecteur offrait de la basculer en routeur de relais : une bascule
   que basculerMecanisme() sait construire et qu'aucun écran ne sait défaire. Une borne a
   maintenant sa section, en lecture seule, qui dit pourquoi il n'y a rien à régler.

2. BorneSection existait pour rattraper des bornes qui n'apparaissaient nulle part. Elles
   apparaissent maintenant dans leur domaine : la section les afficherait DEUX fois, en
   affirmant qu'elles n'ont « ni rang ni domaine » et qu'elles ne passent pas par
   SetLoadConfig. Trois affirmations devenues fausses. Retirée ; son contenu de runtime
   vit désormais dans l'écran de mécanisme, où il a un sens.

3. Le rang par défaut d'une borne est un ARTEFACT, et l'écran le disait comme un réglage.
   À la création le plugin donne « le plus petit rang existant moins un, borné à 1 » ;
   le chauffe-eau occupant déjà le rang 1, l'intention « en tête » dégénère en égalité —
   trois charges à 1 sur le banc. Le tri reste total (l'id départage), donc l'ordre est
   reproductible, et c'est précisément ce qui le rend crédible : l'installateur lit un
   classement plausible et n'y touche pas, validant un ordre que personne n'a posé. Une
   note le nomme, et disparaît dès le premier vrai réglage.

Le glissement, lui, n'a demandé aucun cas particulier — c'était tout l'intérêt d'avoir une
seule liste. Vérifié par test, y compris l'inversion borne / charge pilotée.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
2026-08-27 14:32:56 +02:00

1965 lines
73 KiB
Dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../l10n/l10n_ext.dart';
import '../../models/load_config_entry.dart';
import '../../providers/load_config_provider.dart';
import '../../providers/load_telemetry_provider.dart';
import '../../providers/schema_provider.dart';
import '../../services/load_hardware.dart';
import '../../services/load_mechanism_switch.dart';
import '../../services/load_priority.dart';
import '../../services/nymea_service.dart';
import '../../services/rpc_schema.dart';
import '../../theme/etm_tokens.dart';
import '../../widgets/load_cards.dart';
import '../../widgets/schema_gated.dart';
import '../../widgets/thing_picker.dart';
/// **Configurer** une charge — un écran, trois charges utiles qui ne se ressemblent pas.
///
/// Le bouton « Configurer » ne peut pas ouvrir un formulaire unique : un routeur de relais
/// déclare des **contacteurs**, une charge modulable un **plafond**, une PAC SG-Ready un
/// **encodage à quatre états normés**. Ce qui est commun — nom, domaine, temporisations,
/// arbitrage — encadre une section centrale qui, elle, change entièrement.
///
/// ## Tout ce qui se règle ici n'a pas le même coût
///
/// `label`, `domain` et `enabled` passent en **mise à jour sur place**. Tout le reste —
/// contacteurs, table d'états, plafond, temporisations — franchit `sameHardware()` :
/// l'adaptateur est **reconstruit**, ses contacts passent par l'état sûr et ses verrous se
/// réarment à froid. L'écran l'annonce **avant** d'écrire.
///
/// ## Valider avant d'envoyer
///
/// `SetLoadConfig` refuse **en bloc et sans motif** : la règle enfreinte reste au journal
/// de la box. Un formulaire qui laisse partir une configuration invalide condamne donc
/// l'installateur à un refus muet, et fait perdre **toutes** les charges de l'appel. D'où
/// [_valider], qui reprend les règles du plugin côté app.
class LoadMechanismScreen extends StatefulWidget {
final String loadId;
const LoadMechanismScreen({super.key, required this.loadId});
@override
State<LoadMechanismScreen> createState() => _LoadMechanismScreenState();
}
class _LoadMechanismScreenState extends State<LoadMechanismScreen> {
LoadConfigEntry? _entree(LoadConfigProvider p) {
for (final g in p.groups) {
for (final e in g.loads) {
if (e.id == widget.loadId) return e;
}
}
return null;
}
@override
Widget build(BuildContext context) {
final provider = context.watch<LoadConfigProvider>();
final schema = context.watch<SchemaProvider>();
final service = context.watch<NymeaService>();
final telemetry = context.watch<LoadTelemetryProvider>();
final e = _entree(provider);
return Scaffold(
backgroundColor: EtmTokens.bgOf(context),
appBar: AppBar(
title: Text(e == null ? 'Charge' : 'Configurer · ${e.label}'),
backgroundColor: EtmTokens.surfaceOf(context),
foregroundColor: EtmTokens.inkOf(context),
elevation: 0,
),
body: e == null
? const Center(child: Text('Charge introuvable dans la configuration.'))
: ListView(
key: const ValueKey('liste-mecanisme'),
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
children: [
SchemaVersionBanner(
versionLine: schema.versionLine(),
schemaLoaded: schema.schema.loaded,
),
const SizedBox(height: 14),
_Identite(entry: e, provider: provider),
const SizedBox(height: 14),
_Mecanisme(entry: e, provider: provider),
const SizedBox(height: 14),
// Une borne ne porte AUCUNE charge utile de mécanisme, et pas non plus
// de temporisation : `minOnS`/`minOffS` valent 0 et ne se règlent pas
// ici. Le repli `_SectionModulable` lui proposait une puissance nominale
// et des paliers — des réglages qui n'existent pas pour elle, et dont
// l'écriture partirait quand même dans `SetLoadConfig`.
if (e.isEvCharger)
_SectionBorne(entry: e, telemetry: telemetry)
else if (e.isRelayRouter)
_SectionRelais(
entry: e,
provider: provider,
service: service,
telemetry: telemetry)
else if (e.isSgReady)
_SectionSgReady(entry: e, provider: provider, service: service)
else
_SectionModulable(
entry: e,
provider: provider,
service: service,
schema: schema,
telemetry: telemetry),
const SizedBox(height: 14),
if (e.isEvCharger)
const SizedBox.shrink()
else if (e.isSgReady)
_Temporisation(
titre: 'Anti court-cycle',
sousTitre: 'Durée avant tout changement d\'état.',
lignes: [
_LigneDuree(
libelle: 'Maintien d\'état minimal',
aide: 'produit le verrou LOCK_MIN_STATE_HOLD',
valeur: e.minStateHoldS,
onChange: (v) => _majSgReady(provider, e, hold: v),
),
],
)
else
_Temporisation(
titre: 'Temporisations',
sousTitre:
'Ces deux durées produisent les verrous que la carte de la '
'charge affiche en secondes restantes.',
lignes: [
_LigneDuree(
libelle: 'Maintien ON minimal',
aide: 'anti court-cycle à l\'enclenchement',
valeur: e.minOnS,
onChange: (v) => provider.replaceEntry(
e.id, e.patched(minOnS: v)),
),
_LigneDuree(
libelle: 'Maintien OFF minimal',
aide: 'délai avant réenclenchement',
valeur: e.minOffS,
onChange: (v) => provider.replaceEntry(
e.id, e.patched(minOffS: v)),
),
],
),
const SizedBox(height: 14),
_CompteurEtSonde(
entry: e,
provider: provider,
schema: schema.schema,
service: service),
const SizedBox(height: 14),
_Arbitrage(entry: e, provider: provider),
const SizedBox(height: 18),
_BarreEnregistrement(
entry: e, provider: provider, service: service),
],
),
);
}
void _majSgReady(LoadConfigProvider p, LoadConfigEntry e, {required int hold}) {
p.replaceEntry(
e.id,
e.patched(sgReady: {
'minStateHoldS': hold,
'states': [for (final s in e.sgReadyStates) s.toMap()],
}),
);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Identité — nom, domaine
// ═══════════════════════════════════════════════════════════════════════════
class _Identite extends StatefulWidget {
final LoadConfigEntry entry;
final LoadConfigProvider provider;
const _Identite({required this.entry, required this.provider});
@override
State<_Identite> createState() => _IdentiteState();
}
class _IdentiteState extends State<_Identite> {
late final TextEditingController _c =
TextEditingController(text: widget.entry.label);
@override
void dispose() {
_c.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final t = context.l10n;
return _Carte(
titre: 'Nom de la charge',
badge: 'enregistré · label',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: _c,
style: const TextStyle(fontSize: 14),
decoration: const InputDecoration(isDense: true),
onChanged: (v) {
if (v.trim().isNotEmpty) {
widget.provider.setLabel(widget.entry.id, v.trim());
}
},
),
const SizedBox(height: 6),
_Aide(
'Le seul texte de l\'installation que l\'utilisateur voit dans chaque motif '
'de décision.',
),
const SizedBox(height: 14),
_SousTitre('Domaine', badge: 'enregistré · domain'),
const SizedBox(height: 6),
Align(
alignment: Alignment.centerLeft,
child: DomainMenu(entry: widget.entry, provider: widget.provider),
),
const SizedBox(height: 4),
_Aide(
'Métadonnée d\'intention : elle groupe l\'ordre de service. '
'L\'arbitre ne la lit pas — ${domainLabel(t, widget.entry.domain ?? kUnclassifiedDomain)}.',
),
],
),
);
}
}
/// Le mécanisme, et il **se change** — c'est un champ enregistré (`adapter`).
///
/// Le geste n'est pas anodin : `adapter` et `mode` entrent dans `sameHardware()`, donc
/// basculer **reconstruit l'adaptateur** (contacts à l'état sûr, verrous réarmés à froid).
/// Et la charge utile du mécanisme quitté doit **partir** — la box refuse en bloc une
/// configuration qui porterait celle d'un autre. D'où la confirmation, qui dit les deux.
class _Mecanisme extends StatelessWidget {
final LoadConfigEntry entry;
final LoadConfigProvider provider;
const _Mecanisme({required this.entry, required this.provider});
Future<void> _demander(BuildContext context, String vers) async {
if (vers == entry.adapter) return;
final ok = await showDialog<bool>(
context: context,
builder: (c) => AlertDialog(
title: Text('Passer en « ${kMecanismeLabels[vers]} » ?'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'La charge sera RECONSTRUITE : ses contacts passent par leur état sûr, '
'puis ses verrous se réarment à froid.',
style: TextStyle(fontSize: 13, height: 1.4, color: EtmTokens.inkOf(c)),
),
const SizedBox(height: 10),
Text(
_cePartQuoi(vers),
style: TextStyle(
fontSize: 12, height: 1.4, color: EtmTokens.mutedOf(c)),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(c).pop(false),
child: const Text('Annuler')),
FilledButton(
onPressed: () => Navigator.of(c).pop(true),
child: const Text('Changer')),
],
),
);
if (ok == true) {
provider.replaceEntry(entry.id, basculerMecanisme(entry, vers));
}
}
/// Ce que la bascule emporte et ce qu'elle récupère — dit AVANT, pas après.
String _cePartQuoi(String vers) => switch (vers) {
'relay-router' =>
'Les contacts déjà déclarés deviennent des lignes de relais ; leur puissance '
'reste à saisir — une estimation d\'état SG-Ready n\'est pas la puissance '
'd\'une résistance.',
'etmvariableload' =>
'Les contacts et la table d\'états sont retirés : ce mécanisme ne commande '
'aucun relais. Une puissance nominale sera requise.',
_ =>
'Les deux premiers contacts deviennent SG1 et SG2 dans l\'encodage standard, '
'et les puissances estimées restent à renseigner.',
};
@override
Widget build(BuildContext context) {
// Une BORNE n'est pas un mécanisme au choix. Son entrée est créée d'office par le
// plugin à la détection du Thing, et `adapter: "evcharger"` n'est pas dans
// [kMecanismes] : proposer les trois autres reviendrait à offrir de transformer une
// borne en routeur de relais — une bascule que `basculerMecanisme()` sait construire
// et qui détruirait le rattachement, sans qu'aucun écran n'ait de quoi le défaire.
if (entry.isEvCharger) {
return _Carte(
titre: 'Mécanisme',
badge: 'imposé · evcharger',
sousTitre: 'La borne est déclarée par la box à la détection de l\'appareil.',
child: Text(
'Une borne ne porte aucun réglage de mécanisme : ni contacts, ni paliers, ni '
'puissance nominale. Ses limites viennent de la borne et de la voiture '
'branchée, et changent avec elle — les figer ici en ferait une seconde source '
'de vérité, fausse dès le prochain véhicule.\n\n'
'Ce qui se règle depuis cet écran : le libellé, le domaine, le rang. Le mode de '
'recharge, lui, se règle depuis la carte véhicule du tableau de bord.',
style: TextStyle(
fontSize: 12, height: 1.4, color: EtmTokens.mutedOf(context)),
),
);
}
return _Carte(
titre: 'Mécanisme',
badge: 'enregistré · adapter',
sousTitre: 'Où sont les contacteurs ? Sur les sorties de la box → routeur de '
'relais. Dans l\'appareil, piloté par une consigne → modulable.',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final m in kMecanismes)
InkWell(
borderRadius: BorderRadius.circular(9),
onTap: () => _demander(context, m),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 11, vertical: 7),
decoration: BoxDecoration(
color: m == entry.adapter
? EtmTokens.brand.withValues(alpha: 0.14)
: Colors.transparent,
borderRadius: BorderRadius.circular(9),
border: Border.all(
color: m == entry.adapter
? EtmTokens.brand.withValues(alpha: 0.5)
: EtmTokens.lineOf(context)),
),
child: Text(kMecanismeLabels[m]!,
style: TextStyle(
fontSize: 12,
fontWeight: m == entry.adapter
? FontWeight.w700
: FontWeight.w400,
color: m == entry.adapter
? EtmTokens.brand
: EtmTokens.mutedOf(context),
)),
),
),
],
),
if (estHeritageFixed(entry)) ...[
const SizedBox(height: 10),
_HeritageFixed(entry: entry, provider: provider),
],
],
),
);
}
}
/// Borne de recharge — **rien à régler ici, et le dire vaut mieux que se taire**.
///
/// L'entrée `evcharger` est créée d'office par le plugin à la détection du Thing, pour que
/// l'invariant `loads[] ⊆ GetLoadConfig` tienne : une charge publiée en télémétrie doit
/// pouvoir résoudre son libellé, son domaine et son rang quelque part. Elle ne porte
/// **aucune** charge utile de mécanisme.
///
/// Ce que cette section montre est donc du **runtime**, en lecture seule : le nombre de
/// phases, le courant, le plancher, et la présence d'un véhicule. Les afficher n'est pas
/// décoratif — c'est ici que se lit l'écart entre ce que l'arbitre a commandé et ce que la
/// borne fait, écart que l'allocation seule ne montre jamais.
///
/// Et le plancher d'une borne n'est pas un réglage : il vaut `max(borne, voiture)` et
/// change avec le véhicule branché. Le proposer à la saisie créerait une seconde source de
/// vérité, fausse dès la voiture suivante.
class _SectionBorne extends StatelessWidget {
final LoadConfigEntry entry;
final LoadTelemetryProvider telemetry;
const _SectionBorne({required this.entry, required this.telemetry});
@override
Widget build(BuildContext context) {
final muted = EtmTokens.mutedOf(context);
final live = telemetry.entryFor(entry.id);
final mech = live?.mechanism;
final lignes = <String>[];
if (mech != null) {
if (mech.phaseCount != null) lignes.add('${mech.phaseCount} phase(s)');
if (mech.currentA != null) {
lignes.add('${mech.currentA!.toStringAsFixed(1)} A');
}
if (mech.minPowerW != null) {
lignes.add('plancher ${mech.minPowerW!.round()} W');
}
if (mech.pluggedIn != null) {
lignes.add(mech.pluggedIn! ? 'véhicule branché' : 'aucun véhicule');
}
}
return _Carte(
titre: 'Borne',
badge: 'lecture seule · runtime',
sousTitre: 'Ce que la box publie pour cette borne au dernier cycle.',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (lignes.isEmpty)
// Absente de la télémétrie : configurée, mais hors arbitrage EN CE MOMENT.
// Depuis +etm24, c'est notamment le cas quand aucun véhicule n'est branché.
// « Perdue » serait faux, et « désactivée » aussi.
Text(
'Hors arbitrage en ce moment — la box ne publie rien pour elle au dernier '
'cycle. C\'est le cas sans véhicule branché, sans voiture assignée, ou en '
'mode manuel. La borne n\'est ni perdue ni désactivée : son entrée de '
'configuration est là, au-dessus.',
style: TextStyle(fontSize: 12, height: 1.4, color: muted),
)
else ...[
Text(lignes.join(' · '),
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: EtmTokens.inkOf(context))),
const SizedBox(height: 6),
Text(
'Mesuré, pas réglé. Le plancher d\'une borne vaut max(borne, voiture) et '
'suit le véhicule branché : il n\'y a pas de champ à saisir, et il ne faut '
'pas en fabriquer un.',
style: TextStyle(fontSize: 11.5, height: 1.35, color: muted),
),
],
],
),
);
}
}
/// Configuration **héritée** : `etmvariableload` en multipalier natif (`fixed`).
///
/// L'app ne propose plus ce mode — le plugin lui-même le qualifie de *legacy* — mais elle
/// ne le réécrit pas en douce : basculer `fixed` vers `dynamic` change l'arbitrage réel de
/// la charge, et cela demande un geste explicite. **Ouvrir cet écran ne convertit
/// personne.**
class _HeritageFixed extends StatelessWidget {
final LoadConfigEntry entry;
final LoadConfigProvider provider;
const _HeritageFixed({required this.entry, required this.provider});
Future<void> _convertir(BuildContext context) async {
final ctrl = TextEditingController(
text: entry.powerLevelsInt.isEmpty
? ''
: entry.powerLevelsInt.last.toString());
final w = await showDialog<int>(
context: context,
builder: (c) => AlertDialog(
title: const Text('Convertir en modulation continue'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'La box refuse « dynamic » sans plafond, et refuse les paliers avec. La '
'déclaration en paliers ne porte pas de puissance nominale : il faut donc '
'la fournir.',
style: TextStyle(
fontSize: 12.5, height: 1.4, color: EtmTokens.mutedOf(c)),
),
const SizedBox(height: 12),
TextField(
controller: ctrl,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
isDense: true,
labelText: 'Puissance nominale',
suffixText: 'W'),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(c).pop(),
child: const Text('Annuler')),
FilledButton(
onPressed: () {
final n = int.tryParse(ctrl.text.trim());
if (n != null && n > 0) Navigator.of(c).pop(n);
},
child: const Text('Convertir'),
),
],
),
);
if (w != null && w > 0) {
provider.replaceEntry(entry.id, convertirEnModulation(entry, w));
}
}
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(11),
decoration: BoxDecoration(
color: EtmTokens.orange.withValues(alpha: 0.09),
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Configuration héritée — lecture seule · mode fixed',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: EtmTokens.inkOf(context))),
const SizedBox(height: 4),
Text('Paliers déclarés : ${entry.powerLevelsInt.join(' · ')} W',
style: TextStyle(
fontSize: 11.5,
fontFamily: 'monospace',
color: EtmTokens.inkOf(context))),
const SizedBox(height: 5),
Text(
'Le multipalier natif n\'est plus proposé, mais il n\'est pas réécrit sans '
'votre accord : passer en modulation continue change l\'arbitrage réel de '
'cette charge.',
style: TextStyle(
fontSize: 11, height: 1.35, color: EtmTokens.mutedOf(context)),
),
Align(
alignment: Alignment.centerLeft,
child: TextButton(
onPressed: () => _convertir(context),
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
foregroundColor: EtmTokens.orange),
child: const Text('Convertir en modulation continue…'),
),
),
],
),
);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Routeur de relais
// ═══════════════════════════════════════════════════════════════════════════
/// Les contacteurs, et **rien d'autre de matériel**.
///
/// Les paliers atteignables sont **publiés par la box** (`mechanism.stagesW`, depuis
/// `+etm16`) et jamais enregistrés : ce sont les relais qui se déclarent, les paliers s'en
/// déduisent. L'app les LIT au lieu de refaire la combinatoire — une seule fonction moteur
/// sert la construction du routeur, la comparaison matérielle et cette publication, donc
/// plus de divergence possible.
///
/// Tant que le brouillon n'a pas touché aux contacteurs, c'est cette table publiée qui
/// s'affiche. Dès qu'il y touche, elle devient périmée : on montre alors une **prévision**,
/// annoncée comme telle — mieux vaut dire « voici ce que ça donnera » que d'afficher une
/// table qui décrit encore l'état d'avant.
class _SectionRelais extends StatelessWidget {
final LoadConfigEntry entry;
final LoadConfigProvider provider;
final NymeaService service;
final LoadTelemetryProvider telemetry;
const _SectionRelais({
required this.entry,
required this.provider,
required this.service,
required this.telemetry,
});
/// Vrai si le brouillon a touché aux contacteurs — la table publiée est alors périmée.
bool get _relaisModifies {
for (final e in provider.entries) {
if (e.id == entry.id) return hardwareDiff(e, entry).contains('relays');
}
return false;
}
void _remplacer(List<Map<String, dynamic>> relais) =>
provider.replaceEntry(entry.id, entry.patched(relays: relais));
List<Map<String, dynamic>> get _actuels =>
[for (final r in entry.relays) r.toMap()];
@override
Widget build(BuildContext context) {
// La table PUBLIÉE par la box fait foi. Elle décrit ce qui tourne — donc elle est
// périmée dès que le brouillon touche aux contacteurs.
final publiee = telemetry.entryFor(entry.id)?.mechanism?.stagesW;
final aJour = publiee != null && !_relaisModifies;
final p = aJour ? publiee : deriveStages(entry.relays).levels;
final pris = {
for (final r in entry.relays) normalizeThingId(r.thingId),
};
return _Carte(
titre: 'Relais commandés',
badge: 'enregistré · relays[]',
sousTitre:
'Un contacteur par ligne — puissance de la résistance câblée derrière.',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var i = 0; i < entry.relays.length; i++)
_LigneRelais(
index: i,
relais: entry.relays[i],
service: service,
dejaPris: pris,
onThing: (id) {
final l = _actuels;
l[i] = {'thingId': id, 'powerW': entry.relays[i].powerW};
_remplacer(l);
},
onPuissance: (w) {
final l = _actuels;
l[i] = {'thingId': entry.relays[i].thingId, 'powerW': w};
_remplacer(l);
},
onSupprimer: entry.relays.length <= 1
? null
: () {
final l = _actuels..removeAt(i);
_remplacer(l);
},
),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: () =>
_remplacer([..._actuels, {'thingId': '', 'powerW': 0}]),
icon: const Icon(Icons.add_rounded, size: 18),
label: const Text('Ajouter un relais'),
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
foregroundColor: EtmTokens.brand),
),
),
const SizedBox(height: 6),
Container(
width: double.infinity,
padding: const EdgeInsets.all(11),
decoration: BoxDecoration(
color: EtmTokens.brand.withValues(alpha: 0.07),
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
aJour
? '${entry.relays.length} relais → paliers publiés par la box :'
: '${entry.relays.length} relais → paliers prévus :',
style: TextStyle(
fontSize: 11.5,
fontWeight: FontWeight.w700,
color: EtmTokens.inkOf(context)),
),
const SizedBox(height: 3),
Text(
p.isEmpty ? '—' : '${p.join(' · ')} W',
style: TextStyle(
fontSize: 12.5,
fontFamily: 'monospace',
color: EtmTokens.inkOf(context)),
),
const SizedBox(height: 5),
Text(
aJour
? 'Publiés par la box en télémétrie, jamais enregistrés : ce sont '
'les contacteurs qui se déclarent, les paliers s\'en déduisent.'
: 'PRÉVISION — les contacteurs ont changé dans ce brouillon. La box '
'publiera sa propre table au premier cycle après enregistrement.',
style: TextStyle(
fontSize: 10.5,
height: 1.35,
color: EtmTokens.mutedOf(context)),
),
],
),
),
const SizedBox(height: 8),
_Aide(
'La puissance saisie est celle de la RÉSISTANCE, pas le calibre du '
'contacteur : rien dans nymea ne dit ce qu\'un relais commande.',
),
],
),
);
}
}
class _LigneRelais extends StatelessWidget {
final int index;
final LoadRelay relais;
final NymeaService service;
final Set<String> dejaPris;
final ValueChanged<String> onThing;
final ValueChanged<int> onPuissance;
final VoidCallback? onSupprimer;
const _LigneRelais({
required this.index,
required this.relais,
required this.service,
required this.dejaPris,
required this.onThing,
required this.onPuissance,
this.onSupprimer,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 10),
child: Container(
width: 26,
height: 26,
alignment: Alignment.center,
decoration: BoxDecoration(
color: EtmTokens.brand.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Text('K${index + 1}',
style: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: EtmTokens.brand)),
),
),
const SizedBox(width: 10),
Expanded(
child: ThingPicker(
service: service,
interfaceRequise: 'power',
valeur: relais.thingId.isEmpty ? null : relais.thingId,
dejaPris: dejaPris,
placeholder: 'Choisir un contacteur…',
onChoisi: onThing,
),
),
const SizedBox(width: 10),
SizedBox(
width: 86,
child: TextFormField(
key: ValueKey('w-$index-${relais.thingId}'),
initialValue: '${relais.powerW}',
keyboardType: TextInputType.number,
textAlign: TextAlign.right,
decoration: const InputDecoration(isDense: true, suffixText: 'W'),
onChanged: (v) {
final n = int.tryParse(v.trim());
if (n != null) onPuissance(n);
},
),
),
IconButton(
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.remove_circle_outline, size: 19),
color: EtmTokens.mutedOf(context),
onPressed: onSupprimer,
tooltip: onSupprimer == null
? 'Une charge relais doit garder au moins un contacteur'
: 'Retirer ce contacteur',
),
],
),
);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Modulable
// ═══════════════════════════════════════════════════════════════════════════
class _SectionModulable extends StatelessWidget {
final LoadConfigEntry entry;
final LoadConfigProvider provider;
final NymeaService service;
final SchemaProvider schema;
final LoadTelemetryProvider telemetry;
const _SectionModulable({
required this.entry,
required this.provider,
required this.service,
required this.schema,
required this.telemetry,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
_Carte(
titre: 'Appareil piloté',
badge: 'enregistré · id',
sousTitre: 'Un seul Thing, portant l\'interface etmvariableload.',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// L'identifiant de la charge EST le ThingId : le sélecteur ne peut donc
// pas « changer d'appareil » sur place. On montre lequel est piloté.
ThingPicker(
service: service,
interfaceRequise: 'etmvariableload',
valeur: entry.id,
onChoisi: (_) {},
placeholder: 'Appareil introuvable',
),
const SizedBox(height: 8),
_Aide(
'⚠ Changer d\'appareil, c\'est changer de charge. L\'identifiant de '
'cette charge EST le ThingId piloté : choisir un autre appareil ne la '
'modifie pas, cela en crée une autre et supprime celle-ci — avec son '
'passage par l\'état sûr (consigne 0 W). Le geste se fait donc depuis la '
'liste, pas ici.',
alerte: true,
),
],
),
),
const SizedBox(height: 14),
_Carte(
titre: 'Puissance nominale',
badge: 'enregistré · maxPowerW',
sousTitre:
'Plafond de modulation — la consigne ira de 0 à cette valeur.',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormField(
initialValue: '${entry.maxPowerW}',
keyboardType: TextInputType.number,
decoration: const InputDecoration(
isDense: true, suffixText: 'W'),
onChanged: (v) {
final n = int.tryParse(v.trim());
if (n != null) {
provider.replaceEntry(
entry.id, entry.patched(maxPowerW: n));
}
},
),
const SizedBox(height: 6),
_Aide(
'Requis, et strictement positif : la box refuse une charge modulable '
'sans plafond. C\'est la plaque signalétique de la résistance, pas une '
'estimation — et elle sert au recrédit du budget rendu au reste de la '
'cascade.',
),
const SizedBox(height: 14),
_Plancher(
entry: entry,
provider: provider,
schema: schema.schema,
telemetry: telemetry),
],
),
),
],
);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// SG-Ready
// ═══════════════════════════════════════════════════════════════════════════
/// Les deux contacts, puis la table des quatre états.
///
/// ## « SG1 » et « SG2 » n'existent pas côté box
///
/// `sgReady.states[]` ne stocke qu'une **liste de relais par état**, sans nom ni ordre.
/// Les deux étiquettes sont une convention de cet écran, retrouvée à la lecture :
/// **SG1 = le contact fermé en état 1** (blocage), **SG2 = celui fermé en état 3**
/// (recommandation). C'est l'encodage SG-Ready standard.
///
/// Si la configuration lue ne correspond pas à ce schéma — trois contacts, un état 1 sans
/// contact fermé — l'écran **renonce aux noms** et affiche les relais tels quels. Attribuer
/// une étiquette au hasard serait pire que de ne pas en donner. Le tableau reste éditable ;
/// ce sont les **noms** qui disparaissent, pas la configuration.
class _SectionSgReady extends StatelessWidget {
final LoadConfigEntry entry;
final LoadConfigProvider provider;
final NymeaService service;
const _SectionSgReady({
required this.entry,
required this.provider,
required this.service,
});
static const _nomsEtats = {
1: ('Blocage', 'arrêt forcé'),
2: ('Normal', 'aucune consigne'),
3: ('Recommandation', 'incitation douce'),
4: ('Forcé', 'marche imposée'),
};
/// Contacts déduits : SG1 depuis l'état 1, SG2 depuis l'état 3.
///
/// `null` quand l'encodage ne suit pas la convention — c'est ce qui fait tomber les
/// étiquettes.
({String? sg1, String? sg2, bool standard}) _contacts() {
String? unSeul(int etat) {
for (final s in entry.sgReadyStates) {
if (s.state == etat) return s.relays.length == 1 ? s.relays.first : null;
}
return null;
}
final sg1 = unSeul(1);
final sg2 = unSeul(3);
final tous = {
for (final s in entry.sgReadyStates) ...s.relays.map(normalizeThingId)
};
final standard = sg1 != null &&
sg2 != null &&
normalizeThingId(sg1) != normalizeThingId(sg2) &&
tous.length == 2;
return (sg1: sg1, sg2: sg2, standard: standard);
}
void _ecrire(List<SgReadyState> etats) {
provider.replaceEntry(
entry.id,
entry.patched(sgReady: {
'minStateHoldS': entry.minStateHoldS,
'states': [for (final s in etats) s.toMap()],
}),
);
}
/// Remplace un contact partout où il apparaît — le choix des Things se fait **une fois,
/// en haut**, pas état par état.
void _majContact(String? ancien, String neuf) {
final etats = [
for (final s in entry.sgReadyStates)
SgReadyState(
state: s.state,
relays: [
for (final r in s.relays)
if (ancien != null && normalizeThingId(r) == normalizeThingId(ancien))
neuf
else
r
],
estimatedPowerW: s.estimatedPowerW,
)
];
_ecrire(etats);
}
/// Coche/décoche un contact pour un état — c'est ça qui devient `relays[]`.
void _basculer(int etat, String contact, bool ferme) {
final etats = [
for (final s in entry.sgReadyStates)
if (s.state != etat)
s
else
SgReadyState(
state: s.state,
relays: ferme
? [
...s.relays.where(
(r) => normalizeThingId(r) != normalizeThingId(contact)),
contact
]
: [
...s.relays.where(
(r) => normalizeThingId(r) != normalizeThingId(contact))
],
estimatedPowerW: s.estimatedPowerW,
)
];
_ecrire(etats);
}
void _majEstimation(int etat, double w) {
final etats = [
for (final s in entry.sgReadyStates)
if (s.state != etat)
s
else
SgReadyState(
state: s.state, relays: s.relays, estimatedPowerW: w)
];
_ecrire(etats);
}
@override
Widget build(BuildContext context) {
final c = _contacts();
final etats = [...entry.sgReadyStates]..sort((a, b) => a.state - b.state);
final tousContacts = <String>[];
for (final s in etats) {
for (final r in s.relays) {
if (!tousContacts.any((x) => normalizeThingId(x) == normalizeThingId(r))) {
tousContacts.add(r);
}
}
}
return Column(
children: [
_Carte(
titre: 'Contacts SG-Ready',
badge: 'déduit · pas de champ dédié',
sousTitre:
'Les deux entrées de la pompe à chaleur, et le relais qui les commande.',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (c.standard) ...[
_LigneContact(
etiquette: 'SG1',
service: service,
valeur: c.sg1,
dejaPris: {if (c.sg2 != null) normalizeThingId(c.sg2!)},
onChoisi: (id) => _majContact(c.sg1, id),
),
_LigneContact(
etiquette: 'SG2',
service: service,
valeur: c.sg2,
dejaPris: {if (c.sg1 != null) normalizeThingId(c.sg1!)},
onChoisi: (id) => _majContact(c.sg2, id),
),
const SizedBox(height: 6),
_Aide(
'« SG1 » et « SG2 » n\'existent pas côté box : la configuration ne '
'stocke qu\'une liste de relais par état. Ces deux noms sont une '
'convention de cet écran — SG1 est le contact fermé en état 1, SG2 '
'celui fermé en état 3.',
),
] else ...[
for (var i = 0; i < tousContacts.length; i++)
_LigneContact(
etiquette: 'C${i + 1}',
service: service,
valeur: tousContacts[i],
dejaPris: {
for (final x in tousContacts)
if (normalizeThingId(x) != normalizeThingId(tousContacts[i]))
normalizeThingId(x)
},
onChoisi: (id) => _majContact(tousContacts[i], id),
),
const SizedBox(height: 6),
_Aide(
'⚠ Encodage non standard : les étiquettes SG1/SG2 sont abandonnées et '
'les contacts affichés tels quels. Leur attribuer un nom au hasard '
'serait pire que de n\'en donner aucun — la configuration, elle, reste '
'éditable.',
alerte: true,
),
],
],
),
),
const SizedBox(height: 14),
_Carte(
titre: 'Table d\'états',
badge: 'enregistré · sgReady.states[]',
sousTitre:
'Quels contacts fermer pour chaque état, et ce que la PAC consomme alors.',
child: Column(
children: [
for (final s in etats)
_LigneEtat(
etat: s,
nom: _nomsEtats[s.state]?.$1 ?? 'État ${s.state}',
detail: _nomsEtats[s.state]?.$2 ?? '',
contacts: tousContacts,
etiquettes: c.standard
? {
normalizeThingId(c.sg1!): 'SG1',
normalizeThingId(c.sg2!): 'SG2',
}
: {
for (var i = 0; i < tousContacts.length; i++)
normalizeThingId(tousContacts[i]): 'C${i + 1}'
},
onBasculer: (contact, ferme) =>
_basculer(s.state, contact, ferme),
onEstimation: (w) => _majEstimation(s.state, w),
),
const SizedBox(height: 4),
_Aide(
'estimatedPowerW est une ESTIMATION, jamais un engagement. Elle sert de '
'base au recrédit de budget et au choix d\'état : une valeur fausse ne '
'décale pas seulement la PAC, elle décale l\'allocation de tout le '
'waterfall en aval. À relever sur la plaque, puis à mesurer.',
),
],
),
),
],
);
}
}
class _LigneContact extends StatelessWidget {
final String etiquette;
final NymeaService service;
final String? valeur;
final Set<String> dejaPris;
final ValueChanged<String> onChoisi;
const _LigneContact({
required this.etiquette,
required this.service,
required this.valeur,
required this.dejaPris,
required this.onChoisi,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 10),
child: Container(
width: 34,
height: 26,
alignment: Alignment.center,
decoration: BoxDecoration(
color: EtmTokens.brand.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Text(etiquette,
style: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: EtmTokens.brand)),
),
),
const SizedBox(width: 10),
Expanded(
child: ThingPicker(
service: service,
interfaceRequise: 'power',
valeur: valeur,
dejaPris: dejaPris,
placeholder: 'Choisir le relais…',
onChoisi: onChoisi,
),
),
],
),
);
}
}
class _LigneEtat extends StatelessWidget {
final SgReadyState etat;
final String nom;
final String detail;
final List<String> contacts;
final Map<String, String> etiquettes;
final void Function(String contact, bool ferme) onBasculer;
final ValueChanged<double> onEstimation;
const _LigneEtat({
required this.etat,
required this.nom,
required this.detail,
required this.contacts,
required this.etiquettes,
required this.onBasculer,
required this.onEstimation,
});
bool _ferme(String c) =>
etat.relays.any((r) => normalizeThingId(r) == normalizeThingId(c));
@override
Widget build(BuildContext context) {
final muted = EtmTokens.mutedOf(context);
return Container(
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
border: Border.all(color: EtmTokens.lineOf(context)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Container(
width: 24,
height: 24,
alignment: Alignment.center,
decoration: BoxDecoration(
color: EtmTokens.brand.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(7),
),
child: Text('${etat.state}',
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: EtmTokens.brand)),
),
const SizedBox(width: 8),
Text(nom,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: EtmTokens.inkOf(context))),
const SizedBox(width: 6),
Expanded(
child: Text(detail,
style: TextStyle(fontSize: 11, color: muted),
overflow: TextOverflow.ellipsis),
),
]),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 6,
children: [
for (final c in contacts)
_Bascule(
libelle: etiquettes[normalizeThingId(c)] ?? '?',
ferme: _ferme(c),
onChange: (v) => onBasculer(c, v),
),
],
),
const SizedBox(height: 8),
Row(children: [
Text('Puissance estimée',
style: TextStyle(fontSize: 11.5, color: muted)),
const Spacer(),
SizedBox(
width: 96,
child: TextFormField(
key: ValueKey('est-${etat.state}'),
initialValue: etat.estimatedPowerW.round().toString(),
keyboardType: TextInputType.number,
textAlign: TextAlign.right,
decoration:
const InputDecoration(isDense: true, suffixText: 'W'),
onChanged: (v) {
final n = double.tryParse(v.trim().replaceAll(',', '.'));
if (n != null && n >= 0) onEstimation(n);
},
),
),
]),
],
),
);
}
}
/// Contact fermé / ouvert pour un état donné.
class _Bascule extends StatelessWidget {
final String libelle;
final bool ferme;
final ValueChanged<bool> onChange;
const _Bascule({
required this.libelle,
required this.ferme,
required this.onChange,
});
@override
Widget build(BuildContext context) {
return InkWell(
borderRadius: BorderRadius.circular(20),
onTap: () => onChange(!ferme),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: ferme
? EtmTokens.eco.withValues(alpha: 0.15)
: Colors.transparent,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: ferme
? EtmTokens.eco.withValues(alpha: 0.55)
: EtmTokens.lineOf(context)),
),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(ferme ? Icons.circle : Icons.circle_outlined,
size: 11,
color: ferme ? EtmTokens.eco : EtmTokens.mutedOf(context)),
const SizedBox(width: 5),
Text('$libelle ${ferme ? "fermé" : "ouvert"}',
style: TextStyle(
fontSize: 11.5,
fontWeight: ferme ? FontWeight.w600 : FontWeight.w400,
color: ferme
? EtmTokens.inkOf(context)
: EtmTokens.mutedOf(context))),
]),
),
);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Communs : temporisations, compteur, arbitrage, enregistrement
// ═══════════════════════════════════════════════════════════════════════════
class _Temporisation extends StatelessWidget {
final String titre;
final String sousTitre;
final List<_LigneDuree> lignes;
const _Temporisation({
required this.titre,
required this.sousTitre,
required this.lignes,
});
@override
Widget build(BuildContext context) => _Carte(
titre: titre,
badge: 'enregistré',
sousTitre: sousTitre,
child: Column(children: lignes),
);
}
class _LigneDuree extends StatelessWidget {
final String libelle;
final String aide;
final int valeur;
final ValueChanged<int> onChange;
const _LigneDuree({
required this.libelle,
required this.aide,
required this.valeur,
required this.onChange,
});
/// Pas de réglage : 30 s en dessous de 5 min, 60 s au-delà. Une PAC se règle en
/// centaines de secondes, un relais en dizaines.
int get _pas => valeur >= 300 ? 60 : 30;
@override
Widget build(BuildContext context) {
final muted = EtmTokens.mutedOf(context);
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(libelle,
style: TextStyle(
fontSize: 13, color: EtmTokens.inkOf(context))),
Text(aide, style: TextStyle(fontSize: 10.5, color: muted)),
],
),
),
IconButton(
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.remove_circle_outline, size: 21),
onPressed: valeur <= 0
? null
: () => onChange((valeur - _pas).clamp(0, 86400)),
),
SizedBox(
width: 58,
child: Text('$valeur s',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
fontFamily: 'monospace',
fontWeight: FontWeight.w600,
color: EtmTokens.inkOf(context))),
),
IconButton(
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.add_circle_outline, size: 21),
onPressed: () => onChange((valeur + _pas).clamp(0, 86400)),
),
]),
);
}
}
/// Compteur dédié et sonde — deux champs de **même forme**, une même règle.
///
/// ## Ils ne sont ni du matériel, ni exclusifs
///
/// Rattacher un compteur ne reconstruit rien : ces deux champs n'entrent pas dans
/// `sameHardware()`, donc la mise à jour est **en place**, verrous préservés. 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. Le sélecteur ne grise donc **rien** ici, contrairement aux contacteurs.
///
/// ## Ce que le compteur autorise à conclure, et rien de plus
///
/// Il sert à **vérifier**, jamais à décider : commandé 3 000 W, mesuré 0 W sur plusieurs
/// cycles, la charge ne fait pas ce qu'on lui demande. La mesure n'entre pas dans le
/// budget — elle est en retard sur la commande, et la charge est déjà comptée dans le
/// compteur racine dont elle n'est qu'une décomposition. L'ajouter compterait deux fois la
/// même énergie.
///
/// ## Sélection par INTERFACE, jamais par plugin
///
/// Le compteur se choisit parmi les Things portant `smartmeter`, la sonde parmi celles qui
/// portent une interface de température. Nommer un plugin rendrait le modèle faux au
/// prochain qui rend le même service, et obligerait à tenir une table à la main.
///
/// ## Et si la box ne les porte pas
///
/// Le grisage vient du **schéma**, jamais d'une table de versions : sur une box antérieure
/// le champ est absent, le bloc se grise et le dit. Sur `.75` en `+etm16` il est là — le
/// réglage s'est donc activé **seul**, sans nouvelle version de l'app, exactement comme
/// promis.
class _CompteurEtSonde extends StatelessWidget {
final LoadConfigEntry entry;
final LoadConfigProvider provider;
final RpcSchema schema;
final NymeaService service;
const _CompteurEtSonde({
required this.entry,
required this.provider,
required this.schema,
required this.service,
});
FieldSupport _support(String champ) => schema.paramSupport(
'NymeaEnergy.SetLoadConfig', ['loadConfigs', '[]', champ]);
@override
Widget build(BuildContext context) {
final supportCompteur = _support('meterThingId');
final supportSonde = _support('sensorThingId');
return Column(
children: [
_Carte(
titre: 'Compteur dédié',
badge: supportCompteur.isWritable
? 'enregistré · meterThingId'
: 'pas encore supporté',
sousTitre: 'Mesure de CETTE charge — pour vérifier ce qui est commandé, '
'jamais pour décider.',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SchemaGated(
support: supportCompteur,
fieldName: 'meterThingId',
label: 'Compteur dédié',
child: ThingPicker(
service: service,
interfaceRequise: 'smartmeter',
valeur: entry.meterThingId.isEmpty ? null : entry.meterThingId,
placeholder: 'Choisir un appareil de mesure…',
onChoisi: (id) => provider.replaceEntry(
entry.id, entry.patched(meterThingId: id)),
),
),
const SizedBox(height: 6),
_Aide(
'La mesure n\'entre pas dans le budget : elle est en retard sur la '
'commande, et cette charge est déjà comptée dans le compteur racine dont '
'elle n\'est qu\'une décomposition. L\'y ajouter compterait deux fois la '
'même énergie.',
),
],
),
),
const SizedBox(height: 14),
_Carte(
titre: 'Sonde de température',
badge: supportSonde.isWritable
? 'enregistré · sensorThingId'
: 'pas encore supporté',
sousTitre: 'La grandeur physique du service rendu — ce que la charge produit, '
'pas ce qu\'elle consomme.',
child: SchemaGated(
support: supportSonde,
fieldName: 'sensorThingId',
label: 'Sonde de température',
child: ThingPicker(
service: service,
interfaceRequise: 'temperaturesensor',
valeur: entry.sensorThingId.isEmpty ? null : entry.sensorThingId,
placeholder: 'Choisir une sonde…',
onChoisi: (id) => provider.replaceEntry(
entry.id, entry.patched(sensorThingId: id)),
),
),
),
],
);
}
}
/// Inclusion dans l'arbitrage — et ce que la désactivation fait vraiment.
class _Arbitrage extends StatelessWidget {
final LoadConfigEntry entry;
final LoadConfigProvider provider;
const _Arbitrage({required this.entry, required this.provider});
String get _etatSur => entry.isSgReady
? 'état 2 (normal), jamais le blocage'
: entry.isRelayRouter
? 'tous les relais ouverts'
: 'consigne 0 W';
@override
Widget build(BuildContext context) {
final t = context.l10n;
return _Carte(
titre: 'Arbitrage',
badge: 'enregistré · enabled',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Inclure dans l\'arbitrage',
style: TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.w600,
color: EtmTokens.inkOf(context))),
Text(
'rang ${entry.priority} · '
'${domainLabel(t, entry.domain ?? kUnclassifiedDomain)}',
style: TextStyle(
fontSize: 11.5, color: EtmTokens.mutedOf(context)),
),
],
),
),
Switch.adaptive(
value: entry.enabled,
onChanged: (v) => provider.setEnabled(entry.id, v),
),
]),
const SizedBox(height: 6),
_Aide(
'À la désactivation, le retrait passe par l\'état sûr : $_etatSur. '
'Vérifié sur matériel côté plugin — mais c\'est le RETRAIT qui protège, pas '
'le drapeau : il n\'agit ni au redémarrage du service, ni sur une charge '
'déjà désactivée.',
),
],
),
);
}
}
/// Valide **avant** d'envoyer, annonce l'impact, puis enregistre.
class _BarreEnregistrement extends StatelessWidget {
final LoadConfigEntry entry;
final LoadConfigProvider provider;
final NymeaService service;
const _BarreEnregistrement({
required this.entry,
required this.provider,
required this.service,
});
/// Règles que la box applique et **ne motive pas**. Les reprendre ici, c'est la
/// différence entre « corrigez la ligne 2 » et un refus muet qui perd tout l'appel.
List<String> _valider() {
final erreurs = <String>[];
if (entry.label.trim().isEmpty) erreurs.add('Le nom ne peut pas être vide.');
if (entry.isRelayRouter) {
if (entry.relays.isEmpty) {
erreurs.add('Au moins un contacteur est requis.');
}
final vus = <String>{};
for (var i = 0; i < entry.relays.length; i++) {
final r = entry.relays[i];
if (r.thingId.trim().isEmpty) {
erreurs.add('Ligne ${i + 1} : aucun contacteur choisi.');
} else if (!vus.add(normalizeThingId(r.thingId))) {
erreurs.add(
'Ligne ${i + 1} : ce contacteur est déjà utilisé par une autre ligne.');
}
if (r.powerW <= 0) {
erreurs.add('Ligne ${i + 1} : la puissance doit être supérieure à 0 W.');
}
}
} else if (entry.isSgReady) {
final etats = {for (final s in entry.sgReadyStates) s.state};
if (!etats.contains(2)) {
// ECS-110 : sans état 2, la PAC n'est jamais pilotable — la box publie
// UNUSABLE_ENCODING et la charge sort de l'arbitrage définitivement.
erreurs.add(
'L\'état 2 (normal) doit être exprimable, sans quoi la pompe à chaleur '
'ne sera jamais pilotable.');
}
for (final s in entry.sgReadyStates) {
if (s.state < 1 || s.state > 4) {
erreurs.add('État ${s.state} hors plage (1 à 4).');
}
if (s.estimatedPowerW < 0) {
erreurs.add('État ${s.state} : puissance estimée négative.');
}
for (final r in s.relays) {
if (r.trim().isEmpty) {
erreurs.add('État ${s.state} : un contact n\'est pas choisi.');
}
}
}
} else {
if (entry.maxPowerW <= 0) {
erreurs.add(
'La puissance nominale est requise et doit être supérieure à 0 W.');
}
}
return erreurs;
}
@override
Widget build(BuildContext context) {
final erreurs = _valider();
final impacts =
provider.pendingImpact().where((i) => i.loadId == entry.id).toList();
final rebuild = impacts.any((i) => i.impact == LoadChangeImpact.rebuild);
final rearm = impacts
.map((i) => i.rearmSeconds)
.whereType<int>()
.fold<int>(0, (a, b) => a > b ? a : b);
final enCours = provider.saveState == LoadSaveState.pending;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (erreurs.isNotEmpty)
Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(11),
decoration: BoxDecoration(
color: EtmTokens.danger.withValues(alpha: 0.09),
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('La box refuserait cette configuration',
style: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w700,
color: EtmTokens.danger)),
const SizedBox(height: 5),
for (final e in erreurs)
Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text('• $e',
style: TextStyle(
fontSize: 11.5,
height: 1.35,
color: EtmTokens.inkOf(context))),
),
const SizedBox(height: 4),
Text(
'Le refus porterait sur l\'ensemble des charges, sans motif : la règle '
'enfreinte ne serait visible que dans le journal de la box.',
style: TextStyle(
fontSize: 10.5,
height: 1.35,
color: EtmTokens.mutedOf(context)),
),
],
),
),
if (rebuild && erreurs.isEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _Aide(
'À l\'enregistrement : les contacts de cette charge passent par leur état '
'sûr, l\'adaptateur est reconstruit, et son verrou se réarme'
'${rearm > 0 ? rearm >= 60 ? ' pendant ${(rearm / 60).round()} min' : ' pendant $rearm s' : ''}.',
alerte: true,
),
),
Row(children: [
TextButton(
onPressed: provider.hasPendingChanges ? provider.discardDraft : null,
child: const Text('Annuler'),
),
const Spacer(),
FilledButton(
onPressed: (!provider.hasPendingChanges ||
erreurs.isNotEmpty ||
enCours)
? null
: provider.save,
child: Text(enCours ? 'Envoi…' : 'Enregistrer'),
),
]),
],
);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Habillage
// ═══════════════════════════════════════════════════════════════════════════
class _Carte extends StatelessWidget {
final String titre;
final String? badge;
final String? sousTitre;
final Widget child;
const _Carte({
required this.titre,
this.badge,
this.sousTitre,
required this.child,
});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: EtmTokens.surfaceOf(context),
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
boxShadow: EtmTokens.cardShadowOf(context),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SousTitre(titre, badge: badge),
if (sousTitre != null) ...[
const SizedBox(height: 3),
Text(sousTitre!,
style: TextStyle(
fontSize: 11.5,
height: 1.35,
color: EtmTokens.mutedOf(context))),
],
const SizedBox(height: 12),
child,
],
),
);
}
}
class _SousTitre extends StatelessWidget {
final String texte;
final String? badge;
const _SousTitre(this.texte, {this.badge});
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Flexible(
child: Text(texte,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: EtmTokens.inkOf(context))),
),
if (badge != null) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
decoration: BoxDecoration(
color: EtmTokens.lineOf(context),
borderRadius: BorderRadius.circular(6),
),
child: Text(badge!,
style: TextStyle(
fontSize: 9.5,
fontFamily: 'monospace',
color: EtmTokens.mutedOf(context))),
),
],
],
);
}
}
class _Aide extends StatelessWidget {
final String texte;
final bool alerte;
const _Aide(this.texte, {this.alerte = false});
@override
Widget build(BuildContext context) {
final couleur = alerte ? EtmTokens.orange : EtmTokens.mutedOf(context);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(alerte ? Icons.warning_amber_rounded : Icons.info_outline,
size: 14, color: couleur),
const SizedBox(width: 6),
Expanded(
child: Text(texte,
style: TextStyle(
fontSize: 11,
height: 1.4,
color: alerte ? EtmTokens.inkOf(context) : couleur)),
),
],
);
}
}
/// Plancher de modulation — **préparé avant son arrivée au schéma**, délibérément.
///
/// ## Pourquoi l'écrire maintenant
///
/// Le grisage de cet écran se dérive de `JSONRPC.Introspect` : le jour où le plugin ajoute
/// le champ, le bloc s'active **tout seul**. C'est la promesse, et elle a déjà été tenue —
/// `meterThingId` et `sensorThingId` se sont activés sans nouvelle version de l'app.
///
/// Mais ce jour-là, ils se sont activés sur un **placeholder mort** : un contrôle d'allure
/// utilisable qui n'écrivait nulle part. Pire qu'un bloc grisé, parce qu'un bloc grisé dit
/// ce qu'il est. La leçon vaut d'être appliquée en avance : le plancher est câblé
/// maintenant, il restera grisé tant que le champ n'existe pas, et il fonctionnera le jour
/// où il apparaîtra — sans que personne n'ait à y revenir.
///
/// ## Ce que la box publie déjà
///
/// `mechanism.minPowerW` est **en télémétrie** avant d'être configurable. On l'affiche
/// donc en lecture, ce qui donne la valeur réellement en vigueur même quand le réglage,
/// lui, n'est pas encore ouvert.
///
/// ## Ce plancher-ci n'est PAS celui d'une borne de recharge
///
/// Même mot, deux natures. Ici, une **propriété déclarée de l'appareil** : la consigne
/// non nulle la plus basse qu'il accepte, saisie une fois et stable.
///
/// Le minimum d'une borne, lui, vaut `max(borne, voiture)` et **dépend du véhicule
/// branché** — il change quand la voiture change. Il dépend aussi du nombre de phases
/// visé, de l'ordre de 1,4 kW en monophasé contre 4,1 kW en triphasé. C'est du **runtime**,
/// il se constate et ne se règle pas ; il s'affiche donc sur la carte de la borne, en
/// lecture, tel que la box le donne — sans conversion côté app.
///
/// Ce bloc ne vit que dans la section `etmvariableload`, ce qui l'écarte structurellement
/// des bornes : elles ne sont pas des `LoadConfig` et n'ouvrent pas cet écran.
class _Plancher extends StatelessWidget {
final LoadConfigEntry entry;
final LoadConfigProvider provider;
final RpcSchema schema;
final LoadTelemetryProvider telemetry;
const _Plancher({
required this.entry,
required this.provider,
required this.schema,
required this.telemetry,
});
/// Nom pressenti du champ. S'il diffère à l'arrivée, le bloc restera simplement grisé —
/// une erreur visible et sans conséquence, là où une écriture inventée ferait rejeter
/// l'enregistrement de TOUTES les charges.
static const String champ = 'minPowerW';
@override
Widget build(BuildContext context) {
final support = schema
.paramSupport('NymeaEnergy.SetLoadConfig', ['loadConfigs', '[]', champ]);
final publie = telemetry.entryFor(entry.id)?.mechanism?.minPowerW;
return _Carte(
titre: 'Plancher de modulation',
badge: support.isWritable ? 'enregistré · $champ' : 'pas encore supporté',
sousTitre: 'La consigne non nulle la plus basse que CET appareil accepte — en '
'dessous, il vaut mieux ne rien commander du tout. Propriété déclarée, stable : '
'à ne pas confondre avec le minimum d\'une borne, qui suit le véhicule branché.',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SchemaGated(
support: support,
fieldName: champ,
label: 'Plancher de modulation',
child: TextFormField(
key: ValueKey('plancher-${entry.id}'),
initialValue: (publie ?? 0).round().toString(),
enabled: support.isWritable,
keyboardType: TextInputType.number,
decoration:
const InputDecoration(isDense: true, suffixText: 'W'),
onChanged: (v) {
final n = int.tryParse(v.trim());
// Rien ne part tant que le champ n'est pas au schéma : nymea rejette
// l'appel ENTIER sur une clé inconnue, et le refus emporterait toutes
// les charges.
if (n != null && n >= 0 && support.isWritable) {
provider.replaceEntry(
entry.id, entry.patchedRaw({champ: n}));
}
},
),
),
if (publie != null) ...[
const SizedBox(height: 6),
_Aide('Valeur en vigueur, publiée par la box : ${publie.round()} W.'),
],
],
),
);
}
}