fix(config): l'ordre entrelacé se voit enfin, et un appareil disparu cesse d'être un défaut à lever

L'arrivée de la V2C sur .75 a révélé deux défauts que le banc ne pouvait pas montrer avant,
plus deux pièges de contrat à documenter.

1. ⚠️ isNotGrouped ÉTAIT CALCULÉ ET AFFICHÉ NULLE PART. GroupedOrder détecte depuis le
   premier jour qu'un ordre n'est pas groupé par domaine, le provider l'expose, et aucune
   vue ne le lisait — le cas ne s'était jamais produit, chaque domaine du banc ayant ses
   charges d'un seul tenant.

   Il se produit maintenant : la V2C, créée d'office au rang 1 que le chauffe-eau occupait
   déjà, donne l'ordre à plat « ecs · ev · heating · ev · ev ». Le domaine ev est coupé en
   deux par le chauffage.

   Ce n'était pas un détail d'affichage. L'écran groupe pour afficher, donc ce qu'on voit
   n'est plus l'ordre réel de la box — et le premier enregistrement réaplatit EN GROUPANT,
   ce qui change l'arbitrage sans que personne ne l'ait demandé. Chiffré par test sur la
   configuration réelle : la PAC passerait du rang 2 au rang 5. C'est exactement ce que le
   commentaire de GroupedOrder interdit depuis le début ; le garde-fou existait, il n'était
   pas branché.

2. « L'APPAREIL N'EXISTE PLUS » N'EST PAS UN DÉFAUT À LEVER. La Terra AC a été supprimée de
   nymea ; son entrée LoadConfig survit, occupe le rang 4, et s'affiche en THING_MISSING
   avec un bouton « Lever le défaut ». ClearLoadFault y répondrait EnergyErrorNoError sans
   rien changer, et le défaut reviendrait au cycle suivant — un bouton qui ne peut pas
   tenir sa promesse fait douter de la box au lieu de désigner la cause.

   Le bouton disparaît sur ce code, remplacé par ce qu'il faut faire : réinstaller
   l'appareil, ou retirer la charge. Et le libellé cesse de se lire « votre configuration
   est cassée » : le cas normal est un appareil remplacé, le plugin crée d'office une
   entrée par borne détectée mais ne la retire pas quand le Thing s'en va.

3. Deux pièges du Thing V2C, documentés là où quelqu'un serait tenté de les câbler :

   chargeEnergy est EXACT pendant la charge — sa croissance recoupe currentPower à mieux
   d'un pour cent — et REMIS À ZÉRO à l'arrêt de la charge, pas au débranchement. Mesuré :
   0,3456 kWh → 0 dès que charging passe à faux, 119 secondes avant que le câble ne bouge.
   Sur du pilotage par surplus il repartirait de zéro à chaque nuage, et après coup une
   nuit entière vaudrait 0 — soit « rien livré » affiché là où la vérité est « pas
   mesurable ». Le plugin publiera un sessionEnergy construit ; rien n'est bâti en
   attendant, et surtout aucune accumulation côté app.

   powerL1/L2/L3 sont des ampères mal nommés, renommage currentL* en cours côté plugin.
   Ni affichage, ni conversion : une conversion écrite ici survivrait au correctif et le
   contredirait.

Fixture ajoutée : la configuration réelle à cinq entrées, avec ses deux enseignements de
terrain — deux rangs à 1, et une entrée orpheline qui consomme un rang.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
This commit is contained in:
Patrick Schurig 2026-08-28 11:57:12 +02:00
parent 0311766b2b
commit 553a182e9b
8 changed files with 375 additions and 25 deletions

View File

