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:
parent
8b7bfbcded
commit
e26000945d
@ -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
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user