Fusion de fix/q7-suite dans release/etm73 (Q7 : motif SetLoadConfig, bornes id et dailyDeadline, journal sans saisie client)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HE6X3TcKdiuCucBHA2XX4n
This commit is contained in:
Patrick Schurig 2026-10-23 06:16:09 +02:00
commit 1b5915d016
21 changed files with 1508 additions and 136 deletions

View File

@ -1409,7 +1409,10 @@ But : comparer des optimiseurs **sur les mêmes entrées** et rejouer un inciden
"powerW":1200,"clamped":false}],"strategy":"optimizer","latencyMs":12} "powerW":1200,"clamped":false}],"strategy":"optimizer","latencyMs":12}
``` ```
- `request` : les params `GetPlan` envoyés (ou qui l'auraient été) ; `response` : ce qui a - `request` : les params `GetPlan` envoyés (ou qui l'auraient été) — **ou, à leur place,
`requestBytes`**, la taille en octets de la ligne `GetPlan` quand elle dépasse 1 Mio (§2) : une
telle requête n'est pas envoyée (soumise au client, elle a pour réponse `request-too-long`),
elle n'est pas recopiée, et le rejeu passe la ligne ; `response` : ce qui a
répondu à **cette** requête — `result` brut, ou `error.code` (`no-response` si elle a été répondu à **cette** requête — `result` brut, ou `error.code` (`no-response` si elle a été
remplacée sans réponse) ; `executed` : actions appliquées au cycle ; `latencyMs` : optimiseur remplacée sans réponse) ; `executed` : actions appliquées au cycle ; `latencyMs` : optimiseur
seulement. La réponse à la requête N sert au cycle N+1 : la ligne N l'attend. seulement. La réponse à la requête N sert au cycle N+1 : la ligne N l'attend.

View File

@ -6,6 +6,7 @@
#include <nymeasettings.h> #include <nymeasettings.h>
#include <QDebug>
#include <QFile> #include <QFile>
#include <QFileInfo> #include <QFileInfo>
#include <QSaveFile> #include <QSaveFile>
@ -13,6 +14,52 @@
#include <QJsonObject> #include <QJsonObject>
#include <QJsonArray> #include <QJsonArray>
#include <QJsonParseError> #include <QJsonParseError>
#include <QRegularExpression>
namespace {
//! L'identifiant tel qu'il entre dans un motif : entier s'il est bien formé (donc borné), sinon
//! ses 64 premiers caractères — une configuration héritée peut porter n'importe quoi.
QString idPourMotif(const QString &id)
{
if (LoadConfigStore::idIsWellFormed(id) || id.toUcs4().size() <= 64)
return id;
return LoadConfigStore::leftChars(id, 64) + QStringLiteral("…");
}
//! Une valeur saisie citée dans un motif : assez pour la reconnaître, jamais en entier.
QString valeurPourMotif(const QString &valeur)
{
return valeur.toUcs4().size() <= 16 ? valeur
: LoadConfigStore::leftChars(valeur, 16) + QStringLiteral("…");
}
//! Un libellé cité dans un motif : ses 64 premiers caractères. Celui d'une entrée héritée n'est
//! pas borné, et l'ajout automatique d'une borne journalise ce motif (relecture q7-suite, m-1).
QString libellePourMotif(const QString &libelle)
{
return libelle.toUcs4().size() <= 64 ? libelle
: LoadConfigStore::leftChars(libelle, 64) + QStringLiteral("…");
}
//! Une valeur citée au journal, entre guillemets et ÉCHAPPÉE par Qt (\c "\n" pour un saut de
//! ligne) : une valeur persistée par un client ne fabrique pas de fausse ligne de journal.
QString citee(const QString &valeur)
{
QString s;
QDebug(&s).nospace() << valeur;
return s;
}
//! Le motif publiable d'un refus PAR ENTRÉE : la cause, la position, et l'identifiant quand il
//! est bien formé — un identifiant refusé ne se recopie pas.
QVariantMap motifEntree(const char *cause, int index, const QString &id)
{
QVariantMap m{{QStringLiteral("cause"), QLatin1String(cause)}, {QStringLiteral("index"), index}};
if (LoadConfigStore::idIsWellFormed(id))
m.insert(QStringLiteral("id"), id);
return m;
}
}
LoadConfigStore::LoadConfigStore(QObject *parent) LoadConfigStore::LoadConfigStore(QObject *parent)
: QObject{parent} : QObject{parent}
@ -91,31 +138,62 @@ bool LoadConfigStore::load()
} }
// Q7 — même règle pour un libellé trop long : CHARGÉ TEL QUEL, jamais tronqué ni écarté. Le // Q7 — même règle pour un libellé trop long : CHARGÉ TEL QUEL, jamais tronqué ni écarté. Le
// client d'optimiseur refuse d'émettre un GetPlan de plus de 1 Mio ; c'est lui le filet. // client d'optimiseur refuse d'émettre un GetPlan de plus de 1 Mio ; c'est lui le filet.
{ // Chaque entrée est signalée, pas seulement la première : c'est la liste des réparations.
// Les valeurs persistées sont citées ÉCHAPPÉES (citee(), ou `<<` sans noquote()) : avant Q7,
// un client pouvait écrire un identifiant ou une échéance portant un saut de ligne.
for (int i = 0; i < loaded.count(); ++i) {
QString pourquoi; QString pourquoi;
if (!validateLabels(loaded, &pourquoi)) if (!validateLabels(LoadConfigs(QList<LoadConfig>{loaded.at(i)}), &pourquoi))
qCWarning(dcNymeaEnergy()).noquote() qCWarning(dcNymeaEnergy())
<< "[LoadConfigStore] CONFIGURATION CONSERVÉE, LIBELLÉ TROP LONG :" << pourquoi << "[LoadConfigStore] CONFIGURATION CONSERVÉE, LIBELLÉ TROP LONG :" << pourquoi
<< "— chargé tel quel ; toute écriture sera refusée tant qu'il n'est pas raccourci."; << "— chargé tel quel ; toute écriture cliente sera refusée tant qu'il n'est pas raccourci.";
if (!idIsWellFormed(loaded.at(i).id()))
qCWarning(dcNymeaEnergy()).noquote()
<< QStringLiteral("[LoadConfigStore] CONFIGURATION CONSERVÉE, IDENTIFIANT HORS RÈGLE : "
"charge %1 (%2 caractères ; attendu 1 à %3 caractères "
"[A-Za-z0-9._{}-]) — chargée telle quelle ; toute écriture cliente "
"sera refusée tant qu'elle porte cet identifiant.")
.arg(citee(idPourMotif(loaded.at(i).id()))).arg(loaded.at(i).id().toUcs4().size())
.arg(MaxIdChars);
// L'échéance, elle, est RETIRÉE de la mémoire — l'entrée reste. Une échéance illisible
// ne se résout en aucun instant ; la garder la ferait partir brute dans GetPlan, où
// l'optimiseur devrait deviner ce qu'elle veut dire. Sans elle, une obligation en énergie
// n'a plus de cadence et rien n'est acheté pour elle : le côté sûr. Le fichier n'est pas
// réécrit ici ; la première écriture cliente le remplacera.
const QString echeance = loaded.at(i).needs().dailyDeadline();
if (!deadlineIsWellFormed(echeance)) {
qCWarning(dcNymeaEnergy()).noquote()
<< QStringLiteral("[LoadConfigStore] CONFIGURATION CONSERVÉE, ÉCHÉANCE ILLISIBLE : "
"charge %1, needs.dailyDeadline %2 (attendu HH:MM, de 00:00 "
"à 23:59) — IGNORÉE : ni l'arbitre ni l'optimiseur ne la reçoivent, "
"l'obligation n'a plus d'échéance.")
.arg(citee(idPourMotif(loaded.at(i).id())), citee(valeurPourMotif(echeance)));
LoadConfigNeeds besoins = loaded.at(i).needs();
besoins.setDailyDeadline(QString());
loaded[i].setNeeds(besoins);
}
} }
m_configs = loaded; m_configs = loaded;
return true; return true;
} }
bool LoadConfigStore::validateSet(const LoadConfigs &configs, QString *error) bool LoadConfigStore::validateSet(const LoadConfigs &configs, QString *error, QVariantMap *fault)
{ {
auto fail = [error](const QString &why) { auto fail = [error, fault](const QString &why, const QVariantMap &motif) {
if (error) *error = why; if (error) *error = why;
if (fault) *fault = motif;
return false; return false;
}; };
QSet<QString> ids; QSet<QString> ids;
QHash<QString, QString> proprietaire; // ThingId normalisé → libellé de la charge QHash<QString, int> proprietaire; // ThingId normalisé → index de la charge
for (const LoadConfig &c : configs) { for (int i = 0; i < configs.count(); ++i) {
const LoadConfig &c = configs.at(i);
if (ids.contains(c.id())) if (ids.contains(c.id()))
return fail(QStringLiteral("l'identifiant de charge \"%1\" apparaît deux fois").arg(c.id())); return fail(QStringLiteral("l'identifiant de charge \"%1\" apparaît deux fois").arg(idPourMotif(c.id())),
motifEntree("ID_DUPLICATE", i, c.id()));
ids.insert(c.id()); ids.insert(c.id());
if (!c.enabled()) if (!c.enabled())
@ -128,45 +206,57 @@ bool LoadConfigStore::validateSet(const LoadConfigs &configs, QString *error)
// les autres — d'où ce contrôle d'ENSEMBLE, et d'où le fait qu'il soit distinct de // les autres — d'où ce contrôle d'ENSEMBLE, et d'où le fait qu'il soit distinct de
// isValid(). // isValid().
for (const QString &thing : c.claimedThingIds()) { for (const QString &thing : c.claimedThingIds()) {
const QString deja = proprietaire.value(thing); if (proprietaire.contains(thing)) {
if (!deja.isEmpty()) const int j = proprietaire.value(thing);
return fail(QStringLiteral("le Thing %1 est commandé par deux charges actives, " return fail(QStringLiteral("le Thing %1 est commandé par deux charges actives, "
"\"%2\" et \"%3\" : deux commandes contradictoires et " "\"%2\" et \"%3\" : deux commandes contradictoires et "
"une puissance comptée deux fois") "une puissance comptée deux fois")
.arg(thing, deja, c.label())); .arg(thing, libellePourMotif(configs.at(j).label()), libellePourMotif(c.label())),
proprietaire.insert(thing, c.label()); QVariantMap{{QStringLiteral("cause"), QStringLiteral("THING_SHARED")},
{QStringLiteral("indexes"), QVariantList{j, i}},
{QStringLiteral("ids"), QVariantList{configs.at(j).id(), c.id()}}});
}
proprietaire.insert(thing, i);
} }
} }
return true; return true;
} }
bool LoadConfigStore::validateRanks(const LoadConfigs &configs, QString *error) bool LoadConfigStore::validateRanks(const LoadConfigs &configs, QString *error, QVariantMap *fault)
{ {
QHash<int, QString> parRang; QHash<int, int> parRang; // rang → index de la première charge qui le porte
for (const LoadConfig &c : configs) { for (int i = 0; i < configs.count(); ++i) {
const QString deja = parRang.value(c.priority()); const LoadConfig &c = configs.at(i);
if (!deja.isEmpty()) { if (parRang.contains(c.priority())) {
const int j = parRang.value(c.priority());
if (error) if (error)
*error = QStringLiteral("les charges \"%1\" et \"%2\" portent toutes deux le rang " *error = QStringLiteral("les charges \"%1\" et \"%2\" portent toutes deux le rang "
"%3 : leur ordre de service serait stable mais NON " "%3 : leur ordre de service serait stable mais NON "
"RÉGLABLE — il se déciderait sur leurs identifiants") "RÉGLABLE — il se déciderait sur leurs identifiants")
.arg(deja, c.label()).arg(c.priority()); .arg(libellePourMotif(configs.at(j).label()), libellePourMotif(c.label()))
.arg(c.priority());
if (fault)
*fault = QVariantMap{{QStringLiteral("cause"), QStringLiteral("RANK_DUPLICATE")},
{QStringLiteral("indexes"), QVariantList{j, i}},
{QStringLiteral("ids"), QVariantList{configs.at(j).id(), c.id()}},
{QStringLiteral("rank"), c.priority()}};
return false; return false;
} }
parRang.insert(c.priority(), c.label()); parRang.insert(c.priority(), i);
} }
return true; return true;
} }
bool LoadConfigStore::reconcileRankOrigin(const LoadConfigs &incoming, LoadConfigs *out, bool LoadConfigStore::reconcileRankOrigin(const LoadConfigs &incoming, LoadConfigs *out,
QString *error) const QString *error, QVariantMap *fault) const
{ {
QHash<QString, const LoadConfig *> stocke; QHash<QString, const LoadConfig *> stocke;
for (const LoadConfig &c : m_configs) for (const LoadConfig &c : m_configs)
stocke.insert(c.id(), &c); stocke.insert(c.id(), &c);
LoadConfigs resultat; LoadConfigs resultat;
for (LoadConfig c : incoming) { for (int i = 0; i < incoming.count(); ++i) {
LoadConfig c = incoming.at(i);
const LoadConfig *avant = stocke.value(c.id(), nullptr); const LoadConfig *avant = stocke.value(c.id(), nullptr);
const QString marqueAvant = avant ? avant->rankOrigin() : QString(); const QString marqueAvant = avant ? avant->rankOrigin() : QString();
@ -180,6 +270,8 @@ bool LoadConfigStore::reconcileRankOrigin(const LoadConfigs &incoming, LoadConfi
"moteur n'a pas posé : c'est une affirmation sur un geste " "moteur n'a pas posé : c'est une affirmation sur un geste "
"du moteur, elle ne s'écrit pas depuis un client") "du moteur, elle ne s'écrit pas depuis un client")
.arg(c.label()); .arg(c.label());
if (fault)
*fault = motifEntree("RANK_ORIGIN_AUTO", i, c.id());
return false; return false;
} }
@ -205,49 +297,170 @@ bool LoadConfigStore::reconcileRankOrigin(const LoadConfigs &incoming, LoadConfi
return true; return true;
} }
bool LoadConfigStore::validateLabels(const LoadConfigs &configs, QString *error) bool LoadConfigStore::validateLabels(const LoadConfigs &configs, QString *error, QVariantMap *fault,
int from)
{ {
for (const LoadConfig &c : configs) { for (int i = qMax(0, from); i < configs.count(); ++i) {
const LoadConfig &c = configs.at(i);
const qsizetype caracteres = c.label().toUcs4().size(); const qsizetype caracteres = c.label().toUcs4().size();
if (caracteres > MaxLabelChars) { if (caracteres > MaxLabelChars) {
if (error) if (error)
*error = QStringLiteral("le libellé de la charge \"%1\" compte %2 caractères, %3 au plus") *error = QStringLiteral("le libellé de la charge \"%1\" compte %2 caractères, %3 au plus")
.arg(c.id().left(64)).arg(caracteres).arg(MaxLabelChars); .arg(idPourMotif(c.id())).arg(caracteres).arg(MaxLabelChars);
if (fault) {
*fault = motifEntree("LABEL", i, c.id());
fault->insert(QStringLiteral("length"), caracteres);
fault->insert(QStringLiteral("max"), MaxLabelChars);
}
return false; return false;
} }
} }
return true; return true;
} }
bool LoadConfigStore::setConfigs(const LoadConfigs &configs, QString *error) bool LoadConfigStore::idIsWellFormed(const QString &id)
{ {
// Les libellés D'ABORD : les motifs de refus suivants citent le libellé, et un libellé // Le jeu est ASCII : un caractère y vaut une unité UTF-16, et size() compte des caractères.
// démesuré ne doit entrer ni dans la configuration ni dans le journal (Q7). static const QRegularExpression forme(
if (!validateLabels(configs, error)) QRegularExpression::anchoredPattern(QStringLiteral("[A-Za-z0-9._{}-]+")));
return id.size() <= MaxIdChars && forme.match(id).hasMatch();
}
bool LoadConfigStore::validateIds(const LoadConfigs &configs, QString *error, QVariantMap *fault, int from)
{
static const QRegularExpression horsJeu(QStringLiteral("[^A-Za-z0-9._{}-]"));
for (int i = qMax(0, from); i < configs.count(); ++i) {
const QString id = configs.at(i).id();
if (idIsWellFormed(id))
continue;
const qsizetype caracteres = id.toUcs4().size();
// La position du premier caractère hors jeu, en caractères — pas en unités UTF-16.
const QRegularExpressionMatch m = horsJeu.match(id);
const qsizetype position = m.hasMatch() ? id.left(m.capturedStart()).toUcs4().size() : -1;
if (error) {
*error = caracteres == 0
? QStringLiteral("l'identifiant de la charge en position %1 est vide").arg(i + 1)
: position >= 0
? QStringLiteral("l'identifiant de la charge en position %1 contient un caractère hors "
"[A-Za-z0-9._{}-] (caractère %2)").arg(i + 1).arg(position + 1)
: QStringLiteral("l'identifiant de la charge en position %1 compte %2 caractères, %3 au plus")
.arg(i + 1).arg(caracteres).arg(MaxIdChars);
}
if (fault) {
*fault = QVariantMap{{QStringLiteral("cause"), QStringLiteral("ID")},
{QStringLiteral("index"), i},
{QStringLiteral("length"), caracteres},
{QStringLiteral("max"), MaxIdChars}};
if (position >= 0)
fault->insert(QStringLiteral("position"), position);
}
return false;
}
return true;
}
bool LoadConfigStore::deadlineIsWellFormed(const QString &deadline)
{
// anchoredPattern, et non ^…$ : « $ » accepterait un saut de ligne final.
static const QRegularExpression forme(
QRegularExpression::anchoredPattern(QStringLiteral("([01][0-9]|2[0-3]):[0-5][0-9]")));
return deadline.isEmpty() || forme.match(deadline).hasMatch();
}
bool LoadConfigStore::validateDeadlines(const LoadConfigs &configs, QString *error, QVariantMap *fault,
int from)
{
for (int i = qMax(0, from); i < configs.count(); ++i) {
const LoadConfig &c = configs.at(i);
if (deadlineIsWellFormed(c.needs().dailyDeadline()))
continue;
if (error)
*error = QStringLiteral("l'échéance quotidienne de la charge \"%1\" est illisible (\"%2\") : "
"attendu HH:MM, de 00:00 à 23:59, ou absente")
.arg(idPourMotif(c.id()), valeurPourMotif(c.needs().dailyDeadline()));
if (fault)
*fault = motifEntree("DEADLINE", i, c.id());
return false;
}
return true;
}
bool LoadConfigStore::validateBounds(const LoadConfigs &configs, QString *error, QVariantMap *fault, int from)
{
// L'identifiant D'ABORD : tous les motifs suivants le recopient.
return validateIds(configs, error, fault, from)
&& validateLabels(configs, error, fault, from)
&& validateDeadlines(configs, error, fault, from);
}
QString LoadConfigStore::leftChars(const QString &texte, int n)
{
// Couper en points de code puis recomposer : une paire de substitution reste entière.
const QList<uint> points = texte.toUcs4();
if (points.size() <= n)
return texte;
return QString::fromUcs4(reinterpret_cast<const char32_t *>(points.constData()), qMax(0, n));
}
bool LoadConfigStore::setConfigs(const LoadConfigs &configs, QString *error, QVariantMap *fault)
{
return commit(configs, 0, error, fault);
}
bool LoadConfigStore::appendConfig(const LoadConfig &config, QString *error, QVariantMap *fault)
{
LoadConfigs configs = m_configs;
configs.append(config);
// Les bornes de saisie ne portent que sur l'entrée APPORTÉE — voir la déclaration.
return commit(configs, configs.count() - 1, error, fault);
}
bool LoadConfigStore::commit(const LoadConfigs &configs, int premierBorne, QString *error, QVariantMap *fault)
{
// Les bornes de saisie D'ABORD : les motifs de refus suivants citent l'identifiant et le
// libellé, et une saisie démesurée ne doit entrer ni dans la configuration ni dans le
// journal (Q7).
if (!validateBounds(configs, error, fault, premierBorne))
return false; return false;
// Validation EN BLOC : une seule entrée invalide → rejet total (rien persisté). // Validation EN BLOC : une seule entrée invalide → rejet total (rien persisté).
for (const LoadConfig &c : configs) { for (int i = 0; i < configs.count(); ++i) {
const LoadConfig &c = configs.at(i);
QString why; QString why;
if (!c.isValid(&why)) { QVariantMap motif;
if (!c.isValid(&why, &motif)) {
if (error) *error = QStringLiteral("Config \"%1\" invalide : %2").arg(c.label(), why); if (error) *error = QStringLiteral("Config \"%1\" invalide : %2").arg(c.label(), why);
if (fault) {
*fault = motif;
fault->insert(QStringLiteral("index"), i);
if (idIsWellFormed(c.id()))
fault->insert(QStringLiteral("id"), c.id());
}
return false; return false;
} }
} }
// …puis la cohérence d'ensemble, qu'aucune charge ne peut vérifier seule. // …puis la cohérence d'ensemble, qu'aucune charge ne peut vérifier seule.
if (!validateSet(configs, error)) if (!validateSet(configs, error, fault))
return false; return false;
// …puis la BONNE FORME, qui ne s'applique qu'ici. load() ne l'appelle pas : elle refuse // …puis la BONNE FORME, qui ne s'applique qu'ici. load() n'écarte rien sur sa foi : elle
// une écriture, elle n'écarte jamais une entrée déjà persistée (LM-1209-a). // refuse une écriture, elle n'écarte jamais une entrée déjà persistée (LM-1209-a).
if (!validateRanks(configs, error)) if (!validateRanks(configs, error, fault))
return false; return false;
const LoadConfigs previous = m_configs; const LoadConfigs previous = m_configs;
m_configs = configs; m_configs = configs;
if (!save(error)) { QString echec;
if (!save(&echec)) {
m_configs = previous; // rollback mémoire si l'écriture échoue m_configs = previous; // rollback mémoire si l'écriture échoue
// La cause système est dite ICI : le refus de SetLoadConfig ne journalise que son motif,
// et WRITE_FAILED ne dit pas pourquoi le disque a refusé. Aucune saisie n'y entre.
qCWarning(dcNymeaEnergy()) << "[LoadConfigStore] Écriture de la configuration échouée :"
<< filePath() << echec;
if (error) *error = echec;
if (fault)
*fault = QVariantMap{{QStringLiteral("cause"), QStringLiteral("WRITE_FAILED")}};
return false; return false;
} }

View File

@ -41,9 +41,32 @@ public:
* \brief Remplace l'ensemble des configs : valide tout → persiste → \c emit changed(). * \brief Remplace l'ensemble des configs : valide tout → persiste → \c emit changed().
* \param configs Nouvel ensemble (remplace l'existant — l'app envoie la liste complète). * \param configs Nouvel ensemble (remplace l'existant — l'app envoie la liste complète).
* \param[out] error Message FR si rejet (aucune écriture dans ce cas). * \param[out] error Message FR si rejet (aucune écriture dans ce cas).
* \param[out] fault Si rejet, les \c faultParams du motif \c LOAD_CONFIG_REFUSED : \c cause
* et l'endroit (\c index, \c id, \c field, …) — liste des causes : \c DecisionCode::LoadConfigRefused.
* \return true si validé + persisté ; false si invalide ou échec d'écriture. * \return true si validé + persisté ; false si invalide ou échec d'écriture.
*/ */
bool setConfigs(const LoadConfigs &configs, QString *error = nullptr); bool setConfigs(const LoadConfigs &configs, QString *error = nullptr, QVariantMap *fault = nullptr);
/*!
* \brief AJOUT d'une entrée par le MOTEUR (auto-provisionnement d'une borne, LM-1209).
*
* Mêmes contrôles que \c setConfigs(), à une différence près : les bornes de saisie
* (\c validateBounds() — identifiant, libellé, échéance) ne portent que sur l'entrée AJOUTÉE.
* Un client renvoie l'ensemble entier : il l'apporte, il en répond, et une entrée héritée hors
* règle bloque son écriture jusqu'à ce qu'il la répare. Le moteur n'apporte qu'une entrée ; il
* ne sait pas réparer les autres (raccourcir un libellé humain n'est pas son geste), et en être
* bloqué laisserait la borne arbitrée sans rang réglable — le trou refermé en 3g-2.
*
* \param config Entrée à ajouter ; son \c id ne doit pas déjà figurer dans l'ensemble.
* \param[out] error Message FR si rejet (aucune écriture dans ce cas).
* \param[out] fault Comme pour \c setConfigs().
* \return true si validé + persisté.
*
* \note La cohérence d'ensemble (\c validateSet()) et l'unicité des rangs (\c validateRanks())
* restent contrôlées sur l'ensemble complet : la première protège un organe de deux
* commandeurs, et un rang en double hérité bloque encore l'ajout, comme avant ce lot.
*/
bool appendConfig(const LoadConfig &config, QString *error = nullptr, QVariantMap *fault = nullptr);
/*! \brief Cohérence de l'ENSEMBLE, ce qu'aucune charge ne peut vérifier seule. /*! \brief Cohérence de l'ENSEMBLE, ce qu'aucune charge ne peut vérifier seule.
* *
@ -60,15 +83,21 @@ public:
* \c setConfigs(), donc par cette vérification. * \c setConfigs(), donc par cette vérification.
* *
* \param configs Ensemble complet à confronter (les charges désactivées sont ignorées). * \param configs Ensemble complet à confronter (les charges désactivées sont ignorées).
* \param[out] error Motif de refus, en français, exploitable tel quel. * \param[out] error Motif de refus, en français, exploitable tel quel ; un libellé n'y est
* cité que sur ses 64 premiers caractères (celui d'une entrée héritée n'est pas borné).
* \param[out] fault Si refus : \c ID_DUPLICATE (\c index, \c id) ou \c THING_SHARED
* (\c indexes, \c ids — la paire de charges).
* \return true si l'ensemble est cohérent ; false au PREMIER conflit rencontré. * \return true si l'ensemble est cohérent ; false au PREMIER conflit rencontré.
*/ */
static bool validateSet(const LoadConfigs &configs, QString *error = nullptr); static bool validateSet(const LoadConfigs &configs, QString *error = nullptr,
QVariantMap *fault = nullptr);
/*! /*!
* \brief Contrôle de BONNE FORME, appliqué aux ÉCRITURES et à elles seules. §12 / LM-1209-a. * \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. * \param configs Ensemble candidat. \param error Motif du refus, si refus ; un libellé n'y
* est cité que sur ses 64 premiers caractères.
* \param[out] fault Si refus : \c RANK_DUPLICATE (\c indexes, \c ids, \c rank).
* \return Faux si deux charges portent le même rang. * \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é** * \note **Distincte de \c validateSet(), et la distinction est de sûreté, pas de propreté**
@ -81,10 +110,11 @@ public:
* héritée à rangs doublés : des charges disparaîtraient de la configuration ET de * 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 * l'arbitrage, sans \c applySafeState — une règle de contrat convertie en perte de
* configuration silencieuse. * configuration silencieuse.
* \note N'est donc JAMAIS appelée par \c load(). Un doublon hérité est **signalé * \note \c load() n'écarte donc rien sur sa foi. Un doublon hérité est **signalé
* bruyamment et CONSERVÉ** ; la première écriture cliente le répare. * bruyamment et CONSERVÉ** ; la première écriture cliente le répare.
*/ */
static bool validateRanks(const LoadConfigs &configs, QString *error = nullptr); static bool validateRanks(const LoadConfigs &configs, QString *error = nullptr,
QVariantMap *fault = nullptr);
//! Plus long libellé de charge accepté à l'écriture, en caractères (points de code Unicode). //! Plus long libellé de charge accepté à l'écriture, en caractères (points de code Unicode).
static constexpr int MaxLabelChars = 256; static constexpr int MaxLabelChars = 256;
@ -99,19 +129,99 @@ public:
* *
* \param configs Ensemble candidat. \param[out] error Motif du refus, en français ; il nomme * \param configs Ensemble candidat. \param[out] error Motif du refus, en français ; il nomme
* la charge par son identifiant et donne la longueur, jamais le libellé. * la charge par son identifiant et donne la longueur, jamais le libellé.
* \param[out] fault Si refus : \c LABEL (\c index, \c id, \c length, \c max).
* \param from Premier index contrôlé (les précédents ne le sont pas) ; voir \c appendConfig().
* \return Faux si un libellé compte plus de \c MaxLabelChars caractères. * \return Faux si un libellé compte plus de \c MaxLabelChars caractères.
* *
* \note Même famille que \c validateRanks() (\rule{LM-1209-a-bis}) : un refus d'écriture, jamais * \note Même famille que \c validateRanks() (\rule{LM-1209-a-bis}) : un refus d'écriture, jamais
* un écart au chargement. \c load() ne l'appelle pas — un libellé plus long déjà persisté est * un écart au chargement. \c load() n'écarte rien sur sa foi — un libellé plus long déjà
* **chargé tel quel, signalé, jamais tronqué** ; la première écriture devra le raccourcir. * persisté est **chargé tel quel, signalé, jamais tronqué** ; la première écriture cliente
* devra le raccourcir.
*/ */
static bool validateLabels(const LoadConfigs &configs, QString *error = nullptr); static bool validateLabels(const LoadConfigs &configs, QString *error = nullptr,
QVariantMap *fault = nullptr, int from = 0);
//! Plus long identifiant de charge accepté à l'écriture, en caractères.
static constexpr int MaxIdChars = 128;
/*!
* \brief \return Vrai si \p id est un identifiant de charge bien formé : 1 à \c MaxIdChars
* caractères, tous dans \c [A-Za-z0-9._{}-].
* \param id Identifiant à juger.
*
* L'identifiant est une CLÉ, pas un texte : il part dans chaque \c GetPlan (\c loads[].id, et
* clé des actions), il est recopié dans les motifs et le journal. Le jeu couvre les deux formes
* réelles — un ThingId entre accolades (38 caractères) et un nom court à tirets.
*/
static bool idIsWellFormed(const QString &id);
/*!
* \brief Borne les identifiants, à l'ÉCRITURE seulement (Q7).
* \param configs Ensemble candidat.
* \param[out] error Motif du refus, en français ; il donne la position de l'entrée et la
* longueur, jamais l'identifiant refusé.
* \param[out] fault Si refus : \c ID (\c index, \c length, \c max, et \c position, premier
* caractère hors jeu, s'il y en a un). Jamais \c id : c'est lui qui est refusé.
* \param from Premier index contrôlé ; voir \c appendConfig().
* \return Faux si un identifiant n'est pas \c idIsWellFormed().
*
* \note Même partage que \c validateLabels() : un identifiant hors règle déjà persisté est
* **chargé tel quel et signalé** — le changer reviendrait à retirer une charge et en créer
* une autre, ce qu'une lecture n'a pas à décider.
*/
static bool validateIds(const LoadConfigs &configs, QString *error = nullptr,
QVariantMap *fault = nullptr, int from = 0);
/*!
* \brief \return Vrai si \p deadline est vide ou de la forme \c HH:MM, de 00:00 à 23:59, deux
* chiffres de chaque côté. \param deadline Valeur de \c needs.dailyDeadline.
*/
static bool deadlineIsWellFormed(const QString &deadline);
/*!
* \brief Contrôle l'échéance quotidienne (\c needs.dailyDeadline), à l'ÉCRITURE seulement.
* \param configs Ensemble candidat.
* \param[out] error Motif du refus, en français ; la valeur refusée n'y est citée que tronquée.
* \param[out] fault Si refus : \c DEADLINE (\c index, \c id).
* \param from Premier index contrôlé ; voir \c appendConfig().
* \return Faux si une échéance n'est pas \c deadlineIsWellFormed().
*
* \note Au chargement, l'entrée est conservée mais l'échéance illisible est **retirée de la
* mémoire** (le fichier n'est pas réécrit) : elle n'atteint ni l'arbitre ni l'optimiseur.
* Sans échéance, une obligation en énergie n'a pas de cadence et rien n'est acheté pour
* elle — le côté sûr pour un mécanisme qui achète. Une échéance illisible n'était de toute
* façon résolue en aucun instant ; elle partait seulement, brute, dans \c GetPlan.
*/
static bool validateDeadlines(const LoadConfigs &configs, QString *error = nullptr,
QVariantMap *fault = nullptr, int from = 0);
/*!
* \brief Les bornes de SAISIE, dans l'ordre : identifiants, libellés, échéances (Q7).
*
* Avant tout autre contrôle, et avant \c reconcileRankOrigin() : les motifs suivants citent
* l'identifiant et le libellé, qui doivent donc déjà être bornés pour entrer au journal.
*
* \param configs Ensemble candidat. \param[out] error Motif du refus, en français.
* \param[out] fault Si refus, le motif de \c validateIds(), \c validateLabels() ou
* \c validateDeadlines(). \param from Premier index contrôlé ; voir \c appendConfig().
* \return Faux au premier manquement.
*/
static bool validateBounds(const LoadConfigs &configs, QString *error = nullptr,
QVariantMap *fault = nullptr, int from = 0);
/*!
* \brief \return Les \p n premiers caractères de \p texte — en points de code, jamais au milieu
* d'une paire de substitution UTF-16 (à l'inverse de \c QString::left()).
* \param texte Texte à couper. \param n Nombre de caractères gardés.
*/
static QString leftChars(const QString &texte, int n);
/*! /*!
* \brief Réconcilie \c rankOrigin d'une écriture CLIENTE avec l'état persisté. LM-1209-c. * \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 incoming Ensemble reçu du client. \param error Motif du refus, si refus.
* \param[out] out Ensemble à persister, \c rankOrigin réconcilié. * \param[out] out Ensemble à persister, \c rankOrigin réconcilié.
* \param[out] fault Si refus : \c RANK_ORIGIN_AUTO (\c index, \c id).
* \return Faux si le client tente de FABRIQUER un \c "auto". * \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 : * Trois règles, et la deuxième est celle qu'on se serait trompé à écrire :
@ -129,7 +239,7 @@ public:
* seule voie, et elle est explicite. L'omission ne lève rien (règle 2). * seule voie, et elle est explicite. L'omission ne lève rien (règle 2).
*/ */
bool reconcileRankOrigin(const LoadConfigs &incoming, LoadConfigs *out, bool reconcileRankOrigin(const LoadConfigs &incoming, LoadConfigs *out,
QString *error = nullptr) const; QString *error = nullptr, QVariantMap *fault = nullptr) const;
signals: signals:
/*! \brief Émis après une persistance réussie de \c setConfigs(). */ /*! \brief Émis après une persistance réussie de \c setConfigs(). */
@ -139,6 +249,8 @@ private:
QString filePath() const; QString filePath() const;
bool load(); bool load();
bool save(QString *error) const; bool save(QString *error) const;
// Valide puis persiste ; les bornes de saisie ne portent qu'à partir de l'index premierBorne.
bool commit(const LoadConfigs &configs, int premierBorne, QString *error, QVariantMap *fault);
LoadConfigs m_configs; LoadConfigs m_configs;
}; };