@ -351,7 +351,14 @@
"decisionSafeStateSgReady": "État sûr avant retrait : état 2 (normal), jamais le blocage.",
"faultWriteFailed": "Échec d'écriture : l'échelle de commande est épuisée, plus aucune commande n'est émise tant que le défaut n'est pas levé.",
"faultThingMissing": "L'appareil commandé est absent de la configuration nymea.",
"faultThingMissing": "L'appareil commandé n'existe plus dans nymea — retiré, ou jamais réinstallé après un remplacement. La charge, elle, est toujours déclarée : c'est pour ça qu'elle apparaît ici.",
"@faultThingMissing": {
"description": "À NE PAS lire comme « votre configuration est cassée ». Le cas normal est un appareil physiquement remplacé : le Thing disparaît, l'entrée LoadConfig reste — le plugin crée d'office une entrée par borne détectée mais ne la retire pas quand le Thing s'en va. Constaté sur .75 le 2026-08-28 avec une ABB Terra AC remplacée par une V2C."
},
"faultThingMissingAction": "Rien à lever ici : le défaut ne partira pas tant qu'aucun appareil ne répond. Réinstallez-le dans nymea, ou retirez cette charge de la configuration.",
"@faultThingMissingAction": {
"description": "Remplace le bouton « Lever le défaut » sur THING_MISSING. ClearLoadFault y répondrait EnergyErrorNoError sans rien changer, et le défaut reviendrait au cycle suivant — un bouton qui ne peut pas tenir sa promesse est pire que pas de bouton."
},
"faultUnusableEncoding": "Encodage SG-Ready inexploitable : l'état 2 ne peut pas être exprimé, cette pompe à chaleur n'est pas pilotable.",
"faultClearPending": "Levée demandée — en attente de la télémétrie…",
"faultClearStillFaulty": "La charge est toujours en défaut après la levée.",

View File

