feat(§12): LM-1209 — insertion en queue, rang unique, et la création se dit
Les trois conditions, d'un bloc. INSERTION EN QUEUE. provisionEvChargerConfig() posait rang min − 1, écrasé à 1 — donc à égalité avec la charge que l'installateur avait posée là exprès. C'est désormais 1 + max(rangs). Queue de LISTE et non de domaine : les domaines ne sont pas contigus, donc « juste après le dernier ev » exigerait de pousser une charge d'un autre domaine, ce que la condition 2 interdit. Et comme max(domaine) ≤ max(liste), la queue de liste est toujours au moins aussi reculée — elle coïncide quand le domaine occupe la fin, et sinon place la nouvelle charge derrière tout le monde plutôt que de l'intercaler devant une charge classée. Plus fidèle au principe que la formulation d'origine. UNICITÉ. validateRanks() refuse une écriture où deux charges portent le même rang. Avec la mise en garde écrite noir sur blanc : l'égalité ne coûte pas le déterminisme — le tri est total sur (rang, identifiant) depuis 3g-2 — elle coûte la gouvernabilité. S'y tromper ferait chercher le défaut dans le tri. ET LA RÈGLE GÉNÉRALE QUI EN SORT (LM-1209-a-bis) : une vérification qui ÉCARTE et une vérification qui REFUSE ne peuvent pas être la même fonction. validateSet() nomme ce qui rend l'ensemble dangereux et load() s'en sert pour écarter ; y ajouter l'unicité de rang aurait fait perdre des charges au démarrage sur une configuration héritée — disparues de la configuration ET de l'arbitrage, sans applySafeState, soit ECS-413 rouvert par une autre porte. Les deux sont séparées ; validateRanks() n'est appelée que par setConfigs(). Au chargement, un doublon hérité est signalé bruyamment et CONSERVÉ. o:rankOrigin. Énumération fermée "auto" | "user", absente par défaut. Une énumération et non un booléen parce que le troisième état est le point : `rankIsDefault: false` affirmerait que le rang a été choisi, alors que c'est ce qu'on ignore d'une configuration héritée. L'absence doit porter le « on ne sait pas » ; un booléen ne sait pas se taire. Même motif que measurement.source, et la spec le nomme désormais comme un motif de conception plutôt qu'une décision au cas par cas. Et la précision demandée en écrivant : l'effacement porte sur le RANG, jamais sur la réception d'une écriture. SetLoadConfig remplace en bloc, donc réordonner une AUTRE charge renvoie forcément l'entrée de celle-ci — l'effacer à cette occasion perdrait une marque encore vraie. L'omission ne lève rien ; pour lever sans déplacer, un client envoie "user". Un "auto" fabriqué est refusé, l'aller-retour verbatim reste licite. Trois tests. testAutoProvisionedRankGoesToTheQueue vérifié échouant avec l'insertion en tête — et il échoue en disant que la borne n'entre PAS en configuration, les deux mécanismes se répondant : le rang libre par construction et le refus d'écriture en filet. testRankOriginFollowsTheRankNotTheWrite couvre le cas qui se serait perdu, un client naïf qui omet la clé en déplaçant une autre charge. testRankUniquenessRefusesWriteButNeverDiscards couvre la moitié qui ne se voit pas : qu'un chargement conserve ce qu'il aurait pu écarter. amd64 0 erreur / 0 avertissement. simulation 31/31, charging 16/16, loadmodel 20/20, spotmarket 7/7. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
This commit is contained in:
parent
cb8ac9dc6d
commit
8927b0033c
26
AGENTS.md
26
AGENTS.md
@ -268,12 +268,26 @@ actifs.
|
||||
> continue de lire `LoadContext::telemetry.currentPowerW`, qui vient de l'adaptateur et n'a
|
||||
> jamais transité par `meterThingId`. Ne pas y voir une réouverture de la correction B.
|
||||
|
||||
**PROCHAINE ACTION** : **le rang par défaut des bornes auto-provisionnées**, qui retombe à
|
||||
égalité avec le rang 1 déjà occupé au lieu de mettre la borne « en tête »
|
||||
(`docs/RELEVE_3g2.md` §3.2). Trois issues possibles, aucune tranchée — autoriser le rang 0,
|
||||
renuméroter les autres, ou assumer l'égalité et laisser l'app poser les vrais rangs à la mise
|
||||
en service. Sans conséquence sur la reproductibilité (le tri est total), donc à décider, pas à
|
||||
réparer dans l'urgence.
|
||||
> **FAIT (2026-08-28, `1.15.2+etm27`)** — **le rang par défaut des bornes auto-provisionnées**.
|
||||
> Tranché : **insertion en queue** (`1 + max(rangs)`), jamais en tête —
|
||||
> `specs/spec_loadmodel.md` **LM-1209**. L'auto-provisionnement remplit un trou, il ne décide
|
||||
> pas : le rang est un choix de l'installateur, et une détection ne réordonne pas ce qu'un
|
||||
> humain a décidé. Ne contredit pas le défaut « le VE d'abord », qui décrit une mise en service
|
||||
> INITIALE et non un ajout ultérieur.
|
||||
>
|
||||
> Trois pièces avec : **LM-1209-a** l'unicité de rang garantie plutôt que supposée (et la mise
|
||||
> en garde qui va avec — l'égalité ne coûte pas le déterminisme, le tri est total sur
|
||||
> `(rang, identifiant)` depuis 3g-2 ; elle coûte la GOUVERNABILITÉ) ; **LM-1209-b** insérer ne
|
||||
> renumérote personne ; **LM-1209-c** `o:rankOrigin` — la création se dit.
|
||||
>
|
||||
> **Et une règle générale en est sortie, LM-1209-a-bis** : *une vérification qui ÉCARTE et une
|
||||
> vérification qui REFUSE ne peuvent pas être la même fonction.* Mettre l'unicité de rang dans
|
||||
> `validateSet()` — que `load()` utilise pour écarter — aurait fait perdre des charges au
|
||||
> démarrage sur une configuration héritée, sans `applySafeState`. `validateRanks()` est donc
|
||||
> séparée et n'est appelée que par `setConfigs()`. Migration : au chargement, **signalé
|
||||
> bruyamment, conservé, réparé à la première écriture**.
|
||||
|
||||
**PROCHAINE ACTION** : rien de tranché en attente.
|
||||
|
||||
**§10 — OUVERT.** Le design est écrit : **`docs/DESIGN_10.md`**, à valider avant tout code. Il
|
||||
porte l'ordre de travail en cinq étapes (dont deux à comportement constant, qui rendent le reste
|
||||
|
||||
45
INTERFACE.md
45
INTERFACE.md
@ -389,6 +389,43 @@ pas utiliser, et tout le waterfall en aval resterait à sec pendant qu'elle-mêm
|
||||
|
||||
---
|
||||
|
||||
#### `LoadConfig` — champ `o:rankOrigin`
|
||||
|
||||
D'où vient le **rang** d'une charge : d'un choix, ou d'un défaut d'insertion. *(§12 / LM-1209-c.)*
|
||||
|
||||
```jsonc
|
||||
{ "id": "…", "priority": 3,
|
||||
"rankOrigin": "auto" } // o: — "auto" | "user" | absent
|
||||
```
|
||||
|
||||
| Valeur | Ce qu'elle affirme | Qui l'écrit |
|
||||
|---|---|---|
|
||||
| `"auto"` | l'entrée a été **créée d'office** par le moteur, et **personne n'a jamais classé cette charge** — son rang est un défaut d'insertion (queue de liste) | **le moteur seul** |
|
||||
| `"user"` | le rang est **assumé** — la voie pour lever la marque **sans déplacer** la charge | un client |
|
||||
| *absent* | **rien n'est affirmé** — ni « choisi », ni « par défaut » | — |
|
||||
|
||||
- **Un client ne peut pas écrire `"auto"`** là où le moteur ne l'a pas posé : ce serait fabriquer
|
||||
une affirmation sur un geste du moteur, et `SetLoadConfig` refuse. L'aller-retour **verbatim**
|
||||
d'une entrée qui la porte déjà reste licite — sans quoi la neutralité `Get` → `Set` serait
|
||||
rompue.
|
||||
- **La marque tombe quand le RANG change, jamais parce qu'une écriture arrive.** `SetLoadConfig`
|
||||
remplace en bloc : réordonner une **autre** charge renvoie forcément celle-ci, et l'omission de
|
||||
la clé **ne lève rien**. Pour lever sans déplacer, envoyer `"user"` explicitement.
|
||||
- Quand elle tombe, elle tombe vers l'**absence** — le moteur constate un déplacement, il ne sait
|
||||
pas si un humain ou un script l'a voulu.
|
||||
|
||||
> **Pourquoi une énumération et pas un booléen.** `rankIsDefault: false` *affirmerait* que le rang
|
||||
> a été choisi, alors que c'est précisément ce qu'on ignore d'une configuration héritée.
|
||||
> **L'absence doit pouvoir porter le « on ne sait pas »**, et un booléen ne sait pas se taire.
|
||||
> Même motif de conception que `measurement.source` : publier le régime, et ne jamais laisser un
|
||||
> silence passer pour une affirmation.
|
||||
|
||||
> **Ce qu'un client en fait** : afficher « rang par défaut » **uniquement** sur un `"auto"`
|
||||
> explicite — une entrée créée d'office prend une place dans l'ordre de service, et cela ne doit
|
||||
> pas se produire en silence. Sur tout le reste, ne rien affirmer.
|
||||
|
||||
---
|
||||
|
||||
#### `LoadConfig` — champs `o:meterThingId` et `o:sensorThingId`
|
||||
|
||||
Deux Things de **mesure**, optionnels, de même forme. Ils décrivent ce qu'on **observe** d'une
|
||||
@ -419,6 +456,14 @@ charge — jamais ce qu'on lui **commande**.
|
||||
une corvée là où le moteur peut déduire — un client avisé le **masque par défaut** sur les
|
||||
charges dont `source` vaut déjà `device`, sans l'interdire. **Cette règle d'affichage se lit
|
||||
sur `measurement.source`, jamais sur `adapter`** : voir la note sous `measurement`.
|
||||
- **`priority` est UNIQUE, et le contrat le garantit** *(LM-1209-a)*. `SetLoadConfig` **refuse**
|
||||
en bloc un ensemble où deux charges portent le même rang. Ce que l'égalité coûte n'est PAS le
|
||||
déterminisme — le waterfall trie sur `(rang, identifiant)`, donc l'ordre est stable — c'est
|
||||
qu'il devient **non réglable** : il se décide sur des identifiants, c'est-à-dire sur personne.
|
||||
> **Une configuration HÉRITÉE à rangs doublés est conservée, jamais amputée.** Le contrôle
|
||||
> d'unicité ne s'applique qu'à l'écriture ; au chargement, le doublon est signalé au journal et
|
||||
> les deux charges restent. La première écriture le répare. L'inverse ferait disparaître des
|
||||
> charges au redémarrage — de la configuration *et* de l'arbitrage, sans état sûr.
|
||||
- **Ni l'un ni l'autre n'est exclusif**, contrairement à `relays[]`. Ils mesurent, ils ne
|
||||
commandent pas : `validateSet()` les ignore, et deux charges peuvent partager une sonde de
|
||||
pièce ou un compteur de tableau divisionnaire.
|
||||
|
||||
@ -1,3 +1,48 @@
|
||||
powersync-energy-plugin-nymea (1.15.2+etm27) trixie; urgency=medium
|
||||
|
||||
* L'AUTO-PROVISIONNEMENT REMPLIT UN TROU, IL NE DÉCIDE PAS (LM-1209). Une borne détectée
|
||||
entrait EN TÊTE de la liste — rang min − 1, écrasé à 1 — donc à égalité avec la charge que
|
||||
l'installateur avait posée là exprès. Elle entre désormais EN QUEUE : 1 + max(rangs). Le rang
|
||||
est un choix de l'installateur ; une détection ne réordonne pas ce qu'un humain a décidé.
|
||||
* Ne contredit pas le défaut « le VE d'abord » : celui-là décrit une MISE EN SERVICE INITIALE,
|
||||
où personne n'a encore rien classé — liste vide, max + 1 vaut 1. Un ajout ultérieur est une
|
||||
autre situation : il existe un classement et il fait autorité.
|
||||
* Queue de LISTE et non de DOMAINE, et c'est plus fidèle au principe : les domaines ne sont pas
|
||||
contigus, donc « juste après le dernier ev » exigerait de pousser une charge d'un autre
|
||||
domaine. Comme max(domaine) ≤ max(liste), la queue de liste est toujours au moins aussi
|
||||
reculée — elle coïncide quand le domaine occupe la fin, et sinon place la nouvelle charge
|
||||
derrière tout le monde plutôt que de l'intercaler devant une charge classée.
|
||||
* UNICITÉ DE RANG GARANTIE, PAS SUPPOSÉE (LM-1209-a). SetLoadConfig refuse en bloc un ensemble
|
||||
à rangs doublés. Ce que l'égalité coûte n'est PAS le déterminisme — le waterfall trie sur
|
||||
(rang, identifiant) depuis 3g-2, l'ordre est stable — c'est qu'il devient NON RÉGLABLE : il
|
||||
se décide sur des identifiants, donc sur personne. Gouvernabilité, pas déterminisme.
|
||||
* UNE VÉRIFICATION QUI ÉCARTE ET UNE QUI REFUSE NE PEUVENT PAS ÊTRE LA MÊME FONCTION
|
||||
(LM-1209-a-bis, règle générale). load() applique validateSet() en ÉCARTANT l'entrée fautive
|
||||
— bon geste pour un conflit d'organe. Y ajouter l'unicité de rang aurait fait perdre des
|
||||
charges au démarrage sur une configuration héritée : disparues de la configuration ET de
|
||||
l'arbitrage, sans applySafeState, sur la foi d'une règle resserrée après coup. validateRanks()
|
||||
est donc séparée et n'est appelée que par setConfigs(). Migration : au chargement, doublon
|
||||
signalé bruyamment et CONSERVÉ ; la première écriture cliente répare.
|
||||
* LA CRÉATION SE DIT : o:rankOrigin, énumération fermée "auto" | "user", absente par défaut
|
||||
(LM-1209-c). Une entrée créée d'office prend une place dans l'ordre de service ; sans marque,
|
||||
c'est un changement d'arbitrage silencieux, et le journal ne suffit pas — l'app ne le lit pas
|
||||
et c'est elle qui montre le classement.
|
||||
* Énumération et non booléen : `rankIsDefault: false` AFFIRMERAIT que le rang a été choisi,
|
||||
alors que c'est ce qu'on ignore d'une configuration héritée. L'absence doit porter le « on ne
|
||||
sait pas », et un booléen ne sait pas se taire. Même motif que measurement.source.
|
||||
* L'EFFACEMENT PORTE SUR LE RANG, JAMAIS SUR L'ÉCRITURE REÇUE. SetLoadConfig remplace en bloc :
|
||||
réordonner une AUTRE charge renvoie forcément l'entrée de celle-ci, et l'effacer à cette
|
||||
occasion perdrait une marque encore vraie. L'omission de la clé ne lève rien ; pour lever
|
||||
sans déplacer, un client envoie "user". Un "auto" fabriqué par un client est refusé, mais
|
||||
l'aller-retour verbatim reste licite — sans quoi la neutralité Get→Set serait rompue.
|
||||
* Éprouvé par testAutoProvisionedRankGoesToTheQueue (simulation), vérifié échouant avec
|
||||
l'insertion en tête, et par testRankUniquenessRefusesWriteButNeverDiscards +
|
||||
testRankOriginFollowsTheRankNotTheWrite (loadmodel) — dont la moitié qui ne se voit pas :
|
||||
qu'un chargement CONSERVE ce qu'il aurait pu écarter. Suite complète : simulation 31/31,
|
||||
charging 16/16, loadmodel 20/20, spotmarket 7/7. amd64 0 erreur / 0 avertissement.
|
||||
|
||||
-- Patrick Schurig <etm.schurig@gmail.com> Fri, 28 Aug 2026 17:40:00 +0200
|
||||
|
||||
powersync-energy-plugin-nymea (1.15.2+etm26) trixie; urgency=medium
|
||||
|
||||
* LA MESURE D'UNE CHARGE SE LIT SUR L'APPAREIL PILOTÉ (LM-1105). Il fallait jusqu'ici désigner
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
**Contexte** : `docs/RELEVE_3g2.md` (ce dépôt) porte les traces machine, cycle par cycle.
|
||||
**Contrat** : `INTERFACE.md` fait autorité — ce brief ne fait qu'en signaler les mouvements.
|
||||
|
||||
> Sept points. Le sixième répond à vos quatre constats du 28 août, le septième à votre
|
||||
> Huit points. Le sixième répond à vos quatre constats du 28 août, le septième à votre
|
||||
> constat d'usage sur le compteur par charge — et celui-là **bouge un champ du contrat**.
|
||||
> Le deuxième **retire une mise en garde** que vous portiez — du code conditionnel
|
||||
> peut disparaître. Le cinquième répond à **votre** question sur `targetPercentage`, et c'est
|
||||
@ -368,6 +368,54 @@ l'app impose l'inverse.
|
||||
|
||||
---
|
||||
|
||||
## 8. Le rang d'une charge créée d'office — un champ de plus, et un piège d'écriture
|
||||
|
||||
*(Décision Patrick, 2026-08-28 : `spec_loadmodel.md` **LM-1209**. Contrat : `INTERFACE.md`.
|
||||
Livré en `1.15.2+etm27`.)*
|
||||
|
||||
**Ce qui change dans le moteur** : une borne détectée entre désormais **en queue** de la liste,
|
||||
plus en tête. Elle ne passe devant aucune charge que quelqu'un a classée — une détection ne
|
||||
réordonne pas ce qu'un humain a décidé. Corollaire pratique pour vous : **le rang produit est
|
||||
toujours libre**, et `priority` est maintenant **unique**, garanti par le contrat au lieu d'être
|
||||
supposé.
|
||||
|
||||
> ⚠️ **`SetLoadConfig` REFUSE désormais en bloc un ensemble à rangs doublés.** Si votre écran de
|
||||
> réordonnancement peut produire deux charges au même rang — ne serait-ce qu'un instant, pendant
|
||||
> un glisser-déposer — l'écriture entière sera rejetée. À vérifier chez vous.
|
||||
>
|
||||
> Une configuration **héritée** à rangs doublés, elle, n'est pas amputée : elle est conservée au
|
||||
> chargement et signalée au journal. Votre première écriture la répare.
|
||||
|
||||
**Le champ nouveau : `o:rankOrigin`.** `"auto"` dit « cette entrée a été créée d'office et
|
||||
personne n'a jamais classé cette charge » — c'est ce qui vous permet d'afficher **« rang par
|
||||
défaut »** au lieu de laisser croire à un choix. `"user"` dit que le rang est assumé. **Absent
|
||||
n'affirme rien**, et c'est volontaire : une configuration héritée ne prétend pas que ses rangs
|
||||
ont été décidés. N'affichez le badge que sur un `"auto"` **explicite**.
|
||||
|
||||
> C'est le même motif de conception que `measurement.source`, un cran plus loin : **publier le
|
||||
> régime, et ne jamais laisser un silence passer pour une affirmation.** Une énumération plutôt
|
||||
> qu'un booléen précisément pour ça — `rankIsDefault: false` affirmerait que le rang a été
|
||||
> choisi, alors que c'est ce qu'on ignore.
|
||||
|
||||
### Le piège d'écriture, et c'est le point à retenir
|
||||
|
||||
`SetLoadConfig` **remplace en bloc**. Quand l'installateur déplace la charge B, vous renvoyez
|
||||
aussi l'entrée de la charge A — et A n'a toujours été classée par personne.
|
||||
|
||||
**Le moteur protège ce cas** : la marque tombe quand **le rang de cette charge-là** change, jamais
|
||||
parce qu'une écriture est arrivée. Vous pouvez donc omettre la clé sans rien casser — l'omission
|
||||
**ne lève rien**.
|
||||
|
||||
Deux conséquences pour vous :
|
||||
|
||||
- **Pour lever la marque sans déplacer la charge** — « j'ai vu ce défaut, il me va » — envoyez
|
||||
`"user"` explicitement. C'est la seule voie.
|
||||
- **N'écrivez jamais `"auto"`.** Le moteur refuse l'écriture entière : ce serait affirmer un geste
|
||||
qu'il n'a pas fait. Renvoyer verbatim une entrée qui le porte déjà reste licite — l'aller-retour
|
||||
`Get` → `Set` n'est pas cassé.
|
||||
|
||||
---
|
||||
|
||||
## Ce qui n'a PAS changé, et qu'il ne faut pas déduire
|
||||
|
||||
- **L'échéance de départ et le tarif dynamique restent au proxy**, financés au réseau
|
||||
|
||||
@ -76,6 +76,20 @@ bool LoadConfigStore::load()
|
||||
}
|
||||
loaded.append(c);
|
||||
}
|
||||
// LM-1209-a — les doublons de rang sont SIGNALÉS, jamais écartés. Le contrôle de bonne
|
||||
// forme (validateRanks) ne s'applique qu'aux écritures : ici, écarter ferait disparaître
|
||||
// des charges de la configuration ET de l'arbitrage, sans applySafeState, sur la seule foi
|
||||
// d'une règle de contrat resserrée après coup. La première écriture cliente répare.
|
||||
{
|
||||
QString pourquoi;
|
||||
if (!validateRanks(loaded, &pourquoi))
|
||||
qCWarning(dcNymeaEnergy())
|
||||
<< "[LoadConfigStore] CONFIGURATION CONSERVÉE MAIS MAL FORMÉE :" << pourquoi
|
||||
<< "— l'ordre de service reste reproductible (tri sur rang PUIS identifiant),"
|
||||
<< "mais il n'est PAS réglable tant que les rangs sont à égalité : il obéit à"
|
||||
<< "des identifiants, donc à personne. À réparer par un SetLoadConfig.";
|
||||
}
|
||||
|
||||
m_configs = loaded;
|
||||
return true;
|
||||
}
|
||||
@ -111,6 +125,71 @@ bool LoadConfigStore::validateSet(const LoadConfigs &configs, QString *error)
|
||||
return true;
|
||||
}
|
||||
|
||||
bool LoadConfigStore::validateRanks(const LoadConfigs &configs, QString *error)
|
||||
{
|
||||
QHash<int, QString> parRang;
|
||||
for (const LoadConfig &c : configs) {
|
||||
const QString deja = parRang.value(c.priority());
|
||||
if (!deja.isEmpty()) {
|
||||
if (error)
|
||||
*error = QStringLiteral("les charges \"%1\" et \"%2\" portent toutes deux le rang "
|
||||
"%3 : leur ordre de service serait stable mais NON "
|
||||
"RÉGLABLE — il se déciderait sur leurs identifiants")
|
||||
.arg(deja, c.label()).arg(c.priority());
|
||||
return false;
|
||||
}
|
||||
parRang.insert(c.priority(), c.label());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool LoadConfigStore::reconcileRankOrigin(const LoadConfigs &incoming, LoadConfigs *out,
|
||||
QString *error) const
|
||||
{
|
||||
QHash<QString, const LoadConfig *> stocke;
|
||||
for (const LoadConfig &c : m_configs)
|
||||
stocke.insert(c.id(), &c);
|
||||
|
||||
LoadConfigs resultat;
|
||||
for (LoadConfig c : incoming) {
|
||||
const LoadConfig *avant = stocke.value(c.id(), nullptr);
|
||||
const QString marqueAvant = avant ? avant->rankOrigin() : QString();
|
||||
|
||||
// Règle 3 — un client ne FABRIQUE pas un "auto". La seule tolérance est l'aller-retour
|
||||
// verbatim d'une entrée qui le porte déjà : sans elle, la neutralité Get→Set du
|
||||
// contrat serait rompue et tout client renvoyant ce qu'il a lu se verrait refusé.
|
||||
if (c.rankOrigin() == QLatin1String("auto")
|
||||
&& marqueAvant != QLatin1String("auto")) {
|
||||
if (error)
|
||||
*error = QStringLiteral("la charge \"%1\" prétend un rankOrigin \"auto\" que le "
|
||||
"moteur n'a pas posé : c'est une affirmation sur un geste "
|
||||
"du moteur, elle ne s'écrit pas depuis un client")
|
||||
.arg(c.label());
|
||||
return false;
|
||||
}
|
||||
|
||||
if (avant && c.priority() != avant->priority()) {
|
||||
// Règle 1 — le rang a CHANGÉ : quelqu'un a classé la charge. La marque tombe, et
|
||||
// elle tombe vers le VIDE, pas vers "user" : le moteur constate un déplacement, il
|
||||
// ne sait pas si un humain ou un script l'a voulu. Ne rien affirmer est le
|
||||
// minimum honnête (LM-1209-c).
|
||||
c.setRankOrigin(QString());
|
||||
} else if (avant && c.rankOrigin().isEmpty()) {
|
||||
// Règle 2 — rang INCHANGÉ et marque omise : on CONSERVE. SetLoadConfig remplace en
|
||||
// bloc, donc réordonner une AUTRE charge renvoie forcément celle-ci ; l'effacer à
|
||||
// cette occasion perdrait une marque encore vraie. L'effacement porte sur le RANG,
|
||||
// jamais sur la réception d'une écriture.
|
||||
c.setRankOrigin(marqueAvant);
|
||||
}
|
||||
// Reste le cas « rang inchangé, marque explicite » : elle est prise telle quelle —
|
||||
// c'est ainsi qu'un client lève la marque en envoyant "user", sans déplacer la charge.
|
||||
resultat.append(c);
|
||||
}
|
||||
if (out)
|
||||
*out = resultat;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool LoadConfigStore::setConfigs(const LoadConfigs &configs, QString *error)
|
||||
{
|
||||
// Validation EN BLOC : une seule entrée invalide → rejet total (rien persisté).
|
||||
@ -125,6 +204,11 @@ bool LoadConfigStore::setConfigs(const LoadConfigs &configs, QString *error)
|
||||
if (!validateSet(configs, error))
|
||||
return false;
|
||||
|
||||
// …puis la BONNE FORME, qui ne s'applique qu'ici. load() ne l'appelle pas : elle refuse
|
||||
// une écriture, elle n'écarte jamais une entrée déjà persistée (LM-1209-a).
|
||||
if (!validateRanks(configs, error))
|
||||
return false;
|
||||
|
||||
const LoadConfigs previous = m_configs;
|
||||
m_configs = configs;
|
||||
|
||||
|
||||
@ -65,6 +65,49 @@ public:
|
||||
*/
|
||||
static bool validateSet(const LoadConfigs &configs, QString *error = nullptr);
|
||||
|
||||
/*!
|
||||
* \brief Contrôle de BONNE FORME, appliqué aux ÉCRITURES et à elles seules. §12 / LM-1209-a.
|
||||
*
|
||||
* \param configs Ensemble candidat. \param error Motif du refus, si refus.
|
||||
* \return Faux si deux charges portent le même rang.
|
||||
*
|
||||
* \note **Distincte de \c validateSet(), et la distinction est de sûreté, pas de propreté.**
|
||||
* \c validateSet() nomme ce qui rend un ensemble DANGEREUX — deux charges sur un même
|
||||
* organe — et \c load() s'en sert pour ÉCARTER l'entrée fautive. Celle-ci nomme ce qui
|
||||
* rend un ensemble MAL FORMÉ, et le seul geste correct y est de REFUSER l'écriture
|
||||
* entière. Les fusionner ferait écarter au démarrage les entrées d'une configuration
|
||||
* héritée à rangs doublés : des charges disparaîtraient de la configuration ET de
|
||||
* l'arbitrage, sans \c applySafeState — une règle de contrat convertie en perte de
|
||||
* configuration silencieuse.
|
||||
* \note N'est donc JAMAIS appelée par \c load(). Un doublon hérité est **signalé
|
||||
* bruyamment et CONSERVÉ** ; la première écriture cliente le répare.
|
||||
*/
|
||||
static bool validateRanks(const LoadConfigs &configs, QString *error = nullptr);
|
||||
|
||||
/*!
|
||||
* \brief Réconcilie \c rankOrigin d'une écriture CLIENTE avec l'état persisté. LM-1209-c.
|
||||
*
|
||||
* \param incoming Ensemble reçu du client. \param error Motif du refus, si refus.
|
||||
* \param[out] out Ensemble à persister, \c rankOrigin réconcilié.
|
||||
* \return Faux si le client tente de FABRIQUER un \c "auto".
|
||||
*
|
||||
* Trois règles, et la deuxième est celle qu'on se serait trompé à écrire :
|
||||
* 1. Rang CHANGÉ pour cet id → la marque est **effacée** : quelqu'un a classé la charge.
|
||||
* 2. Rang INCHANGÉ → la marque est **conservée telle quelle**, même si le client l'omet.
|
||||
* `SetLoadConfig` remplace en bloc : réordonner une AUTRE charge renvoie forcément
|
||||
* l'entrée de celle-ci, et l'effacer à cette occasion perdrait une marque encore
|
||||
* vraie — la charge n'a toujours été classée par personne. **L'effacement porte sur le
|
||||
* RANG, jamais sur la réception d'une écriture.**
|
||||
* 3. \c "auto" reçu là où le persisté ne l'a pas → **refus**. Un client ne fabrique pas
|
||||
* une affirmation sur un geste du moteur. L'aller-retour verbatim d'une entrée qui la
|
||||
* porte déjà reste licite, sans quoi la neutralité Get→Set serait rompue.
|
||||
*
|
||||
* \note Un client lève la marque SANS déplacer la charge en envoyant \c "user" — c'est la
|
||||
* seule voie, et elle est explicite. L'omission ne lève rien (règle 2).
|
||||
*/
|
||||
bool reconcileRankOrigin(const LoadConfigs &incoming, LoadConfigs *out,
|
||||
QString *error = nullptr) const;
|
||||
|
||||
signals:
|
||||
/*! \brief Émis après une persistance réussie de \c setConfigs(). */
|
||||
void changed();
|
||||
|
||||
@ -743,15 +743,26 @@ void EnergyArbitrator::provisionEvChargerConfig(EvCharger *ev)
|
||||
const QString id = ev->thing()->id().toString();
|
||||
LoadConfigs configs = m_loadConfigStore->configs();
|
||||
|
||||
int rangMin = 0;
|
||||
bool premier = true;
|
||||
// LM-1209 — EN QUEUE, jamais en tête. L'auto-provisionnement REMPLIT UN TROU : sans entrée,
|
||||
// la borne serait arbitrée sans rang réglable. Il ne s'ensuit rien sur sa PLACE — le rang
|
||||
// est un choix de l'installateur, et une détection ne réordonne pas ce qu'un humain a
|
||||
// décidé. Une borne détectée un mois après la mise en service passerait sinon devant l'ECS
|
||||
// que quelqu'un a réglé exprès.
|
||||
//
|
||||
// Ne contredit PAS le défaut « le VE d'abord » : celui-là décrit une MISE EN SERVICE
|
||||
// INITIALE, où personne n'a encore rien classé — ici la liste est vide et max+1 vaut 1.
|
||||
//
|
||||
// Queue de LISTE et non queue du DOMAINE, et c'est plus fidèle au principe : les domaines
|
||||
// ne sont pas contigus, donc « juste après le dernier ev » exigerait de pousser une charge
|
||||
// d'un autre domaine (interdit, LM-1209-b). Comme max(domaine) ≤ max(liste), la queue de
|
||||
// liste est TOUJOURS au moins aussi reculée — elle coïncide quand le domaine occupe la
|
||||
// fin, et sinon elle place la nouvelle charge derrière tout le monde plutôt que de
|
||||
// l'intercaler devant une charge classée.
|
||||
int rangMax = 0;
|
||||
for (const LoadConfig &c : configs) {
|
||||
if (c.id() == id)
|
||||
return; // déjà configurée : on ne touche à rien
|
||||
if (premier || c.priority() < rangMin) {
|
||||
rangMin = c.priority();
|
||||
premier = false;
|
||||
}
|
||||
rangMax = qMax(rangMax, c.priority());
|
||||
}
|
||||
|
||||
LoadConfig c;
|
||||
@ -763,8 +774,14 @@ void EnergyArbitrator::provisionEvChargerConfig(EvCharger *ev)
|
||||
// « le plugin ne range pas une charge d'office » vise les charges pilotées, dont le domaine
|
||||
// ne se lit nulle part.
|
||||
c.setDomain(QStringLiteral("ev"));
|
||||
// En tête, sans réécrire le rang de personne. 1 est le premier rang (protocole §5).
|
||||
c.setPriority(premier ? 1 : qMax(1, rangMin - 1));
|
||||
// 1 est le premier rang (protocole §5) : liste vide → 1. Libre par construction, donc
|
||||
// l'égalité de rang est impossible par ce chemin (LM-1209-a) — et AUCUN autre rang ne
|
||||
// bouge (LM-1209-b) : insérer est un geste de configuration, pas un geste d'arbitrage.
|
||||
c.setPriority(qMax(1, rangMax + 1));
|
||||
// LM-1209-c — la création se DIT. Le journal ne suffit pas : l'app ne le lit pas, et c'est
|
||||
// elle qui montre le classement. Cette marque distingue « ce rang est un défaut » de « ce
|
||||
// rang est un choix » — un client ne peut pas le deviner, rien dans le nombre ne le dit.
|
||||
c.setRankOrigin(QStringLiteral("auto"));
|
||||
c.setEnabled(true);
|
||||
configs.append(c);
|
||||
|
||||
@ -781,8 +798,9 @@ void EnergyArbitrator::provisionEvChargerConfig(EvCharger *ev)
|
||||
|
||||
qCInfo(dcNymeaEnergy()) << "[EnergyArbitrator] Borne" << ev->name()
|
||||
<< "ajoutée à la configuration des charges (rang" << c.priority()
|
||||
<< ", domaine ev) — entrée créée automatiquement, modifiable par "
|
||||
"SetLoadConfig.";
|
||||
<< ", domaine ev, rankOrigin=auto) — entrée créée automatiquement, "
|
||||
"insérée EN QUEUE sans déplacer personne. Le rang est un défaut, "
|
||||
"pas un choix : modifiable par SetLoadConfig.";
|
||||
}
|
||||
|
||||
void EnergyArbitrator::syncAdapters()
|
||||
|
||||
@ -95,6 +95,14 @@ bool LoadConfigSgReady::hasState(int s) const
|
||||
|
||||
// ---- LoadConfig ------------------------------------------------------------
|
||||
|
||||
QStringList LoadConfig::knownRankOrigins()
|
||||
{
|
||||
// Énumération FERMÉE, déclarée UNE fois — même règle que knownDomains(). Le VIDE n'en fait
|
||||
// pas partie et c'est délibéré : il ne vaut pas « autre origine », il vaut « rien n'est
|
||||
// affirmé ». Le faire entrer dans l'énumération en ferait une troisième affirmation.
|
||||
return { QStringLiteral("auto"), QStringLiteral("user") };
|
||||
}
|
||||
|
||||
QStringList LoadConfig::knownDomains()
|
||||
{
|
||||
// Énumération FERMÉE, déclarée UNE fois. isValid() et la documentation en dérivent ; les
|
||||
@ -208,6 +216,15 @@ bool LoadConfig::isValid(QString *error) const
|
||||
"pour une charge non classée)")
|
||||
.arg(m_domain, knownDomains().join(QStringLiteral(", "))));
|
||||
|
||||
// Origine du rang (LM-1209-c) : même règle d'énumération fermée. Le vide reste licite — il
|
||||
// n'affirme rien. Ce qu'isValid() ne peut PAS vérifier, c'est QUI écrit "auto" : la
|
||||
// question n'a de sens que face à l'état PERSISTÉ, et elle est donc tranchée à la
|
||||
// frontière d'écriture (LoadConfigStore::reconcileRankOrigin).
|
||||
if (!m_rankOrigin.isEmpty() && !knownRankOrigins().contains(m_rankOrigin))
|
||||
return fail(QStringLiteral("rankOrigin \"%1\" hors énumération (attendu : %2, ou absent "
|
||||
"— l'absence n'affirme rien)")
|
||||
.arg(m_rankOrigin, knownRankOrigins().join(QStringLiteral(", "))));
|
||||
|
||||
// LM-300 — UNION DISCRIMINÉE par mécanisme. Le discriminant est \c adapter ; chaque
|
||||
// mécanisme a UNE charge utile, et les charges utiles des autres mécanismes DOIVENT être
|
||||
// absentes (LM-302 : les états invalides doivent être inexprimables). Ajouter un
|
||||
@ -360,6 +377,10 @@ QVariantMap LoadConfig::toMap() const
|
||||
// domaine inconnu plutôt que comme une absence de classement (règle « omis, jamais nul »).
|
||||
if (!m_domain.isEmpty())
|
||||
m.insert("domain", m_domain);
|
||||
// Même règle : omis quand vide, jamais une chaîne vide persistée. Un rankOrigin vide dit
|
||||
// « rien d'affirmé » — le persister ferait de ce silence une valeur.
|
||||
if (!m_rankOrigin.isEmpty())
|
||||
m.insert("rankOrigin", m_rankOrigin);
|
||||
// Mêmes règles que le domaine : omis quand vide, jamais une chaîne vide persistée.
|
||||
if (m_minPowerW > 0)
|
||||
m.insert("minPowerW", m_minPowerW);
|
||||
@ -395,6 +416,7 @@ LoadConfig LoadConfig::fromMap(const QVariantMap &map)
|
||||
c.m_adapter = map.value("adapter", QStringLiteral("etmvariableload")).toString();
|
||||
c.m_mode = map.value("mode").toString();
|
||||
c.m_domain = map.value("domain").toString(); // absent → vide → charge non classée
|
||||
c.m_rankOrigin = map.value("rankOrigin").toString(); // absent → vide → rien d'affirmé
|
||||
// Aucun contrôle d'EXISTENCE ici, à dessein : LoadConfig est un type pur, sans
|
||||
// ThingManager. Et refuser un ThingId encore inconnu imposerait un ORDRE aux opérations —
|
||||
// déclarer le compteur avant la charge — que rien ne justifie. Un id qui ne résout pas
|
||||
|
||||
@ -131,6 +131,8 @@ class LoadConfig
|
||||
Q_PROPERTY(QString mode READ mode WRITE setMode)
|
||||
//! Catégorie d'intention déclarée par l'installateur — énumération FERMÉE, code jamais libellé.
|
||||
Q_PROPERTY(QString domain READ domain WRITE setDomain)
|
||||
//! Origine du RANG — "auto" | "user" | vide. LM-1209-c. Vide = rien d'affirmé.
|
||||
Q_PROPERTY(QString rankOrigin READ rankOrigin WRITE setRankOrigin)
|
||||
Q_PROPERTY(QVariantList powerLevels READ powerLevels WRITE setPowerLevels)
|
||||
Q_PROPERTY(int maxPowerW READ maxPowerW WRITE setMaxPowerW)
|
||||
Q_PROPERTY(int priority READ priority WRITE setPriority)
|
||||
@ -210,6 +212,34 @@ public:
|
||||
QString meterThingId() const { return m_meterThingId; }
|
||||
void setMeterThingId(const QString &v) { m_meterThingId = v; }
|
||||
|
||||
/*!
|
||||
* \brief Origine du RANG de cette charge — \c "auto", \c "user", ou vide. §12 / LM-1209-c.
|
||||
*
|
||||
* - \c "auto" : l'entrée a été créée d'office par le moteur et **son rang n'a jamais été
|
||||
* choisi par personne** — c'est un défaut d'insertion (queue de liste), pas une décision.
|
||||
* **Seul le moteur écrit cette valeur.** Un client qui la poserait fabriquerait une
|
||||
* affirmation sur un geste du moteur ; la seule tolérance est l'aller-retour verbatim
|
||||
* d'une entrée qui la porte déjà.
|
||||
* - \c "user" : le rang est **assumé**. C'est la façon pour un client de lever la marque
|
||||
* *sans déplacer la charge* — « j'ai vu ce défaut et il me va ».
|
||||
* - **vide / absent : rien n'est affirmé.** Ni « choisi », ni « par défaut ». Une
|
||||
* configuration héritée ne prétend donc pas que ses rangs ont été décidés, et un client
|
||||
* n'affiche « rang par défaut » que sur un \c "auto" EXPLICITE.
|
||||
*
|
||||
* \note **L'absence n'affirme rien** — c'est le même motif de conception que
|
||||
* \c measurement.source (§11 / LM-1106), où \c "none" AFFIRME « pas mesurable » quand
|
||||
* l'absence de l'entrée n'affirme rien. Publier le régime plutôt que le déduire d'une
|
||||
* valeur, et ne jamais laisser un silence passer pour une affirmation.
|
||||
* \note **L'effacement porte sur le RANG, pas sur l'écriture.** Réordonner d'AUTRES charges
|
||||
* laisse celle-ci en \c "auto" : elle n'a toujours été classée par personne. Voir
|
||||
* \c LoadConfigStore::reconcileRankOrigin().
|
||||
*/
|
||||
QString rankOrigin() const { return m_rankOrigin; }
|
||||
void setRankOrigin(const QString &v) { m_rankOrigin = v; }
|
||||
|
||||
//! \brief Valeurs admises de \c rankOrigin. Énumération FERMÉE, déclarée UNE fois.
|
||||
static QStringList knownRankOrigins();
|
||||
|
||||
/*!
|
||||
* \brief Sonde du service rendu (température). Vide = aucune. **Lecture seule.**
|
||||
*
|
||||
@ -357,6 +387,7 @@ private:
|
||||
//! mesure, il ne commande pas (LM-1104), donc deux charges peuvent le partager.
|
||||
uint m_minPowerW = 0; //!< Plancher de modulation (W) ; 0 = aucun (§12).
|
||||
QString m_meterThingId;
|
||||
QString m_rankOrigin;
|
||||
//! Sonde de température ; vide = aucune. Même règle d'exclusivité que le compteur.
|
||||
QString m_sensorThingId;
|
||||
QList<int> m_powerLevels; //!< Trié croissant, 0 inclus en *fixed* ; vide en *dynamic*.
|
||||
|
||||
@ -201,6 +201,15 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
|
||||
loadItem.insert("o:meterThingId", enumValueName(Uuid));
|
||||
loadItem.insert("o:sensorThingId", enumValueName(Uuid));
|
||||
loadItem.insert("priority", enumValueName(Uint));
|
||||
// §12 (LM-1209-c) — origine du RANG : "auto" | "user". Absent = rien d'affirmé, ni
|
||||
// « choisi » ni « par défaut ». "auto" AFFIRME que le moteur a créé l'entrée d'office et
|
||||
// que personne n'a jamais classé cette charge ; seul le moteur l'écrit, et un client qui
|
||||
// la poserait fabriquerait une affirmation sur un geste du moteur (refusé). Un client lève
|
||||
// la marque sans déplacer la charge en envoyant "user" — l'omission, elle, ne lève rien :
|
||||
// SetLoadConfig remplace en bloc, et réordonner une AUTRE charge renvoie forcément
|
||||
// celle-ci. Même motif de conception que measurement.source : le régime se publie, et une
|
||||
// absence n'affirme rien.
|
||||
loadItem.insert("o:rankOrigin", enumValueName(String));
|
||||
loadItem.insert("enabled", enumValueName(Bool));
|
||||
loadItem.insert("o:powerLevels", QVariantList() << enumValueName(Uint)); // etmvariableload fixed
|
||||
loadItem.insert("o:maxPowerW", enumValueName(Uint)); // etmvariableload dynamic
|
||||
@ -642,6 +651,16 @@ JsonReply *NymeaEnergyJsonHandler::SetLoadConfig(const QVariantMap ¶ms)
|
||||
configs.append(LoadConfig::fromMap(item.toMap()));
|
||||
|
||||
QString why;
|
||||
// LM-1209-c — la marque de rang se réconcilie ICI, à la frontière CLIENTE, et nulle part
|
||||
// ailleurs : c'est la seule couche qui sache qu'une écriture vient du dehors. Le moteur,
|
||||
// lui, pose légitimement "auto" en créant une entrée d'office.
|
||||
LoadConfigs reconciliees;
|
||||
if (!m_loadConfigStore->reconcileRankOrigin(configs, &reconciliees, &why)) {
|
||||
qCWarning(dcNymeaEnergy()) << "[NymeaEnergy] SetLoadConfig rejetée:" << why;
|
||||
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)}});
|
||||
}
|
||||
configs = reconciliees;
|
||||
|
||||
if (!m_loadConfigStore->setConfigs(configs, &why)) {
|
||||
qCWarning(dcNymeaEnergy()) << "[NymeaEnergy] SetLoadConfig rejetée:" << why;
|
||||
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)}});
|
||||
|
||||
@ -1334,6 +1334,35 @@ d'insertion : une écriture cliente doit être **refusée** si elle pose deux ch
|
||||
> et non réglable** : il obéit à des identifiants, c'est-à-dire à personne. C'est cela que
|
||||
> l'unicité corrige, et c'est une exigence de gouvernabilité, pas de déterminisme.
|
||||
|
||||
**LM-1209-a-bis — Une vérification qui ÉCARTE et une vérification qui REFUSE ne peuvent pas
|
||||
être la même fonction.** *(Règle générale, posée le 2026-08-28 sur un piège trouvé en
|
||||
implantant LM-1209-a. Elle vaut au-delà des rangs.)*
|
||||
|
||||
Le moteur porte deux familles de contrôles, et leur seul geste correct est **opposé** :
|
||||
|
||||
| Famille | Ce qu'elle nomme | Geste correct | Où |
|
||||
|---|---|---|---|
|
||||
| **Sûreté d'ensemble** | l'ensemble est **dangereux** — deux charges commandent le même organe, deux commandes contradictoires | **ÉCARTER** l'entrée fautive et continuer | chargement **et** écriture |
|
||||
| **Bonne forme** | l'ensemble est **mal formé** — deux charges au même rang | **REFUSER** l'écriture entière | écriture **seulement** |
|
||||
|
||||
**Les fusionner convertit une règle de contrat en perte de configuration silencieuse.**
|
||||
`LoadConfigStore::load()` applique la vérification d'ensemble en écartant les entrées fautives —
|
||||
« tolérance ascendante », et c'est le bon geste pour un conflit d'organe : une charge absente ne
|
||||
commande rien, deux adaptateurs sur un relais se contrediraient. Y ajouter l'unicité de rang
|
||||
ferait qu'une configuration héritée à rangs doublés **perdrait des charges au démarrage** :
|
||||
disparues de la configuration *et* de l'arbitrage, **sans `applySafeState`** — donc laissées dans
|
||||
leur dernier état commandé, exactement le trou d'ECS-413 rouvert par une autre porte. Et sur la
|
||||
foi d'une règle resserrée *après* que la configuration a été écrite.
|
||||
|
||||
**Le comportement de MIGRATION qui en découle** : au chargement, un doublon hérité est **signalé
|
||||
bruyamment et CONSERVÉ** ; la première écriture cliente le répare. Rien n'est perdu entre-temps,
|
||||
et l'installation continue de fonctionner dans l'ordre stable — quoique non réglable — qu'elle
|
||||
avait déjà.
|
||||
|
||||
> **Le test à écrire est celui de la moitié qui ne se voit pas.** Qu'une écriture mal formée soit
|
||||
> refusée se remarque tout de suite ; qu'un chargement conserve ce qu'il aurait pu écarter ne se
|
||||
> remarque jamais — sauf le jour où une box perd trois charges au redémarrage.
|
||||
|
||||
**LM-1209-b — Insérer ne renumérote personne.** Aucun autre rang ne bouge. C'est le pendant
|
||||
exact de la suppression côté app, qui laisse les rangs en place : **un geste de configuration ne
|
||||
doit pas devenir un geste d'arbitrage.** Renuméroter pour « faire de la place » modifierait
|
||||
@ -1348,7 +1377,37 @@ de **« ce rang est un choix »**.
|
||||
|
||||
C'est la même exigence que C2 de LM-1009 et que LM-1106 : **le régime se publie, il ne se déduit
|
||||
pas.** Un client ne peut pas deviner qu'un rang n'a jamais été choisi — rien dans le nombre ne le
|
||||
dit. *(Forme du champ : voir la note de contrat, à figer avant écriture.)*
|
||||
dit.
|
||||
|
||||
**La forme retenue : `o:rankOrigin`, énumération fermée `"auto" | "user"`, absente par défaut.**
|
||||
|
||||
- **`"auto"`** — l'entrée a été créée d'office et **son rang n'a jamais été choisi**. Seul le
|
||||
moteur l'écrit.
|
||||
- **`"user"`** — le rang est **assumé**. C'est la voie par laquelle un client lève la marque
|
||||
**sans déplacer la charge** : « j'ai vu ce défaut et il me va ».
|
||||
- **absente — rien n'est affirmé.** Ni « choisi », ni « par défaut ».
|
||||
|
||||
> **Une énumération et non un booléen, et la raison est le troisième état.** `rankIsDefault:
|
||||
> false` *affirmerait* que le rang a été choisi — or c'est précisément ce qu'on ignore d'une
|
||||
> configuration héritée. **L'absence doit porter le « on ne sait pas »**, et un booléen ne sait
|
||||
> pas se taire. C'est le même motif de conception que `measurement.source` (§11), et il mérite
|
||||
> d'être nommé comme tel plutôt que redécidé au cas par cas : **publier le régime, et ne jamais
|
||||
> laisser un silence passer pour une affirmation.**
|
||||
|
||||
**Trois règles de transition, et la deuxième est celle qu'on se serait trompé à écrire** :
|
||||
|
||||
1. **Rang CHANGÉ pour cet id → la marque est effacée.** Quelqu'un a classé la charge. Effacée
|
||||
vers le **vide**, pas vers `"user"` : le moteur constate un déplacement, il ne sait pas si un
|
||||
humain ou un script l'a voulu — ne rien affirmer est le minimum honnête.
|
||||
2. **Rang INCHANGÉ → la marque est CONSERVÉE, même si le client l'omet.** `SetLoadConfig`
|
||||
remplace en bloc : réordonner une **autre** charge renvoie forcément l'entrée de celle-ci, et
|
||||
l'effacer à cette occasion perdrait une marque encore vraie — la charge n'a toujours été
|
||||
classée par personne. **L'effacement porte sur le RANG, jamais sur la réception d'une
|
||||
écriture.**
|
||||
3. **`"auto"` reçu là où le persisté ne l'a pas → refus.** Un client ne fabrique pas une
|
||||
affirmation sur un geste du moteur. L'aller-retour **verbatim** d'une entrée qui la porte déjà
|
||||
reste licite — sans cette tolérance, la neutralité `Get` → `Set` serait rompue et tout client
|
||||
renvoyant ce qu'il a lu se verrait refusé.
|
||||
|
||||
**LM-1207 — Ne PAS ajouter de `priority` à `ChargingInfo`.** Ce serait un **second système de
|
||||
priorité**, et il serait immédiatement insuffisant : il ne saurait pas exprimer
|
||||
|
||||
@ -8,6 +8,11 @@
|
||||
#include "etm/config/loadconfigstore.h"
|
||||
|
||||
#include <QTest>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonArray>
|
||||
|
||||
namespace {
|
||||
|
||||
@ -385,3 +390,147 @@ void TestLoadModel::testEvChargerClaimsItsThing()
|
||||
QVERIFY2(!LoadConfigStore::validateSet(ensemble, &err),
|
||||
"deux charges actives sur le Thing d'une borne doivent être refusées");
|
||||
}
|
||||
|
||||
void TestLoadModel::testRankUniquenessRefusesWriteButNeverDiscards()
|
||||
{
|
||||
// [LM-1209-a] Deux vérifications, deux gestes — et les confondre convertirait une règle de
|
||||
// contrat en PERTE DE CONFIGURATION au démarrage.
|
||||
//
|
||||
// validateSet() nomme ce qui rend l'ensemble DANGEREUX (deux charges sur un organe) et
|
||||
// load() s'en sert pour ÉCARTER l'entrée fautive ;
|
||||
// validateRanks() nomme ce qui le rend MAL FORMÉ, et le seul geste correct est de
|
||||
// REFUSER l'écriture entière.
|
||||
QString err;
|
||||
LoadConfigs doublon;
|
||||
doublon.append(LoadConfig::fromMap(borneMap(BORNE, 1)));
|
||||
QVariantMap autre = borneMap(QStringLiteral("{dddddddd-0000-0000-0000-00000000000f}"), 1);
|
||||
autre.insert("label", "Seconde borne");
|
||||
doublon.append(LoadConfig::fromMap(autre));
|
||||
|
||||
// Chaque entrée est valide isolément, et l'ensemble n'est PAS dangereux : aucune des deux
|
||||
// ne commande le Thing de l'autre. C'est pour cela que validateSet() le laisse passer —
|
||||
// et pour cela qu'il fallait une seconde vérification, pas un durcissement de celle-ci.
|
||||
for (const LoadConfig &c : doublon)
|
||||
QVERIFY2(c.isValid(&err), qUtf8Printable(err));
|
||||
QVERIFY2(LoadConfigStore::validateSet(doublon, &err),
|
||||
"un doublon de RANG n'est pas un conflit d'organe : validateSet() ne doit pas le voir");
|
||||
|
||||
// La bonne forme, elle, refuse — et le motif doit NOMMER ce qui est perdu. Ce n'est pas le
|
||||
// déterminisme : depuis 3g-2 le tri est total sur (rang, identifiant). C'est que l'ordre
|
||||
// obéirait à des identifiants, donc à personne.
|
||||
QVERIFY2(!LoadConfigStore::validateRanks(doublon, &err),
|
||||
"deux charges au même rang doivent être refusées à l'écriture");
|
||||
QVERIFY2(err.contains(QStringLiteral("RÉGLABLE")), qUtf8Printable(err));
|
||||
|
||||
// …et une écriture entière est rejetée, pas amputée : rien n'est persisté à moitié.
|
||||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-ranks.json";
|
||||
QFile::remove(cfgPath);
|
||||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||||
{
|
||||
LoadConfigStore store;
|
||||
QVERIFY2(!store.setConfigs(doublon, &err), "setConfigs doit refuser un doublon de rang");
|
||||
QCOMPARE(store.configs().count(), 0);
|
||||
}
|
||||
|
||||
// ---- Et au CHARGEMENT : conservé, jamais écarté --------------------------------------
|
||||
// C'est la moitié qui compte. Une configuration héritée à rangs doublés existe (le banc en
|
||||
// a porté une) ; l'écarter ferait disparaître des charges de la configuration ET de
|
||||
// l'arbitrage, sans applySafeState — sur la seule foi d'une règle resserrée après coup.
|
||||
QFile f(cfgPath);
|
||||
QVERIFY(f.open(QIODevice::WriteOnly | QIODevice::Text));
|
||||
QVariantList brut;
|
||||
for (const LoadConfig &c : doublon)
|
||||
brut.append(c.toMap());
|
||||
f.write(QJsonDocument(QJsonObject{{"loads", QJsonArray::fromVariantList(brut)}}).toJson());
|
||||
f.close();
|
||||
|
||||
LoadConfigStore relu;
|
||||
QCOMPARE(relu.configs().count(), 2); // LES DEUX survivent
|
||||
QCOMPARE(relu.configs().at(0).priority(), 1);
|
||||
QCOMPARE(relu.configs().at(1).priority(), 1); // aucun rang n'a été « réparé » d'office
|
||||
|
||||
// Et la première écriture cliente répare, sans que rien n'ait été perdu entre-temps.
|
||||
LoadConfigs repare = relu.configs();
|
||||
repare[1].setPriority(2);
|
||||
QVERIFY2(relu.setConfigs(repare, &err), qUtf8Printable(err));
|
||||
QCOMPARE(relu.configs().count(), 2);
|
||||
}
|
||||
|
||||
void TestLoadModel::testRankOriginFollowsTheRankNotTheWrite()
|
||||
{
|
||||
// [LM-1209-c] La marque dit « ce rang est un défaut, pas un choix ». Ce qui la lève est
|
||||
// qu'on CLASSE la charge — pas qu'on écrive dans le magasin.
|
||||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-rankorigin.json";
|
||||
QFile::remove(cfgPath);
|
||||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||||
|
||||
QString err;
|
||||
LoadConfigStore store;
|
||||
|
||||
// Deux charges : « posee » classée par un humain au rang 1, « auto » créée d'office en 2.
|
||||
LoadConfigs init;
|
||||
QVariantMap posee = borneMap(QStringLiteral("{dddddddd-0000-0000-0000-0000000000a1}"), 1);
|
||||
posee.insert("label", "Borne posée");
|
||||
init.append(LoadConfig::fromMap(posee));
|
||||
LoadConfig creee = LoadConfig::fromMap(borneMap(BORNE, 2));
|
||||
creee.setRankOrigin(QStringLiteral("auto")); // le MOTEUR l'écrit, lui en a le droit
|
||||
init.append(creee);
|
||||
QVERIFY2(store.setConfigs(init, &err), qUtf8Printable(err));
|
||||
|
||||
// ---- Réordonner une AUTRE charge ne lève PAS la marque ---------------------------------
|
||||
// C'est le cas qui se serait perdu. SetLoadConfig remplace en bloc : déplacer « posee »
|
||||
// renvoie forcément l'entrée de la borne créée d'office. Si l'effacement portait sur
|
||||
// « écriture reçue » plutôt que sur « rang changé », la marque tomberait ici — alors que
|
||||
// la charge n'a toujours été classée par personne.
|
||||
LoadConfigs bougeLautre = store.configs();
|
||||
bougeLautre[0].setPriority(3); // on déplace « posee », pas la borne
|
||||
bougeLautre[1].setRankOrigin(QString()); // et le client OMET la marque, comme un
|
||||
// client naïf qui reconstruit sa charge utile
|
||||
LoadConfigs reconciliee;
|
||||
QVERIFY2(store.reconcileRankOrigin(bougeLautre, &reconciliee, &err), qUtf8Printable(err));
|
||||
QVERIFY2(store.setConfigs(reconciliee, &err), qUtf8Printable(err));
|
||||
QCOMPARE(store.configs().at(1).rankOrigin(), QStringLiteral("auto"));
|
||||
QCOMPARE(store.configs().at(1).priority(), 2);
|
||||
|
||||
// ---- Déplacer LA charge lève la marque -------------------------------------------------
|
||||
LoadConfigs bougeLaBonne = store.configs();
|
||||
bougeLaBonne[1].setPriority(9);
|
||||
QVERIFY2(store.reconcileRankOrigin(bougeLaBonne, &reconciliee, &err), qUtf8Printable(err));
|
||||
QVERIFY2(store.setConfigs(reconciliee, &err), qUtf8Printable(err));
|
||||
// Vers le VIDE, pas vers "user" : le moteur constate un déplacement, il ne sait pas si un
|
||||
// humain ou un script l'a voulu. Ne rien affirmer est le minimum honnête.
|
||||
QVERIFY2(store.configs().at(1).rankOrigin().isEmpty(),
|
||||
"un rang changé doit lever la marque, sans affirmer autre chose à la place");
|
||||
|
||||
// ---- Un client ne FABRIQUE pas un "auto" -----------------------------------------------
|
||||
LoadConfigs fabrique = store.configs();
|
||||
fabrique[1].setRankOrigin(QStringLiteral("auto"));
|
||||
QVERIFY2(!store.reconcileRankOrigin(fabrique, &reconciliee, &err),
|
||||
"un client ne doit pas pouvoir affirmer que le moteur a créé ce rang");
|
||||
QVERIFY2(err.contains(QStringLiteral("moteur")), qUtf8Printable(err));
|
||||
|
||||
// ---- …mais l'aller-retour VERBATIM reste licite ----------------------------------------
|
||||
// Sans cette tolérance, tout client qui renvoie ce qu'il a lu serait refusé — la
|
||||
// neutralité Get→Set du contrat serait rompue.
|
||||
LoadConfigs init2;
|
||||
LoadConfig c2 = LoadConfig::fromMap(borneMap(BORNE, 2));
|
||||
c2.setRankOrigin(QStringLiteral("auto"));
|
||||
init2.append(c2);
|
||||
LoadConfigStore store2;
|
||||
QVERIFY2(store2.setConfigs(init2, &err), qUtf8Printable(err));
|
||||
LoadConfigs verbatim = store2.configs(); // relu tel quel, marque comprise
|
||||
QVERIFY2(store2.reconcileRankOrigin(verbatim, &reconciliee, &err), qUtf8Printable(err));
|
||||
QCOMPARE(reconciliee.at(0).rankOrigin(), QStringLiteral("auto"));
|
||||
|
||||
// ---- « user » est la voie EXPLICITE pour lever sans déplacer ---------------------------
|
||||
LoadConfigs assume = store2.configs();
|
||||
assume[0].setRankOrigin(QStringLiteral("user"));
|
||||
QVERIFY2(store2.reconcileRankOrigin(assume, &reconciliee, &err), qUtf8Printable(err));
|
||||
QCOMPARE(reconciliee.at(0).rankOrigin(), QStringLiteral("user"));
|
||||
|
||||
// Et l'énumération est fermée — une valeur libre serait une affirmation qu'on devrait
|
||||
// supporter le jour où quelqu'un la lit.
|
||||
LoadConfig hors = LoadConfig::fromMap(borneMap(BORNE, 1));
|
||||
hors.setRankOrigin(QStringLiteral("installateur"));
|
||||
QVERIFY2(!hors.isValid(&err), "rankOrigin hors énumération doit être refusé");
|
||||
}
|
||||
|
||||
@ -66,6 +66,15 @@ private slots:
|
||||
|
||||
//! Elle revendique son Thing : une borne ne peut pas être aussi une charge pilotée.
|
||||
void testEvChargerClaimsItsThing();
|
||||
|
||||
// ── §12 / LM-1209 — l'auto-provisionnement remplit un trou, il ne décide pas ───
|
||||
//! L'unicité de rang REFUSE une écriture, mais n'écarte JAMAIS au chargement : les deux
|
||||
//! vérifications sont séparées parce que leurs gestes le sont (LM-1209-a).
|
||||
void testRankUniquenessRefusesWriteButNeverDiscards();
|
||||
|
||||
//! La marque de rang s'efface sur un RANG changé, pas sur une écriture reçue — et un
|
||||
//! client ne peut pas fabriquer un "auto" (LM-1209-c).
|
||||
void testRankOriginFollowsTheRankNotTheWrite();
|
||||
};
|
||||
|
||||
#endif // TESTLOADMODEL_H
|
||||
|
||||
@ -5782,3 +5782,104 @@ void Simulation::testMeasurementSourceIsPublished()
|
||||
QCOMPARE(qRound(mRepli.value("powerW").toDouble()), 2900);
|
||||
#endif
|
||||
}
|
||||
|
||||
void Simulation::testAutoProvisionedRankGoesToTheQueue()
|
||||
{
|
||||
#ifndef ETM_ARBITRATOR
|
||||
QSKIP("testAutoProvisionedRankGoesToTheQueue nécessite ETM_ARBITRATOR.");
|
||||
#else
|
||||
// [LM-1209] L'auto-provisionnement REMPLIT UN TROU : sans entrée, une borne serait arbitrée
|
||||
// sans rang réglable. Il ne s'ensuit rien sur sa PLACE. Une borne détectée un mois après la
|
||||
// mise en service ne doit pas passer devant l'ECS que quelqu'un a réglé exprès — une
|
||||
// détection ne réordonne pas ce qu'un humain a décidé.
|
||||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-queue.json";
|
||||
QFile::remove(cfgPath);
|
||||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||||
|
||||
cleanupTestCase();
|
||||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||||
initTestCase();
|
||||
|
||||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||||
QVERIFY(arb);
|
||||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||||
|
||||
QUuid meterId = addMeter();
|
||||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||||
Thing *meter = tm->findConfiguredThing(meterId);
|
||||
QVERIFY(meter);
|
||||
meter->setStateValue("connected", true);
|
||||
meter->setStateValue("currentPower", -4000);
|
||||
|
||||
// ---- Une installation DÉJÀ classée par un humain ---------------------------------------
|
||||
QUuid rA = addPowerSwitch(1000, 26661);
|
||||
QUuid varId = addEtmVariableLoad(27042);
|
||||
QVERIFY(tm->findConfiguredThing(rA) && tm->findConfiguredThing(varId));
|
||||
|
||||
// Posées par la frontière RPC, comme l'app le ferait — c'est le magasin que le handler
|
||||
// connaît, et c'est lui qui doit porter la marque de rang.
|
||||
QVariantList l;
|
||||
l.append(QVariantMap{{"id", "ecs-classe"}, {"label", "ECS classé"},
|
||||
{"adapter", "relay-router"}, {"mode", "fixed"},
|
||||
{"priority", 1}, {"enabled", true}, {"domain", "ecs"},
|
||||
{"relays", QVariantList() << QVariantMap{{"thingId", rA.toString()},
|
||||
{"powerW", 1000}}},
|
||||
{"minOnS", 0}, {"minOffS", 0}});
|
||||
l.append(QVariantMap{{"id", varId.toString()}, {"label", "Routeur PV"},
|
||||
{"adapter", "etmvariableload"}, {"mode", "dynamic"},
|
||||
{"maxPowerW", 3000}, {"priority", 2}, {"enabled", true},
|
||||
{"domain", "ecs"}});
|
||||
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}}))
|
||||
.toMap().value("params").toMap().value("energyError").toString(),
|
||||
QString("EnergyErrorNoError"));
|
||||
|
||||
// ---- La borne arrive UN MOIS PLUS TARD -------------------------------------------------
|
||||
QUuid borneId = addCharger("A", 16, 26657);
|
||||
Thing *borne = tm->findConfiguredThing(borneId);
|
||||
QVERIFY(borne);
|
||||
armerBorne(borne);
|
||||
|
||||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||||
arb->simulationCallUpdate(t0);
|
||||
QCoreApplication::processEvents();
|
||||
|
||||
QHash<QString, QVariantMap> cfg = configsParId(injectAndWait("NymeaEnergy.GetLoadConfig"));
|
||||
QVERIFY2(cfg.contains(borneId.toString()), "la borne détectée n'est pas entrée en configuration");
|
||||
|
||||
// EN QUEUE : derrière tout le monde, pas en tête. C'est la correction du lot — le rang
|
||||
// retombait à 1, à égalité avec l'ECS que quelqu'un avait posé là exprès.
|
||||
QCOMPARE(cfg.value(borneId.toString()).value("priority").toInt(), 3);
|
||||
|
||||
// AUCUN autre rang n'a bougé : insérer est un geste de configuration, pas d'arbitrage.
|
||||
QCOMPARE(cfg.value("ecs-classe").value("priority").toInt(), 1);
|
||||
QCOMPARE(cfg.value(varId.toString()).value("priority").toInt(), 2);
|
||||
|
||||
// Et la création SE DIT : le journal ne suffit pas, l'app ne le lit pas et c'est elle qui
|
||||
// montre le classement. « ce rang est un défaut, pas un choix. »
|
||||
QCOMPARE(cfg.value(borneId.toString()).value("rankOrigin").toString(), QString("auto"));
|
||||
// Les charges posées par un humain, elles, n'affirment rien — ni « choisi », ni « défaut ».
|
||||
QVERIFY2(cfg.value("ecs-classe").value("rankOrigin").toString().isEmpty(),
|
||||
"une charge posée par un humain ne doit rien affirmer sur l'origine de son rang");
|
||||
|
||||
// ---- Une SECONDE borne va encore derrière ----------------------------------------------
|
||||
// Le rang produit doit rester LIBRE par construction : c'est ce qui rend l'égalité
|
||||
// impossible par ce chemin, plutôt que de la refuser après coup.
|
||||
QUuid borne2Id = addCharger("A", 16, 26670);
|
||||
Thing *borne2 = tm->findConfiguredThing(borne2Id);
|
||||
QVERIFY(borne2);
|
||||
armerBorne(borne2);
|
||||
arb->simulationCallUpdate(t0.addSecs(60));
|
||||
QCoreApplication::processEvents();
|
||||
|
||||
cfg = configsParId(injectAndWait("NymeaEnergy.GetLoadConfig"));
|
||||
QCOMPARE(cfg.value(borne2Id.toString()).value("priority").toInt(), 4);
|
||||
QCOMPARE(cfg.value(borneId.toString()).value("priority").toInt(), 3); // toujours pas bougé
|
||||
QCOMPARE(cfg.value("ecs-classe").value("priority").toInt(), 1);
|
||||
|
||||
// Tous distincts : l'unicité est obtenue PAR CONSTRUCTION, pas par un refus tardif.
|
||||
QSet<int> rangs;
|
||||
for (auto it = cfg.constBegin(); it != cfg.constEnd(); ++it)
|
||||
rangs.insert(it.value().value("priority").toInt());
|
||||
QCOMPARE(rangs.count(), cfg.count());
|
||||
#endif
|
||||
}
|
||||
|
||||
@ -179,6 +179,12 @@ private slots:
|
||||
// mais un compteur injoignable fait retomber sur l'appareil au lieu de taire la mesure.
|
||||
void testMeasurementSourceIsPublished();
|
||||
|
||||
// ── §12 / LM-1209 ──────────────────────────────────────────────────────
|
||||
// Une borne détectée après la mise en service entre EN QUEUE : elle ne passe devant
|
||||
// aucune charge qu'un humain a classée, aucun autre rang ne bouge, le rang produit est
|
||||
// libre par construction, et l'entrée porte rankOrigin="auto" — « ce rang est un défaut ».
|
||||
void testAutoProvisionedRankGoesToTheQueue();
|
||||
|
||||
void printStates(Thing *thing);
|
||||
void updateChargerMeter(Thing *thing);
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user