fix(config): +etm27 refuse deux rangs identiques — l'app le voit avant la box

Livré AVANT le déploiement, parce que ce refus-là est muet : le motif ne franchit pas la
frontière RPC, et le symptôme au doigt aurait été « mon glissement ne s'enregistre pas »,
sans cause visible à l'écran. Même famille que le §9 du contrat de données.

Trois points vérifiés, dans l'ordre demandé :

1. LE RÉAPLATISSAGE REND TOUJOURS DES RANGS DISTINCTS. flattenToPayload numérote 1..n sur
   l'ordre courant, donc par construction — mais « par construction » ne se vérifie pas
   tout seul : le test le refait après CHAQUE permutation de domaines et après un
   déplacement intra-domaine, en partant de la config du banc qui porte déjà deux charges
   au rang 1. Les états intermédiaires du glissement ne partent pas : seul `save()` écrit.

2. RIEN NE PART SI LE BROUILLON N'EST PAS COHÉRENT. validateSet() — le miroir client de
   LoadConfigStore::validateSet() — apprend le contrôle des rangs et rend un conflit
   `tiedPriority` qui NOMME les deux charges et le rang partagé. L'écran distingue
   désormais les trois refus (thing revendiqué deux fois, identifiant en double, rangs à
   égalité) là où il annonçait « deux charges revendiquent le même appareil » pour tout.