@ -709,12 +709,18 @@ abstract class L10n {
/// **'Échec d\'écriture : l\'échelle de commande est épuisée, plus aucune commande n\'est émise tant que le défaut n\'est pas levé.'**
String get faultWriteFailed;
/// No description provided for @faultThingMissing.
/// À NE PAS lire comme « votre configuration est cassée ». Le cas normal est un appareil physiquement remplacé : le Thing disparaît, l'entrée LoadConfig reste — le plugin crée d'office une entrée par borne détectée mais ne la retire pas quand le Thing s'en va. Constaté sur .75 le 2026-08-28 avec une ABB Terra AC remplacée par une V2C.
///
/// In fr, this message translates to:
/// **'L\'appareil commandé est absent de la configuration nymea.'**
/// **'L\'appareil commandé n\'existe plus dans nymea — retiré, ou jamais réinstallé après un remplacement. La charge, elle, est toujours déclarée : c\'est pour ça qu\'elle apparaît ici.'**
String get faultThingMissing;
/// Remplace le bouton « Lever le défaut » sur THING_MISSING. ClearLoadFault y répondrait EnergyErrorNoError sans rien changer, et le défaut reviendrait au cycle suivant — un bouton qui ne peut pas tenir sa promesse est pire que pas de bouton.
///
/// In fr, this message translates to:
/// **'Rien à lever ici : le défaut ne partira pas tant qu\'aucun appareil ne répond. Réinstallez-le dans nymea, ou retirez cette charge de la configuration.'**
String get faultThingMissingAction;
/// No description provided for @faultUnusableEncoding.
///
/// In fr, this message translates to:

View File

@ -567,7 +567,11 @@ class L10nFr extends L10n {
@override
String get faultThingMissing =>
'L\'appareil commandé est absent de la configuration nymea.';
'L\'appareil commandé n\'existe plus dans nymea — retiré, ou jamais réinstallé après un remplacement. La charge, elle, est toujours déclarée : c\'est pour ça qu\'elle apparaît ici.';
@override
String get faultThingMissingAction =>
'Rien à lever ici : le défaut ne partira pas tant qu\'aucun appareil ne répond. Réinstallez-le dans nymea, ou retirez cette charge de la configuration.';
@override
String get faultUnusableEncoding =>

View File

@ -163,6 +163,28 @@ class LoadMechanism {
int? get state => (raw['state'] as num?)?.toInt();
double? get estimatedPowerW => _dn(raw['estimatedPowerW']);
/// ⚠️ **Ne PAS lire `chargeEnergy` comme une énergie de session.**
///
/// Ce champ n'est pas au contrat de télémétrie — il vit sur le Thing — et cette note est
/// ici pour qu'on ne soit pas tenté de l'y faire entrer. Mesuré sur la V2C Trydan de
/// `.75` le 2026-08-28, cycle complet : la valeur est **exacte pendant la charge** (sa
/// croissance recoupe `currentPower` à mieux d'un pour cent, deux mesures de suite),
/// mais elle est **remise à zéro à l'ARRÊT de la charge** — pas au débranchement.
///
/// Relevé : 0,3456 kWh → 0 dès que `charging` passe à faux, **119 secondes avant** que le
/// câble ne bouge. Sur du pilotage par surplus, la charge s'interrompt à chaque nuage :
/// le compteur repartirait de zéro plusieurs fois par jour, et après coup une nuit
/// entière vaudrait 0.
///
/// Afficher ce zéro dirait « rien livré » là où la vérité est « pas mesurable » — les
/// deux états que la condition C1 de LM-1009 exige de distinguer. **Le plugin V2C
/// publiera un `sessionEnergy` construit ; attendre celui-là**, et n'accumuler rien
/// côté app en attendant.
///
/// Même prudence pour `powerL1/L2/L3` de cette borne : ce sont des **ampères mal nommés**
/// (renommage `currentL1/L2/L3` en cours côté plugin). Ne pas les afficher, et surtout
/// n'écrire aucune conversion ici — elle survivrait au correctif et le contredirait.
// ── evcharger (3g) — l'ÉTAT RÉEL de la borne ────────────────────────────────
//
// Ces quatre champs viennent du Thing ; `allocatedW` vient de l'arbitre. Depuis `+etm23`

View File

@ -88,6 +88,7 @@ class _ControllableLoadsSectionState extends State<ControllableLoadsSection> {
children: [
// La note de rang reste EN TÊTE : elle prévient que l'ordre affiché juste en
// dessous n'en est pas un. La lire après les cartes arriverait trop tard.
_NoteOrdreEntrelace(provider: provider),
_NoteRangsExAequo(entries: provider.entries),
// Le niveau glissable est celui des DOMAINES, pas des cartes.
//
@ -143,6 +144,66 @@ class _ControllableLoadsSectionState extends State<ControllableLoadsSection> {
}
}
/// **L'ordre en place n'est PAS groupé par domaine — et l'enregistrer le changerait.**
///
/// `GroupedOrder` calcule cette contiguïté depuis le premier jour, et le provider
/// l'expose (`isNotGrouped`). Rien ne l'affichait : le cas ne s'était jamais produit sur
/// le banc, chaque domaine y ayant ses charges d'un seul tenant.
///
/// Il s'est produit le 2026-08-28, à l'arrivée de la V2C. Créée d'office au rang 1 — que
/// le chauffe-eau occupait déjà — elle a donné l'ordre à plat `ecs · ev · heating · ev ·
/// ev` : le domaine `ev` est coupé en deux par le chauffage.
///
/// **Pourquoi il faut le dire, et pas seulement le savoir.** L'écran groupe les charges
/// par domaine pour les afficher. Sur un ordre entrelacé, ce regroupement ne correspond
/// plus à l'ordre réel de la box, et le premier enregistrement réaplatit en groupant —
/// donc **change l'arbitrage** sans que personne ne l'ait demandé. Mesuré sur la
/// configuration du banc : la PAC passerait du rang 2 au rang 5.
///
/// La note ne bloque rien : elle nomme l'écart et annonce ce que l'enregistrement fera.
/// Décider reste à l'installateur — mais en connaissance de cause.
class _NoteOrdreEntrelace extends StatelessWidget {
final LoadConfigProvider provider;
const _NoteOrdreEntrelace({required this.provider});
@override
Widget build(BuildContext context) {
if (!provider.isNotGrouped) return const SizedBox.shrink();
final doms = provider.interleavedDomains
.map((d) => domainLabel(context.l10n, d))
.join(', ');
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(11),
decoration: BoxDecoration(
color: EtmTokens.orange.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl),
border: Border.all(color: EtmTokens.orange.withValues(alpha: 0.45)),
),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
Icon(Icons.shuffle_rounded, size: 15, color: EtmTokens.orange),
const SizedBox(width: 7),
Expanded(
child: Text(
'L\'ordre en place sur la box n\'est pas groupé par domaine : $doms '
'est coupé en deux par une autre famille de charges. L\'écran les regroupe '
'pour les afficher, donc ce que vous voyez n\'est pas l\'ordre réel.\n\n'
'Le premier enregistrement REGROUPERA pour de bon, et changera l\'ordre de '
'service du surplus. Vérifiez les rangs avant d\'enregistrer.',
style: TextStyle(
fontSize: 11.5, height: 1.35, color: EtmTokens.inkOf(context)),
),
),
]),
),
);
}
}
/// **L'ordre affiché n'est pas un choix quand des rangs sont à égalité.**
///
/// À la détection d'une borne, le plugin lui attribue « le plus petit rang existant moins

