etm-powersync-app/lib/widgets/schema_gated.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

145 lines
5.0 KiB
Dart

import 'package:flutter/material.dart';
import '../services/rpc_schema.dart';
import '../theme/etm_tokens.dart';
/// Enveloppe un champ dont la disponibilité vient du **schéma de la box**.
///
/// ## Un champ grisé sans raison se lit comme un bug
///
/// C'est la règle qui justifie ce widget. « Inactif » tout court laisse l'installateur
/// devant trois hypothèses également plausibles — panne, droit manquant, ou fonction
/// absente — et il choisira la mauvaise. On affiche donc **toujours** la raison, et le
/// nom du champ manquant quand on l'a : c'est ce nom qui rend le rapport actionnable.
///
/// ## Trois états, pas deux
///
/// - [FieldSupport.writable] — actif, l'enfant est rendu tel quel ;
/// - [FieldSupport.readOnly] — le champ existe et se lit, mais l'écrire est une erreur :
/// c'est une **mesure**, pas une fonction manquante ;
/// - [FieldSupport.absent] — cette box ne l'expose pas ;
/// - [FieldSupport.unknown] — le schéma n'a pas pu être lu ; on ne prétend ni l'un ni
/// l'autre.
class SchemaGated extends StatelessWidget {
/// Ce que le schéma dit du champ.
final FieldSupport support;
/// Nom du champ tel qu'il apparaîtrait au schéma (`endDateTime`, `minCurrent`…).
/// Affiché dans la raison — c'est lui qui permet de remonter un manque utilement.
final String fieldName;
/// Le champ actif.
final Widget child;
/// Libellé humain, pour la ligne de raison.
final String label;
const SchemaGated({
super.key,
required this.support,
required this.fieldName,
required this.label,
required this.child,
});
@override
Widget build(BuildContext context) {
if (support.isWritable) return child;
final muted = EtmTokens.mutedOf(context);
return Opacity(
opacity: 0.55,
child: IgnorePointer(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
child,
const SizedBox(height: 4),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(_icon, size: 13, color: muted),
const SizedBox(width: 5),
Expanded(
child: Text(
_reason,
style: TextStyle(fontSize: 11.5, color: muted, height: 1.3),
),
),
],
),
],
),
),
);
}
IconData get _icon => switch (support) {
FieldSupport.readOnly => Icons.visibility_outlined,
FieldSupport.absent => Icons.cloud_off_outlined,
_ => Icons.help_outline,
};
String get _reason => switch (support) {
FieldSupport.readOnly =>
'Lecture seule — « $fieldName » est une mesure publiée par la box, '
'elle ne se règle pas ici.',
FieldSupport.absent =>
'Cette box ne l\'expose pas encore — champ « $fieldName » absent de son '
'schéma. Le réglage s\'activera seul après mise à jour du moteur.',
_ => 'Disponibilité inconnue — le schéma de la box n\'a pas pu être lu. '
'« $fieldName » est laissé inactif par prudence.',
};
}
/// Bandeau d'en-tête annonçant sur quoi l'écran s'appuie.
///
/// Dès qu'un écran se comporte différemment selon la version, il faut pouvoir dire
/// **laquelle on regarde** — sans quoi un rapport de terrain (« le champ est gris chez
/// moi ») n'est pas exploitable.
class SchemaVersionBanner extends StatelessWidget {
/// Ligne de version déjà composée (`SchemaProvider.versionLine()`).
final String versionLine;
/// Faux si le schéma n'a pas pu être lu — le bandeau le dit alors franchement.
final bool schemaLoaded;
const SchemaVersionBanner({
super.key,
required this.versionLine,
required this.schemaLoaded,
});
@override
Widget build(BuildContext context) {
final muted = EtmTokens.mutedOf(context);
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
decoration: BoxDecoration(
color: EtmTokens.surfaceOf(context).withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
border: Border.all(color: EtmTokens.lineOf(context)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(schemaLoaded ? Icons.memory : Icons.help_outline,
size: 15, color: muted),
const SizedBox(width: 8),
Expanded(
child: Text(
schemaLoaded
? '$versionLine — les réglages disponibles sont déduits du schéma '
'publié par cette box.'
: '$versionLine — schéma illisible : les réglages sont laissés '
'inactifs par prudence, pas parce qu\'ils manquent.',
style: TextStyle(fontSize: 11.5, color: muted, height: 1.35),
),
),
],
),
);
}
}