3. ⚠️ LE CHEMIN DE SUPPRESSION ÉTAIT LE PLUS EXPOSÉ, et rien ne l'aurait montré. Il
   réécrit les survivantes VERBATIM — c'est sa règle, et elle est bonne — donc il
   transporte les rangs tels que la box les porte. Or c'est le plugin lui-même qui a créé
   des égalités avant +etm27 (borne détectée au rang 1 d'une installation déjà réglée).
   Une suppression légitime aurait donc été refusée en bloc, sans motif. Elle refuse
   maintenant côté app, avec la raison — plutôt que de renumérer en douce : un geste de
   nettoyage qui redistribue les rangs devient un geste d'arbitrage.

Deux tests de load_priority_test construisaient leurs entrées sans `priority` : les deux
tombaient donc à égalité, et le nouveau contrôle masquait la règle qu'ils éprouvaient.
Rangs distincts posés, avec la raison en commentaire.

Les deux autres nouveautés d'+etm27 sont au TODO, non codées : `o:rankOrigin` (badge « rang
par défaut, pas un choix », avec la règle absent = rien d'affirmé) et l'entrée en queue des
bornes auto-provisionnées — qui devrait faire taire d'elle-même la note d'ordre entrelacé.

213 tests (+8), flutter analyze inchangé à 27 remarques.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
This commit is contained in:
Patrick Schurig 2026-08-28 16:06:11 +02:00
parent 7f0bf71ad8
commit 2c193077f7
7 changed files with 269 additions and 20 deletions

23
TODO.md
View File

@ -2,6 +2,29 @@
## Ouvert au 2026-08-27
- [ ] 🔜 **`1.15.2+etm27` — trois nouveautés, dont une déjà couverte.**
Déploiement annoncé pour demain matin.
- [x] ~~**Refus en bloc sur deux rangs identiques.**~~ **Couvert le 2026-08-28, avant le
déploiement** : `validateSet()` porte le contrôle (`tiedPriority`), l'écran nomme les
deux charges et leur rang, et le chemin de suppression — qui réécrit verbatim, donc
transporte les rangs de la box — refuse plutôt que d'envoyer une égalité héritée. Huit
tests, dont le réaplatissage après **chaque** permutation. Le refus de la box est muet
(le motif ne franchit pas la frontière RPC) : sans ce miroir, le symptôme aurait été
« mon glissement ne s'enregistre pas ».
- [ ] **`o:rankOrigin` — badge « rang par défaut, pas un choix ».** `"auto"` écrit par le
moteur quand il crée une entrée d'office, **effacé dès qu'un rang différent arrive**
pour cet id. **Absent = rien d'affirmé**, jamais « choisi par un humain » — même règle
que `measurement.source`. `"user"` est ce qu'un client écrit pour dire « j'ai vu ce
défaut et il me va » sans déplacer la charge ; l'app ne peut **jamais** écrire `"auto"`,
le moteur le refuse. C'est l'équivalent, pour une charge arrivée seule, de la note de
rangs ex æquo — et il faudra décider si le badge remplace cette note ou s'y ajoute.
- [ ] **Les bornes auto-provisionnées entrent en QUEUE (`1 + max`)**, plus en tête. Une
borne détectée après la mise en service ne passe donc plus devant l'ECS classé exprès ;
sur une installation neuve, la liste étant vide, le VE garde le rang 1. Vérifier que la
note d'ordre entrelacé et celle de rangs ex æquo se taisent bien dans ce nouveau cas —
elles décrivaient un défaut qui disparaît.
- [x] ~~⏳ Retirer l'alias `measuredW`~~ — **fait le 2026-08-28**, `1.15.2+etm26` déployé
sur `.75` et vérifié par sonde : `measuredW` a disparu de la charge utile, `measurement`
est en ligne. `MeasurementSource.legacy` est parti avec lui ; ce qui reste,

View File

@ -364,6 +364,26 @@ class LoadConfigProvider extends ChangeNotifier {
final reste = payloadWithoutLoad(frais, loadId);
if (reste == null) return 'NotFound';
// ⚠️ `1.15.2+etm27` refuse EN BLOC un ensemble où deux charges partagent un rang — et
// le motif ne franchit pas la frontière RPC.
//
// Ce chemin réécrit les survivantes VERBATIM, donc il transporte les rangs tels que la
// box les porte. Or c'est le plugin lui-même qui a créé des égalités avant `+etm27`
// (borne détectée au rang 1, sur une installation où le rang 1 était pris). Une
// suppression parfaitement légitime serait donc refusée, sans cause visible.
//
// On refuse ICI, avec la raison — plutôt que de renuméroter en douce : un geste de
// nettoyage qui redistribue les rangs devient un geste d'arbitrage, et c'est
// exactement ce que ce chemin s'interdit.
final apres = [for (final m in reste) LoadConfigEntry(m)];
if (tiedByPriority(apres).isNotEmpty) {
_saveError = 'La configuration porte des rangs à égalité, que la box refuse '
'depuis 1.15.2+etm27. Classez les charges avant de retirer celle-ci.';
_saveState = LoadSaveState.rejected;
notifyListeners();
return 'TiedRanks';
}
// 3. RÉÉCRIRE.
_expectedSignature = _signatureOfPayload(reste);
_conflict = null;

View File

@ -303,25 +303,43 @@ List<DomainGroup> applyDefaultDomainOrder(List<DomainGroup> groups) {
/// Conflit détecté par [validateSet], suffisamment décrit pour être rendu en français.
class LoadSetConflict {
/// `duplicateId` ou `thingClaimedTwice`.
/// `duplicateId`, `thingClaimedTwice` ou `tiedPriority`.
final String kind;
final String? id;
final String? thingId;
final String? firstLabel;
final String? secondLabel;
/// Rang partagé, pour `tiedPriority`.
final int? rank;
const LoadSetConflict.duplicateId(this.id)
: kind = 'duplicateId',
thingId = null,
firstLabel = null,
secondLabel = null;
secondLabel = null,
rank = null;
const LoadSetConflict.thingClaimedTwice({
required this.thingId,
required this.firstLabel,
required this.secondLabel,
}) : kind = 'thingClaimedTwice',
id = null;
id = null,
rank = null;
/// **Deux charges au même rang** — refusé en bloc depuis `1.15.2+etm27`.
///
/// Le motif du refus ne franchit pas la frontière RPC : sans ce miroir, le symptôme est
/// « mon glissement ne s'enregistre pas », sans cause visible. C'est exactement la
/// famille de panne muette que ce dépôt traque.
const LoadSetConflict.tiedPriority({
required this.rank,
required this.firstLabel,
required this.secondLabel,
}) : kind = 'tiedPriority',
id = null,
thingId = null;
}
/// Rejoue la validation d'ensemble du plugin **avant** l'envoi.
@ -329,17 +347,32 @@ class LoadSetConflict {
/// Miroir de `LoadConfigStore::validateSet()` (`loadconfigstore.cpp:83-112`) :
/// unicité des identifiants sur **toutes** les charges, unicité des Things revendiqués
/// **parmi les `enabled` seulement** — une charge désactivée ne construit aucun
/// adaptateur, donc ne revendique rien.
/// adaptateur, donc ne revendique rien — et, depuis `1.15.2+etm27`, **unicité des rangs**.
///
/// Ce troisième contrôle vaut plus que les deux autres, parce que le refus qu'il anticipe
/// est **muet** : le motif ne franchit pas la frontière RPC, et le symptôme au doigt est
/// « mon glissement ne s'enregistre pas ».
///
/// C'est un **confort d'affichage**. La box reste l'autorité : si elle refuse, c'est son
/// refus qu'il faut montrer.
LoadSetConflict? validateSet(List<LoadConfigEntry> entries) {
final ids = <String>{};
final owner = <String, String>{}; // thingId normalisé → libellé de la charge
final rangs = <int, String>{}; // priority → libellé de la première charge vue
for (final c in entries) {
if (!ids.add(c.id)) return LoadSetConflict.duplicateId(c.id);
// ⚠️ `1.15.2+etm27` : deux charges au même rang font refuser TOUT l'ensemble. Le
// contrôle porte sur ce qui part, y compris une charge désactivée — le moteur ne les
// exclut pas de cette règle-ci, contrairement à la revendication de Thing.
final vu = rangs[c.priority];
if (vu != null) {
return LoadSetConflict.tiedPriority(
rank: c.priority, firstLabel: vu, secondLabel: c.label);
}
rangs[c.priority] = c.label;
if (!c.enabled) continue; // ne construit aucun adaptateur : ne revendique rien
for (final thing in c.claimedThingIds) {

View File

@ -903,9 +903,14 @@ class _RejectionNote extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
conflit != null
? 'Deux charges revendiquent le même appareil.'
: 'La box a refusé l\'enregistrement${err == null ? '' : ' ($err)'}.',
switch (conflit?.kind) {
'thingClaimedTwice' => 'Deux charges revendiquent le même appareil.',
'duplicateId' => 'Deux charges portent le même identifiant.',
'tiedPriority' =>
'Deux charges se retrouvent au rang ${conflit!.rank} : '
'${conflit.firstLabel} et ${conflit.secondLabel}.',
_ => 'La box a refusé l\'enregistrement${err == null ? '' : ' ($err)'}.',
},
style: const TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w700,
@ -913,15 +918,27 @@ class _RejectionNote extends StatelessWidget {
),
const SizedBox(height: 4),
Text(
conflit != null
// LM-201 : deux charges sur un canal unique se contrediraient, et le
// budget compterait la même puissance deux fois. Ce n'est pas une
// limite technique — c'est le modèle.
? 'Un appareil ne peut appartenir qu\'à une seule charge : deux '
'charges sur un même canal de commande se contrediraient.'
: 'Le refus porte sur l\'ensemble : rien n\'a été modifié. La règle '
'enfreinte et le libellé en cause sont dans le journal de la box '
'(journalctl -u nymead).',
switch (conflit?.kind) {
// LM-201 : deux charges sur un canal unique se contrediraient, et le
// budget compterait la même puissance deux fois. Ce n'est pas une
// limite technique — c'est le modèle.
'thingClaimedTwice' =>
'Un appareil ne peut appartenir qu\'à une seule charge : deux '
'charges sur un même canal de commande se contrediraient.',
'duplicateId' =>
'Chaque charge a un identifiant unique côté box — deux entrées ne '
'peuvent pas le partager.',
// Depuis +etm27 la box refuse TOUT l'ensemble sur une égalité de rang, et
// son motif ne franchit pas la frontière RPC : sans ce message, le
// symptôme serait « ça ne s'enregistre pas », sans cause visible.
'tiedPriority' =>
'Depuis la version 1.15.2+etm27, la box refuse toute configuration où '
'deux charges partagent un rang. Classez-les l\'une après l\'autre '
'— rien n\'a été envoyé.',
_ => 'Le refus porte sur l\'ensemble : rien n\'a été modifié. La règle '
'enfreinte et le libellé en cause sont dans le journal de la box '
'(journalctl -u nymead).',
},
style: TextStyle(
fontSize: 11.5,
height: 1.35,

View File

@ -477,6 +477,9 @@ class _BoutonRetirerState extends State<_BoutonRetirer> {
'NotFound' =>
'Cette charge n\'est plus dans la configuration de la box — rien à retirer.',
'NoService' => 'Aucune connexion à la box.',
'TiedRanks' =>
'Deux charges partagent un rang, et la box refuse alors toute la configuration '
'(1.15.2+etm27). Classez-les d\'abord — rien n\'a été envoyé.',
'RereadFailed' =>
'Suppression envoyée, mais impossible de relire la configuration pour le '
'vérifier. Rouvrez l\'écran avant tout autre geste.',

View File

@ -197,8 +197,10 @@ void main() {
test('un Thing commandé par deux charges ACTIVES est refusé', () {
final relay = [{'thingId': '{8538782f-2c8c-4a30-bfce-c8140f791c9b}', 'powerW': 500}];
final c = validateSet([
LoadConfigEntry({'id': 'a', 'label': 'A', 'adapter': 'relay-router', 'enabled': true, 'relays': relay}),
LoadConfigEntry({'id': 'b', 'label': 'B', 'adapter': 'relay-router', 'enabled': true, 'relays': relay}),
// Rangs DISTINCTS : depuis +etm27 une égalité fait refuser l'ensemble, et le
// miroir la voit en premier — elle masquerait la règle éprouvée ici.
LoadConfigEntry({'id': 'a', 'label': 'A', 'priority': 1, 'adapter': 'relay-router', 'enabled': true, 'relays': relay}),
LoadConfigEntry({'id': 'b', 'label': 'B', 'priority': 2, 'adapter': 'relay-router', 'enabled': true, 'relays': relay}),
]);
expect(c?.kind, 'thingClaimedTwice');
expect(c?.firstLabel, 'A');
@ -208,8 +210,8 @@ void main() {
test('une charge DÉSACTIVÉE ne revendique rien — pas de conflit', () {
final relay = [{'thingId': '{8538782f-2c8c-4a30-bfce-c8140f791c9b}', 'powerW': 500}];
final c = validateSet([
LoadConfigEntry({'id': 'a', 'label': 'A', 'adapter': 'relay-router', 'enabled': true, 'relays': relay}),
LoadConfigEntry({'id': 'b', 'label': 'B', 'adapter': 'relay-router', 'enabled': false, 'relays': relay}),
LoadConfigEntry({'id': 'a', 'label': 'A', 'priority': 1, 'adapter': 'relay-router', 'enabled': true, 'relays': relay}),
LoadConfigEntry({'id': 'b', 'label': 'B', 'priority': 2, 'adapter': 'relay-router', 'enabled': false, 'relays': relay}),
]);
expect(c, isNull);
});

View File

@ -0,0 +1,151 @@
/// **`1.15.2+etm27` refuse en bloc un ensemble où deux charges partagent un rang.**
///
/// Le motif du refus ne franchit pas la frontière RPC : le symptôme au doigt serait « mon
/// glissement ne s'enregistre pas », sans cause visible. Ces tests vérifient les trois
/// choses que ça exige de l'app, **avant** le déploiement :
///
/// 1. le réaplatissage produit toujours des rangs distincts, quel que soit l'état d'où il
/// part — y compris un ordre entrelacé ou déjà à égalité ;
/// 2. rien ne part à la box tant que le brouillon n'est pas cohérent — le miroir client
/// voit l'égalité avant l'envoi ;
/// 3. le chemin de suppression, qui réécrit VERBATIM, refuse plutôt que de transporter une
/// égalité que la box rejettera.
library;
import 'dart:convert';
import 'dart:io';
import 'package:etm_powersync_app/models/load_config_entry.dart';
import 'package:etm_powersync_app/services/load_priority.dart';
import 'package:flutter_test/flutter_test.dart';
List<LoadConfigEntry> _banc([String fixture = 'loadconfig_hems75_v2c.json']) {
final raw = jsonDecode(File('test/fixtures/$fixture').readAsStringSync());
return [
for (final m in ((raw as Map<String, dynamic>)['loadConfigs'] as List))
LoadConfigEntry(Map<String, dynamic>.from(m as Map)),
];
}
/// Les rangs d'une charge utile prête à partir.
List<int> _rangs(List<Map<String, dynamic>> payload) =>
[for (final m in payload) m['priority'] as int];
void main() {
group('le réaplatissage rend TOUJOURS des rangs distincts', () {
test('sur le banc, dont l\'ordre porte deux charges au rang 1', () {
// C'est l'état réel du 2026-08-28 : la V2C créée d'office au rang 1 que le
// chauffe-eau occupait déjà. Sous +etm27, réécrire cet ensemble tel quel serait
// refusé — le réaplatissage doit donc le réparer, et il le fait.
final entrees = _banc();
expect(tiedByPriority(entrees), isNotEmpty, reason: 'prémisse du test');
final rangs = _rangs(flattenToPayload(groupByDomain(entrees).groups));
expect(rangs.toSet().length, rangs.length);
expect(rangs, List.generate(entrees.length, (i) => i + 1));
});
test('sur un ordre entrelacé', () {
final rangs = _rangs(
flattenToPayload(groupByDomain(_banc('loadconfig_interleaved.json')).groups));
expect(rangs.toSet().length, rangs.length);
});
test('après CHAQUE déplacement, pas seulement à la fin', () {
// Le glissement produit des états intermédiaires : ce qui compte est qu'aucun d'eux
// ne puisse partir avec une égalité, pas seulement l'état final.
var groupes = groupByDomain(_banc()).groups;
for (var i = 0; i < groupes.length; i++) {
for (var j = 0; j < groupes.length; j++) {
final permute = [...groupes];
permute.insert(j, permute.removeAt(i));
final rangs = _rangs(flattenToPayload(permute));
expect(rangs.toSet().length, rangs.length,
reason: 'domaine $i → $j produit une égalité');
}
}
// Et à l'intérieur d'un domaine.
groupes = groupByDomain(_banc()).groups;
final ev = groupes.indexWhere((g) => g.domain == 'ev');
if (ev >= 0 && groupes[ev].loads.length > 1) {
final charges = [...groupes[ev].loads];
charges.insert(0, charges.removeLast());
final refait = [
for (final (i, g) in groupes.indexed)
if (i == ev) DomainGroup(domain: g.domain, loads: charges) else g
];
final rangs = _rangs(flattenToPayload(refait));
expect(rangs.toSet().length, rangs.length);
}
});
});
group('rien ne part si le brouillon n\'est pas cohérent', () {
test('le miroir client VOIT l\'égalité de rang', () {
// Sans ce contrôle, la box refuserait tout l'ensemble sans dire pourquoi.
final conflit = validateSet(_banc());
expect(conflit, isNotNull);
expect(conflit!.kind, 'tiedPriority');
expect(conflit.rank, 1);
expect([conflit.firstLabel, conflit.secondLabel],
containsAll(['chauffe-eau', 'V2C Trydan']));
});
test('un ensemble réaplati passe le miroir', () {
// Le chemin normal de l'écran : on réaplatit, donc on n'envoie jamais d'égalité.
final aplati = [
for (final m in flattenToPayload(groupByDomain(_banc()).groups))
LoadConfigEntry(m)
];
expect(validateSet(aplati), isNull);
});
test('l\'égalité est vue AVANT les autres conflits qu\'elle masquerait', () {
// Une charge désactivée compte aussi : le moteur ne l'exclut pas de cette règle-ci,
// contrairement à la revendication de Thing.
final entrees = [
LoadConfigEntry(const {
'id': 'a',
'label': 'A',
'priority': 2,
'adapter': 'evcharger',
'enabled': true
}),
LoadConfigEntry(const {
'id': 'b',
'label': 'B',
'priority': 2,
'adapter': 'evcharger',
'enabled': false
}),
];
expect(validateSet(entrees)?.kind, 'tiedPriority');
});
});
group('la suppression ne transporte pas une égalité', () {
test('retirer une charge NE RÉPARE PAS les rangs — et c\'est voulu', () {
// Le chemin de suppression réécrit verbatim : les rangs survivent tels quels. C'est
// ce qui rend le garde-fou nécessaire, et c'est aussi ce qui empêche un geste de
// nettoyage de devenir un geste d'arbitrage.
final frais = [
for (final e in _banc()) Map<String, dynamic>.from(e.raw)
];
final reste = payloadWithoutLoad(
frais, '{88160e45-7df2-48cc-9639-f0d15ae6e382}')!;
final apres = [for (final m in reste) LoadConfigEntry(m)];
expect(tiedByPriority(apres), isNotEmpty,
reason: 'l\'égalité chauffe-eau / V2C survit à la suppression — d\'où le refus');
});
test('quand la charge retirée PORTAIT l\'égalité, la suppression la résout', () {
final frais = [
{'id': 'a', 'label': 'A', 'priority': 1, 'adapter': 'relay-router'},
{'id': 'b', 'label': 'B', 'priority': 1, 'adapter': 'evcharger'},
{'id': 'c', 'label': 'C', 'priority': 2, 'adapter': 'sg-ready'},
];
final reste = payloadWithoutLoad(frais, 'b')!;
expect(tiedByPriority([for (final m in reste) LoadConfigEntry(m)]), isEmpty);
});
});
}