View File

@ -284,6 +284,9 @@ class LoadFaultBox extends StatelessWidget {
final t = context.l10n;
final state = telemetry.clearStateOf(live.loadId);
final code = live.faultCode;
// « L'appareil n'existe plus » n'est pas un défaut de charge comme les autres : aucune
// action de l'app ne peut y remédier, et la seule sortie passe par nymea.
final thingAbsent = code == 'THING_MISSING';
final (String? message, Color messageColor) = switch (state) {
FaultClearState.pending => (t.faultClearPending, const Color(0xFF6B7280)),
@ -319,28 +322,44 @@ class LoadFaultBox extends StatelessWidget {
Text(code == null ? t.faultUnknown('—') : faultText(t, code),
style: const TextStyle(fontSize: 11, color: Color(0xFF7F1D1D))),
const SizedBox(height: 4),
Text(t.faultClearHint,
style: const TextStyle(fontSize: 10, color: Color(0xFF9A5B5B))),
if (message != null) ...[
const SizedBox(height: 6),
Text(message, style: TextStyle(fontSize: 11, color: messageColor)),
],
const SizedBox(height: 4),
Align(
alignment: Alignment.centerRight,
child: OutlinedButton.icon(
icon: state == FaultClearState.pending
? const SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.lock_open_rounded, size: 16),
label: Text(t.faultClearAction),
onPressed: state == FaultClearState.pending
? null
: () => telemetry.clearFault(live.loadId),
// ── L'APPAREIL A DISPARU : il n'y a rien à lever ────────────────────
//
// `ClearLoadFault` répondrait `EnergyErrorNoError` — l'ack ne prouve rien — et
// le défaut reviendrait au cycle suivant, puisque aucun appareil ne répond. Un
// bouton qui ne peut pas tenir sa promesse est pire que pas de bouton : il fait
// douter de la box au lieu de désigner la cause.
//
// Le cas est banal, pas exotique : une borne remplacée. Le plugin crée d'office
// une entrée `LoadConfig` par borne détectée (`+etm23`) mais ne la retire pas
// quand le Thing s'en va — constaté sur `.75` avec une ABB Terra AC remplacée
// par une V2C, dont l'entrée occupait encore un rang deux jours plus tard.
if (thingAbsent)
Text(t.faultThingMissingAction,
style: const TextStyle(fontSize: 10.5, color: Color(0xFF7F1D1D)))
else ...[
Text(t.faultClearHint,
style: const TextStyle(fontSize: 10, color: Color(0xFF9A5B5B))),
if (message != null) ...[
const SizedBox(height: 6),
Text(message, style: TextStyle(fontSize: 11, color: messageColor)),
],
const SizedBox(height: 4),
Align(
alignment: Alignment.centerRight,
child: OutlinedButton.icon(
icon: state == FaultClearState.pending
? const SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.lock_open_rounded, size: 16),
label: Text(t.faultClearAction),
onPressed: state == FaultClearState.pending
? null
: () => telemetry.clearFault(live.loadId),
),
),
),
],
],
),
);

View File

