etm-powersync-app/lib/screens/energy/roles_devices_screen.dart
Patrick Schurig 2a8ad95120 feat(config): lot C — un seul écran de configuration, et le grisage vient du schéma
FUSION. L'écran « Ordre de service » est supprimé ; sa route redirige. C'est son CHEMIN DE
DONNÉES (LoadConfigProvider) qui a migré dans la présentation de « Rôles & appareils »,
jamais l'inverse. EmsRole fige six rôles dont dhw ET heatPump séparés — or LM-201 dit qu'une
PAC sans ballon séparé est UNE charge : le modèle des rôles aurait exigé deux charges sur un
canal unique, ce que validateSet() refuse à raison. Les charges viennent donc de
GetLoadConfig. Les compteurs restent sur EnergySetupProvider (autre contrat, SetRootMeter).

La note « le pilote peut bousculer l'ordre les jours Tempo rouge ou à l'approche d'une
échéance » est retirée : aucun de ces comportements n'existe dans le moteur.

ANNONCE AVANT ÉCRITURE. Miroir exact de EnergyArbitrator::sameHardware(). Le piège, vérifié
sur .75 : la comparaison des relais est INDEXÉE, donc réordonner relays[] sans rien changer
d'autre reconstruit l'adaptateur — contacts ouverts (applySafeState, force) et verrous
réarmés à froid. Un glisser-déposer dans la liste des contacteurs est un geste matériel.
L'écran le nomme, avec la durée de réarmement.

GRISAGE PAR INTROSPECTION. RpcSchema lit JSONRPC.Introspect à la connexion ; trois états et
non deux — écrivable, r: (mesure, pas fonction manquante), absent — plus « inconnu » quand le
schéma n'a pas pu être lu. Chaque champ grisé porte sa raison et le nom du champ manquant.
La version affichée est celle de l'API d'expérience (NymeaEnergy 0.8) : le paquet +etm15
n'est exposé par aucune méthode.

CORRIGÉ AU PASSAGE — les modes de recharge étaient INOPÉRANTS. setChargingInfo() appelait
EnergyPlugin.SetChargingInfo (namespace inexistant) avec mode/targetSoc/endTime/minCurrent
au lieu de chargingMode/targetPercentage/endDateTime, et minCurrent n'existe dans aucun
champ. Vérifié par appel réel : « Missing required key: chargingMode ». Le refus était de
plus avalé et l'état local mis à jour quand même. Désormais l'état ne suit que si la box
accepte, et le refus remonte sans en inventer la cause.

ZONES — constaté, pas corrigé. L'hypothèse « champs grisés » est fausse : AirConditioning
publie 8 méthodes toutes en écriture. ac_screen.dart n'en appelle aucune, ses 4 pièces sont
des constantes de maquette, et .75 déclare zéro zone. getZones() ajouté + bandeau qui lit le
vrai compte et annonce la maquette. Le câblage est un lot en soi, non exerçable sans zone.

Tests 80/80, analyze 0 erreur. Relevé : docs/RELEVE_LOTC.md — dont §6, ce qui n'a PAS pu
être vérifié : l'IU n'a pas pu être pilotée sur l'appareil, MIUI refusant INJECT_EVENTS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
2026-08-25 22:17:16 +02:00

