Deux suites directes du geste réel du 2026-08-28 sur le banc.
── 1. Une vérification qui arrive trop tard ne prouve rien ───────────────────
Sur le banc, la suppression a été vérifiée à la main, APRÈS un autre geste : entre-temps un
« Enregistrer » avait regroupé et renuméroté (chauffe-eau 1 → 3). Il est alors devenu
impossible de dire laquelle des deux écritures avait déplacé les rangs. Le journal de la box
montrait deux persistances à deux minutes d'écart, rien de plus.
`removeLoad` relit donc la configuration IMMÉDIATEMENT après l'écriture — sans attendre
LoadConfigChanged, qui laisserait une fenêtre où un autre geste s'intercale — et compare
clé par clé ce qu'elle a envoyé à ce que la box porte (diffPayloads, fonction pure). Les
survivantes doivent être identiques, rangs compris : c'est le contrôle de l'aller-retour
neutre appliqué à une écriture qui RETIRE.
Le message le dit : « Relu sur la box : les autres sont identiques clé par clé, rangs
compris », ou l'écart en rouge, nommé champ par champ. Sur le seul geste de l'app qui
détruit de la configuration, « la box a dit oui » ne suffisait pas.
La comparaison est structurelle : relays, sgReady et powerLevels sont des listes et des
objets, que `==` compare par référence — l'alerte aurait crié à chaque suppression, donc
plus personne ne l'aurait lue.
── 2. Le sélecteur de compteur était plus restrictif que la box ─────────────
Il filtrait sur l'interface smartmeter. Le moteur n'en regarde AUCUNE : findConfiguredThing
puis hasState("currentPower") (attachMeasurements, LM-1101). Sur .75, la classe wallbox
déclare smartmeter, la classe trydan non — la V2C était donc impossible à désigner comme
son propre compteur, alors que la box l'aurait accepté et que c'est le seul moyen de voir
« mesuré » à côté de « alloué » sur sa carte.
Le sélecteur accepte maintenant, en plus des smartmeter, les Things d'une interface
élargie qui publient l'état exigé par le moteur — ici evcharger + currentPower. Il s'élargit
là où la box est plus large, pas partout : une borne qui ne publie aucune puissance reste
dehors, sans quoi on écrirait une configuration muette sans erreur.
⚠️ Ce n'est pas la fin de l'écart : la classe trydan devrait déclarer smartmeter, ce qui
suppose un CUMUL d'énergie qu'elle ne publie pas — même manque que celui qui a bloqué
LM-1009. Lot plugin noté au TODO avec cette raison.
── Banc ──────────────────────────────────────────────────────────────────────
Chauffe-eau remis au rang 1 (lire → patcher → réécrire, seul `priority` touché, relecture
vérifiée : la box porte exactement ce qui a été envoyé). Ordre courant : chauffe-eau,
V2C, wallbox simulée, PAC.
188 tests (+7), flutter analyze inchangé à 27 remarques.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
256 lines
10 KiB
Dart
256 lines
10 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
import '../models/load_config_entry.dart';
|
|
import '../models/nymea_models.dart';
|
|
import '../services/nymea_service.dart';
|
|
import '../theme/etm_tokens.dart';
|
|
|
|
/// Choisit un Thing **par son nom**, filtré sur une interface nymea.
|
|
///
|
|
/// ## Pourquoi un sélecteur, et pas un champ de saisie
|
|
///
|
|
/// La configuration d'une charge ne contient que des `ThingId` — des UUID. Les faire taper
|
|
/// au doigt est une erreur garantie, et une erreur **muette** : la box accepte un UUID
|
|
/// syntaxiquement valide qui ne désigne rien, et la charge ne commandera jamais rien.
|
|
///
|
|
/// Le sélecteur résout **nom → ThingId** et n'ajoute **aucun champ au contrat** : ce qui
|
|
/// part reste `{thingId, powerW}`. Il est purement d'interface.
|
|
///
|
|
/// ## Le filtre par interface est ce qui rend la liste utilisable
|
|
///
|
|
/// Un routeur de relais commande des Things portant l'interface `power` ; une charge
|
|
/// modulable un Thing portant `etmvariableload`. Sans filtre, l'installateur choisit dans
|
|
/// une liste où figurent l'onduleur, la batterie et le compteur — et rien ne l'avertirait
|
|
/// de son erreur avant le premier cycle d'arbitrage.
|
|
class ThingPicker extends StatelessWidget {
|
|
/// Interface nymea exigée (`power`, `etmvariableload`, …).
|
|
final String interfaceRequise;
|
|
|
|
/// Interfaces **acceptées en plus** de [interfaceRequise], à condition que le Thing
|
|
/// publie [etatRequis].
|
|
///
|
|
/// ## Pourquoi une porte de sortie au filtre
|
|
///
|
|
/// Le sélecteur de compteur filtrait sur `smartmeter`, et se trouvait donc **plus
|
|
/// restrictif que la box**. Le moteur, lui, ne regarde aucune interface : il fait
|
|
/// `findConfiguredThing(meterThingId)` puis `hasState("currentPower")`
|
|
/// (`attachMeasurements`, LM-1101). Une borne qui publie `currentPower` est un compteur
|
|
/// parfaitement valable pour lui.
|
|
///
|
|
/// Constaté sur `.75` le 2026-08-28 : la classe `wallbox` (borne simulée) déclare
|
|
/// `smartmeter`, la classe `trydan` (V2C) non — elle n'a que `connectable, evcharger,
|
|
/// power`. La V2C était donc **impossible à désigner comme son propre compteur**, alors
|
|
/// que la box l'aurait accepté et que c'est le seul moyen de voir « mesuré » à côté de
|
|
/// « alloué » sur sa carte.
|
|
///
|
|
/// Le filtre reste : c'est lui qui rend la liste utilisable. Il s'élargit là où la box
|
|
/// est plus large, pas partout.
|
|
final List<String> interfacesAussi;
|
|
|
|
/// État que doit publier un Thing retenu par [interfacesAussi] — le même que le moteur
|
|
/// exige. Sans lui, l'élargissement proposerait des appareils qui ne mesurent rien.
|
|
final String? etatRequis;
|
|
|
|
/// ThingId actuellement choisi, forme `{uuid}` ou nue. `null` = rien de choisi.
|
|
final String? valeur;
|
|
|
|
/// Appelé avec le ThingId **dans la forme rendue par la box**.
|
|
final ValueChanged<String> onChoisi;
|
|
|
|
/// Texte affiché quand rien n'est choisi.
|
|
final String placeholder;
|
|
|
|
final NymeaService service;
|
|
|
|
/// ThingIds déjà pris par d'autres lignes — grisés, parce que la box refuse **en bloc**
|
|
/// une configuration où un même Thing apparaît deux fois (LM-302-b / ECS-110-b).
|
|
final Set<String> dejaPris;
|
|
|
|
const ThingPicker({
|
|
super.key,
|
|
required this.service,
|
|
required this.interfaceRequise,
|
|
required this.valeur,
|
|
required this.onChoisi,
|
|
this.placeholder = 'Choisir un appareil…',
|
|
this.dejaPris = const {},
|
|
this.interfacesAussi = const [],
|
|
this.etatRequis,
|
|
});
|
|
|
|
/// Things portant [interfaceRequise] — plus ceux de [interfacesAussi] qui publient
|
|
/// [etatRequis]. Triés par nom.
|
|
List<NymeaThing> _candidats() {
|
|
final classesOk = <String>{};
|
|
final classesElargies = <String>{};
|
|
for (final c in service.thingClasses) {
|
|
if (c.interfaces.contains(interfaceRequise)) {
|
|
classesOk.add(c.id);
|
|
} else if (interfacesAussi.any(c.interfaces.contains)) {
|
|
// L'élargissement se juge sur la CLASSE (l'état est déclaré là), pas sur la
|
|
// valeur courante : un Thing qui publie `currentPower` reste un compteur valable
|
|
// même quand cette puissance vaut zéro.
|
|
if (etatRequis == null ||
|
|
c.stateTypes.any((st) => st.name == etatRequis)) {
|
|
classesElargies.add(c.id);
|
|
}
|
|
}
|
|
}
|
|
final out = [
|
|
for (final t in service.things)
|
|
if (classesOk.contains(t.thingClassId) ||
|
|
classesElargies.contains(t.thingClassId))
|
|
t
|
|
]..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
|
return out;
|
|
}
|
|
|
|
/// Nom lisible du Thing choisi, ou `null` s'il n'est pas (ou plus) sur la box.
|
|
String? nomDe(String? id) {
|
|
if (id == null) return null;
|
|
for (final t in service.things) {
|
|
if (normalizeThingId(t.id) == normalizeThingId(id)) return t.name;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final candidats = _candidats();
|
|
final nom = nomDe(valeur);
|
|
final muted = EtmTokens.mutedOf(context);
|
|
|
|
// Un Thing configuré qui a disparu de la box est un état À DIRE : la charge le
|
|
// commande toujours dans la config, mais plus rien ne répond en face.
|
|
final orphelin = valeur != null && valeur!.isNotEmpty && nom == null;
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
InkWell(
|
|
borderRadius: BorderRadius.circular(10),
|
|
onTap: candidats.isEmpty ? null : () => _ouvrir(context, candidats),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 11),
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(10),
|
|
border: Border.all(
|
|
color: orphelin
|
|
? EtmTokens.danger.withValues(alpha: 0.6)
|
|
: EtmTokens.lineOf(context)),
|
|
),
|
|
child: Row(children: [
|
|
Expanded(
|
|
child: Text(
|
|
nom ?? (orphelin ? 'Appareil absent de la box' : placeholder),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 13.5,
|
|
color: nom == null ? muted : EtmTokens.inkOf(context),
|
|
fontWeight: nom == null ? FontWeight.w400 : FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
Icon(Icons.expand_more_rounded, size: 20, color: muted),
|
|
]),
|
|
),
|
|
),
|
|
if (orphelin)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 4),
|
|
child: Text(
|
|
'Ce ThingId est dans la configuration mais n\'existe plus sur la box '
|
|
'(${valeur!}). La charge ne commandera rien tant qu\'il n\'est pas remplacé.',
|
|
style: const TextStyle(
|
|
fontSize: 11, height: 1.3, color: EtmTokens.danger),
|
|
),
|
|
)
|
|
else if (candidats.isEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 4),
|
|
child: Text(
|
|
'Aucun appareil « $interfaceRequise » sur cette box. Il faut d\'abord '
|
|
'l\'appairer dans les Things.',
|
|
style: TextStyle(fontSize: 11, height: 1.3, color: muted),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
void _ouvrir(BuildContext context, List<NymeaThing> candidats) {
|
|
showModalBottomSheet<void>(
|
|
context: context,
|
|
backgroundColor: EtmTokens.surfaceOf(context),
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
|
),
|
|
builder: (c) => SafeArea(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(18, 16, 18, 4),
|
|
child: Text('Appareils « $interfaceRequise »',
|
|
style: TextStyle(
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w700,
|
|
color: EtmTokens.inkOf(c))),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(18, 0, 18, 10),
|
|
child: Text(
|
|
'Choisis par nom ; l\'app enregistre son identifiant.',
|
|
style:
|
|
TextStyle(fontSize: 11.5, color: EtmTokens.mutedOf(c)),
|
|
),
|
|
),
|
|
Flexible(
|
|
child: ListView.builder(
|
|
shrinkWrap: true,
|
|
itemCount: candidats.length,
|
|
itemBuilder: (_, i) {
|
|
final t = candidats[i];
|
|
final pris = dejaPris.contains(normalizeThingId(t.id)) &&
|
|
normalizeThingId(t.id) != normalizeThingId(valeur ?? '');
|
|
final choisi = normalizeThingId(t.id) == normalizeThingId(valeur ?? '');
|
|
return ListTile(
|
|
enabled: !pris,
|
|
dense: true,
|
|
leading: Icon(
|
|
choisi
|
|
? Icons.radio_button_checked
|
|
: Icons.radio_button_unchecked,
|
|
size: 20,
|
|
color: choisi ? EtmTokens.brand : EtmTokens.mutedOf(c),
|
|
),
|
|
title: Text(t.name,
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
color: pris
|
|
? EtmTokens.mutedOf(c)
|
|
: EtmTokens.inkOf(c))),
|
|
subtitle: pris
|
|
? const Text('déjà utilisé par une autre ligne',
|
|
style: TextStyle(fontSize: 11))
|
|
: null,
|
|
onTap: pris
|
|
? null
|
|
: () {
|
|
onChoisi(t.id);
|
|
Navigator.of(c).pop();
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|