diff --git a/docs/BRIEF_plugin_depuis_maquettes.md b/docs/BRIEF_plugin_depuis_maquettes.md index e7e71ad..be8053f 100644 --- a/docs/BRIEF_plugin_depuis_maquettes.md +++ b/docs/BRIEF_plugin_depuis_maquettes.md @@ -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 **Mesuré**, `NymeaEnergy.GetLoadTelemetry` sur `.75` le 2026-08-28 à 10:29 UTC. La charge diff --git a/lib/models/load_config_entry.dart b/lib/models/load_config_entry.dart index 9c8f45a..e156fb1 100644 --- a/lib/models/load_config_entry.dart +++ b/lib/models/load_config_entry.dart @@ -103,11 +103,16 @@ class LoadConfigEntry { /// /// ## Absent n'affirme RIEN /// - /// Ni « choisi par un humain », ni « posé d'office ». Une box antérieure à `+etm27` ne - /// publie pas le champ, et une entrée créée à la main non plus. C'est la même règle que + /// Ni « choisi par un humain », ni « posé d'office ». C'est la même règle que /// `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. /// + /// **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 /// 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. diff --git a/lib/services/load_priority.dart b/lib/services/load_priority.dart index 1be39ad..2f63c29 100644 --- a/lib/services/load_priority.dart +++ b/lib/services/load_priority.dart @@ -129,6 +129,37 @@ List tiedByPriority(List 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> sansRankOriginAuto(List> 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. /// /// `SetLoadConfig` remplace l'ensemble : supprimer une charge, c'est réécrire toutes les diff --git a/lib/services/nymea_service.dart b/lib/services/nymea_service.dart index 7d6f078..796dad5 100644 --- a/lib/services/nymea_service.dart +++ b/lib/services/nymea_service.dart @@ -11,7 +11,8 @@ import '../models/energy_data.dart'; // EnergyData, HistoryPoint, PowerBalanc import '../models/ev_charger_live.dart'; import '../models/modbus_models.dart'; // ModbusRtuMaster, SerialPort 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 ───────────────────────────────────────────────────── enum NymeaProtocol { @@ -1608,7 +1609,13 @@ class NymeaService extends ChangeNotifier { /// `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é. - Future setLoadConfig(List> payloads) async { + Future setLoadConfig(List> 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) { _log('[LoadConfig] sim/déconnecté — SetLoadConfig ignoré', force: true); return 'Disconnected'; diff --git a/test/rangs_distincts_test.dart b/test/rangs_distincts_test.dart index 24400f4..4ff6729 100644 --- a/test/rangs_distincts_test.dart +++ b/test/rangs_distincts_test.dart @@ -148,4 +148,61 @@ void main() { 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); + }); + }); }