fix(config): ne jamais renvoyer rankOrigin "auto" — un renommage aurait été refusé

Trouvé en relisant .75 après le déploiement d'+etm27, pas en écrivant le badge.

`rankOrigin: "auto"` écrit par un client est refusé : EnergyErrorInvalidParameter,
ensemble entier rejeté (vérifié sur machine). Or les deux chemins d'écriture de l'app
recopient les charges VERBATIM — c'est leur règle, et elle est bonne. Recopier verbatim une
entrée marquée "auto", c'est écrire "auto".

Conséquence, qu'aucun test ne montrait et que le banc ne peut pas produire aujourd'hui
(aucune borne auto-provisionnée depuis +etm27) : dès qu'une installation cliente portera
une borne détectée par le moteur, un SIMPLE RENOMMAGE aurait été refusé en bloc — et le
motif ne franchit pas la frontière RPC. Même famille que le refus sur rangs doublés d'hier,
même symptôme muet.

Le nettoyage est posé au point de sortie UNIQUE (NymeaService.setLoadConfig) plutôt que sur
chaque chemin : un chemin ajouté demain hériterait sinon du refus sans que rien ne
l'annonce. `"user"` n'est pas touché — il est permis — et une clé absente le reste : on n'en
fabrique pas.

⚠️ Ce que ça coûte est dit dans le code et remonté au brief : la marque du moteur est
EFFACÉE sur les charges concernées, y compris celles que le geste ne visait pas. Le badge
« rang posé par la box » disparaîtra donc d'une borne que personne n'a classée, au premier
renommage d'une autre charge. Question posée au moteur : un écho inchangé de "auto"
peut-il être toléré ? Trois options écartées, avec leurs raisons.

Et le relevé du jour précise la lecture : depuis +etm27 la clé sort TOUJOURS, à `""` quand
rien n'est déclaré — même convention que `domain` et `meterThingId`. Les quatre charges du
banc portent `rankOrigin: ""`. La traiter comme une valeur afficherait un badge sur toute
l'installation ; le getter la lit comme un silence, et quatre tests le figent.

222 tests (+4), 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-29 10:03:18 +02:00
parent 8b7bfbcded
commit e26000945d
5 changed files with 136 additions and 4 deletions

View File

@ -2,6 +2,38 @@
--- ---
## 2026-08-29 — `rankOrigin` : un écho inchangé de `"auto"` passe-t-il ?
**Une question, et le contournement posé en attendant.**
`"auto"` écrit par un client est refusé — `EnergyErrorInvalidParameter`, ensemble entier
rejeté (vérifié sur `.75`). C'est la bonne règle : elle empêche un écran de fabriquer une
provenance.
Mais **les deux chemins d'écriture de l'app recopient les charges verbatim**, et c'est
délibéré : `SetLoadConfig` remplace tout l'ensemble, donc reconstruire depuis un modèle
typé perdrait, en silence et pour toutes les charges, un champ qu'une box plus récente
porterait. Recopier verbatim une entrée marquée `"auto"`, c'est écrire `"auto"` — donc un
**simple renommage serait refusé en bloc dès qu'une borne auto-provisionnée figure dans
l'installation**, avec un motif qui ne franchit pas la frontière RPC.
**Contournement posé côté app** : `rankOrigin: "auto"` est remplacé par `""` au point de
sortie unique (`NymeaService.setLoadConfig`). L'écriture passe, mais la marque du moteur est
**effacée** — y compris sur des charges que le geste ne visait pas. Le badge « rang posé par
la box » disparaîtra donc d'une borne que personne n'a classée, au premier renommage d'une
autre charge.
**La question** : un écho **inchangé** — même valeur, même id, rang inchangé — peut-il être
toléré ? Si oui, l'app le renverra tel quel et la marque survivra aux gestes qui ne la
concernent pas. Sinon, la marque est par construction fragile, et il vaut mieux le savoir
que le découvrir sur une installation client.
Trois options écartées côté app, pour situer : refuser l'écriture (une installation avec
une borne neuve deviendrait non modifiable), renvoyer `"auto"` (refus systématique),
reconstruire depuis le modèle typé (perte silencieuse de champs inconnus).
---
## 2026-08-28 — `THING_MISSING` sort sur les bornes, et reste masqué sur les charges variables ## 2026-08-28 — `THING_MISSING` sort sur les bornes, et reste masqué sur les charges variables
**Mesuré**, `NymeaEnergy.GetLoadTelemetry` sur `.75` le 2026-08-28 à 10:29 UTC. La charge **Mesuré**, `NymeaEnergy.GetLoadTelemetry` sur `.75` le 2026-08-28 à 10:29 UTC. La charge

View File

