Écrit AVANT le déploiement du moteur, pour qu'il n'y ait pas de trou.
`measuredW` portait trois régimes sous un seul nom et jetait la distinction : une borne
rendait une mesure, un routeur de relais une mesure OU son palier nominal selon que ses
relais mesurent, un SG-Ready une puissance toujours déclarée. C'est la condition C2 de
LM-1009. Le régime se publie désormais :
measurement: { source: "meter" | "device" | "none", powerW }
Les trois pièges d'affichage du §7, figés par test :
· `none` est un état POSITIF — « cette charge n'est pas mesurable ». Il autorise à MASQUER
le bloc au lieu d'afficher un vide, et sépare enfin « pas mesurable » de « compteur en
panne ». Aucune puissance nominale n'est allée la chercher dans mechanism.* pour combler :
elle produirait un écart identiquement nul à tous les cycles, soit un « tout concorde »
permanent qui ne signale plus rien.
· {source: "device", powerW: 0} est une MESURE. Une borne branchée qui ne charge pas mesure
zéro watt. Si la source basculait à l'extinction, le bloc apparaîtrait et disparaîtrait au
rythme du thermostat.
· L'écart n'a pas la même valeur selon la source, et l'écran le DIT : sous `device`, il
montre la désobéissance mais une concordance ne prouve rien — plusieurs wallbox renvoient
leur consigne ; sous `meter`, la mesure est indépendante de l'organe commandé et la
concordance devient une preuve.
Le compteur dédié se décide sur `measurement.source`, JAMAIS sur `adapter` :
source == device → réglage avancé, replié (l'appareil publie déjà)
sinon → proposé, visible
La règle couvre SG-Ready sans avoir à le connaître — `none` par construction, LM-1105-b : un
contact de signal ne voit pas le courant de la machine, et pour une PAC le compteur dédié est
la SEULE voie vers une mesure. Elle couvre aussi la topologie de relais mixte, `none` elle
aussi, qu'un « masqué dès que la charge mesure, sauf SG-Ready » aurait manquée. Redéduire le
régime du mécanisme remettrait ici la déduction que le moteur vient d'en retirer.
⚠️ Une distinction que le contrat n'a pas à porter mais que l'écran doit faire : une charge
ABSENTE de la télémétrie n'est pas `none`. `none` est publié ; l'absence ne dit rien. Les
confondre annoncerait « seule voie vers une mesure » sur une borne qui publiera très bien la
sienne au prochain cycle.
⏳ Alias temporaire `measuredW` en repli, avec sa date de retrait : dès que +etm26 est sur
.75 (entrée TODO, sonde nommée). `measurement` gagne quand les deux sont là.
204 tests (+16), flutter analyze inchangé à 27 remarques. Prêt pour le déploiement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
2160 lines
83 KiB
Dart
2160 lines
83 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../../l10n/l10n_ext.dart';
|
|
import '../../models/energy_data.dart' show ChargingMode;
|
|
import '../../models/ev_charger_live.dart';
|
|
import '../../models/load_config_entry.dart';
|
|
import '../../models/load_telemetry.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/ev_mode_buttons.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, service: service)
|
|
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,
|
|
telemetry: telemetry),
|
|
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 (PV, Min+PV, Boost) se règle depuis la tuile « Borne EV » des '
|
|
'favoris — c\'est le seul endroit de l\'app qui écrit SetChargingInfo.',
|
|
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;
|
|
final NymeaService service;
|
|
|
|
const _SectionBorne(
|
|
{required this.entry, required this.telemetry, required this.service});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final muted = EtmTokens.mutedOf(context);
|
|
final live = telemetry.entryFor(entry.id);
|
|
final mech = live?.mechanism;
|
|
// La borne DE CET ÉCRAN, pas « la première configurée » : le banc en porte trois.
|
|
final borne = service.evChargerFor(entry.id);
|
|
|
|
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),
|
|
),
|
|
],
|
|
// ── LE SEUL RÉGLAGE d'une borne, et le seul lieu où il s'écrit ────
|
|
//
|
|
// Il vivait sur la tuile des favoris. Il est ici depuis le 2026-08-28 : c'est
|
|
// l'écran de mise en service, celui où on découvre qu'une borne neuve est en
|
|
// ChargingModeNormal — pleine puissance, hors arbitrage. La tuile garde la
|
|
// lecture et l'avertissement, pas l'écriture : deux endroits qui écrivent
|
|
// SetChargingInfo sont deux comportements qui divergent.
|
|
if (borne != null) ...[
|
|
const SizedBox(height: 14),
|
|
Divider(height: 1, color: EtmTokens.lineOf(context)),
|
|
const SizedBox(height: 12),
|
|
Row(children: [
|
|
Text('Mode de recharge',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w700,
|
|
color: EtmTokens.inkOf(context))),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(_modeLu(borne),
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(fontSize: 11.5, color: muted)),
|
|
),
|
|
]),
|
|
const SizedBox(height: 8),
|
|
EvModeButtons(
|
|
live: borne,
|
|
onSetMode: (m) =>
|
|
service.setChargingInfo(mode: m, evChargerId: borne.thingId),
|
|
),
|
|
if (borne.isUnmanaged) ...[
|
|
const SizedBox(height: 8),
|
|
Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
Icon(Icons.flash_on_rounded, size: 15, color: EtmTokens.solar),
|
|
const SizedBox(width: 6),
|
|
Expanded(
|
|
child: Text(kEvUnmanagedNote,
|
|
style:
|
|
TextStyle(fontSize: 11.5, height: 1.35, color: muted)),
|
|
),
|
|
]),
|
|
],
|
|
] else ...[
|
|
const SizedBox(height: 10),
|
|
Text(
|
|
'Mode de recharge non lu : la box n\'a pas encore rendu de ChargingInfo '
|
|
'pour cette borne. Sans lecture, l\'écran ne propose pas d\'écriture — un '
|
|
'bouton allumé au hasard vaudrait moins que rien.',
|
|
style: TextStyle(fontSize: 11.5, height: 1.35, color: muted),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Le mode **que la box porte**, jamais celui que l'app vient de demander.
|
|
String _modeLu(EvChargerLive b) {
|
|
if (b.apiMode == null) return 'mode non lu';
|
|
final ui = b.uiMode;
|
|
if (ui == null) return 'mode non reconnu (${b.apiMode})';
|
|
final base = switch (ui) {
|
|
ChargingMode.pv => 'PV',
|
|
ChargingMode.minPv => 'Min+PV',
|
|
ChargingMode.boost => 'Boost',
|
|
};
|
|
return b.hasDeadlineMode ? 'lu : $base + échéance' : 'lu : $base';
|
|
}
|
|
}
|
|
|
|
/// 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;
|
|
final LoadTelemetryProvider telemetry;
|
|
|
|
const _CompteurEtSonde({
|
|
required this.entry,
|
|
required this.provider,
|
|
required this.schema,
|
|
required this.service,
|
|
required this.telemetry,
|
|
});
|
|
|
|
FieldSupport _support(String champ) => schema.paramSupport(
|
|
'NymeaEnergy.SetLoadConfig', ['loadConfigs', '[]', champ]);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final supportCompteur = _support('meterThingId');
|
|
final supportSonde = _support('sensorThingId');
|
|
|
|
// ⚠️ LA RÈGLE PORTE SUR `measurement.source`, JAMAIS SUR `adapter`.
|
|
//
|
|
// « Masqué dès que la charge mesure, sauf SG-Ready » paraît équivalent et ne l'est
|
|
// pas : un routeur de relais à topologie MIXTE publie `none` lui aussi — une mesure
|
|
// partielle n'est pas une mesure — et on masquerait le champ sur un ECS qui ne peut
|
|
// être mesuré QUE par un compteur dédié. Redéduire le régime du mécanisme
|
|
// remettrait ici la déduction que le moteur vient d'en retirer, et il faudrait la
|
|
// maintenir à chaque mécanisme ajouté.
|
|
//
|
|
// source == device → réglage avancé, replié (l'appareil publie déjà)
|
|
// sinon → proposé, visible
|
|
//
|
|
// Le cas SG-Ready est couvert sans avoir à le connaître : son contact commande un
|
|
// signal, la PAC est alimentée ailleurs, la source est `none` par construction
|
|
// (LM-1105-b) — donc le compteur reste visible, et c'est sa SEULE voie vers une
|
|
// mesure.
|
|
// `null` = la charge n'est PAS dans la télémétrie du dernier cycle (hors arbitrage,
|
|
// borne sans véhicule branché, charge désactivée). Ce n'est pas `none` : `none` est un
|
|
// état publié — « pas mesurable » — alors qu'ici le régime n'est simplement pas dit.
|
|
// Les confondre ferait annoncer « seule voie vers une mesure » sur une borne qui, une
|
|
// fois arbitrée, publiera très bien la sienne.
|
|
final source = telemetry.entryFor(entry.id)?.measurement.source;
|
|
final replie = source == MeasurementSource.device;
|
|
|
|
return Column(
|
|
children: [
|
|
_Carte(
|
|
titre: 'Compteur dédié',
|
|
badge: supportCompteur.isWritable
|
|
? (replie ? 'avancé · meterThingId' : 'enregistré · meterThingId')
|
|
: 'pas encore supporté',
|
|
sousTitre: replie
|
|
? 'Cet appareil publie déjà sa puissance : un compteur séparé est un '
|
|
'réglage avancé, pas un manque.'
|
|
: 'Mesure de CETTE charge — pour vérifier ce qui est commandé, '
|
|
'jamais pour décider.',
|
|
replie: replie,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
SchemaGated(
|
|
support: supportCompteur,
|
|
fieldName: 'meterThingId',
|
|
label: 'Compteur dédié',
|
|
child: ThingPicker(
|
|
service: service,
|
|
interfaceRequise: 'smartmeter',
|
|
// Le moteur ne filtre sur aucune interface : `findConfiguredThing` puis
|
|
// `hasState("currentPower")`. Une borne qui publie sa puissance est donc
|
|
// un compteur valable — et c'est le seul moyen de voir « mesuré » sur
|
|
// la carte d'une borne dont la classe ne déclare pas `smartmeter`
|
|
// (la V2C Trydan, contrairement à la wallbox simulée).
|
|
interfacesAussi: const ['evcharger'],
|
|
etatRequis: 'currentPower',
|
|
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: 6),
|
|
_Aide(_aideSelonSource(source)),
|
|
],
|
|
),
|
|
),
|
|
// ── Pas de sonde sur une BORNE ────────────────────────────────────
|
|
//
|
|
// `sensorThingId` est un champ générique de `LoadConfig` : le schéma l'accepte
|
|
// sur n'importe quelle charge, y compris une borne. Mais le moteur ne le lit que
|
|
// pour en publier une **température** (`attachMeasurements`, LM-1103), et une
|
|
// borne de recharge ne rend aucun service thermique : le champ ne mènerait nulle
|
|
// part. Proposé sur cet écran, il faisait chercher à quoi il sert.
|
|
//
|
|
// Le compteur, lui, RESTE : c'est le seul chemin par lequel `measuredW` apparaît
|
|
// pour cette charge dans la télémétrie — et sur une borne qui publie
|
|
// `currentPower`, on peut la désigner elle-même.
|
|
if (!entry.isEvCharger) ...[
|
|
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)),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Ce qu'un compteur dédié apporte **à cette charge-ci**, selon ce que la box publie.
|
|
String _aideSelonSource(MeasurementSource? source) => switch (source) {
|
|
null =>
|
|
'Cette charge n\'est pas dans la télémétrie du dernier cycle : la box ne dit donc '
|
|
'pas d\'où viendrait sa mesure. Le compteur reste réglable — il servira dès '
|
|
'qu\'elle sera arbitrée.',
|
|
MeasurementSource.device =>
|
|
'Cet appareil publie déjà sa puissance, et elle s\'affiche sur sa carte. Un '
|
|
'compteur dédié reste permis, et il apporte une chose que l\'appareil ne peut '
|
|
'pas donner : une mesure INDÉPENDANTE de l\'organe commandé. Sans elle, une '
|
|
'concordance entre commande et mesure ne prouve rien — plusieurs bornes '
|
|
'renvoient leur consigne en guise de puissance.',
|
|
MeasurementSource.meter =>
|
|
'Un compteur est rattaché : la mesure affichée sur la carte vient de lui, pas de '
|
|
'l\'appareil commandé. C\'est la seule configuration où une concordance entre '
|
|
'commande et mesure constitue une preuve.',
|
|
MeasurementSource.none =>
|
|
'La box ne publie AUCUNE mesure pour cette charge — un contact SG-Ready commande '
|
|
'un signal et ne voit pas le courant de la machine, et une topologie de relais '
|
|
'où un seul mesure ne mesure rien d\'exploitable. Un compteur dédié est donc '
|
|
'ici la seule voie vers une mesure.',
|
|
MeasurementSource.legacy =>
|
|
'Cette box ne publie pas encore l\'origine de la mesure (avant 1.15.2+etm26) : '
|
|
'la valeur affichée sur la carte est juste, mais ce qu\'un écart prouverait '
|
|
'reste indéterminé.',
|
|
};
|
|
|
|
/// 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.isEvCharger) {
|
|
// ⚠️ RIEN à valider sur une borne, et surtout pas une puissance nominale.
|
|
//
|
|
// Elle tombait dans la branche `else` avec les charges modulables, et l'écran
|
|
// refusait donc d'enregistrer un simple renommage : « La puissance nominale est
|
|
// requise » — sur la carte qui explique deux blocs plus haut qu'une borne n'en porte
|
|
// pas. Constaté sur téléphone le 2026-08-28, contre `.75`.
|
|
//
|
|
// Le contrat le confirme : l'entrée d'une borne porte `adapter`, `mode`, `domain`,
|
|
// `label`, `priority`, `enabled` — aucune charge utile de mécanisme. Ses limites
|
|
// viennent de la borne et du véhicule branché.
|
|
} 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 StatefulWidget {
|
|
final String titre;
|
|
final String? badge;
|
|
final String? sousTitre;
|
|
final Widget child;
|
|
|
|
/// Carte **repliée à l'ouverture** — un réglage qui existe sans être une étape.
|
|
///
|
|
/// Le contenu reste atteignable en un geste : replier n'est pas masquer. Un réglage
|
|
/// caché pour de bon finit par être réinventé ailleurs.
|
|
final bool replie;
|
|
|
|
const _Carte({
|
|
required this.titre,
|
|
this.badge,
|
|
this.sousTitre,
|
|
required this.child,
|
|
this.replie = false,
|
|
});
|
|
|
|
@override
|
|
State<_Carte> createState() => _CarteState();
|
|
}
|
|
|
|
class _CarteState extends State<_Carte> {
|
|
late bool _ouverte = !widget.replie;
|
|
|
|
@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: [
|
|
InkWell(
|
|
onTap: widget.replie
|
|
? () => setState(() => _ouverte = !_ouverte)
|
|
: null,
|
|
child: Row(children: [
|
|
Expanded(child: _SousTitre(widget.titre, badge: widget.badge)),
|
|
if (widget.replie)
|
|
Icon(
|
|
_ouverte
|
|
? Icons.expand_less_rounded
|
|
: Icons.expand_more_rounded,
|
|
size: 20,
|
|
color: EtmTokens.mutedOf(context)),
|
|
]),
|
|
),
|
|
if (widget.sousTitre != null) ...[
|
|
const SizedBox(height: 3),
|
|
Text(widget.sousTitre!,
|
|
style: TextStyle(
|
|
fontSize: 11.5,
|
|
height: 1.35,
|
|
color: EtmTokens.mutedOf(context))),
|
|
],
|
|
if (_ouverte) ...[
|
|
const SizedBox(height: 12),
|
|
widget.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.'),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|