@ -158,4 +158,75 @@ void main() {
expect(tiedByPriority(regle), isEmpty);
});
});
// ───────────────────────────────────────────────────────────────────────────
group('terrain 2026-08-28 — la V2C remplace la Terra AC', () {
List<LoadConfigEntry> banc() {
final raw =
File('test/fixtures/loadconfig_hems75_v2c.json').readAsStringSync();
final list = (jsonDecode(raw) as Map<String, dynamic>)['loadConfigs'] as List;
return [
for (final m in list.whereType<Map>())
LoadConfigEntry(Map<String, dynamic>.from(m)),
];
}
test('l\'entrée d\'une borne SURVIT à la suppression de son appareil', () {
// Le plugin crée d'office une entrée par borne détectée, et ne la retire pas quand
// le Thing s'en va. Deux jours après le remplacement physique, la Terra AC occupe
// encore un rang dans la liste de l'installateur.
final e = banc();
expect(e.length, 5);
final terra = e.firstWhere((x) => x.label.contains('Terra'));
expect(terra.isEvCharger, isTrue);
expect(terra.enabled, isTrue, reason: 'elle n\'est pas désactivée — elle est ORPHELINE');
expect(terra.priority, 4, reason: 'et elle consomme un rang');
});
test('la note de rangs ex æquo DOIT s\'afficher sur ce banc', () {
// C'est le cas réel qui justifie la note : la V2C, créée d'office, arrive au rang 1
// que le chauffe-eau occupe déjà. Personne n'a choisi l'ordre entre les deux.
final exAequo = tiedByPriority(banc());
expect(exAequo, isNotEmpty);
expect(exAequo.map((e) => e.label),
containsAll(['chauffe-eau', 'V2C Trydan']));
expect(exAequo.length, 2,
reason: 'deux charges au rang 1 — la Terra est seule au rang 4');
});
test('⚠️ le rang par défaut de la V2C CASSE la contiguïté des domaines', () {
// Constat de terrain, et il va plus loin que « l'ordre n'est pas choisi ».
//
// La V2C est créée au rang 1, que le chauffe-eau (ecs) occupe déjà. L'ordre à plat
// devient : ecs, ev, heating, ev, ev — le domaine `ev` est ENTRELACÉ avec heating.
//
// L'app sait le détecter, et c'est vital : regrouper silencieusement puis réaplatir
// changerait l'arbitrage réel au premier enregistrement, sans que personne ne l'ait
// demandé. Ce test fige le fait que le cas se produit VRAIMENT sur une installation
// ordinaire — il n'était jamais apparu sur le banc avant l'arrivée de la V2C.
final groupes = groupByDomain(banc());
expect(groupes.isContiguous, isFalse);
expect(groupes.interleavedDomains, contains('ev'));
final aPlat = groupes.flatOrder.map((e) => e.domain).toList();
expect(aPlat, ['ecs', 'ev', 'heating', 'ev', 'ev']);
final ev = groupes.groups.firstWhere((g) => g.domain == 'ev');
expect(ev.loads.length, 3, reason: 'V2C + wallbox simulée + Terra orpheline');
});
test('réaplatir un ordre entrelacé le CHANGE — d\'où l\'avertissement', () {
// La preuve chiffrée de ce que l'écran doit annoncer avant d'enregistrer : la PAC
// passe du rang 2 au rang 5. Ce n'est pas un détail d'affichage, c'est l'ordre de
// service du surplus qui bascule.
final groupes = groupByDomain(banc());
final avant = {for (final e in banc()) e.id: e.priority};
final apres = {
for (final m in flattenToPayload(groupes.groups)) m['id']: m['priority']
};
expect(avant['pac-terrain'], 2);
expect(apres['pac-terrain'], 5,
reason: 'la PAC recule de trois rangs par le seul fait de regrouper');
});
});
}

160
test/fixtures/loadconfig_hems75_v2c.json vendored Normal file
View File