@ -103,11 +103,16 @@ class LoadConfigEntry {
/// ///
/// ## Absent n'affirme RIEN /// ## Absent n'affirme RIEN
/// ///
/// Ni « choisi par un humain », ni « posé d'office ». Une box antérieure à `+etm27` ne /// Ni « choisi par un humain », ni « posé d'office ». C'est la même règle que
/// publie pas le champ, et une entrée créée à la main non plus. C'est la même règle que
/// `measurement.source` : l'absence est un silence, pas une affirmation — et c'est /// `measurement.source` : l'absence est un silence, pas une affirmation — et c'est
/// pourquoi le badge ne s'affiche que sur `"auto"`, jamais par défaut. /// pourquoi le badge ne s'affiche que sur `"auto"`, jamais par défaut.
/// ///
/// **Deux formes d'absence, un seul sens.** Une box antérieure à `+etm27` ne publie pas
/// la clé ; depuis `+etm27` elle sort **toujours**, à `""` quand rien n'est déclaré —
/// même convention que `domain` et `meterThingId`. Relevé sur `.75` le 2026-08-29 : les
/// quatre charges portent `rankOrigin: ""`. Traiter la chaîne vide comme une valeur
/// afficherait un badge sur toute l'installation.
///
/// `"user"` est ce qu'un client écrit pour dire « j'ai vu ce rang par défaut et il me /// `"user"` est ce qu'un client écrit pour dire « j'ai vu ce rang par défaut et il me
/// va », sans déplacer la charge. **L'app ne peut jamais écrire `"auto"`** — le moteur le /// va », sans déplacer la charge. **L'app ne peut jamais écrire `"auto"`** — le moteur le
/// refuse, et c'est ce qui empêche un écran de fabriquer une provenance. /// refuse, et c'est ce qui empêche un écran de fabriquer une provenance.

View File

@ -129,6 +129,37 @@ List<LoadConfigEntry> tiedByPriority(List<LoadConfigEntry> entries) {
]; ];
} }
/// Retire `rankOrigin: "auto"` de tout ce qui part vers la box.
///
/// ## Pourquoi l'app ne peut PAS renvoyer ce qu'elle vient de lire
///
/// `"auto"` est la marque que le moteur pose lui-même sur une entrée qu'il a créée d'office.
/// **Un client qui l'écrit est refusé** — `EnergyErrorInvalidParameter`, l'ensemble entier
/// rejeté, rien de modifié (vérifié sur `.75` en `1.15.2+etm27`). Seuls l'effacement et
/// `"user"` sont permis.
///
/// Or les deux chemins d'écriture de l'app recopient les charges **verbatim** — c'est leur
/// règle, et elle est bonne : réécrire depuis le modèle typé perdrait un champ qu'une box
/// plus récente porterait. Mais recopier verbatim une entrée marquée `"auto"`, c'est
/// écrire `"auto"`. Un simple renommage, ou la suppression d'une charge orpheline, serait
/// donc **refusé en bloc dès qu'une borne auto-provisionnée figure dans l'installation** —
/// et le motif ne franchit pas la frontière RPC.
///
/// ## Ce que ça coûte, et pourquoi c'est dit
///
/// Le champ part vide, donc la marque du moteur est **effacée** sur les charges concernées
/// — y compris celles que le geste ne visait pas. Ce n'est pas neutre : le badge « rang
/// posé par la box » disparaîtra d'une borne que personne n'a pourtant classée.
///
/// C'est le moindre mal entre trois options, aucune bonne : refuser l'écriture (une
/// installation avec une borne neuve deviendrait non modifiable), renvoyer `"auto"` (refus
/// systématique), ou effacer une marque informative. **Question posée au moteur** : un
/// écho *inchangé* de `"auto"` peut-il être toléré ? Cf. `docs/BRIEF_plugin_depuis_maquettes.md`.
List<Map<String, dynamic>> sansRankOriginAuto(List<Map<String, dynamic>> payload) => [
for (final m in payload)
if (m['rankOrigin'] == 'auto') {...m, 'rankOrigin': ''} else m,
];
/// La charge utile à réécrire pour **supprimer** une charge — `null` si elle n'y est pas. /// La charge utile à réécrire pour **supprimer** une charge — `null` si elle n'y est pas.
/// ///
/// `SetLoadConfig` remplace l'ensemble : supprimer une charge, c'est réécrire toutes les /// `SetLoadConfig` remplace l'ensemble : supprimer une charge, c'est réécrire toutes les

View File