701 lines
25 KiB
Dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../models/load_config_entry.dart';
import '../../models/nymea_models.dart';
import '../../providers/energy_setup_provider.dart';
import '../../providers/load_config_provider.dart';
import '../../providers/load_telemetry_provider.dart';
import '../../providers/schema_provider.dart';
import '../../services/nymea_service.dart';
import '../../theme/etm_tokens.dart';
import '../../widgets/controllable_loads_section.dart';
import '../../widgets/schema_gated.dart';
import 'load_mechanism_screen.dart';
/// **Rôles & appareils** — l'unique écran de configuration des charges.
///
/// ## Fusion : l'ancien « Ordre de service » a disparu ici
///
/// Deux écrans décrivaient la même chose par deux chemins différents. Celui-ci portait la
/// présentation ; l'autre portait le **chemin de données correct**. C'est donc le chemin
/// de données qui a migré, jamais l'inverse — le modèle des rôles ne survit pas comme
/// source de vérité pour les charges.
///
/// La raison est de fond, pas d'hygiène. `EmsRole` fige six rôles, dont `dhw` et
/// `heatPump` **séparés**. Or LM-201 — *un canal de commande, une charge* — dit qu'une PAC
/// air/eau sans ballon séparé est **une seule** charge : le SG-Ready commande la machine
/// entière. Le modèle des rôles aurait exigé d'en déclarer deux pour une seule machine,
/// c'est-à-dire deux charges se disputant un canal unique — ce que `validateSet()` refuse
/// côté box, et à raison.
///
/// **Ce qui reste sur `EnergySetupProvider`** : les compteurs, « à configurer » et « sans
/// appareil ». Ce sont des rôles d'inférence sur les Things, et le compteur principal
/// relève d'un autre contrat (`Energy.SetRootMeter`). Seules les **charges** viennent
/// désormais de `LoadConfigProvider`, donc de `GetLoadConfig`.
class RolesDevicesScreen extends StatefulWidget {
const RolesDevicesScreen({super.key});
@override
State<RolesDevicesScreen> createState() => _RolesDevicesScreenState();
}
class _RolesDevicesScreenState extends State<RolesDevicesScreen> {
NymeaService? _svc;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final svc = context.read<NymeaService>();
_svc = svc;
context.read<EnergySetupProvider>().attach(svc);
// Chemin de données des CHARGES : la configuration de la box, et rien d'autre.
context.read<LoadConfigProvider>()..attach(svc)..load();
context.read<LoadTelemetryProvider>()..attach(svc)..load();
// Le schéma décide de ce qui s'active ou se grise, ici comme dans les écrans de
// mécanisme. Lu une fois : il ne change pas tant que la box ne redémarre pas.
context.read<SchemaProvider>()..attach(svc)..load();
svc.addListener(_sync); // re-inférer quand les things se chargent
_sync();
});
}
/// Ouvre le détail du mécanisme. Il n'y a **pas** de formulaire unique : les trois
/// contrats (routeur de relais, charge continue, SG-Ready) ne se ressemblent pas, et
/// l'écran de détail choisit lui-même sa forme selon `adapter`.
void _openMechanism(BuildContext context, LoadConfigEntry entry) {
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => LoadMechanismScreen(loadId: entry.id),
));
}
void _sync() {
if (!mounted) return;
context.read<EnergySetupProvider>().syncFromThings(context.read<NymeaService>());
}
@override
void dispose() {
_svc?.removeListener(_sync);
super.dispose();
}
@override
Widget build(BuildContext context) {
final setup = context.watch<EnergySetupProvider>();
final svc = context.watch<NymeaService>();
final meters = setup.meterRoles;
final todo = setup.toConfigureRoles;
final absent = setup.absentRoles;
return Scaffold(
backgroundColor: EtmTokens.bgOf(context),
appBar: AppBar(
title: const Text('Rôles & appareils'),
backgroundColor: EtmTokens.surfaceOf(context),
foregroundColor: EtmTokens.inkOf(context),
elevation: 0,
),
body: ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
children: [
_CounterHeader(setup),
if (setup.screenState == SetupScreenState.blank) ...[
const SizedBox(height: 16),
const _BlankInvite(),
],
// ── Compteurs ────────────────────────────────────────────────────
if (meters.isNotEmpty) ...[
const SizedBox(height: 20),
const _SectionHeader('Compteurs', subtitle: 'Mesure — hors priorité'),
const SizedBox(height: 10),
for (final role in meters) ...[
_MeterCard(role: role, setup: setup, service: svc),
const SizedBox(height: 10),
],
],
// ── Charges pilotables — depuis GetLoadConfig, pas depuis les rôles ──
const SizedBox(height: 10),
const _SectionHeader('Charges pilotables',
subtitle: 'Glissez pour l\'ordre de service du surplus'),
const SizedBox(height: 10),
SchemaVersionBanner(
versionLine: context.watch<SchemaProvider>().versionLine(),
schemaLoaded: context.watch<SchemaProvider>().schema.loaded,
),
const SizedBox(height: 12),
ControllableLoadsSection(onConfigure: _openMechanism),
const _Legend(),
// ── À configurer ─────────────────────────────────────────────────
if (todo.isNotEmpty) ...[
const SizedBox(height: 18),
const _SectionHeader('À configurer'),
const SizedBox(height: 10),
for (final role in todo) ...[
_TodoCard(role: role, setup: setup, service: svc),
const SizedBox(height: 10),
],
],
// ── Sans appareil (actés absents) ────────────────────────────────
if (absent.isNotEmpty) ...[
const SizedBox(height: 18),
const _SectionHeader('Sans appareil'),
const SizedBox(height: 10),
for (final role in absent) ...[
_AbsentCard(role: role, setup: setup),
const SizedBox(height: 10),
],
],
],
),
);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// En-tête compteur X/6 + état d'écran
// ═══════════════════════════════════════════════════════════════════════════
class _CounterHeader extends StatelessWidget {
final EnergySetupProvider setup;
const _CounterHeader(this.setup);
@override
Widget build(BuildContext context) {
final detected = EmsRole.values.where(setup.isDetected).length;
final remaining = setup.toConfigureRoles.length;
final complete = setup.screenState == SetupScreenState.complete;
final parts = <String>[
if (detected > 0) '$detected détecté${detected > 1 ? 's' : ''} automatiquement',
if (remaining > 0)
'$remaining reste${remaining > 1 ? 'nt' : ''} à configurer'
else
'tout est assigné',
];
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: EtmTokens.surfaceOf(context),
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
boxShadow: EtmTokens.cardShadowOf(context),
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: (complete ? EtmTokens.eco : EtmTokens.brand)
.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl),
),
child: Icon(
complete ? Icons.check_rounded : Icons.tune_rounded,
color: complete ? EtmTokens.eco : EtmTokens.brand,
size: 22,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${setup.configuredCount} / ${setup.totalRoles} rôles configurés',
style: EtmTokens.sans(
size: 15,
weight: FontWeight.w700,
color: EtmTokens.inkOf(context),
),
),
const SizedBox(height: 2),
Text(
parts.join(' · '),
style: EtmTokens.sans(
size: 12, color: EtmTokens.mutedOf(context)),
),
],
),
),
],
),
);
}
}
class _BlankInvite extends StatelessWidget {
const _BlankInvite();
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: EtmTokens.brand.withValues(alpha: 0.07),
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
border: Border.all(color: EtmTokens.brand.withValues(alpha: 0.18)),
),
child: Row(
children: [
const Icon(Icons.bolt_rounded, color: EtmTokens.brand),
const SizedBox(width: 12),
Expanded(
child: Text(
'Commencez par assigner vos compteurs et charges pilotables. '
'Le solaire et la batterie sont détectés automatiquement.',
style: EtmTokens.sans(
size: 13, color: EtmTokens.inkOf(context), height: 1.35),
),
),
],
),
);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Cartes
// ═══════════════════════════════════════════════════════════════════════════
class _MeterCard extends StatelessWidget {
final EmsRole role;
final EnergySetupProvider setup;
final NymeaService service;
const _MeterCard(
{required this.role, required this.setup, required this.service});
@override
Widget build(BuildContext context) {
final thing = _thingById(service, setup.thingIdOf(role));
final sub = thing == null
? '—'
: [thing.name, _thingClassName(service, thing)]
.where((s) => s.isNotEmpty)
.join(' · ');
return _CardShell(
child: Row(
children: [
_Emoji(role.icon),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(role.label,
style: EtmTokens.sans(
size: 14.5,
weight: FontWeight.w700,
color: EtmTokens.inkOf(context))),
const SizedBox(height: 2),
Text(sub,
style: EtmTokens.sans(
size: 12, color: EtmTokens.mutedOf(context))),
],
),
),
if (role == EmsRole.gridMeter)
_Badge('rootMeter', EtmTokens.brand)
else if (setup.isDetected(role))
_Badge('auto · lecture seule', EtmTokens.eco),
],
),
);
}
}
// _LoadCard a été retiré à la fusion : il affichait un EmsRole, c'est-à-dire un rôle
// INFÉRÉ des Things, là où une charge est désormais lue dans `GetLoadConfig`. Son
// remplaçant est `LoadConfigCard` (widgets/controllable_loads_section.dart), qui porte en
// plus la télémétrie, le renommage, l'activation et l'accès au mécanisme.
class _TodoCard extends StatelessWidget {
final EmsRole role;
final EnergySetupProvider setup;
final NymeaService service;
const _TodoCard(
{required this.role, required this.setup, required this.service});
@override
Widget build(BuildContext context) {
return _CardShell(
dashed: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
_Emoji(role.icon, faded: true),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(role.label,
style: EtmTokens.sans(
size: 14.5,
weight: FontWeight.w700,
color: EtmTokens.inkOf(context))),
const SizedBox(height: 2),
Text('Aucun appareil configuré',
style: EtmTokens.sans(
size: 12, color: EtmTokens.mutedOf(context))),
],
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _Btn(
label: '+ Configurer',
primary: true,
onTap: () => _showThingPicker(context, role, setup, service),
),
),
const SizedBox(width: 10),
Expanded(
child: _Btn(
label: 'Pas d\'appareil',
onTap: () => setup.markAbsent(role),
),
),
],
),
],
),
);
}
}
class _AbsentCard extends StatelessWidget {
final EmsRole role;
final EnergySetupProvider setup;
const _AbsentCard({required this.role, required this.setup});
@override
Widget build(BuildContext context) {
return _CardShell(
child: Row(
children: [
_Emoji(role.icon, faded: true),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(role.label,
style: EtmTokens.sans(
size: 14.5,
weight: FontWeight.w700,
color: EtmTokens.mutedOf(context))),
const SizedBox(height: 2),
Text('Pas d\'appareil',
style: EtmTokens.sans(
size: 12, color: EtmTokens.faintOf(context))),
],
),
),
_Btn(
label: 'Reconfigurer',
onTap: () => setup.unconfigure(role),
),
],
),
);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Sélecteur d'appareil (assign)
// ═══════════════════════════════════════════════════════════════════════════
void _showThingPicker(BuildContext context, EmsRole role,
EnergySetupProvider setup, NymeaService service) {
final matches = setup.compatibleThings(role, service);
showModalBottomSheet<void>(
context: context,
backgroundColor: EtmTokens.surfaceOf(context),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(22)),
),
builder: (sheetCtx) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Assigner — ${role.label}',
style: EtmTokens.sans(
size: 16,
weight: FontWeight.w700,
color: EtmTokens.inkOf(sheetCtx))),
const SizedBox(height: 4),
Text('Appareils compatibles détectés',
style: EtmTokens.sans(
size: 12, color: EtmTokens.mutedOf(sheetCtx))),
const SizedBox(height: 12),
if (matches.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 18),
child: Text('Aucun appareil compatible.',
style: EtmTokens.sans(
size: 13, color: EtmTokens.mutedOf(sheetCtx))),
)
else
...matches.map((t) => ListTile(
contentPadding: EdgeInsets.zero,
leading: _Emoji(role.icon),
title: Text(t.name,
style: EtmTokens.sans(
size: 14,
weight: FontWeight.w600,
color: EtmTokens.inkOf(sheetCtx))),
subtitle: Text(_thingClassName(service, t),
style: EtmTokens.sans(
size: 12, color: EtmTokens.mutedOf(sheetCtx))),
onTap: () {
setup.assignThing(role, t.id);
Navigator.of(sheetCtx).pop();
},
)),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: _Btn(
label: 'Pas d\'appareil',
onTap: () {
setup.markAbsent(role);
Navigator.of(sheetCtx).pop();
},
),
),
],
),
),
);
},
);
}
// ═══════════════════════════════════════════════════════════════════════════
// Petits composants
// ═══════════════════════════════════════════════════════════════════════════
class _SectionHeader extends StatelessWidget {
final String title;
final String? subtitle;
const _SectionHeader(this.title, {this.subtitle});
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(title,
style: EtmTokens.sans(
size: 13.5,
weight: FontWeight.w700,
color: EtmTokens.inkOf(context))),
if (subtitle != null) ...[
const SizedBox(width: 8),
Expanded(
child: Text(subtitle!,
style: EtmTokens.sans(
size: 11, color: EtmTokens.faintOf(context))),
),
],
],
);
}
}
class _Legend extends StatelessWidget {
const _Legend();
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(top: 2),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: EtmTokens.solar.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.info_outline_rounded,
size: 16, color: EtmTokens.heat),
const SizedBox(width: 8),
Expanded(
child: RichText(
text: TextSpan(
style: EtmTokens.sans(
size: 11.5, color: EtmTokens.mutedOf(context), height: 1.35),
children: [
TextSpan(
text: 'Ordre indicatif. ',
style: EtmTokens.sans(
size: 11.5,
weight: FontWeight.w700,
color: EtmTokens.inkOf(context))),
// La phrase qui suivait annonçait que « le pilote peut bousculer
// l'ordre les jours Tempo rouge ou à l'approche d'une échéance ».
// AUCUN de ces deux comportements n'existe dans le moteur : l'ordre
// de service est celui que l'installateur pose, point. Promettre une
// intelligence absente est pire que de ne rien dire — l'installateur
// aurait attribué au pilote un arbitrage qu'il aurait dû configurer.
const TextSpan(
text: 'Il fixe le service du surplus : chaque charge se sert '
'dans cet ordre, sur ce que la précédente a laissé.'),
],
),
),
),
],
),
);
}
}
class _CardShell extends StatelessWidget {
final Widget child;
final bool dashed;
const _CardShell({required this.child, this.dashed = false});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: dashed
? EtmTokens.surfaceOf(context).withValues(alpha: 0.6)
: EtmTokens.surfaceOf(context),
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
boxShadow: dashed ? null : EtmTokens.cardShadowOf(context),
border: dashed
? Border.all(color: EtmTokens.lineOf(context))
: null,
),
child: child,
);
}
}
class _Emoji extends StatelessWidget {
final String emoji;
final bool faded;
const _Emoji(this.emoji, {this.faded = false});
@override
Widget build(BuildContext context) {
return Opacity(
opacity: faded ? 0.55 : 1,
child: Container(
width: 38,
height: 38,
alignment: Alignment.center,
decoration: BoxDecoration(
color: EtmTokens.bg2Of(context),
borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl),
),
child: Text(emoji, style: const TextStyle(fontSize: 18)),
),
);
}
}
class _Badge extends StatelessWidget {
final String text;
final Color color;
const _Badge(this.text, this.color);
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(EtmTokens.radiusPill),
),
child: Text(text,
style: EtmTokens.sans(
size: 11, weight: FontWeight.w600, color: color)),
);
}
}
// _Chip est parti avec _LoadCard : il n'affichait que le résumé d'un EmsRole.
class _Btn extends StatelessWidget {
final String label;
final bool primary;
final VoidCallback onTap;
const _Btn({required this.label, required this.onTap, this.primary = false});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
alignment: Alignment.center,
decoration: BoxDecoration(
color: primary ? EtmTokens.brand : Colors.transparent,
borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl),
border: primary
? null
: Border.all(color: EtmTokens.lineStrongOf(context)),
),
child: Text(label,
style: EtmTokens.sans(
size: 13,
weight: FontWeight.w600,
color: primary ? Colors.white : EtmTokens.mutedOf(context))),
),
);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Résolution d'affichage des things
// ═══════════════════════════════════════════════════════════════════════════
NymeaThing? _thingById(NymeaService service, String? id) {
if (id == null) return null;
for (final t in service.things) {
if (t.id == id) return t;
}
return null;
}
String _thingClassName(NymeaService service, NymeaThing thing) {
try {
return service.thingClasses
.firstWhere((c) => c.id == thing.thingClassId)
.displayName;
} catch (_) {
return '';
}
}