View File

@ -1662,7 +1662,7 @@ void EnergyArbitrator::provisionEvChargerConfig(EvCharger *ev)
return; return;
const QString id = ev->thing()->id().toString(); const QString id = ev->thing()->id().toString();
LoadConfigs configs = m_loadConfigStore->configs(); const LoadConfigs configs = m_loadConfigStore->configs();
// LM-1209 — EN QUEUE, jamais en tête. L'auto-provisionnement REMPLIT UN TROU : sans entrée, // 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 // la borne serait arbitrée sans rang réglable. Il ne s'ensuit rien sur sa PLACE — le rang
@ -1688,7 +1688,10 @@ void EnergyArbitrator::provisionEvChargerConfig(EvCharger *ev)
LoadConfig c; LoadConfig c;
c.setId(id); c.setId(id);
c.setLabel(ev->name()); // Q7 — le libellé est le nom du Thing, COUPÉ à la borne de saisie (en caractères, jamais au
// milieu d'une paire de substitution). Un nom plus long n'est pas une raison de laisser la
// borne sans rang réglable : le libellé n'est qu'un nom, l'installateur le renomme.
c.setLabel(LoadConfigStore::leftChars(ev->name(), LoadConfigStore::MaxLabelChars));
c.setAdapter(QStringLiteral("evcharger")); c.setAdapter(QStringLiteral("evcharger"));
c.setMode(QStringLiteral("dynamic")); c.setMode(QStringLiteral("dynamic"));
// Le domaine d'une borne n'est pas une devinette : c'est le mécanisme lui-même. La réserve // Le domaine d'une borne n'est pas une devinette : c'est le mécanisme lui-même. La réserve
@ -1704,10 +1707,12 @@ void EnergyArbitrator::provisionEvChargerConfig(EvCharger *ev)
// rang est un choix » — un client ne peut pas le deviner, rien dans le nombre ne le dit. // rang est un choix » — un client ne peut pas le deviner, rien dans le nombre ne le dit.
c.setRankOrigin(QStringLiteral("auto")); c.setRankOrigin(QStringLiteral("auto"));
c.setEnabled(true); c.setEnabled(true);
configs.append(c);
// appendConfig() et non setConfigs() : les bornes de saisie ne portent que sur l'entrée
// AJOUTÉE. Un libellé hérité trop long sur une AUTRE charge refuserait sinon l'ajout — le
// moteur ne sait pas le réparer, et c'est l'installateur qu'il attend (relecture etm72).
QString erreur; QString erreur;
if (!m_loadConfigStore->setConfigs(configs, &erreur)) { if (!m_loadConfigStore->appendConfig(c, &erreur)) {
// 7-c — l'échec ne doit pas être plus discret que le succès : sans entrée, la borne // 7-c — l'échec ne doit pas être plus discret que le succès : sans entrée, la borne
// reste arbitrée mais absente de GetLoadConfig, et c'est précisément le trou qu'on // reste arbitrée mais absente de GetLoadConfig, et c'est précisément le trou qu'on
// vient refermer. // vient refermer.
@ -1720,7 +1725,7 @@ void EnergyArbitrator::provisionEvChargerConfig(EvCharger *ev)
if (!m_provisionRefuse.contains(id)) { if (!m_provisionRefuse.contains(id)) {
m_provisionRefuse.insert(id); m_provisionRefuse.insert(id);
qCWarning(dcNymeaEnergy()) << "[EnergyArbitrator] Configuration automatique de la borne" qCWarning(dcNymeaEnergy()) << "[EnergyArbitrator] Configuration automatique de la borne"
<< ev->name() << "REFUSÉE :" << erreur << c.label() << "REFUSÉE :" << erreur
<< "— elle sera arbitrée sans rang configurable. Réessayé" << "— elle sera arbitrée sans rang configurable. Réessayé"
<< "à chaque cycle ; cet avertissement ne sera pas répété."; << "à chaque cycle ; cet avertissement ne sera pas répété.";
} }
@ -1728,7 +1733,7 @@ void EnergyArbitrator::provisionEvChargerConfig(EvCharger *ev)
} }
m_provisionRefuse.remove(id); m_provisionRefuse.remove(id);
qCInfo(dcNymeaEnergy()) << "[EnergyArbitrator] Borne" << ev->name() qCInfo(dcNymeaEnergy()) << "[EnergyArbitrator] Borne" << c.label()
<< "ajoutée à la configuration des charges (rang" << c.priority() << "ajoutée à la configuration des charges (rang" << c.priority()
<< ", domaine ev, rankOrigin=auto) — entrée créée automatiquement, " << ", domaine ev, rankOrigin=auto) — entrée créée automatiquement, "
"insérée EN QUEUE sans déplacer personne. Le rang est un défaut, " "insérée EN QUEUE sans déplacer personne. Le rang est un défaut, "
@ -3117,15 +3122,29 @@ void EnergyArbitrator::tracerCycle(const SurplusContext &ctx, const Plan &plan,
ligne.insert(QStringLiteral("t"), OptimizerTrace::horodatage(now)); ligne.insert(QStringLiteral("t"), OptimizerTrace::horodatage(now));
const bool optimiseur = plan.strategy == QLatin1String(OptimizerPlan::StrategieOptimiseur); const bool optimiseur = plan.strategy == QLatin1String(OptimizerPlan::StrategieOptimiseur);
const quint64 id = m_socketScheduler ? m_socketScheduler->idRequeteDuCycle() : 0; const quint64 id = m_socketScheduler ? m_socketScheduler->idRequeteDuCycle() : 0;
// Q7 — une requête trop longue pour la ligne du protocole n'est PAS partie : la trace n'en
// garde que la TAILLE (`requestBytes`, octets de la ligne). La recopier écrirait plus de 1 Mio
// par cycle, jusqu'au plafond journalier, pour une requête que personne n'a reçue — et le
// rejeu la renverrait à un optimiseur qui la refuserait. La règle est celle du client
// (getPlanLine, kMaxLineBytes), pas une seconde estimation.
auto requeteOuTaille = [&ligne](quint64 idFil, const QVariantMap &requete) {
const qsizetype octets = OptimizerClient::getPlanLine(idFil, requete).size();
if (octets > OptimizerClient::kMaxLineBytes)
ligne.insert(QStringLiteral("requestBytes"), octets);
else
ligne.insert(QStringLiteral("request"), requete);
};
if (id != 0) { if (id != 0) {
// La requête RÉELLEMENT envoyée ; sa réponse complétera la ligne (ou « no-response »). // La requête soumise au client ; sa réponse complétera la ligne (ou « no-response »).
ligne.insert(QStringLiteral("request"), m_socketScheduler->requeteDuCycle()); // Trop longue, elle n'a pas été écrite et sa réponse sera l'erreur `request-too-long`.
requeteOuTaille(id, m_socketScheduler->requeteDuCycle());
} else { } else {
// Aucune n'est partie (pas d'endpoint, client non connecté) : celle qui serait partie, // Aucune n'est partie (pas d'endpoint, client non connecté) : celle qui serait partie,
// même fonction, même fil — et c'est le rule-based qui y a répondu. // même fonction, même fil — et c'est le rule-based qui y a répondu. Même règle de taille,
// calculée sous un identifiant nul : aucun n'a été attribué.
OptimizerProtocol::ContexteFil fil = contexteFil(ctx); OptimizerProtocol::ContexteFil fil = contexteFil(ctx);
fil.trigger = m_socketScheduler ? m_socketScheduler->declencheurEnAttente() : QStringLiteral("cycle"); fil.trigger = m_socketScheduler ? m_socketScheduler->declencheurEnAttente() : QStringLiteral("cycle");
ligne.insert(QStringLiteral("request"), OptimizerProtocol::getPlanParams(ctx, fil)); requeteOuTaille(0, OptimizerProtocol::getPlanParams(ctx, fil));
ligne.insert(QStringLiteral("response"), ligne.insert(QStringLiteral("response"),
optimiseur ? QVariantMap{{QStringLiteral("error"), optimiseur ? QVariantMap{{QStringLiteral("error"),
QVariantMap{{QStringLiteral("code"), QStringLiteral("not-sent")}}}} QVariantMap{{QStringLiteral("code"), QStringLiteral("not-sent")}}}}

View File

@ -254,12 +254,7 @@ quint64 OptimizerClient::requestPlan(const QVariantMap &params)
const quint64 id = ++m_lastId; const quint64 id = ++m_lastId;
m_pendingId = id; m_pendingId = id;
const QByteArray line = serialize(QVariantMap{ const QByteArray line = getPlanLine(id, params);
{QStringLiteral("jsonrpc"), QStringLiteral("2.0")},
{QStringLiteral("id"), id},
{QStringLiteral("method"), QStringLiteral("GetPlan")},
{QStringLiteral("params"), params},
});
// §2 (1.2, Q7) : le moteur n'émet jamais de ligne de plus de maxLineBytes. Un optimiseur // §2 (1.2, Q7) : le moteur n'émet jamais de ligne de plus de maxLineBytes. Un optimiseur
// conforme la jetterait (-32600 à id null) et la requête finirait en délai dépassé, la cause // conforme la jetterait (-32600 à id null) et la requête finirait en délai dépassé, la cause
@ -704,6 +699,16 @@ void OptimizerClient::countProtocolError(const QString &code, const QString &det
emit protocolError(code); emit protocolError(code);
} }
QByteArray OptimizerClient::getPlanLine(quint64 id, const QVariantMap &params)
{
return serialize(QVariantMap{
{QStringLiteral("jsonrpc"), QStringLiteral("2.0")},
{QStringLiteral("id"), id},
{QStringLiteral("method"), QStringLiteral("GetPlan")},
{QStringLiteral("params"), params},
});
}
QByteArray OptimizerClient::serialize(const QVariantMap &message) QByteArray OptimizerClient::serialize(const QVariantMap &message)
{ {
return QJsonDocument(QJsonObject::fromVariantMap(message)).toJson(QJsonDocument::Compact); return QJsonDocument(QJsonObject::fromVariantMap(message)).toJson(QJsonDocument::Compact);

View File

@ -235,6 +235,15 @@ public:
*/ */
quint64 requestPlan(const QVariantMap &params); quint64 requestPlan(const QVariantMap &params);
/*!
* \brief La ligne \c GetPlan exacte que \c requestPlan() écrirait, sans son \c '\\n'.
* \param id Identifiant JSON-RPC de la requête. \param params Ses \c params.
* \return La ligne sérialisée. Sa taille comparée à \c kMaxLineBytes dit si la requête part :
* c'est la règle de \c requestPlan(), qui l'appelle — la trace de décision la lit aussi,
* pour ne pas recopier une requête qui n'est pas partie.
*/
static QByteArray getPlanLine(quint64 id, const QVariantMap &params);
/*! \brief \return Identifiant de la requête attendue, 0 si aucune. */ /*! \brief \return Identifiant de la requête attendue, 0 si aucune. */
quint64 pendingRequestId() const { return m_pendingId; } quint64 pendingRequestId() const { return m_pendingId; }

View File

@ -30,7 +30,9 @@
* *
* \par Ce qu'une ligne porte (annexe D) * \par Ce qu'une ligne porte (annexe D)
* \c t (instant du cycle, heure locale avec décalage), \c request (les paramètres \c GetPlan * \c t (instant du cycle, heure locale avec décalage), \c request (les paramètres \c GetPlan
* envoyés, ou ceux qui l'auraient été), \c response (ce qui a répondu à CETTE requête), * envoyés, ou ceux qui l'auraient été) — ou, à sa place, \c requestBytes (taille de la ligne,
* en octets) pour une requête trop longue pour partir (\c request-too-long, Q7), \c response
* (ce qui a répondu à CETTE requête),
* \c executed (actions appliquées au cycle), \c strategy (\c optimizer | \c rules | \c none), * \c executed (actions appliquées au cycle), \c strategy (\c optimizer | \c rules | \c none),
* \c latencyMs (optimiseur seulement). Jamais le token : il ne voyage que dans le handshake ; * \c latencyMs (optimiseur seulement). Jamais le token : il ne voyage que dans le handshake ;
* le client masque toute réponse qui le recopierait (\c OptimizerSecret), et l'écrivain masque * le client masque toute réponse qui le recopierait (\c OptimizerSecret), et l'écrivain masque
@ -195,7 +197,8 @@ public:
/*! /*!
* \brief Ligne d'un cycle. * \brief Ligne d'un cycle.
* \param ligne \c t, \c request, \c response (si connue), \c executed, \c strategy. * \param ligne \c t, \c request (ou \c requestBytes), \c response (si connue), \c executed,
* \c strategy.
* \param t Instant du cycle (choisit le fichier). * \param t Instant du cycle (choisit le fichier).
* \param idRequete Identifiant de la requête envoyée ce cycle ; 0 = aucune (la ligne part). * \param idRequete Identifiant de la requête envoyée ce cycle ; 0 = aucune (la ligne part).
*/ */

View File

@ -160,7 +160,7 @@ signals:
void reponseJugee(const QString &issue, const QString &code); void reponseJugee(const QString &issue, const QString &code);
/*! \brief La requête attendue n'a pas eu de réponse utilisable. \param code Code du client /*! \brief La requête attendue n'a pas eu de réponse utilisable. \param code Code du client
* (\c timeout, \c disconnected, \c rpc-error, \c invalid-response). */ * (\c timeout, \c disconnected, \c rpc-error, \c invalid-response, \c request-too-long). */
void echecRequete(const QString &code); void echecRequete(const QString &code);
/*! /*!

View File

@ -272,6 +272,39 @@ QString renderFr(const DecisionReason &reason, const QString &label)
ou.isEmpty() ? QString() : QStringLiteral(" : ") + ou.join(QStringLiteral(", "))); ou.isEmpty() ? QString() : QStringLiteral(" : ") + ou.join(QStringLiteral(", ")));
} }
if (c == QLatin1String(DecisionCode::LoadConfigRefused)) {
// L'ENDROIT, comme pour le tarif : une liste de charges ne se corrige pas à l'aveugle.
// Charges numérotées à partir de 1 pour la lecture humaine ; jamais le libellé.
QStringList ou;
if (p.contains(QStringLiteral("index"))) {
const QString id = p.value(QStringLiteral("id")).toString();
ou << (id.isEmpty() ? QStringLiteral("charge %1").arg(p.value(QStringLiteral("index")).toInt() + 1)
: QStringLiteral("charge %1 « %2 »")
.arg(p.value(QStringLiteral("index")).toInt() + 1).arg(id));
}
if (p.contains(QStringLiteral("indexes"))) {
const QVariantList n = p.value(QStringLiteral("indexes")).toList();
if (n.count() == 2)
ou << QStringLiteral("charges %1 et %2").arg(n.at(0).toInt() + 1).arg(n.at(1).toInt() + 1);
}
if (p.contains(QStringLiteral("field")))
ou << QStringLiteral("champ %1").arg(p.value(QStringLiteral("field")).toString());
if (p.contains(QStringLiteral("state")))
ou << QStringLiteral("état %1").arg(p.value(QStringLiteral("state")).toInt());
if (p.contains(QStringLiteral("rank")))
ou << QStringLiteral("rang %1").arg(p.value(QStringLiteral("rank")).toInt());
if (p.contains(QStringLiteral("length")))
ou << QStringLiteral("%1 caractères sur %2 au plus")
.arg(p.value(QStringLiteral("length")).toLongLong())
.arg(p.value(QStringLiteral("max")).toInt());
if (p.contains(QStringLiteral("position")))
ou << QStringLiteral("caractère %1 hors [A-Za-z0-9._{}-]")
.arg(p.value(QStringLiteral("position")).toLongLong() + 1);
return QStringLiteral("Configuration des charges refusée (%1)%2 — rien n'est modifié")
.arg(p.value(QStringLiteral("cause")).toString(),
ou.isEmpty() ? QString() : QStringLiteral(" : ") + ou.join(QStringLiteral(", ")));
}
if (c == QLatin1String(DecisionCode::SpotMarketNotInBuild)) if (c == QLatin1String(DecisionCode::SpotMarketNotInBuild))
// LE GESTE : aucun réglage ne l'active, c'est le paquet qui ne le contient pas. // LE GESTE : aucun réglage ne l'active, c'est le paquet qui ne le contient pas.
return QStringLiteral("Marché spot non disponible dans ce build — aucun fournisseur " return QStringLiteral("Marché spot non disponible dans ce build — aucun fournisseur "
@ -354,7 +387,7 @@ QStringList DecisionCode::knownCodes()
EcoFloorGrid, EcoFloorBelowStage, EcoFloorMet, EcoFloorMissed, RecreditSuspended, EcoFloorGrid, EcoFloorBelowStage, EcoFloorMet, EcoFloorMissed, RecreditSuspended,
DerogationActive, DerogationActive,
DerogationEnded, MissingForecastSite, ForecastPayloadRefused, SpotMarketNotInBuild, DerogationEnded, MissingForecastSite, ForecastPayloadRefused, SpotMarketNotInBuild,
ForecastDateUnreadable, TariffRefused, ForecastDateUnreadable, TariffRefused, LoadConfigRefused,
LockMinStateHold, SgForced, SgRecommended, SgNormal, LockMinStateHold, SgForced, SgRecommended, SgNormal,
OptimizerDecision, PriceSlot, OptimizerDecision, PriceSlot,
DegradedL2, SafeStateRelay, SafeStateSetpoint, SafeStateSgReady}; DegradedL2, SafeStateRelay, SafeStateSetpoint, SafeStateSgReady};

View File

@ -374,6 +374,27 @@ constexpr const char *ForecastDateUnreadable = "FORECAST_DATE_UNREADABLE";
*/ */
constexpr const char *TariffRefused = "TARIFF_REFUSED"; constexpr const char *TariffRefused = "TARIFF_REFUSED";
/*!
* \brief Une configuration des charges est REFUSÉE par \c SetLoadConfig — rien n'est modifié.
* params : \c cause, et OÙ.
* - Par entrée — \c index (position dans \c loadConfigs, à partir de 0) et \c id quand
* l'identifiant est bien formé : \c ID (identifiant vide, trop long ou hors jeu ; \c length,
* \c max, \c position du premier caractère hors jeu — jamais \c id), \c LABEL (\c length,
* \c max), \c DEADLINE, \c MODE, \c DOMAIN, \c RANK_ORIGIN (hors énumération),
* \c RANK_ORIGIN_AUTO (\c "auto" posé par un client), \c ADAPTER, \c FIELD_FORBIDDEN,
* \c FIELD_REQUIRED, \c FIELD_RANGE, \c FIELD_DUPLICATE (\c field, et \c state pour un état
* SG-Ready), \c SG_READY_STATE_2, \c ID_DUPLICATE.
* - Entre deux entrées — \c indexes et \c ids (la paire) : \c THING_SHARED, \c RANK_DUPLICATE
* (avec \c rank).
* - Sans entrée : \c WRITE_FAILED (persistance impossible), \c STORE_UNAVAILABLE.
* Liste faisant foi : \c LoadConfig::isValid() et \c LoadConfigStore.
*
* \note **Pas un motif de décision** : \c fault de \c SetLoadConfig à côté de
* \c EnergyErrorInvalidParameter, inchangé. Jamais le libellé : le client nomme la charge avec
* celui qu'il vient lui-même d'envoyer, à la position donnée.
*/
constexpr const char *LoadConfigRefused = "LOAD_CONFIG_REFUSED";
// --- SG-Ready (PAC) --------------------------------------------------------- // --- SG-Ready (PAC) ---------------------------------------------------------
//! params: \c state — le verrou minStateHold maintient l'état (court-cycling compresseur). //! params: \c state — le verrou minStateHold maintient l'état (court-cycling compresseur).
constexpr const char *LockMinStateHold = "LOCK_MIN_STATE_HOLD"; constexpr const char *LockMinStateHold = "LOCK_MIN_STATE_HOLD";

View File

@ -194,17 +194,31 @@ QSet<QString> LoadConfig::claimedThingIds() const
return ids; return ids;
} }
bool LoadConfig::isValid(QString *error) const bool LoadConfig::isValid(QString *error, QVariantMap *fault) const
{ {
auto fail = [&](const QString &msg) { if (error) *error = msg; return false; }; // Chaque refus porte DEUX choses : la phrase française du journal, inchangée, et le motif
// publiable de SetLoadConfig — une cause, et l'endroit (champ, état). Les deux sont posées au
// même endroit, refus par refus : une table séparée des causes divergerait de ces branches.
auto fail = [&](const char *cause, const QString &msg, const QString &field = QString(),
int state = 0) {
if (error) *error = msg;
if (fault) {
*fault = QVariantMap{{QStringLiteral("cause"), QLatin1String(cause)}};
if (!field.isEmpty())
fault->insert(QStringLiteral("field"), field);
if (state > 0)
fault->insert(QStringLiteral("state"), state);
}
return false;
};
// Validation CONDITIONNELLE au type (contrat rév. 3) — pas "tous champs requis". // Validation CONDITIONNELLE au type (contrat rév. 3) — pas "tous champs requis".
// Les deux formes (relay-router / etmvariableload) sont mutuellement exclusives ; le schéma // Les deux formes (relay-router / etmvariableload) sont mutuellement exclusives ; le schéma
// JSON-RPC SET marque TOUS les champs spécifiques en optionnel, c'est ICI qu'on tranche. // JSON-RPC SET marque TOUS les champs spécifiques en optionnel, c'est ICI qu'on tranche.
if (m_id.isEmpty()) if (m_id.isEmpty())
return fail(QStringLiteral("id de charge vide")); return fail("ID", QStringLiteral("id de charge vide"));
if (m_mode != QStringLiteral("fixed") && m_mode != QStringLiteral("dynamic")) if (m_mode != QStringLiteral("fixed") && m_mode != QStringLiteral("dynamic"))
return fail(QStringLiteral("mode doit être \"fixed\" ou \"dynamic\" (reçu: %1)").arg(m_mode)); return fail("MODE", QStringLiteral("mode doit être \"fixed\" ou \"dynamic\" (reçu: %1)").arg(m_mode));
// Domaine : métadonnée d'intention, OPTIONNELLE. Absent = charge non classée — le plugin ne // Domaine : métadonnée d'intention, OPTIONNELLE. Absent = charge non classée — le plugin ne
// la range pas d'office, parce qu'il n'a aucun moyen de deviner juste et qu'un classement // la range pas d'office, parce qu'il n'a aucun moyen de deviner juste et qu'un classement
@ -212,7 +226,7 @@ bool LoadConfig::isValid(QString *error) const
// l'énumération : une valeur libre acceptée aujourd'hui deviendrait une valeur à supporter // l'énumération : une valeur libre acceptée aujourd'hui deviendrait une valeur à supporter
// le jour où la couche domaine en fera son discriminant. // le jour où la couche domaine en fera son discriminant.
if (!m_domain.isEmpty() && !knownDomains().contains(m_domain)) if (!m_domain.isEmpty() && !knownDomains().contains(m_domain))
return fail(QStringLiteral("domain \"%1\" hors énumération (attendu : %2, ou absent " return fail("DOMAIN", QStringLiteral("domain \"%1\" hors énumération (attendu : %2, ou absent "
"pour une charge non classée)") "pour une charge non classée)")
.arg(m_domain, knownDomains().join(QStringLiteral(", ")))); .arg(m_domain, knownDomains().join(QStringLiteral(", "))));
@ -221,7 +235,7 @@ bool LoadConfig::isValid(QString *error) const
// question n'a de sens que face à l'état PERSISTÉ, et elle est donc tranchée à la // question n'a de sens que face à l'état PERSISTÉ, et elle est donc tranchée à la
// frontière d'écriture (LoadConfigStore::reconcileRankOrigin). // frontière d'écriture (LoadConfigStore::reconcileRankOrigin).
if (!m_rankOrigin.isEmpty() && !knownRankOrigins().contains(m_rankOrigin)) if (!m_rankOrigin.isEmpty() && !knownRankOrigins().contains(m_rankOrigin))
return fail(QStringLiteral("rankOrigin \"%1\" hors énumération (attendu : %2, ou absent " return fail("RANK_ORIGIN", QStringLiteral("rankOrigin \"%1\" hors énumération (attendu : %2, ou absent "
"— l'absence n'affirme rien)") "— l'absence n'affirme rien)")
.arg(m_rankOrigin, knownRankOrigins().join(QStringLiteral(", ")))); .arg(m_rankOrigin, knownRankOrigins().join(QStringLiteral(", "))));
@ -232,38 +246,50 @@ bool LoadConfig::isValid(QString *error) const
const bool aRelays = !m_relays.isEmpty(); const bool aRelays = !m_relays.isEmpty();
const bool aSgReady = !m_sgReady.states.isEmpty(); const bool aSgReady = !m_sgReady.states.isEmpty();
const bool aLevels = !m_powerLevels.isEmpty() || m_maxPowerW > 0; const bool aLevels = !m_powerLevels.isEmpty() || m_maxPowerW > 0;
// Le champ NOMMÉ par un refus groupé : celui qui est effectivement posé.
const QString champNiveaux = !m_powerLevels.isEmpty() ? QStringLiteral("powerLevels")
: QStringLiteral("maxPowerW");
const QString champVerrous = m_minOnS != 0 ? QStringLiteral("minOnS") : QStringLiteral("minOffS");
if (m_adapter == QStringLiteral("sg-ready")) { if (m_adapter == QStringLiteral("sg-ready")) {
if (aRelays) if (aRelays)
return fail(QStringLiteral("sg-ready : relays[] interdit (charge utile d'un autre mécanisme)")); return fail("FIELD_FORBIDDEN", QStringLiteral("sg-ready : relays[] interdit (charge utile d'un autre mécanisme)"),
QStringLiteral("relays"));
if (aLevels) if (aLevels)
return fail(QStringLiteral("sg-ready : powerLevels/maxPowerW interdits (charge utile d'un autre mécanisme)")); return fail("FIELD_FORBIDDEN", QStringLiteral("sg-ready : powerLevels/maxPowerW interdits (charge utile d'un autre mécanisme)"),
champNiveaux);
if (!aSgReady) if (!aSgReady)
return fail(QStringLiteral("sg-ready : charge utile sgReady.states[] requise (non vide)")); return fail("FIELD_REQUIRED", QStringLiteral("sg-ready : charge utile sgReady.states[] requise (non vide)"),
QStringLiteral("sgReady.states"));
if (m_mode != QStringLiteral("fixed")) if (m_mode != QStringLiteral("fixed"))
return fail(QStringLiteral("sg-ready : mode doit être \"fixed\" (4 états normés, pas une modulation)")); return fail("MODE", QStringLiteral("sg-ready : mode doit être \"fixed\" (4 états normés, pas une modulation)"));
if (m_sgReady.minStateHoldS < 0) if (m_sgReady.minStateHoldS < 0)
return fail(QStringLiteral("sg-ready : minStateHoldS doit être ≥ 0")); return fail("FIELD_RANGE", QStringLiteral("sg-ready : minStateHoldS doit être ≥ 0"),
QStringLiteral("sgReady.minStateHoldS"));
QList<int> vus; QList<int> vus;
for (const LoadConfigSgReadyState &e : m_sgReady.states) { for (const LoadConfigSgReadyState &e : m_sgReady.states) {
if (e.state < 1 || e.state > 4) if (e.state < 1 || e.state > 4)
return fail(QStringLiteral("sg-ready : état %1 hors plage (1-4 normés)").arg(e.state)); return fail("FIELD_RANGE", QStringLiteral("sg-ready : état %1 hors plage (1-4 normés)").arg(e.state),
QStringLiteral("sgReady.states.state"));
if (vus.contains(e.state)) if (vus.contains(e.state))
return fail(QStringLiteral("sg-ready : état %1 déclaré deux fois").arg(e.state)); return fail("FIELD_DUPLICATE", QStringLiteral("sg-ready : état %1 déclaré deux fois").arg(e.state),
QStringLiteral("sgReady.states.state"), e.state);
vus.append(e.state); vus.append(e.state);
if (e.estimatedPowerW < 0) if (e.estimatedPowerW < 0)
return fail(QStringLiteral("sg-ready : état %1 a estimatedPowerW < 0").arg(e.state)); return fail("FIELD_RANGE", QStringLiteral("sg-ready : état %1 a estimatedPowerW < 0").arg(e.state),
QStringLiteral("sgReady.states.estimatedPowerW"), e.state);
for (const QString &r : e.relays) for (const QString &r : e.relays)
if (r.isEmpty()) if (r.isEmpty())
return fail(QStringLiteral("sg-ready : état %1 référence un thingId vide").arg(e.state)); return fail("FIELD_REQUIRED", QStringLiteral("sg-ready : état %1 référence un thingId vide").arg(e.state),
QStringLiteral("sgReady.states.relays"), e.state);
QSet<QString> vusEtat; QSet<QString> vusEtat;
for (const QString &r : e.relays) { for (const QString &r : e.relays) {
// Un même relais dans PLUSIEURS états est normal (état 4 = état 1 + état 3) ; // Un même relais dans PLUSIEURS états est normal (état 4 = état 1 + état 3) ;
// deux fois dans le MÊME état ne l'est pas. // deux fois dans le MÊME état ne l'est pas.
if (vusEtat.contains(normalizedThingId(r))) if (vusEtat.contains(normalizedThingId(r)))
return fail(QStringLiteral("sg-ready : état %1 déclare deux fois le Thing %2") return fail("FIELD_DUPLICATE", QStringLiteral("sg-ready : état %1 déclare deux fois le Thing %2")
.arg(e.state).arg(r)); .arg(e.state).arg(r), QStringLiteral("sgReady.states.relays"), e.state);
vusEtat.insert(normalizedThingId(r)); vusEtat.insert(normalizedThingId(r));
} }
} }
@ -272,34 +298,37 @@ bool LoadConfig::isValid(QString *error) const
// L2. Une PAC dont la configuration ne permet pas de l'exprimer est REFUSÉE, jamais // L2. Une PAC dont la configuration ne permet pas de l'exprimer est REFUSÉE, jamais
// construite : c'est le garde-fou qui remplace le Q_ASSERT disparu en release. // construite : c'est le garde-fou qui remplace le Q_ASSERT disparu en release.
if (!m_sgReady.hasState(2)) if (!m_sgReady.hasState(2))
return fail(QStringLiteral("sg-ready : l'état 2 (normal) est OBLIGATOIRE — c'est le " return fail("SG_READY_STATE_2", QStringLiteral("sg-ready : l'état 2 (normal) est OBLIGATOIRE — c'est le "
"repli sûr du mode dégradé L2, sans lui la charge ne peut " "repli sûr du mode dégradé L2, sans lui la charge ne peut "
"pas être mise en sécurité")); "pas être mise en sécurité"));
return true; return true;
} }
if (aSgReady) if (aSgReady)
return fail(QStringLiteral("%1 : sgReady interdit (charge utile du mécanisme sg-ready)").arg(m_adapter)); return fail("FIELD_FORBIDDEN", QStringLiteral("%1 : sgReady interdit (charge utile du mécanisme sg-ready)").arg(m_adapter),
QStringLiteral("sgReady"));
if (m_adapter == QStringLiteral("evcharger")) { if (m_adapter == QStringLiteral("evcharger")) {
// 3g-2 — la borne n'apporte QUE son rang. Ses limites de courant, son nombre de phases // 3g-2 — la borne n'apporte QUE son rang. Ses limites de courant, son nombre de phases
// et sa disponibilité viennent du Thing et changent en continu ; les déclarer ici en // et sa disponibilité viennent du Thing et changent en continu ; les déclarer ici en
// ferait une seconde source de vérité, périmée dès la première voiture branchée. // ferait une seconde source de vérité, périmée dès la première voiture branchée.
if (aRelays) if (aRelays)
return fail(QStringLiteral("evcharger : relays[] interdit (charge utile d'un autre mécanisme)")); return fail("FIELD_FORBIDDEN", QStringLiteral("evcharger : relays[] interdit (charge utile d'un autre mécanisme)"),
QStringLiteral("relays"));
if (aLevels) if (aLevels)
return fail(QStringLiteral("evcharger : powerLevels/maxPowerW interdits — les limites " return fail("FIELD_FORBIDDEN", QStringLiteral("evcharger : powerLevels/maxPowerW interdits — les limites "
"de la borne viennent du Thing, pas de la configuration")); "de la borne viennent du Thing, pas de la configuration"), champNiveaux);
if (m_minPowerW > 0) if (m_minPowerW > 0)
return fail(QStringLiteral("evcharger : minPowerW interdit — le plancher est dérivé du " return fail("FIELD_FORBIDDEN", QStringLiteral("evcharger : minPowerW interdit — le plancher est dérivé du "
"courant minimal de la borne et du véhicule branché")); "courant minimal de la borne et du véhicule branché"),
QStringLiteral("minPowerW"));
if (m_minOnS != 0 || m_minOffS != 0) if (m_minOnS != 0 || m_minOffS != 0)
return fail(QStringLiteral("evcharger : minOnS/minOffS interdits — les verrous d'une " return fail("FIELD_FORBIDDEN", QStringLiteral("evcharger : minOnS/minOffS interdits — les verrous d'une "
"borne vivent dans le manager amont")); "borne vivent dans le manager amont"), champVerrous);
// Une borne module en continu : le mode le dit, comme pour tout autre mécanisme. On // Une borne module en continu : le mode le dit, comme pour tout autre mécanisme. On
// garde le champ plutôt que de l'exempter, pour que la forme de l'union reste UNE. // garde le champ plutôt que de l'exempter, pour que la forme de l'union reste UNE.
if (m_mode != QStringLiteral("dynamic")) if (m_mode != QStringLiteral("dynamic"))
return fail(QStringLiteral("evcharger : mode doit être \"dynamic\" (une borne module, " return fail("MODE", QStringLiteral("evcharger : mode doit être \"dynamic\" (une borne module, "
"elle n'a pas de paliers)")); "elle n'a pas de paliers)"));
return true; return true;
} }
@ -307,60 +336,70 @@ bool LoadConfig::isValid(QString *error) const
if (m_adapter == QStringLiteral("relay-router")) { if (m_adapter == QStringLiteral("relay-router")) {
// Cas relais (rév. 3) : liste de relais power. powerLevels/maxPowerW DÉRIVÉS (ignorés ici). // Cas relais (rév. 3) : liste de relais power. powerLevels/maxPowerW DÉRIVÉS (ignorés ici).
if (m_mode != QStringLiteral("fixed")) if (m_mode != QStringLiteral("fixed"))
return fail(QStringLiteral("relay-router : mode doit être \"fixed\"")); return fail("MODE", QStringLiteral("relay-router : mode doit être \"fixed\""));
if (m_relays.isEmpty()) if (m_relays.isEmpty())
return fail(QStringLiteral("relay-router : relays[] requis (non vide)")); return fail("FIELD_REQUIRED", QStringLiteral("relay-router : relays[] requis (non vide)"),
QStringLiteral("relays"));
QSet<QString> vus; QSet<QString> vus;
for (const LoadConfigRelay &r : m_relays) { for (const LoadConfigRelay &r : m_relays) {
if (r.thingId.isEmpty()) if (r.thingId.isEmpty())
return fail(QStringLiteral("relay-router : un relais a un thingId vide")); return fail("FIELD_REQUIRED", QStringLiteral("relay-router : un relais a un thingId vide"),
QStringLiteral("relays.thingId"));
if (r.powerW <= 0) if (r.powerW <= 0)
return fail(QStringLiteral("relay-router : relais %1 a powerW ≤ 0").arg(r.thingId)); return fail("FIELD_RANGE", QStringLiteral("relay-router : relais %1 a powerW ≤ 0").arg(r.thingId),
QStringLiteral("relays.powerW"));
// ECS-110 — deux étages sur le même Thing : le routeur les fusionnerait // ECS-110 — deux étages sur le même Thing : le routeur les fusionnerait
// silencieusement, et la puissance annoncée serait le double de la réelle. // silencieusement, et la puissance annoncée serait le double de la réelle.
if (vus.contains(normalizedThingId(r.thingId))) if (vus.contains(normalizedThingId(r.thingId)))
return fail(QStringLiteral("relay-router : le Thing %1 est déclaré sur deux étages") return fail("FIELD_DUPLICATE", QStringLiteral("relay-router : le Thing %1 est déclaré sur deux étages")
.arg(r.thingId)); .arg(r.thingId), QStringLiteral("relays.thingId"));
vus.insert(normalizedThingId(r.thingId)); vus.insert(normalizedThingId(r.thingId));
} }
if (m_minOnS < 0 || m_minOffS < 0) if (m_minOnS < 0 || m_minOffS < 0)
return fail(QStringLiteral("relay-router : minOnS/minOffS doivent être ≥ 0")); return fail("FIELD_RANGE", QStringLiteral("relay-router : minOnS/minOffS doivent être ≥ 0"),
m_minOnS < 0 ? QStringLiteral("minOnS") : QStringLiteral("minOffS"));
// §12 / LM-1202 — même raison qu'en etmvariableload fixed : ici le plancher est le // §12 / LM-1202 — même raison qu'en etmvariableload fixed : ici le plancher est le
// plus petit palier atteignable, donc il se dérive de relays[]. // plus petit palier atteignable, donc il se dérive de relays[].
if (m_minPowerW > 0) if (m_minPowerW > 0)
return fail(QStringLiteral("relay-router : minPowerW interdit — le plancher est le " return fail("FIELD_FORBIDDEN", QStringLiteral("relay-router : minPowerW interdit — le plancher est le "
"plus petit palier dérivé des relais")); "plus petit palier dérivé des relais"), QStringLiteral("minPowerW"));
} else if (m_adapter == QStringLiteral("etmvariableload")) { } else if (m_adapter == QStringLiteral("etmvariableload")) {
// Cas thing unique : continu (dynamic) ou multipalier natif (fixed + powerLevels, legacy). // Cas thing unique : continu (dynamic) ou multipalier natif (fixed + powerLevels, legacy).
if (!m_relays.isEmpty()) if (!m_relays.isEmpty())
return fail(QStringLiteral("etmvariableload : relays[] interdit (réservé à relay-router)")); return fail("FIELD_FORBIDDEN", QStringLiteral("etmvariableload : relays[] interdit (réservé à relay-router)"),
QStringLiteral("relays"));
if (m_mode == QStringLiteral("fixed")) { if (m_mode == QStringLiteral("fixed")) {
if (m_powerLevels.isEmpty()) if (m_powerLevels.isEmpty())
return fail(QStringLiteral("etmvariableload fixed : powerLevels requis (non vide)")); return fail("FIELD_REQUIRED", QStringLiteral("etmvariableload fixed : powerLevels requis (non vide)"),
QStringLiteral("powerLevels"));
if (m_powerLevels.first() != 0) if (m_powerLevels.first() != 0)
return fail(QStringLiteral("etmvariableload fixed : powerLevels doit inclure 0")); return fail("FIELD_RANGE", QStringLiteral("etmvariableload fixed : powerLevels doit inclure 0"),
QStringLiteral("powerLevels"));
// §12 / LM-1202 — en fixed le plancher est le plus petit palier non nul : il se // §12 / LM-1202 — en fixed le plancher est le plus petit palier non nul : il se
// DÉRIVE. Le déclarer en plus ouvrirait deux sources d'une même vérité, et rien // DÉRIVE. Le déclarer en plus ouvrirait deux sources d'une même vérité, et rien
// ne dirait laquelle fait foi le jour où elles divergent. // ne dirait laquelle fait foi le jour où elles divergent.
if (m_minPowerW > 0) if (m_minPowerW > 0)
return fail(QStringLiteral("etmvariableload fixed : minPowerW interdit — le " return fail("FIELD_FORBIDDEN", QStringLiteral("etmvariableload fixed : minPowerW interdit — le "
"plancher est le plus petit palier non nul")); "plancher est le plus petit palier non nul"),
QStringLiteral("minPowerW"));
} else { // dynamic } else { // dynamic
if (m_maxPowerW <= 0) if (m_maxPowerW <= 0)
return fail(QStringLiteral("etmvariableload dynamic : maxPowerW requis (> 0)")); return fail("FIELD_REQUIRED", QStringLiteral("etmvariableload dynamic : maxPowerW requis (> 0)"),
QStringLiteral("maxPowerW"));
if (!m_powerLevels.isEmpty()) if (!m_powerLevels.isEmpty())
return fail(QStringLiteral("etmvariableload dynamic : powerLevels doit être absent")); return fail("FIELD_FORBIDDEN", QStringLiteral("etmvariableload dynamic : powerLevels doit être absent"),
QStringLiteral("powerLevels"));
// Un plancher au-dessus du plafond rendrait la charge inatteignable : le domaine // Un plancher au-dessus du plafond rendrait la charge inatteignable : le domaine
// {0} ∪ [min, max] serait réduit à {0}, et la charge ne démarrerait jamais. // {0} ∪ [min, max] serait réduit à {0}, et la charge ne démarrerait jamais.
if (m_minPowerW > static_cast<uint>(m_maxPowerW)) if (m_minPowerW > static_cast<uint>(m_maxPowerW))
return fail(QStringLiteral("etmvariableload dynamic : minPowerW (%1 W) dépasse " return fail("FIELD_RANGE", QStringLiteral("etmvariableload dynamic : minPowerW (%1 W) dépasse "
"maxPowerW (%2 W) — la charge ne pourrait jamais démarrer") "maxPowerW (%2 W) — la charge ne pourrait jamais démarrer")
.arg(m_minPowerW).arg(m_maxPowerW)); .arg(m_minPowerW).arg(m_maxPowerW), QStringLiteral("minPowerW"));
} }
} else { } else {
return fail(QStringLiteral("mécanisme inconnu \"%1\" (attendu \"relay-router\", " return fail("ADAPTER", QStringLiteral("mécanisme inconnu \"%1\" (attendu \"relay-router\", "
"\"etmvariableload\", \"sg-ready\" ou \"evcharger\")").arg(m_adapter)); "\"etmvariableload\", \"sg-ready\" ou \"evcharger\")").arg(m_adapter));
} }
return true; return true;

View File

@ -379,9 +379,14 @@ public:
/*! /*!
* \brief Valide la cohérence (contrat §4/§6) : adapter, id, mode, paliers/plafond. * \brief Valide la cohérence (contrat §4/§6) : adapter, id, mode, paliers/plafond.
* \param[out] error Message FR si invalide. * \param[out] error Message FR si invalide.
* \param[out] fault Si invalide, le motif publiable de \c SetLoadConfig : \c cause (\c MODE,
* \c DOMAIN, \c RANK_ORIGIN, \c ADAPTER, \c FIELD_FORBIDDEN, \c FIELD_REQUIRED,
* \c FIELD_RANGE, \c FIELD_DUPLICATE, \c SG_READY_STATE_2, ou \c ID pour un id vide), et
* selon le cas \c field (chemin du champ en cause) et \c state (état SG-Ready). Des codes,
* jamais une phrase ; l'appelant y ajoute la position de l'entrée.
* \return true si la config est exploitable. * \return true si la config est exploitable.
*/ */
bool isValid(QString *error = nullptr) const; bool isValid(QString *error = nullptr, QVariantMap *fault = nullptr) const;
//! \return Forme sérialisée §4. Les champs \c relays/\c minOnS/\c minOffS ne sont émis //! \return Forme sérialisée §4. Les champs \c relays/\c minOnS/\c minOffS ne sont émis
//! que s'il existe au moins un relais ; \c powerLevels que s'il est non vide. //! que s'il existe au moins un relais ; \c powerLevels que s'il est non vide.

View File

@ -195,7 +195,13 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
params.clear(); returns.clear(); params.clear(); returns.clear();
description = "Replace the full set of controllable load configs. Rejected as a whole " description = "Replace the full set of controllable load configs. Rejected as a whole "
"(energyError) if any entry is invalid; persisted and applied atomically."; "(energyError) if any entry is invalid; persisted and applied atomically. "
"'id' is 1 to 128 characters in [A-Za-z0-9._{}-]; 'label' at most 256 "
"characters (Unicode code points); 'needs.dailyDeadline' empty or HH:MM "
"(00:00-23:59). On refusal: EnergyErrorInvalidParameter, 'fault' "
"LOAD_CONFIG_REFUSED and 'faultParams' {cause, index?, id?, indexes?, ids?, "
"field?, state?, rank?, length?, max?, position?} - codes and positions, never "
"a sentence - and NOTHING is changed.";
// Schéma inline (pas objectRef<LoadConfig>) : le contrat rév. 3 a DEUX formes exclusives // Schéma inline (pas objectRef<LoadConfig>) : le contrat rév. 3 a DEUX formes exclusives
// (relay-router : relays[]+minOnS/minOffS — etmvariableload : powerLevels|maxPowerW). // (relay-router : relays[]+minOnS/minOffS — etmvariableload : powerLevels|maxPowerW).
// objectRef rendrait TOUS les champs requis → nymea rejetterait une des deux formes AVANT // objectRef rendrait TOUS les champs requis → nymea rejetterait une des deux formes AVANT
@ -284,6 +290,10 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
loadItem.insert("o:minOffS", enumValueName(Uint)); loadItem.insert("o:minOffS", enumValueName(Uint));
params.insert("loadConfigs", QVariantList() << loadItem); params.insert("loadConfigs", QVariantList() << loadItem);
returns.insert("energyError", enumRef<EnergyManager::EnergyError>()); returns.insert("energyError", enumRef<EnergyManager::EnergyError>());
// Ajoutés le 2026-10-22 (Q7, suite) : OPTIONNELS, présents sur refus seulement. energyError
// garde sa valeur ; un succès rend exactement ce qu'il rendait.
returns.insert("o:fault", enumValueName(String)); // LOAD_CONFIG_REFUSED
returns.insert("o:faultParams", enumValueName(Variant));
registerMethod("SetLoadConfig", description, params, returns, Types::PermissionScopeConfigureThings); registerMethod("SetLoadConfig", description, params, returns, Types::PermissionScopeConfigureThings);
// [Phase 2] Ratios canoniques autoconsommation / autonomie (% [0,100]). Champ omis // [Phase 2] Ratios canoniques autoconsommation / autonomie (% [0,100]). Champ omis
@ -1139,30 +1149,45 @@ JsonReply *NymeaEnergyJsonHandler::GetLoadConfig(const QVariantMap &params)
JsonReply *NymeaEnergyJsonHandler::SetLoadConfig(const QVariantMap &params) JsonReply *NymeaEnergyJsonHandler::SetLoadConfig(const QVariantMap &params)
{ {
if (!m_loadConfigStore) { // Même forme que SetTariff : le code d'erreur amont, inchangé, et à côté le MOTIF publiable —
qCWarning(dcNymeaEnergy()) << "[NymeaEnergy] SetLoadConfig sans store configuré."; // une cause et un endroit, jamais une phrase (règle 12). Le journal ne porte que la phrase
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)}}); // française rendue depuis ce motif, comme pour SetTariff : la phrase détaillée du refus
} // recopie ce que le client a saisi (mode, domaine, relais, libellé…), et un saut de ligne y
// fabriquerait une fausse ligne de journal (relecture q7-suite, I-1).
auto refuser = [this](const QVariantMap &faultParams) {
// 7-d — le refus au moins aussi haut que l'enregistrement.
qCWarning(dcNymeaEnergy()).noquote()
<< QStringLiteral("[NymeaEnergy] SetLoadConfig rejetée : %1 (motif %2)")
.arg(renderFr({DecisionCode::LoadConfigRefused, faultParams}),
QLatin1String(DecisionCode::LoadConfigRefused));
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)},
{"fault", QLatin1String(DecisionCode::LoadConfigRefused)},
{"faultParams", faultParams}});
};
if (!m_loadConfigStore)
return refuser({{QStringLiteral("cause"), QStringLiteral("STORE_UNAVAILABLE")}});
LoadConfigs configs; LoadConfigs configs;
foreach (const QVariant &item, params.value("loadConfigs").toList()) foreach (const QVariant &item, params.value("loadConfigs").toList())
configs.append(LoadConfig::fromMap(item.toMap())); configs.append(LoadConfig::fromMap(item.toMap()));
QString why; QVariantMap faultParams;
// Q7 — les bornes de saisie AVANT tout le reste, réconciliation comprise : le motif ne cite
// un identifiant que bien formé.
if (!LoadConfigStore::validateBounds(configs, nullptr, &faultParams))
return refuser(faultParams);
// LM-1209-c — la marque de rang se réconcilie ICI, à la frontière CLIENTE, et nulle part // 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, // 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. // lui, pose légitimement "auto" en créant une entrée d'office.
LoadConfigs reconciliees; LoadConfigs reconciliees;
if (!m_loadConfigStore->reconcileRankOrigin(configs, &reconciliees, &why)) { if (!m_loadConfigStore->reconcileRankOrigin(configs, &reconciliees, nullptr, &faultParams))
qCWarning(dcNymeaEnergy()) << "[NymeaEnergy] SetLoadConfig rejetée:" << why; return refuser(faultParams);
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)}});
}
configs = reconciliees; configs = reconciliees;
if (!m_loadConfigStore->setConfigs(configs, &why)) { if (!m_loadConfigStore->setConfigs(configs, nullptr, &faultParams))
qCWarning(dcNymeaEnergy()) << "[NymeaEnergy] SetLoadConfig rejetée:" << why; return refuser(faultParams);
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)}});
}
// store.setConfigs() a déjà persisté + émis changed() → l'arbitre reconstruit, la notif part. // store.setConfigs() a déjà persisté + émis changed() → l'arbitre reconstruit, la notif part.
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorNoError)}}); return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorNoError)}});
} }