@ -11,7 +11,8 @@ import '../models/energy_data.dart'; // EnergyData, HistoryPoint, PowerBalanc
import '../models/ev_charger_live.dart'; import '../models/ev_charger_live.dart';
import '../models/modbus_models.dart'; // ModbusRtuMaster, SerialPort import '../models/modbus_models.dart'; // ModbusRtuMaster, SerialPort
import '../models/nymea_models.dart'; import '../models/nymea_models.dart';
import 'energy_ratios.dart'; // seam interim ratios (§2.1) import 'energy_ratios.dart';
import 'load_priority.dart'; // seam interim ratios (§2.1)
// ── Protocole de connexion ───────────────────────────────────────────────────── // ── Protocole de connexion ─────────────────────────────────────────────────────
enum NymeaProtocol { enum NymeaProtocol {
@ -1608,7 +1609,13 @@ class NymeaService extends ChangeNotifier {
/// `NymeaEnergy.LoadConfigChanged`, émise après toute persistance réussie. /// `NymeaEnergy.LoadConfigChanged`, émise après toute persistance réussie.
/// ///
/// \return `null` si la box a accepté, sinon le code d'erreur qu'elle a renvoyé. /// \return `null` si la box a accepté, sinon le code d'erreur qu'elle a renvoyé.
Future<String?> setLoadConfig(List<Map<String, dynamic>> payloads) async { Future<String?> setLoadConfig(List<Map<String, dynamic>> rawPayloads) async {
// ⚠️ UNE SEULE PORTE, et c'est ici : `rankOrigin: "auto"` est refusé à tout client
// (`EnergyErrorInvalidParameter`, ensemble entier rejeté). Les chemins d'écriture
// recopient les charges verbatim — donc ils recopieraient aussi cette marque. Le
// nettoyage est posé au point de sortie plutôt que sur chaque chemin : un chemin
// ajouté demain hériterait sinon du refus, sans que rien ne l'annonce.
final payloads = sansRankOriginAuto(rawPayloads);
if (_isSimulation || !_connected) { if (_isSimulation || !_connected) {
_log('[LoadConfig] sim/déconnecté — SetLoadConfig ignoré', force: true); _log('[LoadConfig] sim/déconnecté — SetLoadConfig ignoré', force: true);
return 'Disconnected'; return 'Disconnected';

View File

@ -148,4 +148,61 @@ void main() {
expect(tiedByPriority([for (final m in reste) LoadConfigEntry(m)]), isEmpty); expect(tiedByPriority([for (final m in reste) LoadConfigEntry(m)]), isEmpty);
}); });
}); });
group('rankOrigin — ce que l\'app a le droit d\'écrire', () {
test('`""` se lit comme « rien d\'affirmé », pas comme une valeur', () {
// Depuis +etm27 la clé sort TOUJOURS, vide quand rien n'est déclaré — même
// convention que `domain` et `meterThingId`. Relevé sur `.75` le 2026-08-29 : les
// quatre charges du banc portent `rankOrigin: ""`. La traiter comme une valeur
// afficherait un badge sur toute l'installation.
final e = LoadConfigEntry(const {'id': 'a', 'rankOrigin': ''});
expect(e.rankOrigin, isNull);
expect(e.rangPoseDOffice, isFalse);
});
test('`"auto"` se lit, et il est le SEUL à allumer le badge', () {
expect(LoadConfigEntry(const {'id': 'a', 'rankOrigin': 'auto'}).rangPoseDOffice,
isTrue);
expect(LoadConfigEntry(const {'id': 'a', 'rankOrigin': 'user'}).rangPoseDOffice,
isFalse);
expect(LoadConfigEntry(const {'id': 'a'}).rangPoseDOffice, isFalse);
});
test('⚠️ ce qui PART ne porte jamais `"auto"` — la box le refuserait en bloc',
() {
// Les chemins d'écriture recopient verbatim : sans ce nettoyage, un simple renommage
// renverrait la marque que le moteur a posée, et l'ensemble serait rejeté
// (EnergyErrorInvalidParameter) dès qu'une borne auto-provisionnée existe.
final envoye = sansRankOriginAuto([
{'id': 'a', 'priority': 1, 'rankOrigin': 'auto'},
{'id': 'b', 'priority': 2, 'rankOrigin': 'user'},
{'id': 'c', 'priority': 3, 'rankOrigin': ''},
{'id': 'd', 'priority': 4},
]);
expect(envoye[0]['rankOrigin'], '', reason: 'auto → effacé');
expect(envoye[1]['rankOrigin'], 'user', reason: 'user est permis, on n\'y touche pas');
expect(envoye[2]['rankOrigin'], '');
expect(envoye[3].containsKey('rankOrigin'), isFalse,
reason: 'une clé absente le reste — on n\'en fabrique pas');
});
test('le nettoyage ne touche à RIEN d\'autre', () {
final avant = {
'id': 'a',
'label': 'A',
'priority': 1,
'rankOrigin': 'auto',
'relays': [
{'thingId': '{x}', 'powerW': 500}
],
};
final apres = sansRankOriginAuto([avant]).single;
for (final k in avant.keys) {
if (k == 'rankOrigin') continue;
expect(apres[k], avant[k], reason: 'clé $k altérée');
}
expect(apres.keys.length, avant.keys.length);
});
});
} }