@ -0,0 +1,160 @@
{
"_source": "NymeaEnergy.GetLoadConfig sur 192.168.1.75, 2026-08-28 — après remplacement de la ABB Terra AC par une V2C Trydan",
"_note": "CINQ entrées. Deux enseignements du terrain : (1) DEUX charges partagent le rang 1 — chauffe-eau et la V2C créée d'office, dont le rang par défaut « le plus petit moins un, borné à 1 » a dégénéré en égalité ; (2) l'entrée de la Terra AC SURVIT à la suppression de son Thing et occupe encore le rang 4 — le plugin crée d'office une entrée par borne détectée mais ne la retire pas.",
"loadConfigs": [
{
"adapter": "relay-router",
"domain": "ecs",
"enabled": true,
"id": "chauffe-eau",
"label": "chauffe-eau",
"maxPowerW": 0,
"meterThingId": "4938b26a-d862-437f-b681-aaff7bce23ff",
"minOffS": 60,
"minOnS": 60,
"minPowerW": 0,
"mode": "fixed",
"needs": {
"dailyDeadline": "",
"minEnergyWhPerDay": 0
},
"powerLevels": [],
"priority": 1,
"relays": [
{
"powerW": 500,
"thingId": "{8538782f-2c8c-4a30-bfce-c8140f791c9b}"
},
{
"powerW": 1000,
"thingId": "{2ebe6bef-829b-4695-9d11-ddb4c16c5448}"
},
{
"powerW": 2000,
"thingId": "b033b212-1adb-4df0-ba2b-8fa477de52a2"
}
],
"sensorThingId": "",
"sgReady": {}
},
{
"adapter": "sg-ready",
"domain": "heating",
"enabled": true,
"id": "pac-terrain",
"label": "PAC banc",
"maxPowerW": 0,
"meterThingId": "e37be924-c5c5-4858-ad0c-c82650b96db0",
"minOffS": 0,
"minOnS": 0,
"minPowerW": 0,
"mode": "fixed",
"needs": {
"dailyDeadline": "",
"minEnergyWhPerDay": 0
},
"powerLevels": [],
"priority": 2,
"relays": [],
"sensorThingId": "",
"sgReady": {
"minStateHoldS": 300,
"states": [
{
"estimatedPowerW": 0,
"relays": [
"{beaf92e1-aedc-4b84-9ce4-e423648638cc}"
],
"state": 1
},
{
"estimatedPowerW": 1200,
"relays": [],
"state": 2
},
{
"estimatedPowerW": 1500,
"relays": [
"{bf236e64-5ae7-4bf1-82a6-4ede03de75a6}"
],
"state": 3
},
{
"estimatedPowerW": 3000,
"relays": [
"{beaf92e1-aedc-4b84-9ce4-e423648638cc}",
"{bf236e64-5ae7-4bf1-82a6-4ede03de75a6}"
],
"state": 4
}
]
}
},
{
"adapter": "evcharger",
"domain": "ev",
"enabled": true,
"id": "{09e520fd-d472-47c4-8887-6e7759372889}",
"label": "Simulated wallbox",
"maxPowerW": 0,
"meterThingId": "",
"minOffS": 0,
"minOnS": 0,
"minPowerW": 0,
"mode": "dynamic",
"needs": {
"dailyDeadline": "",
"minEnergyWhPerDay": 0
},
"powerLevels": [],
"priority": 3,
"relays": [],
"sensorThingId": "",
"sgReady": {}
},
{
"adapter": "evcharger",
"domain": "ev",
"enabled": true,
"id": "{88160e45-7df2-48cc-9639-f0d15ae6e382}",
"label": "Terra AC Charger (TCP)",
"maxPowerW": 0,
"meterThingId": "",
"minOffS": 0,
"minOnS": 0,
"minPowerW": 0,
"mode": "dynamic",
"needs": {
"dailyDeadline": "",
"minEnergyWhPerDay": 0
},
"powerLevels": [],
"priority": 4,
"relays": [],
"sensorThingId": "",
"sgReady": {}
},
{
"adapter": "evcharger",
"domain": "ev",
"enabled": true,
"id": "{4d1adafd-cafc-4813-93b1-ca8933ce71ab}",
"label": "V2C Trydan",
"maxPowerW": 0,
"meterThingId": "",
"minOffS": 0,
"minOnS": 0,
"minPowerW": 0,
"mode": "dynamic",
"needs": {
"dailyDeadline": "",
"minEnergyWhPerDay": 0
},
"powerLevels": [],
"priority": 1,
"relays": [],
"sensorThingId": "",
"sgReady": {}
}
]
}