View File

@ -89,6 +89,9 @@ public:
* \c currentPower sans \c totalEnergyConsumed (mesuré sur l'ECS du banc). * \c currentPower sans \c totalEnergyConsumed (mesuré sur l'ECS du banc).
*/ */
bool progressMesurable(const LoadConfig &c, QString &cause) const; bool progressMesurable(const LoadConfig &c, QString &cause) const;
//! \brief Remplace l'ensemble des configurations de charge. \param params \c loadConfigs.
//! \return \c energyError ; sur refus \c fault \c LOAD_CONFIG_REFUSED et \c faultParams
//! (\c DecisionCode::LoadConfigRefused) — rien n'est modifié.
Q_INVOKABLE JsonReply *SetLoadConfig(const QVariantMap &params); Q_INVOKABLE JsonReply *SetLoadConfig(const QVariantMap &params);
// [Phase 2] Ratios canoniques (autoconsommation / autonomie), % [0,100]. // [Phase 2] Ratios canoniques (autoconsommation / autonomie), % [0,100].

View File

@ -63,7 +63,7 @@ void TestLoadModel::testOptimizerMayEmitOnlyWhitelistedCodes()
DecisionCode::EvSurplus, DecisionCode::EvEcoMin, DecisionCode::EvIdle, DecisionCode::EvSurplus, DecisionCode::EvEcoMin, DecisionCode::EvIdle,
DecisionCode::MissingForecastSite, DecisionCode::ForecastPayloadRefused, DecisionCode::MissingForecastSite, DecisionCode::ForecastPayloadRefused,
DecisionCode::ForecastDateUnreadable, DecisionCode::TariffRefused, DecisionCode::ForecastDateUnreadable, DecisionCode::TariffRefused,
DecisionCode::SpotMarketNotInBuild}) { DecisionCode::LoadConfigRefused, DecisionCode::SpotMarketNotInBuild}) {
QVERIFY2(!DecisionCode::optimizerMayEmit(QString::fromLatin1(c)), c); QVERIFY2(!DecisionCode::optimizerMayEmit(QString::fromLatin1(c)), c);
} }
QVERIFY(!DecisionCode::optimizerMayEmit(QStringLiteral("CODE_QUI_NEXISTE_PAS"))); QVERIFY(!DecisionCode::optimizerMayEmit(QStringLiteral("CODE_QUI_NEXISTE_PAS")));

View File

@ -17,6 +17,7 @@
#include "etm/types/drawcap.h" #include "etm/types/drawcap.h"
#include "etm/types/ecotarget.h" #include "etm/types/ecotarget.h"
#include "etm/config/loadconfigstore.h" #include "etm/config/loadconfigstore.h"
#include "etm/types/decisionreason.h"
#include "etm/ratios/energyratioscalculator.h" #include "etm/ratios/energyratioscalculator.h"
#include "etm/tariff/tariffconfig.h" #include "etm/tariff/tariffconfig.h"
#include "etm/forecast/baseloadlog.h" #include "etm/forecast/baseloadlog.h"
@ -28,6 +29,8 @@
#include <QJsonObject> #include <QJsonObject>
#include <QTimeZone> #include <QTimeZone>
#include <QJsonArray> #include <QJsonArray>
#include <QRegularExpression>
#include <QScopeGuard>
namespace { namespace {
@ -531,6 +534,467 @@ void TestLoadModel::testLabelIsBoundedOnWriteNeverOnLoad()
QFile::remove(cfgPath); QFile::remove(cfgPath);
} }
// ── Q7, suite — le refus de SetLoadConfig publie sa cause ──────────────────────────────────
//
// Les identifiants ci-dessous sont INVENTÉS, de même forme que ceux d'une installation : un ThingId
// entre accolades, et des noms courts à tirets.
namespace {
const QString RELAIS_A = QStringLiteral("{aaaaaaaa-0000-4000-8000-0000000000a1}");
const QString RELAIS_C = QStringLiteral("{aaaaaaaa-0000-4000-8000-0000000000c3}");
const QString ROUTEUR_B = QStringLiteral("{bbbbbbbb-0000-4000-8000-0000000000b2}");
QVariantMap ballonMap()
{
return {{"id", "ballon-a"}, {"label", "Ballon"}, {"adapter", "relay-router"}, {"mode", "fixed"},
{"priority", 1}, {"enabled", true}, {"minOnS", 0}, {"minOffS", 0},
{"relays", QVariantList{QVariantMap{{"thingId", RELAIS_A}, {"powerW", 1000}}}}};
}
QVariantMap routeurMap()
{
return {{"id", ROUTEUR_B}, {"label", "Routeur"}, {"adapter", "etmvariableload"},
{"mode", "dynamic"}, {"maxPowerW", 3000}, {"priority", 2}, {"enabled", true}};
}
QVariantList baseCharges() { return {ballonMap(), routeurMap()}; }
//! La base, l'entrée \p index modifiée par \p modifs.
QVariantList chargesAvec(int index, const QVariantMap &modifs)
{
QVariantList l = baseCharges();
QVariantMap m = l.at(index).toMap();
for (auto it = modifs.constBegin(); it != modifs.constEnd(); ++it)
m.insert(it.key(), it.value());
l[index] = m;
return l;
}
LoadConfigs versConfigs(const QVariantList &l)
{
LoadConfigs c;
for (const QVariant &v : l)
c.append(LoadConfig::fromMap(v.toMap()));
return c;
}
QVariantList versCartes(const LoadConfigs &c)
{
QVariantList l;
for (const LoadConfig &x : c)
l.append(x.toMap());
return l;
}
QByteArray lireFichier(const QString &chemin)
{
QFile f(chemin);
return f.open(QIODevice::ReadOnly) ? f.readAll() : QByteArray();
}
QByteArray json(const QVariant &v) { return QJsonDocument::fromVariant(v).toJson(QJsonDocument::Compact); }
//! Le chemin d'écriture de SetLoadConfig, dans son ordre : bornes, réconciliation, écriture.
bool ecrireCommeLeClient(LoadConfigStore &store, const LoadConfigs &c, QString *why, QVariantMap *motif)
{
if (!LoadConfigStore::validateBounds(c, why, motif))
return false;
LoadConfigs r;
if (!store.reconcileRankOrigin(c, &r, why, motif))
return false;
return store.setConfigs(r, why, motif);
}
void ecrireFichier(const QString &chemin, const QVariantList &charges)
{
QFile f(chemin);
QVERIFY(f.open(QIODevice::WriteOnly | QIODevice::Text));
f.write(QJsonDocument(QJsonObject{{"version", 1}, {"loads", QJsonArray::fromVariantList(charges)}}).toJson());
}
}
void TestLoadModel::testLoadConfigRefusalNamesItsCause_data()
{
QTest::addColumn<QVariantList>("charges");
QTest::addColumn<QVariantMap>("motif");
QTest::addColumn<bool>("disqueInaccessible");
const QVariantMap paire{{"relays", QVariantList{QVariantMap{{"thingId", RELAIS_A}, {"powerW", 1000}},
QVariantMap{{"thingId", RELAIS_A}, {"powerW", 500}}}}};
auto pac = [](const QVariantList &etats) {
return QVariantMap{{"id", "pac-c"}, {"label", "PAC"}, {"adapter", "sg-ready"}, {"mode", "fixed"},
{"priority", 3}, {"enabled", true},
{"sgReady", QVariantMap{{"states", etats}, {"minStateHoldS", 0}}}};
};
auto etat = [](int n) {
return QVariantMap{{"state", n}, {"relays", QVariantList{RELAIS_C}}, {"estimatedPowerW", 1500}};
};
QVariantMap ballonC = ballonMap();
ballonC.insert("id", "ballon-c");
ballonC.insert("priority", 3);
ballonC.insert("relays", QVariantList{QVariantMap{{"thingId", RELAIS_A}, {"powerW", 500}}});
auto ligne = [](const char *nom, const QVariantList &charges, const QVariantMap &motif, bool disque = false) {
QTest::newRow(nom) << charges << motif << disque;
};
// Par entrée : la position, et l'identifiant quand il est bien formé — jamais celui qu'on refuse.
ligne("ID trop long", chargesAvec(0, {{"id", QString(129, QLatin1Char('x'))}}),
{{"cause", "ID"}, {"index", 0}, {"length", 129}, {"max", 128}});
ligne("ID hors jeu", chargesAvec(0, {{"id", "ballon a"}}),
{{"cause", "ID"}, {"index", 0}, {"length", 8}, {"max", 128}, {"position", 6}});
ligne("ID vide", chargesAvec(1, {{"id", ""}}), {{"cause", "ID"}, {"index", 1}, {"length", 0}, {"max", 128}});
ligne("LABEL", chargesAvec(0, {{"label", QString(257, QLatin1Char('L'))}}),
{{"cause", "LABEL"}, {"index", 0}, {"id", "ballon-a"}, {"length", 257}, {"max", 256}});
ligne("DEADLINE", chargesAvec(0, {{"needs", QVariantMap{{"dailyDeadline", "7:00"}}}}),
{{"cause", "DEADLINE"}, {"index", 0}, {"id", "ballon-a"}});
ligne("MODE", chargesAvec(0, {{"mode", "variable"}}), {{"cause", "MODE"}, {"index", 0}, {"id", "ballon-a"}});
ligne("DOMAIN", chargesAvec(0, {{"domain", "garage"}}), {{"cause", "DOMAIN"}, {"index", 0}, {"id", "ballon-a"}});
ligne("RANK_ORIGIN", chargesAvec(0, {{"rankOrigin", "robot"}}),
{{"cause", "RANK_ORIGIN"}, {"index", 0}, {"id", "ballon-a"}});
ligne("RANK_ORIGIN_AUTO", chargesAvec(1, {{"rankOrigin", "auto"}}),
{{"cause", "RANK_ORIGIN_AUTO"}, {"index", 1}, {"id", ROUTEUR_B}});
ligne("ADAPTER", chargesAvec(0, {{"adapter", "pompe"}}), {{"cause", "ADAPTER"}, {"index", 0}, {"id", "ballon-a"}});
ligne("FIELD_FORBIDDEN", chargesAvec(0, {{"minPowerW", 100}}),
{{"cause", "FIELD_FORBIDDEN"}, {"index", 0}, {"id", "ballon-a"}, {"field", "minPowerW"}});
ligne("FIELD_REQUIRED", chargesAvec(1, {{"maxPowerW", 0}}),
{{"cause", "FIELD_REQUIRED"}, {"index", 1}, {"id", ROUTEUR_B}, {"field", "maxPowerW"}});
ligne("FIELD_RANGE", chargesAvec(0, {{"relays", QVariantList{QVariantMap{{"thingId", RELAIS_A}, {"powerW", 0}}}}}),
{{"cause", "FIELD_RANGE"}, {"index", 0}, {"id", "ballon-a"}, {"field", "relays.powerW"}});
ligne("FIELD_DUPLICATE", chargesAvec(0, paire),
{{"cause", "FIELD_DUPLICATE"}, {"index", 0}, {"id", "ballon-a"}, {"field", "relays.thingId"}});
ligne("FIELD_DUPLICATE état", baseCharges() << pac({etat(2), etat(3), etat(3)}),
{{"cause", "FIELD_DUPLICATE"}, {"index", 2}, {"id", "pac-c"}, {"field", "sgReady.states.state"},
{"state", 3}});
ligne("SG_READY_STATE_2", baseCharges() << pac({etat(3)}),
{{"cause", "SG_READY_STATE_2"}, {"index", 2}, {"id", "pac-c"}});
// Entre entrées : la paire.
ligne("ID_DUPLICATE", chargesAvec(1, {{"id", "ballon-a"}}),
{{"cause", "ID_DUPLICATE"}, {"index", 1}, {"id", "ballon-a"}});
ligne("THING_SHARED", baseCharges() << ballonC,
{{"cause", "THING_SHARED"}, {"indexes", QVariantList{0, 2}}, {"ids", QVariantList{"ballon-a", "ballon-c"}}});
ligne("RANK_DUPLICATE", chargesAvec(1, {{"priority", 1}}),
{{"cause", "RANK_DUPLICATE"}, {"indexes", QVariantList{0, 1}}, {"ids", QVariantList{"ballon-a", ROUTEUR_B}},
{"rank", 1}});
// Sans entrée : l'écriture elle-même a échoué. La charge utile est VALIDE (un libellé changé).
ligne("WRITE_FAILED", chargesAvec(0, {{"label", "Ballon du bas"}}), {{"cause", "WRITE_FAILED"}}, true);
}
void TestLoadModel::testLoadConfigRefusalNamesItsCause()
{
QFETCH(QVariantList, charges);
QFETCH(QVariantMap, motif);
QFETCH(bool, disqueInaccessible);
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-causes.json";
QFile::remove(cfgPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
const auto nettoyer = qScopeGuard([&] { QFile::remove(cfgPath); });
LoadConfigStore store;
QString err;
QVariantMap recu;
QVERIFY2(ecrireCommeLeClient(store, versConfigs(baseCharges()), &err, &recu), qUtf8Printable(err));
QVERIFY(recu.isEmpty()); // un succès ne publie aucun motif
const QVariantList enMemoire = versCartes(store.configs());
const QByteArray surDisque = lireFichier(cfgPath);
QVERIFY(!surDisque.isEmpty());
if (disqueInaccessible)
qputenv("NYMEA_ENERGY_LOAD_CONFIG", "/q7-repertoire-absent/energy-load-configuration.json");
const bool accepte = ecrireCommeLeClient(store, versConfigs(charges), &err, &recu);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
QVERIFY(!accepte);
QVERIFY(!err.isEmpty()); // la phrase du journal reste là
QVERIFY2(json(recu) == json(motif), json(recu).constData());
// RIEN n'est modifié : ni en mémoire, ni sur disque (octet pour octet).
QCOMPARE(json(versCartes(store.configs())), json(enMemoire));
QCOMPARE(lireFichier(cfgPath), surDisque);
}
void TestLoadModel::testLoadConfigRefusedIsRendered()
{
auto rendu = [](const QVariantMap &p) { return renderFr(DecisionReason(DecisionCode::LoadConfigRefused, p)); };
QCOMPARE(rendu({{"cause", "ID"}, {"index", 0}, {"length", 8}, {"max", 128}, {"position", 6}}),
QStringLiteral("Configuration des charges refusée (ID) : charge 1, 8 caractères sur 128 au plus, "
"caractère 7 hors [A-Za-z0-9._{}-] — rien n'est modifié"));
QCOMPARE(rendu({{"cause", "FIELD_DUPLICATE"}, {"index", 2}, {"id", "pac-c"},
{"field", "sgReady.states.state"}, {"state", 3}}),
QStringLiteral("Configuration des charges refusée (FIELD_DUPLICATE) : charge 3 « pac-c », "
"champ sgReady.states.state, état 3 — rien n'est modifié"));
QCOMPARE(rendu({{"cause", "RANK_DUPLICATE"}, {"indexes", QVariantList{0, 1}},
{"ids", QVariantList{"ballon-a", "b"}}, {"rank", 1}}),
QStringLiteral("Configuration des charges refusée (RANK_DUPLICATE) : charges 1 et 2, rang 1 — "
"rien n'est modifié"));
QCOMPARE(rendu({{"cause", "WRITE_FAILED"}}),
QStringLiteral("Configuration des charges refusée (WRITE_FAILED) — rien n'est modifié"));
}
void TestLoadModel::testIdIsBoundedOnWriteNeverOnLoad()
{
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-ids.json";
QFile::remove(cfgPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
const auto nettoyer = qScopeGuard([&] { QFile::remove(cfgPath); });
QString err;
QVariantMap motif;
LoadConfigStore store;
auto avecId = [](const QString &id) { return versConfigs(chargesAvec(0, {{"id", id}})); };
// Acceptés : la borne exacte, et les deux formes réelles (inventées ici) — un ThingId entre
// accolades (38 caractères) et un nom court à tirets ; le point et le souligné aussi.
const QString limite(LoadConfigStore::MaxIdChars, QLatin1Char('x'));
for (const QString &id : {limite, QStringLiteral("{0f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0}"),
QStringLiteral("ballon-test"), QStringLiteral("pac.jardin_2")}) {
QVERIFY2(ecrireCommeLeClient(store, avecId(id), &err, &motif), qUtf8Printable(id + " : " + err));
QCOMPARE(store.configs().at(0).id(), id);
}
const QByteArray surDisque = lireFichier(cfgPath);
// Refusés : 129, un espace, une barre, un accent, un saut de ligne final — rien de modifié.
for (const QString &id : {limite + QLatin1Char('x'), QStringLiteral("ballon test"),
QStringLiteral("ballon/test"), QStringLiteral("bâton"),
QStringLiteral("ballon-test\n")}) {
QVERIFY2(!ecrireCommeLeClient(store, avecId(id), &err, &motif), qUtf8Printable(id));
QCOMPARE(motif.value("cause").toString(), QStringLiteral("ID"));
QVERIFY(!motif.contains("id")); // l'identifiant refusé n'est jamais recopié
QVERIFY2(!err.contains(id), qUtf8Printable(err));
QCOMPARE(store.configs().at(0).id(), QStringLiteral("pac.jardin_2"));
QCOMPARE(lireFichier(cfgPath), surDisque);
}
// ---- Au CHARGEMENT : hors règle, l'entrée est conservée telle quelle, et signalée ---------
QVariantMap long_ = ballonMap();
long_.insert("id", QString(129, QLatin1Char('x')));
QVariantMap espace = routeurMap();
espace.insert("id", "routeur b");
ecrireFichier(cfgPath, {long_, espace});
QTest::ignoreMessage(QtWarningMsg, QRegularExpression(
QStringLiteral("IDENTIFIANT HORS RÈGLE : charge \"x{64}…\" \\(129 caractères")));
QTest::ignoreMessage(QtWarningMsg, QRegularExpression(
QStringLiteral("IDENTIFIANT HORS RÈGLE : charge \"routeur b\" \\(9 caractères")));
LoadConfigStore relu;
QCOMPARE(relu.configs().count(), 2);
QCOMPARE(relu.configs().at(0).id(), QString(129, QLatin1Char('x')));
QCOMPARE(relu.configs().at(1).id(), QStringLiteral("routeur b"));
// L'écho verbatim est refusé ; l'identifiant remplacé, il passe.
QVERIFY(!ecrireCommeLeClient(relu, relu.configs(), &err, &motif));
QCOMPARE(json(motif), json(QVariantMap{{"cause", "ID"}, {"index", 0}, {"length", 129}, {"max", 128}}));
LoadConfigs repare = relu.configs();
repare[0].setId(QStringLiteral("ballon-a"));
repare[1].setId(ROUTEUR_B);
QVERIFY2(ecrireCommeLeClient(relu, repare, &err, &motif), qUtf8Printable(err));
}
void TestLoadModel::testDailyDeadlineIsCheckedOnWriteAndDroppedOnLoad()
{
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-deadline.json";
QFile::remove(cfgPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
const auto nettoyer = qScopeGuard([&] { QFile::remove(cfgPath); });
QString err;
QVariantMap motif;
LoadConfigStore store;
auto avecEcheance = [](const QString &h) {
return versConfigs(chargesAvec(0, {{"needs", QVariantMap{{"dailyDeadline", h}, {"minEnergyWhPerDay", 2000}}}}));
};
for (const QString &h : {QString(), QStringLiteral("00:00"), QStringLiteral("23:59"), QStringLiteral("12:00")}) {
QVERIFY2(ecrireCommeLeClient(store, avecEcheance(h), &err, &motif), qUtf8Printable(h + " : " + err));
QCOMPARE(store.configs().at(0).needs().dailyDeadline(), h);
}
const QByteArray surDisque = lireFichier(cfgPath);
for (const QString &h : {QStringLiteral("24:00"), QStringLiteral("7:00"), QStringLiteral("12:60"),
QStringLiteral("12h00"), QStringLiteral("12:00\n"), QStringLiteral(" 12:00")}) {
QVERIFY2(!ecrireCommeLeClient(store, avecEcheance(h), &err, &motif), qUtf8Printable(h));
QCOMPARE(json(motif), json(QVariantMap{{"cause", "DEADLINE"}, {"index", 0}, {"id", "ballon-a"}}));
QCOMPARE(store.configs().at(0).needs().dailyDeadline(), QStringLiteral("12:00"));
QCOMPARE(lireFichier(cfgPath), surDisque);
}
// ---- Au CHARGEMENT : l'entrée est gardée, l'échéance illisible est RETIRÉE de la mémoire ---
QVariantMap illisible = ballonMap();
illisible.insert("needs", QVariantMap{{"dailyDeadline", "7:00"}, {"minEnergyWhPerDay", 2000}});
QVariantMap lisible = routeurMap();
lisible.insert("needs", QVariantMap{{"dailyDeadline", "07:00"}, {"minEnergyWhPerDay", 1000}});
ecrireFichier(cfgPath, {illisible, lisible});
const QByteArray herite = lireFichier(cfgPath);
QTest::ignoreMessage(QtWarningMsg, QRegularExpression(
QStringLiteral("ÉCHÉANCE ILLISIBLE : charge \"ballon-a\", needs.dailyDeadline \"7:00\"")));
LoadConfigStore relu;
QCOMPARE(relu.configs().count(), 2);
QCOMPARE(relu.configs().at(0).needs().dailyDeadline(), QString()); // retirée
QCOMPARE(relu.configs().at(0).needs().minEnergyWhPerDay(), 2000); // le reste demeure
QCOMPARE(relu.configs().at(1).needs().dailyDeadline(), QStringLiteral("07:00")); // contre-épreuve
QCOMPARE(lireFichier(cfgPath), herite); // le fichier n'est pas réécrit par une lecture
}
void TestLoadModel::testLeftCharsNeverSplitsAPair()
{
const QString prise = QString::fromUcs4(U"\U0001F50C");
// L'emoji est le 256e caractère : gardé ENTIER — QString::left(256) l'aurait coupé en deux.
const QString nom = QString(255, QLatin1Char('a')) + prise + QString(44, QLatin1Char('b'));
QCOMPARE(nom.toUcs4().size(), qsizetype(300));
QVERIFY(nom.left(256).back().isHighSurrogate()); // le piège que leftChars() évite
const QString coupe = LoadConfigStore::leftChars(nom, 256);
QCOMPARE(coupe, QString(255, QLatin1Char('a')) + prise);
QCOMPARE(coupe.toUcs4().size(), qsizetype(256));
QCOMPARE(coupe.size(), qsizetype(257));
// L'emoji est le 257e : il part ENTIER, rien n'en reste.
QCOMPARE(LoadConfigStore::leftChars(QString(256, QLatin1Char('a')) + prise, 256), QString(256, QLatin1Char('a')));
// Plus court que la borne : rendu tel quel.
QCOMPARE(LoadConfigStore::leftChars(prise, 256), prise);
}
void TestLoadModel::testAppendConfigBoundsOnlyTheAddedEntry()
{
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-append.json";
QFile::remove(cfgPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
const auto nettoyer = qScopeGuard([&] { QFile::remove(cfgPath); });
// Une configuration HÉRITÉE dont une entrée porte un libellé de 300 caractères.
QVariantMap herite = ballonMap();
const QString tropLong(300, QLatin1Char('L'));
herite.insert("label", tropLong);
ecrireFichier(cfgPath, {herite});
QTest::ignoreMessage(QtWarningMsg, QRegularExpression(QStringLiteral("LIBELLÉ TROP LONG")));
LoadConfigStore store;
QCOMPARE(store.configs().count(), 1);
QString err;
QVariantMap motif;
// Le moteur ajoute une borne : le libellé hérité ne la bloque pas.
LoadConfig borne = LoadConfig::fromMap(borneMap(BORNE, 2));
borne.setLabel(QString(LoadConfigStore::MaxLabelChars, QLatin1Char('B')));
QVERIFY2(store.appendConfig(borne, &err, &motif), qUtf8Printable(err));
QCOMPARE(store.configs().count(), 2);
QCOMPARE(store.configs().at(0).label(), tropLong); // l'hérité, intact
LoadConfigStore relu; // et persisté avec lui
QCOMPARE(relu.configs().count(), 2);
// Contre-épreuves : l'entrée AJOUTÉE reste bornée…
LoadConfig autre = LoadConfig::fromMap(borneMap(QStringLiteral("{dddddddd-0000-0000-0000-0000000000f1}"), 3));
autre.setLabel(QString(LoadConfigStore::MaxLabelChars + 1, QLatin1Char('B')));
QVERIFY(!store.appendConfig(autre, &err, &motif));
QCOMPARE(motif.value("cause").toString(), QStringLiteral("LABEL"));
QCOMPARE(motif.value("index").toInt(), 2);
QCOMPARE(store.configs().count(), 2);
// …et l'écriture CLIENTE de l'ensemble reste refusée tant que l'hérité n'est pas raccourci.
QVERIFY(!store.setConfigs(store.configs(), &err, &motif));
QCOMPARE(json(motif), json(QVariantMap{{"cause", "LABEL"}, {"index", 0}, {"id", "ballon-a"},
{"length", 300}, {"max", 256}}));
}
// ── relecture q7-suite — le journal ne recopie jamais une saisie telle quelle ──────────────────
namespace {
//! Ce qu'un client glisse dans un champ pour fabriquer une ligne de journal.
const QString FORGE = QStringLiteral("x\n[NymeaEnergy] FAUX");
//! Capture les messages Qt UN PAR UN : une fausse ligne ne se voit qu'à l'intérieur d'un message.
class Messages
{
public:
Messages() { s_courant = this; s_origine = qInstallMessageHandler(&Messages::collecte); }
~Messages() { qInstallMessageHandler(s_origine); s_courant = nullptr; }
bool contient(const QString &fragment) const
{
for (const QString &m : m_liste)
if (m.contains(fragment))
return true;
return false;
}
//! Les lignes qu'un message AJOUTE au journal en se faisant passer pour un message du moteur.
QStringList faussesLignes() const
{
QStringList f;
for (const QString &m : m_liste) {
const QStringList lignes = m.split(QLatin1Char('\n'));
for (int i = 1; i < lignes.count(); ++i)
if (lignes.at(i).startsWith(QLatin1String("[NymeaEnergy]"), Qt::CaseInsensitive))
f << lignes.at(i);
}
return f;
}
private:
static void collecte(QtMsgType type, const QMessageLogContext &ctx, const QString &msg)
{
if (s_courant)
s_courant->m_liste << msg;
if (s_origine)
s_origine(type, ctx, msg);
}
QStringList m_liste;
static inline Messages *s_courant = nullptr;
static inline QtMessageHandler s_origine = nullptr;
};
}
void TestLoadModel::testInheritedValuesAreEscapedInTheJournal()
{
// Avant Q7, un client pouvait persister un identifiant ou une échéance portant un saut de
// ligne. load() les cite : échappés, ils ne fabriquent aucune fausse ligne de journal.
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-journal.json";
QFile::remove(cfgPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
const auto nettoyer = qScopeGuard([&] { QFile::remove(cfgPath); });
QVariantMap idForge = ballonMap();
idForge.insert("id", FORGE);
idForge.insert("label", QString(300, QLatin1Char('L'))); // LIBELLÉ TROP LONG cite l'identifiant
QVariantMap echeanceForgee = routeurMap();
echeanceForgee.insert("needs", QVariantMap{{"dailyDeadline", FORGE}, {"minEnergyWhPerDay", 1000}});
ecrireFichier(cfgPath, {idForge, echeanceForgee});
Messages journal;
LoadConfigStore relu;
QCOMPARE(relu.configs().count(), 2);
// Non vide : les trois avertissements sont partis, et ils citent la valeur ÉCHAPPÉE.
QVERIFY(journal.contient(QStringLiteral("LIBELLÉ TROP LONG")));
QVERIFY(journal.contient(QStringLiteral("IDENTIFIANT HORS RÈGLE : charge \"x\\n[NymeaEnergy] FAUX\"")));
QVERIFY(journal.contient(QStringLiteral("needs.dailyDeadline \"x\\n[NymeaEnergy] …\"")));
QVERIFY2(journal.faussesLignes().isEmpty(), qUtf8Printable(journal.faussesLignes().join(QStringLiteral(" | "))));
}
void TestLoadModel::testInheritedLabelIsCutInSetRefusals()
{
// [m-1] L'ajout d'une borne ne borne que l'entrée ajoutée ; ses refus d'ENSEMBLE citaient
// pourtant le libellé hérité en entier, et provisionEvChargerConfig() le journalise.
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-libelle-motif.json";
QFile::remove(cfgPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
const auto nettoyer = qScopeGuard([&] { QFile::remove(cfgPath); });
// Héritée : rang 2, libellé de 300 caractères, et elle commande le Thing de la borne.
const QString tropLong = QString(299, QLatin1Char('L')) + QLatin1Char('Z');
QVariantMap herite = ballonMap();
herite.insert("label", tropLong);
herite.insert("priority", 2);
herite.insert("relays", QVariantList{QVariantMap{{"thingId", BORNE}, {"powerW", 1000}}});
ecrireFichier(cfgPath, {herite});
QTest::ignoreMessage(QtWarningMsg, QRegularExpression(QStringLiteral("LIBELLÉ TROP LONG")));
LoadConfigStore store;
QCOMPARE(store.configs().count(), 1);
const QString cite = LoadConfigStore::leftChars(tropLong, 64) + QStringLiteral("…");
QString err;
QVariantMap motif;
// THING_SHARED : le motif publié est inchangé, la phrase ne cite que 64 caractères.
QVERIFY(!store.appendConfig(LoadConfig::fromMap(borneMap(BORNE, 3)), &err, &motif));
QCOMPARE(json(motif), json(QVariantMap{{"cause", "THING_SHARED"}, {"indexes", QVariantList{0, 1}},
{"ids", QVariantList{"ballon-a", BORNE}}}));
QVERIFY2(err.contains(cite) && !err.contains(tropLong), qUtf8Printable(err));
// RANK_DUPLICATE : idem.
const QString autreBorne = QStringLiteral("{dddddddd-0000-0000-0000-0000000000f2}");
QVERIFY(!store.appendConfig(LoadConfig::fromMap(borneMap(autreBorne, 2)), &err, &motif));
QCOMPARE(json(motif), json(QVariantMap{{"cause", "RANK_DUPLICATE"}, {"indexes", QVariantList{0, 1}},
{"ids", QVariantList{"ballon-a", autreBorne}}, {"rank", 2}}));
QVERIFY2(err.contains(cite) && !err.contains(tropLong), qUtf8Printable(err));
QCOMPARE(store.configs().count(), 1);
}
void TestLoadModel::testRankOriginFollowsTheRankNotTheWrite() void TestLoadModel::testRankOriginFollowsTheRankNotTheWrite()
{ {
// [LM-1209-c] La marque dit « ce rang est un défaut, pas un choix ». Ce qui la lève est // [LM-1209-c] La marque dit « ce rang est un défaut, pas un choix ». Ce qui la lève est

View File

@ -77,6 +77,27 @@ private slots:
void testRankOriginFollowsTheRankNotTheWrite(); void testRankOriginFollowsTheRankNotTheWrite();
// Protocole optimiseur 1.2, Q7 — le libellé est borné à l'écriture, jamais au chargement // Protocole optimiseur 1.2, Q7 — le libellé est borné à l'écriture, jamais au chargement
void testLabelIsBoundedOnWriteNeverOnLoad(); void testLabelIsBoundedOnWriteNeverOnLoad();
// Q7, suite — le refus de SetLoadConfig publie sa cause ; id et échéance bornés à l'écriture
//! Chaque cause de refus : son motif (cause et endroit), et rien de modifié, ni en mémoire
//! ni sur disque.
void testLoadConfigRefusalNamesItsCause_data();
void testLoadConfigRefusalNamesItsCause();
//! Le motif se rend en français, avec l'endroit et sans le libellé.
void testLoadConfigRefusedIsRendered();
//! id : 128 caractères de [A-Za-z0-9._{}-] à l'écriture ; une config héritée hors règle est
//! chargée telle quelle et signalée.
void testIdIsBoundedOnWriteNeverOnLoad();
//! dailyDeadline : vide ou HH:MM à l'écriture ; héritée illisible, l'entrée est chargée et
//! l'échéance retirée de la mémoire.
void testDailyDeadlineIsCheckedOnWriteAndDroppedOnLoad();
//! Couper en caractères ne coupe jamais une paire de substitution.
void testLeftCharsNeverSplitsAPair();
//! L'ajout par le moteur ne borne que l'entrée ajoutée — un hérité hors règle ne le bloque pas.
void testAppendConfigBoundsOnlyTheAddedEntry();
//! Relecture q7-suite : une valeur héritée citée par load() est échappée — aucune fausse ligne.
void testInheritedValuesAreEscapedInTheJournal();
//! Relecture q7-suite (m-1) : un refus d'ensemble ne cite que 64 caractères d'un libellé hérité.
void testInheritedLabelIsCutInSetRefusals();
// ── LM-1006-1 — le plafond de soutirage, étape 1 ─────────────────────── // ── LM-1006-1 — le plafond de soutirage, étape 1 ───────────────────────
//! On compare des MARGES, jamais des plafonds : le plus contraignant mord, et on sait //! On compare des MARGES, jamais des plafonds : le plus contraignant mord, et on sait

View File

@ -59,6 +59,9 @@ using namespace nymeaserver;
#include <QDir> #include <QDir>
#include <QFile> #include <QFile>
#include <QFileInfo> #include <QFileInfo>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QHash> #include <QHash>
#include <QScopeGuard> #include <QScopeGuard>
@ -144,6 +147,8 @@ public:
bool contains(const QString &fragment) const { return m_texte.contains(fragment); } bool contains(const QString &fragment) const { return m_texte.contains(fragment); }
bool contient(QtMsgType type) const { return m_types.contains(type); } bool contient(QtMsgType type) const { return m_types.contains(type); }
QString texte() const { return m_texte; } QString texte() const { return m_texte; }
//! Les messages un par un : une fausse ligne de journal ne se voit qu'À L'INTÉRIEUR d'un message.
QStringList messages() const { return m_messages; }
private: private:
static void collecte(QtMsgType type, const QMessageLogContext &ctx, const QString &msg) static void collecte(QtMsgType type, const QMessageLogContext &ctx, const QString &msg)
@ -151,12 +156,14 @@ private:
// Toutes les captures vivantes reçoivent le message — c'est ce que le chaînage visait. // Toutes les captures vivantes reçoivent le message — c'est ce que le chaînage visait.
for (JournalCapture *c : s_pile) { for (JournalCapture *c : s_pile) {
c->m_texte += msg + QLatin1Char('\n'); c->m_texte += msg + QLatin1Char('\n');
c->m_messages.append(msg);
c->m_types.insert(type); c->m_types.insert(type);
} }
if (s_origine) if (s_origine)
s_origine(type, ctx, msg); // le message reste visible dans la sortie de test s_origine(type, ctx, msg); // le message reste visible dans la sortie de test
} }
QString m_texte; QString m_texte;
QStringList m_messages;
QSet<QtMsgType> m_types; QSet<QtMsgType> m_types;
static QList<JournalCapture *> s_pile; static QList<JournalCapture *> s_pile;
static QtMessageHandler s_origine; static QtMessageHandler s_origine;
@ -6108,6 +6115,262 @@ void Simulation::testDeletedEvChargerConfigComesBack()
#endif #endif
} }
void Simulation::testSetLoadConfigRefusalIsPublished()
{
#ifndef ETM_ARBITRATOR
QSKIP("testSetLoadConfigRefusalIsPublished nécessite ETM_ARBITRATOR.");
#else
// [Q7, suite] Même forme que SetTariff : EnergyErrorInvalidParameter INCHANGÉ, et à côté un
// code et un endroit. Avant, l'app recevait le code d'erreur seul, pour tous les refus.
// Identifiants inventés, de la forme de ceux d'une installation.
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-refus.json";
QFile::remove(cfgPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
const auto nettoyer = qScopeGuard([&] {
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
QFile::remove(cfgPath);
});
// Le contrat PUBLIÉ : les deux champs sont déclarés, OPTIONNELS ; energyError reste requis.
const QVariantMap retours = injectAndWait("JSONRPC.Introspect").toMap().value("params").toMap()
.value("methods").toMap().value("NymeaEnergy.SetLoadConfig").toMap()
.value("returns").toMap();
QStringList cles = retours.keys();
cles.sort();
QCOMPARE(cles, (QStringList{"energyError", "o:fault", "o:faultParams"}));
auto poser = [&](const QVariantList &l) {
return injectAndWait("NymeaEnergy.SetLoadConfig", {{"loadConfigs", l}}).toMap();
};
auto lire = [&] {
return QJsonDocument::fromVariant(injectAndWait("NymeaEnergy.GetLoadConfig").toMap()
.value("params").toMap().value("loadConfigs"))
.toJson(QJsonDocument::Compact);
};
auto fichier = [&] {
QFile f(cfgPath);
return f.open(QIODevice::ReadOnly) ? f.readAll() : QByteArray();
};
auto json = [](const QVariant &v) { return QJsonDocument::fromVariant(v).toJson(QJsonDocument::Compact); };
const QString relaisA = QStringLiteral("{aaaaaaaa-0000-4000-8000-0000000000a1}");
const QString relaisC = QStringLiteral("{aaaaaaaa-0000-4000-8000-0000000000c3}");
const QString routeur = QStringLiteral("{bbbbbbbb-0000-4000-8000-0000000000b2}");
const QVariantMap ballon{{"id", "ballon-a"}, {"label", "Ballon"}, {"adapter", "relay-router"},
{"mode", "fixed"}, {"priority", 1}, {"enabled", true}, {"minOnS", 0}, {"minOffS", 0},
{"relays", QVariantList{QVariantMap{{"thingId", relaisA}, {"powerW", 1000}}}}};
const QVariantMap routeurPv{{"id", routeur}, {"label", "Routeur"}, {"adapter", "etmvariableload"},
{"mode", "dynamic"}, {"maxPowerW", 3000}, {"priority", 2}, {"enabled", true}};
const QVariantList base{ballon, routeurPv};
auto avec = [&](int i, const QVariantMap &modifs) {
QVariantList l = base;
QVariantMap m = l.at(i).toMap();
for (auto it = modifs.constBegin(); it != modifs.constEnd(); ++it)
m.insert(it.key(), it.value());
l[i] = m;
return l;
};
auto pac = [&](const QVariantList &etats) {
return QVariantMap{{"id", "pac-c"}, {"label", "PAC"}, {"adapter", "sg-ready"}, {"mode", "fixed"},
{"priority", 3}, {"enabled", true},
{"sgReady", QVariantMap{{"states", etats}, {"minStateHoldS", 0}}}};
};
auto etat = [&](int n) {
return QVariantMap{{"state", n}, {"relays", QVariantList{relaisC}}, {"estimatedPowerW", 1500}};
};
QVariantMap ballonC = ballon;
ballonC.insert("id", "ballon-c");
ballonC.insert("priority", 3);
ballonC.insert("relays", QVariantList{QVariantMap{{"thingId", relaisA}, {"powerW", 500}}});
// Le point de départ : accepté, et la réponse d'un SUCCÈS est exactement celle d'avant.
QVariantMap r = poser(base);
QCOMPARE(r.value("status").toString(), QStringLiteral("success"));
QCOMPARE(r.value("params").toMap().keys(), QStringList{"energyError"});
QCOMPARE(r.value("params").toMap().value("energyError").toString(), QStringLiteral("EnergyErrorNoError"));
const QByteArray avantGet = lire();
const QByteArray avantDisque = fichier();
QVERIFY(!avantDisque.isEmpty());
struct Cas { const char *quoi; QVariantList charges; QVariantMap attendu; bool disque; };
const QList<Cas> cas{
{"ID trop long", avec(0, {{"id", QString(129, QLatin1Char('x'))}}),
{{"cause", "ID"}, {"index", 0}, {"length", 129}, {"max", 128}}, false},
{"ID hors jeu", avec(0, {{"id", "ballon a"}}),
{{"cause", "ID"}, {"index", 0}, {"length", 8}, {"max", 128}, {"position", 6}}, false},
{"ID vide", avec(1, {{"id", ""}}), {{"cause", "ID"}, {"index", 1}, {"length", 0}, {"max", 128}}, false},
{"LABEL", avec(0, {{"label", QString(257, QLatin1Char('L'))}}),
{{"cause", "LABEL"}, {"index", 0}, {"id", "ballon-a"}, {"length", 257}, {"max", 256}}, false},
{"DEADLINE", avec(0, {{"needs", QVariantMap{{"dailyDeadline", "12h00"}}}}),
{{"cause", "DEADLINE"}, {"index", 0}, {"id", "ballon-a"}}, false},
{"MODE", avec(0, {{"mode", "variable"}}), {{"cause", "MODE"}, {"index", 0}, {"id", "ballon-a"}}, false},
{"DOMAIN", avec(0, {{"domain", "garage"}}), {{"cause", "DOMAIN"}, {"index", 0}, {"id", "ballon-a"}}, false},
{"RANK_ORIGIN", avec(0, {{"rankOrigin", "robot"}}),
{{"cause", "RANK_ORIGIN"}, {"index", 0}, {"id", "ballon-a"}}, false},
{"RANK_ORIGIN_AUTO", avec(1, {{"rankOrigin", "auto"}}),
{{"cause", "RANK_ORIGIN_AUTO"}, {"index", 1}, {"id", routeur}}, false},
{"ADAPTER", avec(0, {{"adapter", "pompe"}}), {{"cause", "ADAPTER"}, {"index", 0}, {"id", "ballon-a"}}, false},
{"FIELD_FORBIDDEN", avec(0, {{"minPowerW", 100}}),
{{"cause", "FIELD_FORBIDDEN"}, {"index", 0}, {"id", "ballon-a"}, {"field", "minPowerW"}}, false},
{"FIELD_REQUIRED", avec(1, {{"maxPowerW", 0}}),
{{"cause", "FIELD_REQUIRED"}, {"index", 1}, {"id", routeur}, {"field", "maxPowerW"}}, false},
{"FIELD_RANGE", avec(0, {{"relays", QVariantList{QVariantMap{{"thingId", relaisA}, {"powerW", 0}}}}}),
{{"cause", "FIELD_RANGE"}, {"index", 0}, {"id", "ballon-a"}, {"field", "relays.powerW"}}, false},
{"FIELD_DUPLICATE", avec(0, {{"relays", QVariantList{QVariantMap{{"thingId", relaisA}, {"powerW", 1000}},
QVariantMap{{"thingId", relaisA}, {"powerW", 500}}}}}),
{{"cause", "FIELD_DUPLICATE"}, {"index", 0}, {"id", "ballon-a"}, {"field", "relays.thingId"}}, false},
{"FIELD_DUPLICATE état", QVariantList(base) << pac({etat(2), etat(3), etat(3)}),
{{"cause", "FIELD_DUPLICATE"}, {"index", 2}, {"id", "pac-c"}, {"field", "sgReady.states.state"},
{"state", 3}}, false},
{"SG_READY_STATE_2", QVariantList(base) << pac({etat(3)}),
{{"cause", "SG_READY_STATE_2"}, {"index", 2}, {"id", "pac-c"}}, false},
{"ID_DUPLICATE", avec(1, {{"id", "ballon-a"}}), {{"cause", "ID_DUPLICATE"}, {"index", 1}, {"id", "ballon-a"}}, false},
{"THING_SHARED", QVariantList(base) << ballonC,
{{"cause", "THING_SHARED"}, {"indexes", QVariantList{0, 2}}, {"ids", QVariantList{"ballon-a", "ballon-c"}}}, false},
{"RANK_DUPLICATE", avec(1, {{"priority", 1}}),
{{"cause", "RANK_DUPLICATE"}, {"indexes", QVariantList{0, 1}}, {"ids", QVariantList{"ballon-a", routeur}},
{"rank", 1}}, false},
// Une charge utile VALIDE, mais le stockage est inaccessible.
{"WRITE_FAILED", avec(0, {{"label", "Ballon du bas"}}), {{"cause", "WRITE_FAILED"}}, true},
};
for (const Cas &c : cas) {
if (c.disque)
qputenv("NYMEA_ENERGY_LOAD_CONFIG", "/q7-repertoire-absent/energy-load-configuration.json");
r = poser(c.charges);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
QVERIFY2(r.value("status").toString() == QLatin1String("success"), c.quoi); // c'est le MOTEUR qui refuse
const QVariantMap p = r.value("params").toMap();
QVERIFY2(p.value("energyError").toString() == QLatin1String("EnergyErrorInvalidParameter"), c.quoi);
QVERIFY2(p.value("fault").toString() == QLatin1String(DecisionCode::LoadConfigRefused), c.quoi);
QVERIFY2(json(p.value("faultParams")) == json(c.attendu),
qPrintable(QStringLiteral("%1 : %2").arg(c.quoi, QString::fromUtf8(json(p.value("faultParams"))))));
// RIEN n'a bougé : ni ce que l'app relit, ni le fichier, octet pour octet.
QVERIFY2(lire() == avantGet, c.quoi);
QVERIFY2(fichier() == avantDisque, c.quoi);
}
// [relecture q7-suite, I-1] Le journal d'un refus ne recopie RIEN de la saisie. La phrase
// détaillée y partait sans échappement : un saut de ligne glissé dans un champ fabriquait une
// fausse ligne de journal. Chaque champ qu'elle citait, refusé tour à tour.
const QString forge = QStringLiteral("x\n[NymeaEnergy] FAUX");
QVariantMap ballonF = ballon;
ballonF.insert("relays", QVariantList{QVariantMap{{"thingId", forge}, {"powerW", 1000}}});
QVariantMap ballonCF = ballonC;
ballonCF.insert("relays", QVariantList{QVariantMap{{"thingId", forge}, {"powerW", 500}}});
struct Forge { const char *quoi; QVariantList charges; const char *cause; };
const QList<Forge> forges{
{"mode", avec(0, {{"mode", forge}}), "MODE"},
{"domain", avec(0, {{"domain", forge}}), "DOMAIN"},
{"rankOrigin", avec(0, {{"rankOrigin", forge}}), "RANK_ORIGIN"},
{"adapter", avec(0, {{"adapter", forge}}), "ADAPTER"},
{"relays[].thingId", avec(0, {{"relays", QVariantList{QVariantMap{{"thingId", forge}, {"powerW", 1000}},
QVariantMap{{"thingId", forge}, {"powerW", 500}}}}}),
"FIELD_DUPLICATE"},
{"dailyDeadline", avec(0, {{"needs", QVariantMap{{"dailyDeadline", forge}}}}), "DEADLINE"},
{"label (Config … invalide)", avec(0, {{"label", forge}, {"mode", "variable"}}), "MODE"},
{"label (rang en double)", avec(1, {{"label", forge}, {"priority", 1}}), "RANK_DUPLICATE"},
{"label (auto fabriqué)", avec(1, {{"label", forge}, {"rankOrigin", "auto"}}), "RANK_ORIGIN_AUTO"},
{"relais partagé", QVariantList{ballonF, routeurPv, ballonCF}, "THING_SHARED"},
};
for (const Forge &f : forges) {
JournalCapture journal;
const QVariantMap p = poser(f.charges).value("params").toMap();
QVERIFY2(p.value("faultParams").toMap().value("cause").toString() == QLatin1String(f.cause),
qPrintable(QStringLiteral("%1 : %2").arg(f.quoi, QString::fromUtf8(json(p.value("faultParams"))))));
// Non vide : le refus EST journalisé…
QVERIFY2(journal.contains(QStringLiteral("SetLoadConfig rejetée")), f.quoi);
// …et aucun message n'y ajoute une ligne qui se fasse passer pour un message du moteur.
for (const QString &m : journal.messages()) {
const QStringList lignes = m.split(QLatin1Char('\n'));
for (int i = 1; i < lignes.count(); ++i)
QVERIFY2(!lignes.at(i).startsWith(QLatin1String("[NymeaEnergy]"), Qt::CaseInsensitive),
qPrintable(QStringLiteral("%1 : fausse ligne de journal « %2 »").arg(f.quoi, lignes.at(i))));
}
QVERIFY2(lire() == avantGet, f.quoi);
}
#endif
}
void Simulation::testAutoProvisionedLabelIsCutNeverRefused()
{
#ifndef ETM_ARBITRATOR
QSKIP("testAutoProvisionedLabelIsCutNeverRefused nécessite ETM_ARBITRATOR.");
#else
// [Q7, suite — relecture etm72, écart « important »] La borne prenait le nom de son Thing pour
// libellé et passait par l'écriture complète : un nom de plus de 256 caractères, ou un libellé
// hérité trop long sur une AUTRE entrée, refusait l'ajout, et la borne restait arbitrée sans
// rang réglable. Le nom est désormais coupé, et l'ajout ne borne que ce qu'il apporte.
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-nomlong.json";
QFile::remove(cfgPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
const auto nettoyer = qScopeGuard([&] {
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
QFile::remove(cfgPath);
});
// Une configuration HÉRITÉE dont une autre entrée porte un libellé de 300 caractères.
const QString libelleHerite(300, QLatin1Char('L'));
{
QFile f(cfgPath);
QVERIFY(f.open(QIODevice::WriteOnly | QIODevice::Text));
f.write(QJsonDocument(QJsonObject{{"version", 1}, {"loads", QJsonArray{QJsonObject::fromVariantMap({
{"id", "ballon-herite"}, {"label", libelleHerite}, {"adapter", "relay-router"}, {"mode", "fixed"},
{"priority", 1}, {"enabled", false}, {"minOnS", 0}, {"minOffS", 0},
{"relays", QVariantList{QVariantMap{{"thingId", "{aaaaaaaa-0000-4000-8000-0000000000e1}"},
{"powerW", 1500}}}}})}}}).toJson());
}
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);
QUuid borneId = addCharger("A", 16, 26657);
Thing *borne = tm->findConfiguredThing(borneId);
QVERIFY(borne);
// 300 caractères, un emoji en 256e position : couper en unités UTF-16 le briserait en deux.
const QString prise = QString::fromUcs4(U"\U0001F50C");
const QString nom = QString(255, QLatin1Char('a')) + prise + QString(44, QLatin1Char('b'));
QCOMPARE(nom.toUcs4().size(), qsizetype(300));
borne->setName(nom);
armerBorne(borne);
arb->simulationCallUpdate(utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)));
QCoreApplication::processEvents();
const QHash<QString, QVariantMap> cfg = configsParId(injectAndWait("NymeaEnergy.GetLoadConfig"));
QVERIFY2(cfg.contains(borneId.toString()), "la borne au nom de 300 caractères n'a pas été ajoutée");
const QString libelle = cfg.value(borneId.toString()).value("label").toString();
QCOMPARE(libelle, QString(255, QLatin1Char('a')) + prise); // la paire est ENTIÈRE
QCOMPARE(libelle.toUcs4().size(), qsizetype(LoadConfigStore::MaxLabelChars));
QCOMPARE(cfg.value(borneId.toString()).value("rankOrigin").toString(), QStringLiteral("auto"));
QCOMPARE(cfg.value(borneId.toString()).value("priority").toInt(), 2); // en queue
// L'entrée héritée est intacte : le moteur n'a rien « réparé » d'office.
QCOMPARE(cfg.value("ballon-herite").value("label").toString(), libelleHerite);
// CONTRE-ÉPREUVE en place : le libellé hérité dépasse BIEN la borne — l'écho client de ce
// même ensemble est refusé, sur lui. C'est l'ajout du moteur qui en est exempté, pas l'hérité
// qui serait passé inaperçu.
const QVariantList ensemble = injectAndWait("NymeaEnergy.GetLoadConfig").toMap()
.value("params").toMap().value("loadConfigs").toList();
const QVariantMap p = injectAndWait("NymeaEnergy.SetLoadConfig", {{"loadConfigs", ensemble}})
.toMap().value("params").toMap();
QCOMPARE(p.value("energyError").toString(), QStringLiteral("EnergyErrorInvalidParameter"));
QCOMPARE(p.value("faultParams").toMap().value("cause").toString(), QStringLiteral("LABEL"));
QCOMPARE(p.value("faultParams").toMap().value("id").toString(), QStringLiteral("ballon-herite"));
#endif
}
void Simulation::testVanishedRelayFreezesTheLoad() void Simulation::testVanishedRelayFreezesTheLoad()
{ {
#ifndef ETM_ARBITRATOR #ifndef ETM_ARBITRATOR

View File

@ -250,6 +250,15 @@ private slots:
// réglable — le trou de 3g-2 rouvert par le côté client. // réglable — le trou de 3g-2 rouvert par le côté client.
void testDeletedEvChargerConfigComesBack(); void testDeletedEvChargerConfigComesBack();
// [Q7, suite] Le refus de SetLoadConfig PUBLIE sa cause (fault LOAD_CONFIG_REFUSED et
// faultParams), déclarée dans le schéma, sans rien modifier — mémoire, GetLoadConfig, disque.
void testSetLoadConfigRefusalIsPublished();
// [Q7, suite — relecture etm72] Une borne au nom de 300 caractères est AJOUTÉE, son libellé
// coupé à 256 caractères sans couper de paire de substitution ; un libellé hérité trop long
// sur une autre entrée ne bloque pas l'ajout.
void testAutoProvisionedLabelIsCutNeverRefused();
// [ECS-111] Un relais DÉCLARÉ qui disparaît du registre nymea : la charge sort de // [ECS-111] Un relais DÉCLARÉ qui disparaît du registre nymea : la charge sort de
// l'arbitrage (available faux, THING_MISSING) et reste dans la comptabilité, figée à la // l'arbitrage (available faux, THING_MISSING) et reste dans la comptabilité, figée à la
// puissance crue engagée. Avant, elle publiait le nominal du palier entier — part du // puissance crue engagée. Avant, elle publiait le nominal du palier entier — part du

View File

@ -13,6 +13,8 @@
#include "energysettings.h" #include "energysettings.h"
#include "etm/energyarbitrator.h" #include "etm/energyarbitrator.h"
#include "etm/config/loadconfigstore.h"
#include "etm/scheduler/optimizerclient.h"
#include "etm/scheduler/socketscheduler.h" #include "etm/scheduler/socketscheduler.h"
#include "etm/scheduler/optimizerstatus.h" #include "etm/scheduler/optimizerstatus.h"
#include "etm/scheduler/publishedplan.h" #include "etm/scheduler/publishedplan.h"
@ -24,6 +26,7 @@
#include <QJsonArray> #include <QJsonArray>
#include <QJsonDocument> #include <QJsonDocument>
#include <QLoggingCategory> #include <QLoggingCategory>
#include <QRegularExpression>
#include <QSignalSpy> #include <QSignalSpy>
#include <QTemporaryDir> #include <QTemporaryDir>
@ -127,6 +130,8 @@ private slots:
void testUpdateLearnedIsLoggedOnceAndIgnored(); void testUpdateLearnedIsLoggedOnceAndIgnored();
// Protocole 1.2, Q7 — une requête trop longue n'est pas envoyée, et l'échec compte // Protocole 1.2, Q7 — une requête trop longue n'est pas envoyée, et l'échec compte
void testARequestTooLongCountsAsAFailure(); void testARequestTooLongCountsAsAFailure();
// Q7, suite — une configuration héritée hors règle, et ce qui part réellement dans GetPlan
void testInheritedOutOfRuleConfigWhatGetPlanSends();
// Brief 4 T6 — traces de décision (annexe D) // Brief 4 T6 — traces de décision (annexe D)
void testTraceIsOffByDefault(); void testTraceIsOffByDefault();
void testTraceSettingByRpc(); void testTraceSettingByRpc();
@ -139,6 +144,8 @@ private slots:
void testTokenIsInNoTrace(); void testTokenIsInNoTrace();
void testTraceLineSize(); void testTraceLineSize();
void testTracerPairsTheResponseByRequestId(); void testTracerPairsTheResponseByRequestId();
//! Q7, suite : une requête trop longue pour partir n'est tracée que par sa TAILLE.
void testARequestNotSentIsTracedBySizeOnly();
// Brief 4 T7 — correctif A : un token recopié par l'optimiseur ne sort nulle part // Brief 4 T7 — correctif A : un token recopié par l'optimiseur ne sort nulle part
void testAnEchoedTokenLeaksNowhere(); void testAnEchoedTokenLeaksNowhere();
// Lot contrat OpenRPC — le moteur parle le contrat qu'il sert // Lot contrat OpenRPC — le moteur parle le contrat qu'il sert
@ -182,6 +189,9 @@ private:
void amenerAOptimiseur(const QDateTime &t0, int validiteS = 1800); void amenerAOptimiseur(const QDateTime &t0, int validiteS = 1800);
//! Triplets publiés par les notifications \c OptimizerStatusChanged de l'arbitre. //! Triplets publiés par les notifications \c OptimizerStatusChanged de l'arbitre.
static QStringList triplets(const QSignalSpy &spy); static QStringList triplets(const QSignalSpy &spy);
//! Q7, suite : remplace la configuration par \p charges ÉCRITES SUR DISQUE et relues par un
//! store neuf — le chemin d'une configuration héritée, que SetLoadConfig refuserait.
void chargerConfigHeritee(const QVariantList &charges);
//! T6 : active les traces dans un répertoire temporaire propre ; rend son chemin. //! T6 : active les traces dans un répertoire temporaire propre ; rend son chemin.
QString activerTraces(); QString activerTraces();
//! T6 : les lignes de trace de \p dossier, tous fichiers, dans l'ordre ; attend d'en avoir //! T6 : les lignes de trace de \p dossier, tous fichiers, dans l'ordre ; attend d'en avoir
@ -1565,6 +1575,121 @@ QList<QVariantMap> TestSocketScheduler::traces(const QString &dossier, int n)
return lignes; return lignes;
} }
void TestSocketScheduler::chargerConfigHeritee(const QVariantList &charges)
{
QFile f(qEnvironmentVariable("NYMEA_ENERGY_LOAD_CONFIG"));
QVERIFY(f.open(QIODevice::WriteOnly | QIODevice::Text));
f.write(QJsonDocument(QJsonObject{{"version", 1}, {"loads", QJsonArray::fromVariantList(charges)}}).toJson());
f.close();
m_arb->setLoadConfigStore(new LoadConfigStore(m_arb));
}
void TestSocketScheduler::testInheritedOutOfRuleConfigWhatGetPlanSends()
{
// Q7, suite : une configuration ENREGISTRÉE avant la règle est chargée, signalée — et voici ce
// qui en part réellement vers l'optimiseur, lu sur le faux optimiseur. Un identifiant hors
// règle part tel quel (c'est une clé : le changer serait retirer une charge) ; une échéance
// illisible NE PART PAS — le contrat (LoadNeeds.dailyDeadline, motif ^[0-9]{2}:[0-9]{2}$)
// l'aurait de toute façon déclarée non conforme.
monter();
m_repondre = [](const QVariantMap &) { return QVariantMap{{"abstain", true}, {"reason", "test"}}; };
QTest::ignoreMessage(QtWarningMsg, QRegularExpression(
QStringLiteral("IDENTIFIANT HORS RÈGLE : charge \"ballon test\"")));
QTest::ignoreMessage(QtWarningMsg, QRegularExpression(
QStringLiteral("ÉCHÉANCE ILLISIBLE : charge \"%1\", needs.dailyDeadline \"7:00\"")
.arg(QRegularExpression::escape(m_var))));
chargerConfigHeritee({
QVariantMap{{"id", "ballon test"}, {"label", "Ballon"}, {"adapter", "relay-router"}, {"mode", "fixed"},
{"priority", 1}, {"enabled", true}, {"minOnS", 0}, {"minOffS", 0},
{"relays", QVariantList{QVariantMap{{"thingId", m_relais.toString()}, {"powerW", 1000}}}},
{"needs", QVariantMap{{"dailyDeadline", "07:00"}, {"minEnergyWhPerDay", 1000}}}},
QVariantMap{{"id", m_var}, {"label", "Routeur"}, {"adapter", "etmvariableload"}, {"mode", "dynamic"},
{"maxPowerW", 3000}, {"priority", 2}, {"enabled", true},
{"needs", QVariantMap{{"dailyDeadline", "7:00"}, {"minEnergyWhPerDay", 2000}}}}});
cycle(utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)), -3000);
QVERIFY(!m_requetes.isEmpty());
QHash<QString, QVariantMap> charges;
for (const QVariant &v : m_requetes.last().value("loads").toList())
charges.insert(v.toMap().value("id").toString(), v.toMap());
// L'identifiant hors règle part TEL QUEL, et l'échéance lisible avec lui (contre-épreuve) :
// « 07:00 » est envoyée, et l'obligation a son échéance absolue.
QVERIFY2(charges.contains("ballon test"), qUtf8Printable(charges.keys().join(", ")));
const QVariantMap besoinsLisibles = charges.value("ballon test").value("needs").toMap();
QCOMPARE(besoinsLisibles.value("dailyDeadline").toString(), QStringLiteral("07:00"));
QVERIFY(!besoinsLisibles.value("energyTargets").toList().isEmpty());
// L'échéance illisible est ABSENTE : ni brute, ni résolue. L'obligation en énergie part
// encore — sans échéance, elle n'a pas de cadence, et rien n'est acheté pour elle.
QVERIFY(charges.contains(m_var));
const QVariantMap besoins = charges.value(m_var).value("needs").toMap();
QVERIFY2(!besoins.contains("dailyDeadline"), qUtf8Printable(QString::fromUtf8(QJsonDocument::fromVariant(besoins).toJson())));
QVERIFY(!besoins.contains("energyTargets"));
QCOMPARE(besoins.value("minEnergyWhPerDay").toInt(), 2000);
// Et ce qui est parti est CONFORME au contrat publié.
jugerLesRequetesDuMoteur();
}
void TestSocketScheduler::testARequestNotSentIsTracedBySizeOnly()
{
// Q7, suite : la trace recopiait la requête du cycle, même quand le client ne l'avait PAS
// écrite (ligne de plus de 1 Mio, request-too-long) — environ 1 Mio par cycle jusqu'au plafond
// journalier, pour une requête que personne n'a reçue. Seule sa TAILLE y figure désormais.
monter();
const QString d = activerTraces();
m_repondre = [](const QVariantMap &) { return QVariantMap{{"abstain", true}, {"reason", "test"}}; };
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
cycle(t0, -3000); // CONTRE-ÉPREUVE : une requête envoyée est recopiée
const int recues = m_requetes.count();
// Une configuration héritée au libellé démesuré : SetLoadConfig la refuserait, le chargement
// la garde, et le contexte dépasse la ligne du protocole.
const QString demesure(OptimizerClient::kMaxLineBytes + 1000, QLatin1Char('L'));
const QVariantList charges{
QVariantMap{{"id", "ecs"}, {"label", "ECS"}, {"adapter", "relay-router"}, {"mode", "fixed"},
{"priority", 1}, {"enabled", true}, {"minOnS", 0}, {"minOffS", 0},
{"relays", QVariantList{QVariantMap{{"thingId", m_relais.toString()}, {"powerW", 1000}}}}},
QVariantMap{{"id", m_var}, {"label", demesure}, {"adapter", "etmvariableload"}, {"mode", "dynamic"},
{"maxPowerW", 3000}, {"priority", 2}, {"enabled", true}}};
chargerConfigHeritee(charges);
m_compteur->setStateValue("currentPower", -3000);
m_arb->recordMeterUpdate(t0.addSecs(60));
m_arb->simulationCallUpdate(t0.addSecs(60));
QVERIFY(waitFor([&] { return m_codesEchec.contains(QStringLiteral("request-too-long")); }));
settle();
QCOMPARE(m_requetes.count(), recues); // rien n'est parti
QList<QVariantMap> l = traces(d, 2);
QCOMPARE(l.count(), 2);
QVERIFY(l.at(0).contains("request"));
QVERIFY(!l.at(0).contains("requestBytes"));
QVERIFY(!l.at(1).contains("request"));
const qint64 octets = l.at(1).value("requestBytes").toLongLong();
QVERIFY2(octets > OptimizerClient::kMaxLineBytes && octets < 2 * OptimizerClient::kMaxLineBytes,
qPrintable(QString::number(octets)));
QCOMPARE(l.at(1).value("response").toMap(),
(QVariantMap{{"error", QVariantMap{{"code", "request-too-long"}}}}));
// Le libellé n'est NULLE PART dans la trace : le fichier entier est loin du mégaoctet.
qint64 taille = 0;
for (const QString &f : fichiersTrace(d))
taille += QFileInfo(QDir(d).filePath(f)).size();
QVERIFY2(taille < 200 * 1024, qPrintable(QString::number(taille)));
// Sans optimiseur : la requête qui SERAIT partie suit la même règle.
monter(false);
const QString d2 = activerTraces();
chargerConfigHeritee(charges);
cycle(t0.addSecs(120), -3000);
l = traces(d2, 1);
QCOMPARE(l.count(), 1);
QVERIFY(!l.at(0).contains("request"));
QVERIFY(l.at(0).value("requestBytes").toLongLong() > OptimizerClient::kMaxLineBytes);
QCOMPARE(l.at(0).value("strategy").toString(), QStringLiteral("rules"));
QVERIFY(l.at(0).value("response").toMap().contains("result"));
}
void TestSocketScheduler::testARequestTooLongCountsAsAFailure() void TestSocketScheduler::testARequestTooLongCountsAsAFailure()
{ {
// Protocole 1.2, Q7 : une requête que le client n'émet pas (ligne de plus de 1 Mio) est un // Protocole 1.2, Q7 : une requête que le client n'émet pas (ligne de plus de 1 Mio) est un